Initial commit

This commit is contained in:
2026-02-01 09:31:38 +01:00
commit e02db93960
4396 changed files with 1511612 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Provide all models that map to Reddit objects."""

View File

@@ -0,0 +1,95 @@
"""Provide the RedditBase class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from ...endpoints import API_PATH
from ...exceptions import InvalidURL
from ..base import PRAWBase
if TYPE_CHECKING: # pragma: no cover
import praw
class RedditBase(PRAWBase):
"""Base class that represents actual Reddit objects."""
@staticmethod
def _url_parts(url: str) -> list[str]:
parsed = urlparse(url)
if not parsed.netloc:
raise InvalidURL(url)
return parsed.path.rstrip("/").split("/")
def __eq__(self, other: Any | str) -> bool:
"""Return whether the other instance equals the current."""
if isinstance(other, str):
return other.lower() == str(self).lower()
return (
isinstance(other, self.__class__)
and str(self).lower() == str(other).lower()
)
def __getattr__(self, attribute: str) -> Any:
"""Return the value of ``attribute``."""
if not attribute.startswith("_") and not self._fetched:
self._fetch()
return getattr(self, attribute)
msg = f"{self.__class__.__name__!r} object has no attribute {attribute!r}"
raise AttributeError(msg)
def __hash__(self) -> int:
"""Return the hash of the current instance."""
return hash(self.__class__.__name__) ^ hash(str(self).lower())
def __init__(
self,
reddit: praw.Reddit,
_data: dict[str, Any] | None,
_extra_attribute_to_check: str | None = None,
_fetched: bool = False,
_str_field: bool = True,
):
"""Initialize a :class:`.RedditBase` instance.
:param reddit: An instance of :class:`.Reddit`.
"""
super().__init__(reddit, _data=_data)
self._fetched = _fetched
if _str_field and self.STR_FIELD not in self.__dict__:
if (
_extra_attribute_to_check is not None
and _extra_attribute_to_check in self.__dict__
):
return
msg = f"An invalid value was specified for {self.STR_FIELD}. Check that the argument for the {self.STR_FIELD} parameter is not empty."
raise ValueError(msg)
def __ne__(self, other: object) -> bool:
"""Return whether the other instance differs from the current."""
return not self == other
def __repr__(self) -> str:
"""Return an object initialization representation of the instance."""
return f"{self.__class__.__name__}({self.STR_FIELD}={str(self)!r})"
def __str__(self) -> str:
"""Return a string representation of the instance."""
return getattr(self, self.STR_FIELD)
def _fetch(self):
self._fetched = True
def _fetch_data(self):
name, fields, params = self._fetch_info()
path = API_PATH[name].format(**fields)
return self._reddit.request(method="GET", params=params, path=path)
def _reset_attributes(self, *attributes: str):
for attribute in attributes:
if attribute in self.__dict__:
del self.__dict__[attribute]
self._fetched = False

View File

@@ -0,0 +1,586 @@
"""Provide Collections functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ...const import API_PATH
from ...exceptions import ClientException
from ...util import _deprecate_args
from ...util.cache import cachedproperty
from ..base import PRAWBase
from .base import RedditBase
from .submission import Submission
from .subreddit import Subreddit
if TYPE_CHECKING: # pragma: no cover
from collections.abc import Iterator
import praw.models
class CollectionModeration(PRAWBase):
"""Class to support moderation actions on a :class:`.Collection`.
Obtain an instance via:
.. code-block:: python
reddit.subreddit("test").collections("some_uuid").mod
"""
def __init__(self, reddit: praw.Reddit, collection_id: str):
"""Initialize a :class:`.CollectionModeration` instance.
:param collection_id: The ID of a :class:`.Collection`.
"""
super().__init__(reddit, _data=None)
self.collection_id = collection_id
def _post_fullname(self, post: str | praw.models.Submission) -> str:
"""Get a post's fullname.
:param post: A fullname, a :class:`.Submission`, a permalink, or an ID.
:returns: The fullname of the post.
"""
if isinstance(post, Submission):
return post.fullname
if not isinstance(post, str):
msg = f"Cannot get fullname from object of type {type(post)}."
raise TypeError(msg)
if post.startswith(f"{self._reddit.config.kinds['submission']}_"):
return post
try:
return self._reddit.submission(url=post).fullname
except ClientException:
return self._reddit.submission(post).fullname
def add_post(self, submission: praw.models.Submission):
"""Add a post to the collection.
:param submission: The post to add, a :class:`.Submission`, its permalink as a
``str``, its fullname as a ``str``, or its ID as a ``str``.
Example usage:
.. code-block:: python
collection = reddit.subreddit("test").collections("some_uuid")
collection.mod.add_post("bgibu9")
.. seealso::
:meth:`.remove_post`
"""
link_fullname = self._post_fullname(submission)
self._reddit.post(
API_PATH["collection_add_post"],
data={"collection_id": self.collection_id, "link_fullname": link_fullname},
)
def delete(self):
"""Delete this collection.
Example usage:
.. code-block:: python
reddit.subreddit("test").collections("some_uuid").mod.delete()
.. seealso::
:meth:`~.SubredditCollectionsModeration.create`
"""
self._reddit.post(
API_PATH["collection_delete"], data={"collection_id": self.collection_id}
)
def remove_post(self, submission: praw.models.Submission):
"""Remove a post from the collection.
:param submission: The post to remove, a :class:`.Submission`, its permalink as
a ``str``, its fullname as a ``str``, or its ID as a ``str``.
Example usage:
.. code-block:: python
collection = reddit.subreddit("test").collections("some_uuid")
collection.mod.remove_post("bgibu9")
.. seealso::
:meth:`.add_post`
"""
link_fullname = self._post_fullname(submission)
self._reddit.post(
API_PATH["collection_remove_post"],
data={"collection_id": self.collection_id, "link_fullname": link_fullname},
)
def reorder(self, links: list[str | praw.models.Submission]):
r"""Reorder posts in the collection.
:param links: A list of :class:`.Submission`\ s or a ``str`` that is either a
fullname or an ID.
Example usage:
.. code-block:: python
collection = reddit.subreddit("test").collections("some_uuid")
current_order = collection.link_ids
new_order = reversed(current_order)
collection.mod.reorder(new_order)
"""
link_ids = ",".join(self._post_fullname(post) for post in links)
self._reddit.post(
API_PATH["collection_reorder"],
data={"collection_id": self.collection_id, "link_ids": link_ids},
)
def update_description(self, description: str):
"""Update the collection's description.
:param description: The new description.
Example usage:
.. code-block:: python
collection = reddit.subreddit("test").collections("some_uuid")
collection.mod.update_description("Please enjoy these links!")
.. seealso::
:meth:`.update_title`
"""
self._reddit.post(
API_PATH["collection_desc"],
data={"collection_id": self.collection_id, "description": description},
)
def update_display_layout(self, display_layout: str):
"""Update the collection's display layout.
:param display_layout: Either ``"TIMELINE"`` for events or discussions or
``"GALLERY"`` for images or memes. Passing ``None`` will clear the set
layout and ``collection.display_layout`` will be ``None``, however, the
collection will appear on Reddit as if ``display_layout`` is set to
``"TIMELINE"``.
Example usage:
.. code-block:: python
collection = reddit.subreddit("test").collections("some_uuid")
collection.mod.update_display_layout("GALLERY")
"""
self._reddit.post(
API_PATH["collection_layout"],
data={
"collection_id": self.collection_id,
"display_layout": display_layout,
},
)
def update_title(self, title: str):
"""Update the collection's title.
:param title: The new title.
Example usage:
.. code-block:: python
collection = reddit.subreddit("test").collections("some_uuid")
collection.mod.update_title("Titley McTitleface")
.. seealso::
:meth:`.update_description`
"""
self._reddit.post(
API_PATH["collection_title"],
data={"collection_id": self.collection_id, "title": title},
)
class SubredditCollectionsModeration(PRAWBase):
r"""Class to represent moderator actions on a :class:`.Subreddit`'s :class:`.Collection`\ s.
Obtain an instance via:
.. code-block:: python
reddit.subreddit("test").collections.mod
"""
def __init__(
self,
reddit: praw.Reddit,
sub_fullname: str,
_data: dict[str, Any] | None = None,
):
"""Initialize a :class:`.SubredditCollectionsModeration` instance."""
super().__init__(reddit, _data)
self.subreddit_fullname = sub_fullname
@_deprecate_args("title", "description", "display_layout")
def create(
self, *, description: str, display_layout: str | None = None, title: str
) -> Collection:
"""Create a new :class:`.Collection`.
The authenticated account must have appropriate moderator permissions in the
subreddit this collection belongs to.
:param description: The description, up to 500 characters.
:param display_layout: Either ``"TIMELINE"`` for events or discussions or
``"GALLERY"`` for images or memes. Passing ``""`` or ``None`` will make the
collection appear on Reddit as if this is set to ``"TIMELINE"`` (default:
``None``).
:param title: The title of the collection, up to 300 characters.
:returns: The newly created :class:`.Collection`.
Example usage:
.. code-block:: python
my_sub = reddit.subreddit("test")
new_collection = my_sub.collections.mod.create(title="Title", description="desc")
new_collection.mod.add_post("bgibu9")
To specify the display layout as ``"GALLERY"`` when creating the collection:
.. code-block:: python
my_sub = reddit.subreddit("test")
new_collection = my_sub.collections.mod.create(
title="Title", description="desc", display_layout="GALLERY"
)
new_collection.mod.add_post("bgibu9")
.. seealso::
:meth:`~.CollectionModeration.delete`
"""
data = {
"sr_fullname": self.subreddit_fullname,
"title": title,
"description": description,
}
if display_layout:
data["display_layout"] = display_layout
return self._reddit.post(
API_PATH["collection_create"],
data=data,
)
class SubredditCollections(PRAWBase):
r"""Class to represent a :class:`.Subreddit`'s :class:`.Collection`\ s.
Obtain an instance via:
.. code-block:: python
reddit.subreddit("test").collections
"""
@cachedproperty
def mod(self) -> SubredditCollectionsModeration:
"""Get an instance of :class:`.SubredditCollectionsModeration`.
Provides :meth:`~SubredditCollectionsModeration.create`:
.. code-block:: python
my_sub = reddit.subreddit("test")
new_collection = my_sub.collections.mod.create(title="Title", description="desc")
"""
return SubredditCollectionsModeration(self._reddit, self.subreddit.fullname)
def __call__(
self,
collection_id: str | None = None,
permalink: str | None = None,
) -> Collection:
"""Return the :class:`.Collection` with the specified ID.
:param collection_id: The ID of a :class:`.Collection` (default: ``None``).
:param permalink: The permalink of a collection (default: ``None``).
:returns: The specified :class:`.Collection`.
Exactly one of ``collection_id`` or ``permalink`` is required.
Example usage:
.. code-block:: python
subreddit = reddit.subreddit("test")
uuid = "847e4548-a3b5-4ad7-afb4-edbfc2ed0a6b"
collection = subreddit.collections(uuid)
print(collection.title)
print(collection.description)
permalink = "https://www.reddit.com/r/SUBREDDIT/collection/" + uuid
collection = subreddit.collections(permalink=permalink)
print(collection.title)
print(collection.description)
"""
if (collection_id is None) == (permalink is None):
msg = "Exactly one of 'collection_id' or 'permalink' must be provided."
raise TypeError(msg)
return Collection(
self._reddit, collection_id=collection_id, permalink=permalink
)
def __init__(
self,
reddit: praw.Reddit,
subreddit: praw.models.Subreddit,
_data: dict[str, Any] | None = None,
):
"""Initialize a :class:`.SubredditCollections` instance."""
super().__init__(reddit, _data)
self.subreddit = subreddit
def __iter__(self):
r"""Iterate over the :class:`.Subreddit`'s :class:`.Collection`\ s.
Example usage:
.. code-block:: python
for collection in reddit.subreddit("test").collections:
print(collection.permalink)
"""
request = self._reddit.get(
API_PATH["collection_subreddit"],
params={"sr_fullname": self.subreddit.fullname},
)
yield from request
class Collection(RedditBase):
"""Class to represent a :class:`.Collection`.
Obtain an instance via:
.. code-block:: python
collection = reddit.subreddit("test").collections("some_uuid")
or
.. code-block:: python
collection = reddit.subreddit("test").collections(
permalink="https://reddit.com/r/SUBREDDIT/collection/some_uuid"
)
.. include:: ../../typical_attributes.rst
=================== =============================================================
Attribute Description
=================== =============================================================
``author`` The :class:`.Redditor` who created the collection.
``collection_id`` The UUID of the collection.
``created_at_utc`` Time the collection was created, represented in `Unix Time`_.
``description`` The collection description.
``display_layout`` The collection display layout.
``last_update_utc`` Time the collection was last updated, represented in `Unix
Time`_.
``link_ids`` A list of :class:`.Submission` fullnames.
``permalink`` The collection's permalink (to view on the web).
``sorted_links`` An iterable listing of the posts in this collection.
``title`` The title of the collection.
=================== =============================================================
.. _unix time: https://en.wikipedia.org/wiki/Unix_time
"""
STR_FIELD = "collection_id"
@cachedproperty
def mod(self) -> CollectionModeration:
"""Get an instance of :class:`.CollectionModeration`.
Provides access to various methods, including
:meth:`~.CollectionModeration.add_post`, :meth:`~.CollectionModeration.delete`,
:meth:`~.CollectionModeration.reorder`, and
:meth:`~.CollectionModeration.update_title`.
Example usage:
.. code-block:: python
collection = reddit.subreddit("test").collections("some_uuid")
collection.mod.update_title("My new title!")
"""
return CollectionModeration(self._reddit, self.collection_id)
@cachedproperty
def subreddit(self) -> praw.models.Subreddit:
"""Get the subreddit that this collection belongs to.
For example:
.. code-block:: python
collection = reddit.subreddit("test").collections("some_uuid")
subreddit = collection.subreddit
"""
return next(self._reddit.info(fullnames=[self.subreddit_id]))
def __init__(
self,
reddit: praw.Reddit,
_data: dict[str, Any] = None,
collection_id: str | None = None,
permalink: str | None = None,
):
"""Initialize a :class:`.Collection` instance.
:param reddit: An instance of :class:`.Reddit`.
:param _data: Any data associated with the :class:`.Collection`.
:param collection_id: The ID of the :class:`.Collection`.
:param permalink: The permalink of the :class:`.Collection`.
"""
if (_data, collection_id, permalink).count(None) != 2:
msg = "Exactly one of '_data', 'collection_id', or 'permalink' must be provided."
raise TypeError(msg)
if permalink:
collection_id = self._url_parts(permalink)[4]
if collection_id:
self.collection_id = collection_id # set from _data otherwise
super().__init__(reddit, _data)
self._info_params = {
"collection_id": self.collection_id,
"include_links": True,
}
def __iter__(self) -> Iterator:
"""Provide a way to iterate over the posts in this :class:`.Collection`.
Example usage:
.. code-block:: python
collection = reddit.subreddit("test").collections("some_uuid")
for submission in collection:
print(submission.title, submission.permalink)
"""
yield from self.sorted_links
def __len__(self) -> int:
"""Get the number of posts in this :class:`.Collection`.
Example usage:
.. code-block:: python
collection = reddit.subreddit("test").collections("some_uuid")
print(len(collection))
"""
return len(self.link_ids)
def __setattr__(self, attribute: str, value: Any):
"""Objectify author, subreddit, and sorted_links attributes."""
if attribute == "author_name":
self.author = self._reddit.redditor(value)
elif attribute == "sorted_links":
value = self._reddit._objector.objectify(value)
super().__setattr__(attribute, value)
def _fetch(self):
data = self._fetch_data()
try:
self._reddit._objector.check_error(data)
except ClientException:
# A well-formed but invalid Collections ID during fetch time
# causes Reddit to return something that looks like an error
# but with no content.
msg = f"Error during fetch. Check collection ID {self.collection_id!r} is correct."
raise ClientException(msg) from None
other = type(self)(self._reddit, _data=data)
self.__dict__.update(other.__dict__)
super()._fetch()
def _fetch_info(self):
return "collection", {}, self._info_params
def follow(self):
"""Follow this :class:`.Collection`.
Example usage:
.. code-block:: python
reddit.subreddit("test").collections("some_uuid").follow()
.. seealso::
:meth:`.unfollow`
"""
self._reddit.post(
API_PATH["collection_follow"],
data={"collection_id": self.collection_id, "follow": True},
)
def unfollow(self):
"""Unfollow this :class:`.Collection`.
Example usage:
.. code-block:: python
reddit.subreddit("test").collections("some_uuid").unfollow()
.. seealso::
:meth:`.follow`
"""
self._reddit.post(
API_PATH["collection_follow"],
data={"collection_id": self.collection_id, "follow": False},
)
Subreddit._subreddit_collections_class = SubredditCollections

View File

@@ -0,0 +1,355 @@
"""Provide the Comment class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ...const import API_PATH
from ...exceptions import ClientException, InvalidURL
from ...util.cache import cachedproperty
from ..comment_forest import CommentForest
from .base import RedditBase
from .mixins import (
FullnameMixin,
InboxableMixin,
ThingModerationMixin,
UserContentMixin,
)
from .redditor import Redditor
if TYPE_CHECKING: # pragma: no cover
import praw.models
class Comment(InboxableMixin, UserContentMixin, FullnameMixin, RedditBase):
"""A class that represents a Reddit comment.
.. include:: ../../typical_attributes.rst
================= =================================================================
Attribute Description
================= =================================================================
``author`` Provides an instance of :class:`.Redditor`.
``body`` The body of the comment, as Markdown.
``body_html`` The body of the comment, as HTML.
``created_utc`` Time the comment was created, represented in `Unix Time`_.
``distinguished`` Whether or not the comment is distinguished.
``edited`` Whether or not the comment has been edited.
``id`` The ID of the comment.
``is_submitter`` Whether or not the comment author is also the author of the
submission.
``link_id`` The submission ID that the comment belongs to.
``parent_id`` The ID of the parent comment (prefixed with ``t1_``). If it is a
top-level comment, this returns the submission ID instead
(prefixed with ``t3_``).
``permalink`` A permalink for the comment. :class:`.Comment` objects from the
inbox have a ``context`` attribute instead.
``replies`` Provides an instance of :class:`.CommentForest`.
``saved`` Whether or not the comment is saved.
``score`` The number of upvotes for the comment.
``stickied`` Whether or not the comment is stickied.
``submission`` Provides an instance of :class:`.Submission`. The submission that
the comment belongs to.
``subreddit`` Provides an instance of :class:`.Subreddit`. The subreddit that
the comment belongs to.
``subreddit_id`` The subreddit ID that the comment belongs to.
================= =================================================================
.. _unix time: https://en.wikipedia.org/wiki/Unix_time
"""
MISSING_COMMENT_MESSAGE = "This comment does not appear to be in the comment tree"
STR_FIELD = "id"
@staticmethod
def id_from_url(url: str) -> str:
"""Get the ID of a comment from the full URL."""
parts = RedditBase._url_parts(url)
try:
comment_index = parts.index("comments")
except ValueError:
raise InvalidURL(url) from None
if len(parts) - 4 != comment_index:
raise InvalidURL(url)
return parts[-1]
@cachedproperty
def mod(self) -> praw.models.reddit.comment.CommentModeration:
"""Provide an instance of :class:`.CommentModeration`.
Example usage:
.. code-block:: python
comment = reddit.comment("dkk4qjd")
comment.mod.approve()
"""
return CommentModeration(self)
@property
def _kind(self):
"""Return the class's kind."""
return self._reddit.config.kinds["comment"]
@property
def is_root(self) -> bool:
"""Return ``True`` when the comment is a top-level comment."""
parent_type = self.parent_id.split("_", 1)[0]
return parent_type == self._reddit.config.kinds["submission"]
@property
def replies(self) -> CommentForest:
"""Provide an instance of :class:`.CommentForest`.
This property may return an empty list if the comment has not been refreshed
with :meth:`.refresh`
Sort order and reply limit can be set with the ``reply_sort`` and
``reply_limit`` attributes before replies are fetched, including any call to
:meth:`.refresh`:
.. code-block:: python
comment.reply_sort = "new"
comment.refresh()
replies = comment.replies
.. note::
The appropriate values for ``reply_sort`` include ``"confidence"``,
``"controversial"``, ``"new"``, ``"old"``, ``"q&a"``, and ``"top"``.
"""
if isinstance(self._replies, list):
self._replies = CommentForest(self.submission, self._replies)
return self._replies
@property
def submission(self) -> praw.models.Submission:
"""Return the :class:`.Submission` object this comment belongs to."""
if not self._submission: # Comment not from submission
self._submission = self._reddit.submission(self._extract_submission_id())
return self._submission
@submission.setter
def submission(self, submission: praw.models.Submission):
"""Update the :class:`.Submission` associated with the :class:`.Comment`."""
submission._comments_by_id[self.fullname] = self
self._submission = submission
for reply in getattr(self, "replies", []):
reply.submission = submission
def __init__(
self,
reddit: praw.Reddit,
id: str | None = None,
url: str | None = None,
_data: dict[str, Any] | None = None,
):
"""Initialize a :class:`.Comment` instance."""
if (id, url, _data).count(None) != 2:
msg = "Exactly one of 'id', 'url', or '_data' must be provided."
raise TypeError(msg)
fetched = False
self._replies = []
self._submission = None
if id:
self.id = id
elif url:
self.id = self.id_from_url(url)
else:
fetched = True
super().__init__(reddit, _data=_data, _fetched=fetched)
def __setattr__(
self,
attribute: str,
value: str | Redditor | CommentForest | praw.models.Subreddit,
):
"""Objectify author, replies, and subreddit."""
if attribute == "author":
value = Redditor.from_data(self._reddit, value)
elif attribute == "replies":
if value == "":
value = []
else:
value = self._reddit._objector.objectify(value).children
attribute = "_replies"
elif attribute == "subreddit":
value = self._reddit.subreddit(value)
super().__setattr__(attribute, value)
def _extract_submission_id(self):
if "context" in self.__dict__:
return self.context.rsplit("/", 4)[1]
return self.link_id.split("_", 1)[1]
def _fetch(self):
data = self._fetch_data()
data = data["data"]
if not data["children"]:
msg = f"No data returned for comment {self.fullname}"
raise ClientException(msg)
comment_data = data["children"][0]["data"]
other = type(self)(self._reddit, _data=comment_data)
self.__dict__.update(other.__dict__)
super()._fetch()
def _fetch_info(self):
return "info", {}, {"id": self.fullname}
def parent(
self,
) -> Comment | praw.models.Submission:
"""Return the parent of the comment.
The returned parent will be an instance of either :class:`.Comment`, or
:class:`.Submission`.
If this comment was obtained through a :class:`.Submission`, then its entire
ancestry should be immediately available, requiring no extra network requests.
However, if this comment was obtained through other means, e.g.,
``reddit.comment("COMMENT_ID")``, or ``reddit.inbox.comment_replies``, then the
returned parent may be a lazy instance of either :class:`.Comment`, or
:class:`.Submission`.
Lazy comment example:
.. code-block:: python
comment = reddit.comment("cklhv0f")
parent = comment.parent()
# 'replies' is empty until the comment is refreshed
print(parent.replies) # Output: []
parent.refresh()
print(parent.replies) # Output is at least: [Comment(id="cklhv0f")]
.. warning::
Successive calls to :meth:`.parent` may result in a network request per call
when the comment is not obtained through a :class:`.Submission`. See below
for an example of how to minimize requests.
If you have a deeply nested comment and wish to most efficiently discover its
top-most :class:`.Comment` ancestor you can chain successive calls to
:meth:`.parent` with calls to :meth:`.refresh` at every 9 levels. For example:
.. code-block:: python
comment = reddit.comment("dkk4qjd")
ancestor = comment
refresh_counter = 0
while not ancestor.is_root:
ancestor = ancestor.parent()
if refresh_counter % 9 == 0:
ancestor.refresh()
refresh_counter += 1
print(f"Top-most Ancestor: {ancestor}")
The above code should result in 5 network requests to Reddit. Without the calls
to :meth:`.refresh` it would make at least 31 network requests.
"""
if self.parent_id == self.submission.fullname:
return self.submission
if self.parent_id in self.submission._comments_by_id:
# The Comment already exists, so simply return it
return self.submission._comments_by_id[self.parent_id]
parent = Comment(self._reddit, self.parent_id.split("_", 1)[1])
parent._submission = self.submission
return parent
def refresh(self) -> Comment:
"""Refresh the comment's attributes.
If using :meth:`.Reddit.comment` this method must be called in order to obtain
the comment's replies.
Example usage:
.. code-block:: python
comment = reddit.comment("dkk4qjd")
comment.refresh()
"""
if "context" in self.__dict__: # Using hasattr triggers a fetch
comment_path = self.context.split("?", 1)[0]
else:
path = API_PATH["submission"].format(id=self.submission.id)
comment_path = f"{path}_/{self.id}"
# The context limit appears to be 8, but let's ask for more anyway.
params = {"context": 100}
if "reply_limit" in self.__dict__:
params["limit"] = self.reply_limit
if "reply_sort" in self.__dict__:
params["sort"] = self.reply_sort
comment_list = self._reddit.get(comment_path, params=params)[1].children
if not comment_list:
raise ClientException(self.MISSING_COMMENT_MESSAGE)
# With context, the comment may be nested so we have to find it
comment = None
queue = comment_list[:]
while queue and (comment is None or comment.id != self.id):
comment = queue.pop()
if isinstance(comment, Comment):
queue.extend(comment._replies)
if comment.id != self.id:
raise ClientException(self.MISSING_COMMENT_MESSAGE)
if self._submission is not None:
del comment.__dict__["_submission"] # Don't replace if set
self.__dict__.update(comment.__dict__)
for reply in comment_list:
reply.submission = self.submission
return self
class CommentModeration(ThingModerationMixin):
"""Provide a set of functions pertaining to :class:`.Comment` moderation.
Example usage:
.. code-block:: python
comment = reddit.comment("dkk4qjd")
comment.mod.approve()
"""
REMOVAL_MESSAGE_API = "removal_comment_message"
def __init__(self, comment: praw.models.Comment):
"""Initialize a :class:`.CommentModeration` instance.
:param comment: The comment to moderate.
"""
self.thing = comment
def show(self):
"""Uncollapse a :class:`.Comment` that has been collapsed by Crowd Control.
Example usage:
.. code-block:: python
# Uncollapse a comment:
comment = reddit.comment("dkk4qjd")
comment.mod.show()
"""
url = API_PATH["show_comment"]
self.thing._reddit.post(url, data={"id": self.thing.fullname})

View File

@@ -0,0 +1,308 @@
"""Provide the draft class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ...const import API_PATH
from ...exceptions import ClientException
from .base import RedditBase
from .subreddit import Subreddit
from .user_subreddit import UserSubreddit
if TYPE_CHECKING: # pragma: no cover
import praw.models
class Draft(RedditBase):
"""A class that represents a Reddit submission draft.
.. include:: ../../typical_attributes.rst
========================== ======================================================
Attribute Description
========================== ======================================================
``link_flair_template_id`` The link flair's ID.
``link_flair_text`` The link flair's text content, or ``None`` if not
flaired.
``modified`` Time the submission draft was modified, represented in
`Unix Time`_.
``original_content`` Whether the submission draft will be set as original
content.
``selftext`` The submission draft's selftext. ``None`` if a link
submission draft.
``spoiler`` Whether the submission will be marked as a spoiler.
``subreddit`` Provides an instance of :class:`.Subreddit` or
:class:`.UserSubreddit` (if set).
``title`` The title of the submission draft.
``url`` The URL the submission draft links to.
========================== ======================================================
.. _unix time: https://en.wikipedia.org/wiki/Unix_time
"""
STR_FIELD = "id"
@classmethod
def _prepare_data(
cls,
*,
flair_id: str | None = None,
flair_text: str | None = None,
is_public_link: bool | None = None,
nsfw: bool | None = None,
original_content: bool | None = None,
selftext: str | None = None,
send_replies: bool | None = None,
spoiler: bool | None = None,
subreddit: praw.models.Subreddit | praw.models.UserSubreddit | None = None,
title: str | None = None,
url: str | None = None,
**draft_kwargs: Any,
) -> dict[str, Any]:
data = {
"body": selftext or url,
"flair_id": flair_id,
"flair_text": flair_text,
"is_public_link": is_public_link,
"kind": "markdown" if selftext is not None else "link",
"nsfw": nsfw,
"original_content": original_content,
"send_replies": send_replies,
"spoiler": spoiler,
"title": title,
}
if subreddit:
data.update(
{
"subreddit": subreddit.fullname,
"target": (
"profile"
if subreddit.display_name.startswith("u_")
else "subreddit"
),
}
)
data.update(draft_kwargs)
return data
def __init__(
self, reddit: praw.Reddit, id: str | None = None, _data: dict[str, Any] = None
):
"""Initialize a :class:`.Draft` instance."""
if (id, _data).count(None) != 1:
msg = "Exactly one of 'id' or '_data' must be provided."
raise TypeError(msg)
fetched = False
if id:
self.id = id
elif len(_data) > 1:
if _data["kind"] == "markdown":
_data["selftext"] = _data.pop("body")
elif _data["kind"] == "link":
_data["url"] = _data.pop("body")
fetched = True
super().__init__(reddit, _data=_data, _fetched=fetched)
def __repr__(self) -> str:
"""Return an object initialization representation of the instance."""
if self._fetched:
subreddit = (
f" subreddit={self.subreddit.display_name!r}" if self.subreddit else ""
)
title = f" title={self.title!r}" if self.title else ""
return f"{self.__class__.__name__}(id={self.id!r}{subreddit}{title})"
return f"{self.__class__.__name__}(id={self.id!r})"
def _fetch(self):
for draft in self._reddit.drafts():
if draft.id == self.id:
self.__dict__.update(draft.__dict__)
super()._fetch()
return
msg = (
f"The currently authenticated user not have a draft with an ID of {self.id}"
)
raise ClientException(msg)
def delete(self):
"""Delete the :class:`.Draft`.
Example usage:
.. code-block:: python
draft = reddit.drafts("124862bc-e1e9-11eb-aa4f-e68667a77cbb")
draft.delete()
"""
self._reddit.delete(API_PATH["draft"], params={"draft_id": self.id})
def submit(
self,
*,
flair_id: str | None = None,
flair_text: str | None = None,
nsfw: bool | None = None,
selftext: str | None = None,
spoiler: bool | None = None,
subreddit: (
str | praw.models.Subreddit | praw.models.UserSubreddit | None
) = None,
title: str | None = None,
url: str | None = None,
**submit_kwargs: Any,
) -> praw.models.Submission:
"""Submit a draft.
:param flair_id: The flair template to select (default: ``None``).
:param flair_text: If the template's ``flair_text_editable`` value is ``True``,
this value will set a custom text (default: ``None``). ``flair_id`` is
required when ``flair_text`` is provided.
:param nsfw: Whether or not the submission should be marked NSFW (default:
``None``).
:param selftext: The Markdown formatted content for a ``text`` submission. Use
an empty string, ``""``, to make a title-only submission (default:
``None``).
:param spoiler: Whether or not the submission should be marked as a spoiler
(default: ``None``).
:param subreddit: The subreddit to submit the draft to. This accepts a subreddit
display name, :class:`.Subreddit` object, or :class:`.UserSubreddit` object.
:param title: The title of the submission (default: ``None``).
:param url: The URL for a ``link`` submission (default: ``None``).
:returns: A :class:`.Submission` object for the newly created submission.
.. note::
Parameters set here will override their respective :class:`.Draft`
attributes.
Additional keyword arguments are passed to the :meth:`.Subreddit.submit` method.
For example, to submit a draft as is:
.. code-block:: python
draft = reddit.drafts("5f87d55c-e4fb-11eb-8965-6aeb41b0880e")
submission = draft.submit()
For example, to submit a draft but use a different title than what is set:
.. code-block:: python
draft = reddit.drafts("5f87d55c-e4fb-11eb-8965-6aeb41b0880e")
submission = draft.submit(title="New Title")
.. seealso::
- :meth:`~.Subreddit.submit` to submit url posts and selftexts
- :meth:`~.Subreddit.submit_gallery`. to submit more than one image in the
same post
- :meth:`~.Subreddit.submit_image` to submit images
- :meth:`~.Subreddit.submit_poll` to submit polls
- :meth:`~.Subreddit.submit_video` to submit videos and videogifs
"""
submit_kwargs["draft_id"] = self.id
if not (self.subreddit or subreddit):
msg = "'subreddit' must be set on the Draft instance or passed as a keyword argument."
raise ValueError(msg)
for key, attribute in [
("flair_id", flair_id),
("flair_text", flair_text),
("nsfw", nsfw),
("selftext", selftext),
("spoiler", spoiler),
("title", title),
("url", url),
]:
value = attribute or getattr(self, key, None)
if value is not None:
submit_kwargs[key] = value
if isinstance(subreddit, str):
_subreddit = self._reddit.subreddit(subreddit)
elif isinstance(subreddit, (Subreddit, UserSubreddit)):
_subreddit = subreddit
else:
_subreddit = self.subreddit
return _subreddit.submit(**submit_kwargs)
def update(
self,
*,
flair_id: str | None = None,
flair_text: str | None = None,
is_public_link: bool | None = None,
nsfw: bool | None = None,
original_content: bool | None = None,
selftext: str | None = None,
send_replies: bool | None = None,
spoiler: bool | None = None,
subreddit: (
str | praw.models.Subreddit | praw.models.UserSubreddit | None
) = None,
title: str | None = None,
url: str | None = None,
**draft_kwargs: Any,
):
"""Update the :class:`.Draft`.
.. note::
Only provided values will be updated.
:param flair_id: The flair template to select.
:param flair_text: If the template's ``flair_text_editable`` value is ``True``,
this value will set a custom text. ``flair_id`` is required when
``flair_text`` is provided.
:param is_public_link: Whether to enable public viewing of the draft before it
is submitted.
:param nsfw: Whether the draft should be marked NSFW.
:param original_content: Whether the submission should be marked as original
content.
:param selftext: The Markdown formatted content for a text submission draft. Use
``None`` to make a title-only submission draft. ``selftext`` can not be
provided if ``url`` is provided.
:param send_replies: When ``True``, messages will be sent to the submission
author when comments are made to the submission.
:param spoiler: Whether the submission should be marked as a spoiler.
:param subreddit: The subreddit to create the draft for. This accepts a
subreddit display name, :class:`.Subreddit` object, or
:class:`.UserSubreddit` object.
:param title: The title of the draft.
:param url: The URL for a ``link`` submission draft. ``url`` can not be provided
if ``selftext`` is provided.
Additional keyword arguments can be provided to handle new parameters as Reddit
introduces them.
For example, to update the title of a draft do:
.. code-block:: python
draft = reddit.drafts("5f87d55c-e4fb-11eb-8965-6aeb41b0880e")
draft.update(title="New title")
"""
if isinstance(subreddit, str):
subreddit = self._reddit.subreddit(subreddit)
data = self._prepare_data(
flair_id=flair_id,
flair_text=flair_text,
is_public_link=is_public_link,
nsfw=nsfw,
original_content=original_content,
selftext=selftext,
send_replies=send_replies,
spoiler=spoiler,
subreddit=subreddit,
title=title,
url=url,
**draft_kwargs,
)
data["id"] = self.id
_new_draft = self._reddit.put(API_PATH["draft"], data=data)
_new_draft._fetch()
self.__dict__.update(_new_draft.__dict__)

View File

@@ -0,0 +1,246 @@
"""Provide the Emoji class."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
from ...const import API_PATH
from ...exceptions import ClientException
from ...util import _deprecate_args
from .base import RedditBase
if TYPE_CHECKING: # pragma: no cover
import praw
class Emoji(RedditBase):
"""An individual :class:`.Emoji` object.
.. include:: ../../typical_attributes.rst
====================== =================================================
Attribute Description
====================== =================================================
``mod_flair_only`` Whether the emoji is restricted for mod use only.
``name`` The name of the emoji.
``post_flair_allowed`` Whether the emoji may appear in post flair.
``url`` The URL of the emoji image.
``user_flair_allowed`` Whether the emoji may appear in user flair.
====================== =================================================
"""
STR_FIELD = "name"
def __eq__(self, other: str | Emoji) -> bool:
"""Return whether the other instance equals the current."""
if isinstance(other, str):
return other == str(self)
if isinstance(other, self.__class__):
return str(self) == str(other) and other.subreddit == self.subreddit
return super().__eq__(other)
def __hash__(self) -> int:
"""Return the hash of the current instance."""
return hash(self.__class__.__name__) ^ hash(str(self)) ^ hash(self.subreddit)
def __init__(
self,
reddit: praw.Reddit,
subreddit: praw.models.Subreddit,
name: str,
_data: dict[str, Any] | None = None,
):
"""Initialize an :class:`.Emoji` instance."""
self.name = name
self.subreddit = subreddit
super().__init__(reddit, _data=_data)
def _fetch(self):
for emoji in self.subreddit.emoji:
if emoji.name == self.name:
self.__dict__.update(emoji.__dict__)
super()._fetch()
return
msg = f"r/{self.subreddit} does not have the emoji {self.name}"
raise ClientException(msg)
def delete(self):
"""Delete an emoji from this subreddit by :class:`.Emoji`.
To delete ``"emoji"`` as an emoji on r/test try:
.. code-block:: python
reddit.subreddit("test").emoji["emoji"].delete()
"""
url = API_PATH["emoji_delete"].format(
emoji_name=self.name, subreddit=self.subreddit
)
self._reddit.delete(url)
@_deprecate_args("mod_flair_only", "post_flair_allowed", "user_flair_allowed")
def update(
self,
*,
mod_flair_only: bool | None = None,
post_flair_allowed: bool | None = None,
user_flair_allowed: bool | None = None,
):
"""Update the permissions of an emoji in this subreddit.
:param mod_flair_only: Indicate whether the emoji is restricted to mod use only.
Respects pre-existing settings if not provided.
:param post_flair_allowed: Indicate whether the emoji may appear in post flair.
Respects pre-existing settings if not provided.
:param user_flair_allowed: Indicate whether the emoji may appear in user flair.
Respects pre-existing settings if not provided.
.. note::
In order to retain pre-existing values for those that are not explicitly
passed, a network request is issued. To avoid that network request,
explicitly provide all values.
To restrict the emoji ``"emoji"`` in r/test to mod use only, try:
.. code-block:: python
reddit.subreddit("test").emoji["emoji"].update(mod_flair_only=True)
"""
locals_reference = locals()
mapping = {
attribute: locals_reference[attribute]
for attribute in (
"mod_flair_only",
"post_flair_allowed",
"user_flair_allowed",
)
}
if all(value is None for value in mapping.values()):
msg = "At least one attribute must be provided"
raise TypeError(msg)
data = {"name": self.name}
for attribute, value in mapping.items():
if value is None:
value = getattr(self, attribute) # noqa: PLW2901
data[attribute] = value
url = API_PATH["emoji_update"].format(subreddit=self.subreddit)
self._reddit.post(url, data=data)
for attribute, value in data.items():
setattr(self, attribute, value)
class SubredditEmoji:
"""Provides a set of functions to a :class:`.Subreddit` for emoji."""
def __getitem__(self, name: str) -> Emoji:
"""Lazily return the :class:`.Emoji` for the subreddit named ``name``.
:param name: The name of the emoji.
This method is to be used to fetch a specific emoji url, like so:
.. code-block:: python
emoji = reddit.subreddit("test").emoji["emoji"]
print(emoji)
"""
return Emoji(self._reddit, self.subreddit, name)
def __init__(self, subreddit: praw.models.Subreddit):
"""Initialize a :class:`.SubredditEmoji` instance.
:param subreddit: The subreddit whose emoji are affected.
"""
self.subreddit = subreddit
self._reddit = subreddit._reddit
def __iter__(self) -> list[Emoji]:
"""Return a list of :class:`.Emoji` for the subreddit.
This method is to be used to discover all emoji for a subreddit:
.. code-block:: python
for emoji in reddit.subreddit("test").emoji:
print(emoji)
"""
response = self._reddit.get(
API_PATH["emoji_list"].format(subreddit=self.subreddit)
)
subreddit_keys = [
key
for key in response
if key.startswith(self._reddit.config.kinds["subreddit"])
]
assert len(subreddit_keys) == 1
for emoji_name, emoji_data in response[subreddit_keys[0]].items():
yield Emoji(self._reddit, self.subreddit, emoji_name, _data=emoji_data)
def add(
self,
*,
image_path: str,
mod_flair_only: bool | None = None,
name: str,
post_flair_allowed: bool | None = None,
user_flair_allowed: bool | None = None,
) -> Emoji:
"""Add an emoji to this subreddit.
:param image_path: A path to a jpeg or png image.
:param mod_flair_only: When provided, indicate whether the emoji is restricted
to mod use only (default: ``None``).
:param name: The name of the emoji.
:param post_flair_allowed: When provided, indicate whether the emoji may appear
in post flair (default: ``None``).
:param user_flair_allowed: When provided, indicate whether the emoji may appear
in user flair (default: ``None``).
:returns: The :class:`.Emoji` added.
To add ``"emoji"`` to r/test try:
.. code-block:: python
reddit.subreddit("test").emoji.add(name="emoji", image_path="emoji.png")
"""
file = Path(image_path)
data = {
"filepath": file.name,
"mimetype": "image/jpeg",
}
if image_path.lower().endswith(".png"):
data["mimetype"] = "image/png"
url = API_PATH["emoji_lease"].format(subreddit=self.subreddit)
# until we learn otherwise, assume this request always succeeds
upload_lease = self._reddit.post(url, data=data)["s3UploadLease"]
upload_data = {item["name"]: item["value"] for item in upload_lease["fields"]}
upload_url = f"https:{upload_lease['action']}"
with file.open("rb") as image:
response = self._reddit._core._requestor._http.post(
upload_url, data=upload_data, files={"file": image}
)
response.raise_for_status()
data = {
"mod_flair_only": mod_flair_only,
"name": name,
"post_flair_allowed": post_flair_allowed,
"s3_key": upload_data["key"],
"user_flair_allowed": user_flair_allowed,
}
url = API_PATH["emoji_upload"].format(subreddit=self.subreddit)
self._reddit.post(url, data=data)
return Emoji(self._reddit, self.subreddit, name)

View File

@@ -0,0 +1,56 @@
"""Provide classes related to inline media."""
from __future__ import annotations
from ..util import _deprecate_args
class InlineMedia:
"""Provides a way to embed media in self posts."""
TYPE = None
def __eq__(self, other: InlineMedia) -> bool:
"""Return whether the other instance equals the current."""
return all(
getattr(self, attr) == getattr(other, attr)
for attr in ["TYPE", "path", "caption", "media_id"]
)
@_deprecate_args("path", "caption")
def __init__(self, *, caption: str = None, path: str):
"""Initialize an :class:`.InlineMedia` instance.
:param caption: An optional caption to add to the image (default: ``None``).
:param path: The path to a media file.
"""
self.path = path
self.caption = caption
self.media_id = None
def __repr__(self) -> str:
"""Return an object initialization representation of the instance."""
return f"<{self.__class__.__name__} caption={self.caption!r}>"
def __str__(self) -> str:
"""Return a string representation of the media in Markdown format."""
return f'\n\n![{self.TYPE}]({self.media_id} "{self.caption if self.caption else ""}")\n\n'
class InlineGif(InlineMedia):
"""Class to provide a gif to embed in text."""
TYPE = "gif"
class InlineImage(InlineMedia):
"""Class to provide am image to embed in text."""
TYPE = "img"
class InlineVideo(InlineMedia):
"""Class to provide a video to embed in text."""
TYPE = "video"

View File

@@ -0,0 +1,804 @@
"""Provide the LiveThread class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Iterable, Iterator
from ...const import API_PATH
from ...util import _deprecate_args
from ...util.cache import cachedproperty
from ..list.redditor import RedditorList
from ..listing.generator import ListingGenerator
from ..util import stream_generator
from .base import RedditBase
from .mixins import FullnameMixin
from .redditor import Redditor
if TYPE_CHECKING: # pragma: no cover
import praw.models
class LiveContributorRelationship:
"""Provide methods to interact with live threads' contributors."""
@staticmethod
def _handle_permissions(permissions: Iterable[str]) -> str:
permissions = {"all"} if permissions is None else set(permissions)
return ",".join(f"+{x}" for x in permissions)
def __call__(self) -> list[praw.models.Redditor]:
"""Return a :class:`.RedditorList` for live threads' contributors.
Usage:
.. code-block:: python
thread = reddit.live("ukaeu1ik4sw5")
for contributor in thread.contributor():
print(contributor)
"""
url = API_PATH["live_contributors"].format(id=self.thread.id)
temp = self.thread._reddit.get(url)
return temp if isinstance(temp, RedditorList) else temp[0]
def __init__(self, thread: praw.models.LiveThread):
"""Initialize a :class:`.LiveContributorRelationship` instance.
:param thread: An instance of :class:`.LiveThread`.
.. note::
This class should not be initialized directly. Instead, obtain an instance
via: :meth:`.LiveThread.contributor`.
"""
self.thread = thread
def accept_invite(self):
"""Accept an invite to contribute the live thread.
Usage:
.. code-block:: python
thread = reddit.live("ydwwxneu7vsa")
thread.contributor.accept_invite()
"""
url = API_PATH["live_accept_invite"].format(id=self.thread.id)
self.thread._reddit.post(url)
@_deprecate_args("redditor", "permissions")
def invite(
self,
redditor: str | praw.models.Redditor,
*,
permissions: list[str] | None = None,
):
"""Invite a redditor to be a contributor of the live thread.
:param redditor: A redditor name or :class:`.Redditor` instance.
:param permissions: When provided (not ``None``), permissions should be a list
of strings specifying which subset of permissions to grant. An empty list
``[]`` indicates no permissions, and when not provided (``None``), indicates
full permissions.
:raises: :class:`.RedditAPIException` if the invitation already exists.
Usage:
.. code-block:: python
thread = reddit.live("ukaeu1ik4sw5")
redditor = reddit.redditor("spez")
# "manage" and "settings" permissions
thread.contributor.invite(redditor, permissions=["manage", "settings"])
.. seealso::
:meth:`.LiveContributorRelationship.remove_invite` to remove the invite for
redditor.
"""
url = API_PATH["live_invite"].format(id=self.thread.id)
data = {
"name": str(redditor),
"type": "liveupdate_contributor_invite",
"permissions": self._handle_permissions(permissions),
}
self.thread._reddit.post(url, data=data)
def leave(self):
"""Abdicate the live thread contributor position (use with care).
Usage:
.. code-block:: python
thread = reddit.live("ydwwxneu7vsa")
thread.contributor.leave()
"""
url = API_PATH["live_leave"].format(id=self.thread.id)
self.thread._reddit.post(url)
def remove(self, redditor: str | praw.models.Redditor):
"""Remove the redditor from the live thread contributors.
:param redditor: A redditor fullname (e.g., ``"t2_1w72"``) or :class:`.Redditor`
instance.
Usage:
.. code-block:: python
thread = reddit.live("ukaeu1ik4sw5")
redditor = reddit.redditor("spez")
thread.contributor.remove(redditor)
thread.contributor.remove("t2_1w72") # with fullname
"""
fullname = redditor.fullname if isinstance(redditor, Redditor) else redditor
data = {"id": fullname}
url = API_PATH["live_remove_contrib"].format(id=self.thread.id)
self.thread._reddit.post(url, data=data)
def remove_invite(self, redditor: str | praw.models.Redditor):
"""Remove the invite for redditor.
:param redditor: A redditor fullname (e.g., ``"t2_1w72"``) or :class:`.Redditor`
instance.
Usage:
.. code-block:: python
thread = reddit.live("ukaeu1ik4sw5")
redditor = reddit.redditor("spez")
thread.contributor.remove_invite(redditor)
thread.contributor.remove_invite("t2_1w72") # with fullname
.. seealso::
:meth:`.LiveContributorRelationship.invite` to invite a redditor to be a
contributor of the live thread.
"""
fullname = redditor.fullname if isinstance(redditor, Redditor) else redditor
data = {"id": fullname}
url = API_PATH["live_remove_invite"].format(id=self.thread.id)
self.thread._reddit.post(url, data=data)
@_deprecate_args("redditor", "permissions")
def update(
self,
redditor: str | praw.models.Redditor,
*,
permissions: list[str] | None = None,
):
"""Update the contributor permissions for ``redditor``.
:param redditor: A redditor name or :class:`.Redditor` instance.
:param permissions: When provided (not ``None``), permissions should be a list
of strings specifying which subset of permissions to grant (other
permissions are removed). An empty list ``[]`` indicates no permissions, and
when not provided (``None``), indicates full permissions.
For example, to grant all permissions to the contributor, try:
.. code-block:: python
thread = reddit.live("ukaeu1ik4sw5")
thread.contributor.update("spez")
To grant ``"access"`` and ``"edit"`` permissions (and to remove other
permissions), try:
.. code-block:: python
thread.contributor.update("spez", permissions=["access", "edit"])
To remove all permissions from the contributor, try:
.. code-block:: python
subreddit.moderator.update("spez", permissions=[])
"""
url = API_PATH["live_update_perms"].format(id=self.thread.id)
data = {
"name": str(redditor),
"type": "liveupdate_contributor",
"permissions": self._handle_permissions(permissions),
}
self.thread._reddit.post(url, data=data)
@_deprecate_args("redditor", "permissions")
def update_invite(
self,
redditor: str | praw.models.Redditor,
*,
permissions: list[str] | None = None,
):
"""Update the contributor invite permissions for ``redditor``.
:param redditor: A redditor name or :class:`.Redditor` instance.
:param permissions: When provided (not ``None``), permissions should be a list
of strings specifying which subset of permissions to grant (other
permissions are removed). An empty list ``[]`` indicates no permissions, and
when not provided (``None``), indicates full permissions.
For example, to set all permissions to the invitation, try:
.. code-block:: python
thread = reddit.live("ukaeu1ik4sw5")
thread.contributor.update_invite("spez")
To set ``"access"`` and ``"edit"`` permissions (and to remove other permissions)
to the invitation, try:
.. code-block:: python
thread.contributor.update_invite("spez", permissions=["access", "edit"])
To remove all permissions from the invitation, try:
.. code-block:: python
thread.contributor.update_invite("spez", permissions=[])
"""
url = API_PATH["live_update_perms"].format(id=self.thread.id)
data = {
"name": str(redditor),
"type": "liveupdate_contributor_invite",
"permissions": self._handle_permissions(permissions),
}
self.thread._reddit.post(url, data=data)
class LiveThread(RedditBase):
"""An individual :class:`.LiveThread` object.
.. include:: ../../typical_attributes.rst
==================== =========================================================
Attribute Description
==================== =========================================================
``created_utc`` The creation time of the live thread, in `Unix Time`_.
``description`` Description of the live thread, as Markdown.
``description_html`` Description of the live thread, as HTML.
``id`` The ID of the live thread.
``nsfw`` A ``bool`` representing whether or not the live thread is
marked as NSFW.
==================== =========================================================
.. _unix time: https://en.wikipedia.org/wiki/Unix_time
"""
STR_FIELD = "id"
@cachedproperty
def contrib(self) -> praw.models.reddit.live.LiveThreadContribution:
"""Provide an instance of :class:`.LiveThreadContribution`.
Usage:
.. code-block:: python
thread = reddit.live("ukaeu1ik4sw5")
thread.contrib.add("### update")
"""
return LiveThreadContribution(self)
@cachedproperty
def contributor(self) -> praw.models.reddit.live.LiveContributorRelationship:
"""Provide an instance of :class:`.LiveContributorRelationship`.
You can call the instance to get a list of contributors which is represented as
:class:`.RedditorList` instance consists of :class:`.Redditor` instances. Those
:class:`.Redditor` instances have ``permissions`` attributes as contributors:
.. code-block:: python
thread = reddit.live("ukaeu1ik4sw5")
for contributor in thread.contributor():
# prints `Redditor(name="Acidtwist") ["all"]`
print(contributor, contributor.permissions)
"""
return LiveContributorRelationship(self)
@cachedproperty
def stream(self) -> praw.models.reddit.live.LiveThreadStream:
"""Provide an instance of :class:`.LiveThreadStream`.
Streams are used to indefinitely retrieve new updates made to a live thread,
like:
.. code-block:: python
for live_update in reddit.live("ta535s1hq2je").stream.updates():
print(live_update.body)
Updates are yielded oldest first as :class:`.LiveUpdate`. Up to 100 historical
updates will initially be returned. To only retrieve new updates starting from
when the stream is created, pass ``skip_existing=True``:
.. code-block:: python
live_thread = reddit.live("ta535s1hq2je")
for live_update in live_thread.stream.updates(skip_existing=True):
print(live_update.author)
"""
return LiveThreadStream(self)
def __eq__(self, other: str | praw.models.LiveThread) -> bool:
"""Return whether the other instance equals the current.
.. note::
This comparison is case sensitive.
"""
if isinstance(other, str):
return other == str(self)
return isinstance(other, self.__class__) and str(self) == str(other)
def __getitem__(self, update_id: str) -> praw.models.LiveUpdate:
"""Return a lazy :class:`.LiveUpdate` instance.
:param update_id: A live update ID, e.g.,
``"7827987a-c998-11e4-a0b9-22000b6a88d2"``.
Usage:
.. code-block:: python
thread = reddit.live("ukaeu1ik4sw5")
update = thread["7827987a-c998-11e4-a0b9-22000b6a88d2"]
update.thread # LiveThread(id="ukaeu1ik4sw5")
update.id # "7827987a-c998-11e4-a0b9-22000b6a88d2"
update.author # "umbrae"
"""
return LiveUpdate(self._reddit, self.id, update_id)
def __hash__(self) -> int:
"""Return the hash of the current instance."""
return hash(self.__class__.__name__) ^ hash(str(self))
def __init__(
self,
reddit: praw.Reddit,
id: str | None = None,
_data: dict[str, Any] | None = None,
):
"""Initialize a :class:`.LiveThread` instance.
:param reddit: An instance of :class:`.Reddit`.
:param id: A live thread ID, e.g., ``"ukaeu1ik4sw5"``
"""
if (id, _data).count(None) != 1:
msg = "Either 'id' or '_data' must be provided."
raise TypeError(msg)
if id:
self.id = id
super().__init__(reddit, _data=_data)
def _fetch(self):
data = self._fetch_data()
data = data["data"]
other = type(self)(self._reddit, _data=data)
self.__dict__.update(other.__dict__)
super()._fetch()
def _fetch_info(self):
return "liveabout", {"id": self.id}, None
def discussions(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Submission]:
"""Get submissions linking to the thread.
:param generator_kwargs: keyword arguments passed to :class:`.ListingGenerator`
constructor.
:returns: A :class:`.ListingGenerator` object which yields :class:`.Submission`
objects.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
Usage:
.. code-block:: python
thread = reddit.live("ukaeu1ik4sw5")
for submission in thread.discussions(limit=None):
print(submission.title)
"""
url = API_PATH["live_discussions"].format(id=self.id)
return ListingGenerator(self._reddit, url, **generator_kwargs)
def report(self, type: str):
"""Report the thread violating the Reddit rules.
:param type: One of ``"spam"``, ``"vote-manipulation"``,
``"personal-information"``, ``"sexualizing-minors"``, or
``"site-breaking"``.
Usage:
.. code-block:: python
thread = reddit.live("xyu8kmjvfrww")
thread.report("spam")
"""
url = API_PATH["live_report"].format(id=self.id)
self._reddit.post(url, data={"type": type})
def updates(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.LiveUpdate]:
"""Return a :class:`.ListingGenerator` yields :class:`.LiveUpdate` s.
:param generator_kwargs: keyword arguments passed to :class:`.ListingGenerator`
constructor.
:returns: A :class:`.ListingGenerator` object which yields :class:`.LiveUpdate`
objects.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
Usage:
.. code-block:: python
thread = reddit.live("ukaeu1ik4sw5")
after = "LiveUpdate_fefb3dae-7534-11e6-b259-0ef8c7233633"
for submission in thread.updates(limit=5, params={"after": after}):
print(submission.body)
"""
url = API_PATH["live_updates"].format(id=self.id)
for update in ListingGenerator(self._reddit, url, **generator_kwargs):
update._thread = self
yield update
class LiveThreadContribution:
"""Provides a set of contribution functions to a :class:`.LiveThread`."""
def __init__(self, thread: praw.models.LiveThread):
"""Initialize a :class:`.LiveThreadContribution` instance.
:param thread: An instance of :class:`.LiveThread`.
This instance can be retrieved through ``thread.contrib`` where thread is a
:class:`.LiveThread` instance. E.g.,
.. code-block:: python
thread = reddit.live("ukaeu1ik4sw5")
thread.contrib.add("### update")
"""
self.thread = thread
def add(self, body: str):
"""Add an update to the live thread.
:param body: The Markdown formatted content for the update.
Usage:
.. code-block:: python
thread = reddit.live("ydwwxneu7vsa")
thread.contrib.add("test `LiveThreadContribution.add()`")
"""
url = API_PATH["live_add_update"].format(id=self.thread.id)
self.thread._reddit.post(url, data={"body": body})
def close(self):
"""Close the live thread permanently (cannot be undone).
Usage:
.. code-block:: python
thread = reddit.live("ukaeu1ik4sw5")
thread.contrib.close()
"""
url = API_PATH["live_close"].format(id=self.thread.id)
self.thread._reddit.post(url)
@_deprecate_args("title", "description", "nsfw", "resources")
def update(
self,
*,
description: str | None = None,
nsfw: bool | None = None,
resources: str | None = None,
title: str | None = None,
**other_settings: str | None,
):
"""Update settings of the live thread.
:param description: The live thread's description (default: ``None``).
:param nsfw: Indicate whether this thread is not safe for work (default:
``None``).
:param resources: Markdown formatted information that is useful for the live
thread (default: ``None``).
:param title: The title of the live thread (default: ``None``).
Does nothing if no arguments are provided.
Each setting will maintain its current value if ``None`` is specified.
Additional keyword arguments can be provided to handle new settings as Reddit
introduces them.
Usage:
.. code-block:: python
thread = reddit.live("xyu8kmjvfrww")
# update 'title' and 'nsfw'
updated_thread = thread.contrib.update(title=new_title, nsfw=True)
If Reddit introduces new settings, you must specify ``None`` for the setting you
want to maintain:
.. code-block:: python
# update 'nsfw' and maintain new setting 'foo'
thread.contrib.update(nsfw=True, foo=None)
"""
settings = {
"title": title,
"description": description,
"nsfw": nsfw,
"resources": resources,
}
settings.update(other_settings)
if all(value is None for value in settings.values()):
return
# get settings from Reddit (not cache)
thread = LiveThread(self.thread._reddit, self.thread.id)
data = {
key: getattr(thread, key) if value is None else value
for key, value in settings.items()
}
url = API_PATH["live_update_thread"].format(id=self.thread.id)
# prawcore (0.7.0) Session.request() modifies `data` kwarg
self.thread._reddit.post(url, data=data.copy())
self.thread._reset_attributes(*data.keys())
class LiveThreadStream:
"""Provides a :class:`.LiveThread` stream.
Usually used via:
.. code-block:: python
for live_update in reddit.live("ta535s1hq2je").stream.updates():
print(live_update.body)
"""
def __init__(self, live_thread: praw.models.LiveThread):
"""Initialize a :class:`.LiveThreadStream` instance.
:param live_thread: The live thread associated with the stream.
"""
self.live_thread = live_thread
def updates(
self, **stream_options: dict[str, Any]
) -> Iterator[praw.models.LiveUpdate]:
"""Yield new updates to the live thread as they become available.
:param skip_existing: Set to ``True`` to only fetch items created after the
stream (default: ``False``).
As with :meth:`.LiveThread.updates()`, updates are yielded as
:class:`.LiveUpdate`.
Updates are yielded oldest first. Up to 100 historical updates will initially be
returned.
Keyword arguments are passed to :func:`.stream_generator`.
For example, to retrieve all new updates made to the ``"ta535s1hq2je"`` live
thread, try:
.. code-block:: python
for live_update in reddit.live("ta535s1hq2je").stream.updates():
print(live_update.body)
To only retrieve new updates starting from when the stream is created, pass
``skip_existing=True``:
.. code-block:: python
live_thread = reddit.live("ta535s1hq2je")
for live_update in live_thread.stream.updates(skip_existing=True):
print(live_update.author)
"""
return stream_generator(self.live_thread.updates, **stream_options)
class LiveUpdateContribution:
"""Provides a set of contribution functions to :class:`.LiveUpdate`."""
def __init__(self, update: praw.models.LiveUpdate):
"""Initialize a :class:`.LiveUpdateContribution` instance.
:param update: An instance of :class:`.LiveUpdate`.
This instance can be retrieved through ``update.contrib`` where update is a
:class:`.LiveUpdate` instance. E.g.,
.. code-block:: python
thread = reddit.live("ukaeu1ik4sw5")
update = thread["7827987a-c998-11e4-a0b9-22000b6a88d2"]
update.contrib # LiveUpdateContribution instance
update.contrib.remove()
"""
self.update = update
def remove(self):
"""Remove a live update.
Usage:
.. code-block:: python
thread = reddit.live("ydwwxneu7vsa")
update = thread["6854605a-efec-11e6-b0c7-0eafac4ff094"]
update.contrib.remove()
"""
url = API_PATH["live_remove_update"].format(id=self.update.thread.id)
data = {"id": self.update.fullname}
self.update.thread._reddit.post(url, data=data)
def strike(self):
"""Strike a content of a live update.
.. code-block:: python
thread = reddit.live("xyu8kmjvfrww")
update = thread["cb5fe532-dbee-11e6-9a91-0e6d74fabcc4"]
update.contrib.strike()
To check whether the update is stricken or not, use ``update.stricken``
attribute.
.. note::
Accessing lazy attributes on updates (includes ``update.stricken``) may
raise :py:class:`AttributeError`. See :class:`.LiveUpdate` for details.
"""
url = API_PATH["live_strike"].format(id=self.update.thread.id)
data = {"id": self.update.fullname}
self.update.thread._reddit.post(url, data=data)
class LiveUpdate(FullnameMixin, RedditBase):
"""An individual :class:`.LiveUpdate` object.
.. include:: ../../typical_attributes.rst
=============== ===================================================================
Attribute Description
=============== ===================================================================
``author`` The :class:`.Redditor` who made the update.
``body`` Body of the update, as Markdown.
``body_html`` Body of the update, as HTML.
``created_utc`` The time the update was created, as `Unix Time`_.
``stricken`` A ``bool`` representing whether or not the update was stricken (see
:meth:`.strike`).
=============== ===================================================================
.. _unix time: https://en.wikipedia.org/wiki/Unix_time
"""
STR_FIELD = "id"
_kind = "LiveUpdate"
@cachedproperty
def contrib(self) -> praw.models.reddit.live.LiveUpdateContribution:
"""Provide an instance of :class:`.LiveUpdateContribution`.
Usage:
.. code-block:: python
thread = reddit.live("ukaeu1ik4sw5")
update = thread["7827987a-c998-11e4-a0b9-22000b6a88d2"]
update.contrib # LiveUpdateContribution instance
"""
return LiveUpdateContribution(self)
@property
def thread(self) -> LiveThread:
"""Return :class:`.LiveThread` object the update object belongs to."""
return self._thread
def __init__(
self,
reddit: praw.Reddit,
thread_id: str | None = None,
update_id: str | None = None,
_data: dict[str, Any] | None = None,
):
"""Initialize a :class:`.LiveUpdate` instance.
Either ``thread_id`` and ``update_id``, or ``_data`` must be provided.
:param reddit: An instance of :class:`.Reddit`.
:param thread_id: A live thread ID, e.g., ``"ukaeu1ik4sw5"``.
:param update_id: A live update ID, e.g.,
``"7827987a-c998-11e4-a0b9-22000b6a88d2"``.
Usage:
.. code-block:: python
update = LiveUpdate(reddit, "ukaeu1ik4sw5", "7827987a-c998-11e4-a0b9-22000b6a88d2")
update.thread # LiveThread(id="ukaeu1ik4sw5")
update.id # "7827987a-c998-11e4-a0b9-22000b6a88d2"
update.author # "umbrae"
"""
if _data is not None:
# Since _data (part of JSON returned from reddit) have no thread ID,
# self._thread must be set by the caller of LiveUpdate(). See the code of
# LiveThread.updates() for example.
super().__init__(reddit, _data=_data, _fetched=True)
elif thread_id and update_id:
self.id = update_id
super().__init__(reddit, _data=None)
self._thread = LiveThread(self._reddit, thread_id)
else:
msg = "Either 'thread_id' and 'update_id', or '_data' must be provided."
raise TypeError(msg)
def __setattr__(self, attribute: str, value: Any):
"""Objectify author."""
if attribute == "author":
value = Redditor(self._reddit, name=value)
super().__setattr__(attribute, value)
def _fetch(self):
url = API_PATH["live_focus"].format(thread_id=self.thread.id, update_id=self.id)
other = self._reddit.get(url)[0]
self.__dict__.update(other.__dict__)
super()._fetch()

View File

@@ -0,0 +1,175 @@
"""Provide the Message class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ...const import API_PATH
from .base import RedditBase
from .mixins import FullnameMixin, InboxableMixin, ReplyableMixin
from .redditor import Redditor
from .subreddit import Subreddit
if TYPE_CHECKING: # pragma: no cover
import praw.models
class Message(InboxableMixin, ReplyableMixin, FullnameMixin, RedditBase):
"""A class for private messages.
.. include:: ../../typical_attributes.rst
=============== ================================================================
Attribute Description
=============== ================================================================
``author`` Provides an instance of :class:`.Redditor`.
``body`` The body of the message, as Markdown.
``body_html`` The body of the message, as HTML.
``created_utc`` Time the message was created, represented in `Unix Time`_.
``dest`` Provides an instance of :class:`.Redditor`. The recipient of the
message.
``id`` The ID of the message.
``name`` The full ID of the message, prefixed with ``t4_``.
``subject`` The subject of the message.
``was_comment`` Whether or not the message was a comment reply.
=============== ================================================================
.. _unix time: https://en.wikipedia.org/wiki/Unix_time
"""
STR_FIELD = "id"
@classmethod
def parse(
cls, data: dict[str, Any], reddit: praw.Reddit
) -> Message | SubredditMessage:
"""Return an instance of :class:`.Message` or :class:`.SubredditMessage` from ``data``.
:param data: The structured data.
:param reddit: An instance of :class:`.Reddit`.
"""
if data["author"]:
data["author"] = Redditor(reddit, data["author"])
if data["dest"].startswith("#"):
data["dest"] = Subreddit(reddit, data["dest"][1:])
else:
data["dest"] = Redditor(reddit, data["dest"])
if data["replies"]:
replies = data["replies"]
data["replies"] = reddit._objector.objectify(replies["data"]["children"])
else:
data["replies"] = []
if data["subreddit"]:
data["subreddit"] = Subreddit(reddit, data["subreddit"])
return SubredditMessage(reddit, _data=data)
return cls(reddit, _data=data)
@property
def _kind(self) -> str:
"""Return the class's kind."""
return self._reddit.config.kinds["message"]
@property
def parent(self) -> praw.models.Message | None:
"""Return the parent of the message if it exists."""
if not self._parent and self.parent_id:
self._parent = self._reddit.inbox.message(self.parent_id.split("_")[1])
return self._parent
@parent.setter
def parent(self, value: praw.models.Message | None):
self._parent = value
def __init__(self, reddit: praw.Reddit, _data: dict[str, Any]):
"""Initialize a :class:`.Message` instance."""
super().__init__(reddit, _data=_data, _fetched=True)
self._parent = None
for reply in _data.get("replies", []):
if reply.parent_id == self.fullname:
reply.parent = self
def delete(self):
"""Delete the message.
.. note::
Reddit does not return an indication of whether or not the message was
successfully deleted.
For example, to delete the most recent message in your inbox:
.. code-block:: python
next(reddit.inbox.all()).delete()
"""
self._reddit.post(API_PATH["delete_message"], data={"id": self.fullname})
class SubredditMessage(Message):
"""A class for messages to a subreddit.
.. include:: ../../typical_attributes.rst
=============== =================================================================
Attribute Description
=============== =================================================================
``author`` Provides an instance of :class:`.Redditor`.
``body`` The body of the message, as Markdown.
``body_html`` The body of the message, as HTML.
``created_utc`` Time the message was created, represented in `Unix Time`_.
``dest`` Provides an instance of :class:`.Redditor`. The recipient of the
message.
``id`` The ID of the message.
``name`` The full ID of the message, prefixed with ``t4_``.
``subject`` The subject of the message.
``subreddit`` If the message was sent from a subreddit, provides an instance of
:class:`.Subreddit`.
``was_comment`` Whether or not the message was a comment reply.
=============== =================================================================
.. _unix time: https://en.wikipedia.org/wiki/Unix_time
"""
def mute(self):
"""Mute the sender of this :class:`.SubredditMessage`.
For example, to mute the sender of the first :class:`.SubredditMessage` in the
authenticated users' inbox:
.. code-block:: python
from praw.models import SubredditMessage
msg = next(
message for message in reddit.inbox.all() if isinstance(message, SubredditMessage)
)
msg.mute()
"""
self._reddit.post(API_PATH["mute_sender"], data={"id": self.fullname})
def unmute(self):
"""Unmute the sender of this :class:`.SubredditMessage`.
For example, to unmute the sender of the first :class:`.SubredditMessage` in the
authenticated users' inbox:
.. code-block:: python
from praw.models import SubredditMessage
msg = next(
message for message in reddit.inbox.all() if isinstance(message, SubredditMessage)
)
msg.unmute()
"""
self._reddit.post(API_PATH["unmute_sender"], data={"id": self.fullname})

View File

@@ -0,0 +1,322 @@
"""Package providing reddit class mixins."""
from __future__ import annotations
from json import dumps
from typing import TYPE_CHECKING, Optional
from ....const import API_PATH
from ....util import _deprecate_args
from .editable import EditableMixin
from .fullname import FullnameMixin
from .gildable import GildableMixin
from .inboxable import InboxableMixin
from .inboxtoggleable import InboxToggleableMixin
from .messageable import MessageableMixin
from .modnote import ModNoteMixin
from .replyable import ReplyableMixin
from .reportable import ReportableMixin
from .savable import SavableMixin
from .votable import VotableMixin
if TYPE_CHECKING: # pragma: no cover
import praw.models
class ThingModerationMixin(ModNoteMixin):
r"""Provides moderation methods for :class:`.Comment`\ s and :class:`.Submission`\ s."""
REMOVAL_MESSAGE_API = None
def _add_removal_reason(self, *, mod_note: str = "", reason_id: str | None = None):
"""Add a removal reason for a :class:`.Comment` or :class:`.Submission`.
:param mod_note: A message for the other moderators.
:param reason_id: The removal reason ID.
It is necessary to first call :meth:`.remove` on the :class:`.Comment` or
:class:`.Submission`.
If ``reason_id`` is not specified, ``mod_note`` cannot be blank.
"""
if not reason_id and not mod_note:
msg = "mod_note cannot be blank if reason_id is not specified"
raise ValueError(msg)
# Only the first element of the item_id list is used.
data = {
"item_ids": [self.thing.fullname],
"mod_note": mod_note,
"reason_id": reason_id,
}
self.thing._reddit.post(API_PATH["removal_reasons"], data={"json": dumps(data)})
def approve(self):
"""Approve a :class:`.Comment` or :class:`.Submission`.
Approving a comment or submission reverts a removal, resets the report counter,
adds a green check mark indicator (only visible to other moderators) on the
website view, and sets the ``approved_by`` attribute to the authenticated user.
Example usage:
.. code-block:: python
# approve a comment:
comment = reddit.comment("dkk4qjd")
comment.mod.approve()
# approve a submission:
submission = reddit.submission("5or86n")
submission.mod.approve()
"""
self.thing._reddit.post(API_PATH["approve"], data={"id": self.thing.fullname})
@_deprecate_args("how", "sticky")
def distinguish(self, *, how: str = "yes", sticky: bool = False):
"""Distinguish a :class:`.Comment` or :class:`.Submission`.
:param how: One of ``"yes"``, ``"no"``, ``"admin"``, or ``"special"``. ``"yes"``
adds a moderator level distinguish. ``"no"`` removes any distinction.
``"admin"`` and ``"special"`` require special user privileges to use
(default ``"yes"``).
:param sticky: :class:`.Comment` is stickied if ``True``, placing it at the top
of the comment page regardless of score. If thing is not a top-level
comment, this parameter is silently ignored (default ``False``).
Example usage:
.. code-block:: python
# distinguish and sticky a comment:
comment = reddit.comment("dkk4qjd")
comment.mod.distinguish(sticky=True)
# undistinguish a submission:
submission = reddit.submission("5or86n")
submission.mod.distinguish(how="no")
.. seealso::
:meth:`.undistinguish`
"""
data = {"how": how, "id": self.thing.fullname}
if sticky and getattr(self.thing, "is_root", False):
data["sticky"] = True
self.thing._reddit.post(API_PATH["distinguish"], data=data)
def ignore_reports(self):
"""Ignore future reports on a :class:`.Comment` or :class:`.Submission`.
Calling this method will prevent future reports on this :class:`.Comment` or
:class:`.Submission` from both triggering notifications and appearing in the
various moderation listings. The report count will still increment on the
:class:`.Comment` or :class:`.Submission`.
Example usage:
.. code-block:: python
# ignore future reports on a comment:
comment = reddit.comment("dkk4qjd")
comment.mod.ignore_reports()
# ignore future reports on a submission:
submission = reddit.submission("5or86n")
submission.mod.ignore_reports()
.. seealso::
:meth:`.unignore_reports`
"""
self.thing._reddit.post(
API_PATH["ignore_reports"], data={"id": self.thing.fullname}
)
def lock(self):
"""Lock a :class:`.Comment` or :class:`.Submission`.
Example usage:
.. code-block:: python
# lock a comment:
comment = reddit.comment("dkk4qjd")
comment.mod.lock()
# lock a submission:
submission = reddit.submission("5or86n")
submission.mod.lock()
.. seealso::
:meth:`.unlock`
"""
self.thing._reddit.post(API_PATH["lock"], data={"id": self.thing.fullname})
@_deprecate_args("spam", "mod_note", "reason_id")
def remove(
self, *, mod_note: str = "", spam: bool = False, reason_id: str | None = None
):
"""Remove a :class:`.Comment` or :class:`.Submission`.
:param mod_note: A message for the other moderators.
:param spam: When ``True``, use the removal to help train the
:class:`.Subreddit`'s spam filter (default: ``False``).
:param reason_id: The removal reason ID.
If either ``reason_id`` or ``mod_note`` are provided, a second API call is made
to add the removal reason.
Example usage:
.. code-block:: python
# remove a comment and mark as spam:
comment = reddit.comment("dkk4qjd")
comment.mod.remove(spam=True)
# remove a submission
submission = reddit.submission("5or86n")
submission.mod.remove()
# remove a submission with a removal reason
reason = reddit.subreddit.mod.removal_reasons["110ni21zo23ql"]
submission = reddit.submission("5or86n")
submission.mod.remove(reason_id=reason.id)
"""
data = {"id": self.thing.fullname, "spam": bool(spam)}
self.thing._reddit.post(API_PATH["remove"], data=data)
if any([reason_id, mod_note]):
self._add_removal_reason(mod_note=mod_note, reason_id=reason_id)
@_deprecate_args("message", "title", "type")
def send_removal_message(
self,
*,
message: str,
title: str = "ignored",
type: str = "public",
) -> praw.models.Comment | None:
"""Send a removal message for a :class:`.Comment` or :class:`.Submission`.
.. warning::
The object has to be removed before giving it a removal reason. Remove the
object with :meth:`.remove`. Trying to add a removal reason without removing
the object will result in :class:`.RedditAPIException` being thrown with an
``INVALID_ID`` error_type.
Reddit adds human-readable information about the object to the message.
:param type: One of ``"public"``, ``"public_as_subreddit"``, ``"private"``, or
``"private_exposed"``. ``"public"`` leaves a stickied comment on the post.
``"public_as_subreddit"`` leaves a stickied comment on the post with the
u/subreddit-ModTeam account. ``"private"`` sends a modmail message with
hidden username. ``"private_exposed"`` sends a modmail message without
hidden username (default: ``"public"``).
:param title: The short reason given in the message. Ignored if type is
``"public"`` or ``"public_as_subreddit"``.
:param message: The body of the message.
:returns: The new :class:`.Comment` if ``type`` is ``"public"`` or
``"public_as_subreddit"``.
"""
# The API endpoint used to send removal messages is different for posts and
# comments, so the derived classes specify which one.
if self.REMOVAL_MESSAGE_API is None:
msg = "ThingModerationMixin must be extended."
raise NotImplementedError(msg)
url = API_PATH[self.REMOVAL_MESSAGE_API]
# Only the first element of the item_id list is used.
data = {
"item_id": [self.thing.fullname],
"message": message,
"title": title,
"type": type,
}
return self.thing._reddit.post(url, data={"json": dumps(data)}) or None
def undistinguish(self):
"""Remove mod, admin, or special distinguishing from an object.
Also unstickies the object if applicable.
Example usage:
.. code-block:: python
# undistinguish a comment:
comment = reddit.comment("dkk4qjd")
comment.mod.undistinguish()
# undistinguish a submission:
submission = reddit.submission("5or86n")
submission.mod.undistinguish()
.. seealso::
:meth:`.distinguish`
"""
self.distinguish(how="no")
def unignore_reports(self):
"""Resume receiving future reports on a :class:`.Comment` or :class:`.Submission`.
Future reports on this :class:`.Comment` or :class:`.Submission` will cause
notifications, and appear in the various moderation listings.
Example usage:
.. code-block:: python
# accept future reports on a comment:
comment = reddit.comment("dkk4qjd")
comment.mod.unignore_reports()
# accept future reports on a submission:
submission = reddit.submission("5or86n")
submission.mod.unignore_reports()
.. seealso::
:meth:`.ignore_reports`
"""
self.thing._reddit.post(
API_PATH["unignore_reports"], data={"id": self.thing.fullname}
)
def unlock(self):
"""Unlock a :class:`.Comment` or :class:`.Submission`.
Example usage:
.. code-block:: python
# unlock a comment:
comment = reddit.comment("dkk4qjd")
comment.mod.unlock()
# unlock a submission:
submission = reddit.submission("5or86n")
submission.mod.unlock()
.. seealso::
:meth:`.lock`
"""
self.thing._reddit.post(API_PATH["unlock"], data={"id": self.thing.fullname})
class UserContentMixin(
EditableMixin,
GildableMixin,
InboxToggleableMixin,
ReplyableMixin,
ReportableMixin,
SavableMixin,
VotableMixin,
):
"""A convenience mixin that applies to both Comments and Submissions."""

View File

@@ -0,0 +1,67 @@
"""Provide the EditableMixin class."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ....const import API_PATH
if TYPE_CHECKING: # pragma: no cover
import praw.models
class EditableMixin:
"""Interface for classes that can be edited and deleted."""
def delete(self):
"""Delete the object.
Example usage:
.. code-block:: python
comment = reddit.comment("dkk4qjd")
comment.delete()
submission = reddit.submission("8dmv8z")
submission.delete()
"""
self._reddit.post(API_PATH["del"], data={"id": self.fullname})
def edit(self, body: str) -> praw.models.Comment | praw.models.Submission:
"""Replace the body of the object with ``body``.
:param body: The Markdown formatted content for the updated object.
:returns: The current instance after updating its attributes.
Example usage:
.. code-block:: python
comment = reddit.comment("dkk4qjd")
# construct the text of an edited comment
# by appending to the old body:
edited_body = comment.body + "Edit: thanks for the gold!"
comment.edit(edited_body)
"""
data = {
"text": body,
"thing_id": self.fullname,
"validate_on_submit": self._reddit.validate_on_submit,
}
updated = self._reddit.post(API_PATH["edit"], data=data)[0]
for attribute in [
"_fetched",
"_reddit",
"_submission",
"replies",
"subreddit",
]:
if attribute in updated.__dict__:
delattr(updated, attribute)
self.__dict__.update(updated.__dict__)
return self

View File

@@ -0,0 +1,19 @@
"""Provide the FullnameMixin class."""
class FullnameMixin:
"""Interface for classes that have a fullname."""
_kind = None
@property
def fullname(self) -> str:
"""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``.
"""
if "_" in self.id:
return self.id
return f"{self._kind}_{self.id}"

View File

@@ -0,0 +1,118 @@
"""Provide the GildableMixin class."""
from warnings import warn
from ....const import API_PATH
from ....util import _deprecate_args
class GildableMixin:
"""Interface for classes that can be gilded."""
@_deprecate_args("gild_type", "is_anonymous", "message")
def award(
self,
*,
gild_type: str = "gid_2",
is_anonymous: bool = True,
message: str = None,
) -> dict:
"""Award the author of the item.
:param gild_type: Type of award to give. See table below for currently know
global award types.
:param is_anonymous: If ``True``, the authenticated user's username will not be
revealed to the recipient.
:param message: Message to include with the award.
:returns: A dict containing info similar to what is shown below:
.. code-block:: python
{
"subreddit_balance": 85260,
"treatment_tags": [],
"coins": 8760,
"gildings": {"gid_1": 0, "gid_2": 1, "gid_3": 0},
"awarder_karma_received": 4,
"all_awardings": [
{
"giver_coin_reward": 0,
"subreddit_id": None,
"is_new": False,
"days_of_drip_extension": 0,
"coin_price": 75,
"id": "award_9663243a-e77f-44cf-abc6-850ead2cd18d",
"penny_donate": 0,
"coin_reward": 0,
"icon_url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_512.png",
"days_of_premium": 0,
"icon_height": 512,
"tiers_by_required_awardings": None,
"icon_width": 512,
"static_icon_width": 512,
"start_date": None,
"is_enabled": True,
"awardings_required_to_grant_benefits": None,
"description": "For an especially amazing showing.",
"end_date": None,
"subreddit_coin_reward": 0,
"count": 1,
"static_icon_height": 512,
"name": "Bravo Grande!",
"icon_format": "APNG",
"award_sub_type": "PREMIUM",
"penny_price": 0,
"award_type": "global",
"static_icon_url": "https://i.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png",
}
],
}
.. warning::
Requires the authenticated user to own Reddit Coins. Calling this method
will consume Reddit Coins.
To award the gold award anonymously do:
.. code-block:: python
comment = reddit.comment("dkk4qjd")
comment.award()
submission = reddit.submission("8dmv8z")
submission.award()
To award the platinum award with the message 'Nice!' and reveal your username to
the recipient do:
.. code-block:: python
comment = reddit.comment("dkk4qjd")
comment.award(gild_type="gild_3", message="Nice!", is_anonymous=False)
submission = reddit.submission("8dmv8z")
submission.award(gild_type="gild_3", message="Nice!", is_anonymous=False)
.. include:: awards.txt
"""
params = {
"api_type": "json",
"gild_type": gild_type,
"is_anonymous": is_anonymous,
"thing_id": self.fullname,
"message": message,
}
return self._reddit.post(API_PATH["award_thing"], params=params)
def gild(self) -> dict:
"""Alias for :meth:`.award` to maintain backwards compatibility."""
warn(
"'.gild' has been renamed to '.award'.",
category=DeprecationWarning,
stacklevel=2,
)
return self.award()

View File

@@ -0,0 +1,153 @@
"""Provide the InboxableMixin class."""
from ....const import API_PATH
class InboxableMixin:
"""Interface for :class:`.RedditBase` subclasses that originate from the inbox."""
def block(self):
"""Block the user who sent the item.
.. note::
This method pertains only to objects which were retrieved via the inbox.
Example usage:
.. code-block:: python
comment = reddit.comment("dkk4qjd")
comment.block()
# or, identically:
comment.author.block()
"""
self._reddit.post(API_PATH["block"], data={"id": self.fullname})
def collapse(self):
"""Mark the item as collapsed.
.. note::
This method pertains only to objects which were retrieved via the inbox.
Example usage:
.. code-block:: python
inbox = reddit.inbox()
# select first inbox item and collapse it message = next(inbox)
message.collapse()
.. seealso::
:meth:`.uncollapse`
"""
self._reddit.inbox.collapse([self])
def mark_read(self):
"""Mark a single inbox item as read.
.. note::
This method pertains only to objects which were retrieved via the inbox.
Example usage:
.. code-block:: python
inbox = reddit.inbox.unread()
for message in inbox:
# process unread messages
...
.. seealso::
:meth:`.mark_unread`
To mark the whole inbox as read with a single network request, use
:meth:`.Inbox.mark_all_read`
"""
self._reddit.inbox.mark_read([self])
def mark_unread(self):
"""Mark the item as unread.
.. note::
This method pertains only to objects which were retrieved via the inbox.
Example usage:
.. code-block:: python
inbox = reddit.inbox(limit=10)
for message in inbox:
# process messages
...
.. seealso::
:meth:`.mark_read`
"""
self._reddit.inbox.mark_unread([self])
def unblock_subreddit(self):
"""Unblock a subreddit.
.. note::
This method pertains only to objects which were retrieved via the inbox.
For example, to unblock all blocked subreddits that you can find by going
through your inbox:
.. code-block:: python
from praw.models import SubredditMessage
subs = set()
for item in reddit.inbox.messages(limit=None):
if isinstance(item, SubredditMessage):
if (
item.subject == "[message from blocked subreddit]"
and str(item.subreddit) not in subs
):
item.unblock_subreddit()
subs.add(str(item.subreddit))
"""
self._reddit.post(API_PATH["unblock_subreddit"], data={"id": self.fullname})
def uncollapse(self):
"""Mark the item as uncollapsed.
.. note::
This method pertains only to objects which were retrieved via the inbox.
Example usage:
.. code-block:: python
inbox = reddit.inbox()
# select first inbox item and uncollapse it
message = next(inbox)
message.uncollapse()
.. seealso::
:meth:`.collapse`
"""
self._reddit.inbox.uncollapse([self])

View File

@@ -0,0 +1,59 @@
"""Provide the InboxToggleableMixin class."""
from ....const import API_PATH
class InboxToggleableMixin:
"""Interface for classes that can optionally receive inbox replies."""
def disable_inbox_replies(self):
"""Disable inbox replies for the item.
.. note::
This can only apply to items created by the authenticated user.
Example usage:
.. code-block:: python
comment = reddit.comment("dkk4qjd")
comment.disable_inbox_replies()
submission = reddit.submission("8dmv8z")
submission.disable_inbox_replies()
.. seealso::
:meth:`.enable_inbox_replies`
"""
self._reddit.post(
API_PATH["sendreplies"], data={"id": self.fullname, "state": False}
)
def enable_inbox_replies(self):
"""Enable inbox replies for the item.
.. note::
This can only apply to items created by the authenticated user.
Example usage:
.. code-block:: python
comment = reddit.comment("dkk4qjd")
comment.enable_inbox_replies()
submission = reddit.submission("8dmv8z")
submission.enable_inbox_replies()
.. seealso::
:meth:`.disable_inbox_replies`
"""
self._reddit.post(
API_PATH["sendreplies"], data={"id": self.fullname, "state": True}
)

View File

@@ -0,0 +1,67 @@
"""Provide the MessageableMixin class."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ....const import API_PATH
from ....util import _deprecate_args
if TYPE_CHECKING: # pragma: no cover
import praw
class MessageableMixin:
"""Interface for classes that can be messaged."""
@_deprecate_args("subject", "message", "from_subreddit")
def message(
self,
*,
from_subreddit: praw.models.Subreddit | str | None = None,
message: str,
subject: str,
):
"""Send a message to a :class:`.Redditor` or a :class:`.Subreddit`'s moderators (modmail).
:param from_subreddit: A :class:`.Subreddit` instance or string to send the
message from. When provided, messages are sent from the subreddit rather
than from the authenticated user.
.. note::
The authenticated user must be a moderator of the subreddit and have the
``mail`` moderator permission.
:param message: The message content.
:param subject: The subject of the message.
For example, to send a private message to u/spez, try:
.. code-block:: python
reddit.redditor("spez").message(subject="TEST", message="test message from PRAW")
To send a message to u/spez from the moderators of r/test try:
.. code-block:: python
reddit.redditor("spez").message(
subject="TEST", message="test message from r/test", from_subreddit="test"
)
To send a message to the moderators of r/test, try:
.. code-block:: python
reddit.subreddit("test").message(subject="TEST", message="test PM from PRAW")
"""
data = {
"subject": subject,
"text": message,
"to": f"{getattr(self.__class__, 'MESSAGE_PREFIX', '')}{self}",
}
if from_subreddit:
data["from_sr"] = str(from_subreddit)
self._reddit.post(API_PATH["compose"], data=data)

View File

@@ -0,0 +1,61 @@
"""Provide the ModNoteMixin class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Generator
if TYPE_CHECKING: # pragma: no cover
import praw.models
class ModNoteMixin:
"""Interface for classes that can have a moderator note set on them."""
def author_notes(
self, **generator_kwargs: Any
) -> Generator[praw.models.ModNote, None, None]:
"""Get the moderator notes for the author of this object in the subreddit it's posted in.
:param generator_kwargs: Additional keyword arguments are passed in the
initialization of the moderator note generator.
:returns: A generator of :class:`.ModNote`.
For example, to list all notes the author of a submission, try:
.. code-block:: python
for note in reddit.submission("92dd8").mod.author_notes():
print(f"{note.label}: {note.note}")
"""
return self.thing.subreddit.mod.notes.redditors(
self.thing.author, **generator_kwargs
)
def create_note(
self, *, label: str | None = None, note: str, **other_settings: Any
) -> praw.models.ModNote:
"""Create a moderator note on the author of this object in the subreddit it's posted in.
:param label: The label for the note. As of this writing, this can be one of the
following: ``"ABUSE_WARNING"``, ``"BAN"``, ``"BOT_BAN"``,
``"HELPFUL_USER"``, ``"PERMA_BAN"``, ``"SOLID_CONTRIBUTOR"``,
``"SPAM_WARNING"``, ``"SPAM_WATCH"``, or ``None`` (default: ``None``).
:param note: The content of the note. As of this writing, this is limited to 250
characters.
:param other_settings: Additional keyword arguments are passed to
:meth:`~.BaseModNotes.create`.
:returns: The new :class:`.ModNote` object.
For example, to create a note on a :class:`.Submission`, try:
.. code-block:: python
reddit.submission("92dd8").mod.create_note(label="HELPFUL_USER", note="Test note")
"""
return self.thing.subreddit.mod.notes.create(
label=label, note=note, thing=self.thing, **other_settings
)

View File

@@ -0,0 +1,48 @@
"""Provide the ReplyableMixin class."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ....const import API_PATH
if TYPE_CHECKING: # pragma: no cover
import praw.models
class ReplyableMixin:
"""Interface for :class:`.RedditBase` classes that can be replied to."""
def reply(self, body: str) -> praw.models.Comment | praw.models.Message | None:
"""Reply to the object.
:param body: The Markdown formatted content for a comment.
:returns: A :class:`.Comment` or :class:`.Message` object for the newly created
comment or message or ``None`` if Reddit doesn't provide one.
:raises: ``prawcore.exceptions.Forbidden`` when attempting to reply to some
items, such as locked submissions/comments or non-replyable messages.
A ``None`` value can be returned if the target is a comment or submission in a
quarantined subreddit and the authenticated user has not opt-ed into viewing the
content. When this happens the comment will be successfully created on Reddit
and can be retried by drawing the comment from the user's comment history.
Example usage:
.. code-block:: python
submission = reddit.submission("5or86n")
submission.reply("reply")
comment = reddit.comment("dxolpyc")
comment.reply("reply")
"""
data = {"text": body, "thing_id": self.fullname}
comments = self._reddit.post(API_PATH["comment"], data=data)
try:
return comments[0]
except IndexError:
return None

View File

@@ -0,0 +1,30 @@
"""Provide the ReportableMixin class."""
from ....const import API_PATH
class ReportableMixin:
"""Interface for :class:`.RedditBase` classes that can be reported."""
def report(self, reason: str):
"""Report this object to the moderators of its subreddit.
:param reason: The reason for reporting.
:raises: :class:`.RedditAPIException` if ``reason`` is longer than 100
characters.
Example usage:
.. code-block:: python
submission = reddit.submission("5or86n")
submission.report("report reason")
comment = reddit.comment("dxolpyc")
comment.report("report reason")
"""
self._reddit.post(
API_PATH["report"], data={"id": self.fullname, "reason": reason}
)

View File

@@ -0,0 +1,56 @@
"""Provide the SavableMixin class."""
from __future__ import annotations
from ....const import API_PATH
from ....util import _deprecate_args
class SavableMixin:
"""Interface for :class:`.RedditBase` classes that can be saved."""
@_deprecate_args("category")
def save(self, *, category: str | None = None):
"""Save the object.
:param category: The category to save to. If the authenticated user does not
have Reddit Premium this value is ignored by Reddit (default: ``None``).
Example usage:
.. code-block:: python
submission = reddit.submission("5or86n")
submission.save(category="view later")
comment = reddit.comment("dxolpyc")
comment.save()
.. seealso::
:meth:`.unsave`
"""
self._reddit.post(
API_PATH["save"], data={"category": category, "id": self.fullname}
)
def unsave(self):
"""Unsave the object.
Example usage:
.. code-block:: python
submission = reddit.submission("5or86n")
submission.unsave()
comment = reddit.comment("dxolpyc")
comment.unsave()
.. seealso::
:meth:`.save`
"""
self._reddit.post(API_PATH["unsave"], data={"id": self.fullname})

View File

@@ -0,0 +1,94 @@
"""Provide the VotableMixin class."""
from __future__ import annotations
from ....const import API_PATH
class VotableMixin:
"""Interface for :class:`.RedditBase` classes that can be voted on."""
def _vote(self, direction: int):
self._reddit.post(
API_PATH["vote"], data={"dir": str(direction), "id": self.fullname}
)
def clear_vote(self):
"""Clear the authenticated user's vote on the object.
.. 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 manipulation. [`Ref
<https://www.reddit.com/dev/api#POST_api_vote>`_]
Example usage:
.. code-block:: python
submission = reddit.submission("5or86n")
submission.clear_vote()
comment = reddit.comment("dxolpyc")
comment.clear_vote()
"""
self._vote(direction=0)
def downvote(self):
"""Downvote the object.
.. 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 manipulation. [`Ref
<https://www.reddit.com/dev/api#POST_api_vote>`_]
Example usage:
.. code-block:: python
submission = reddit.submission("5or86n")
submission.downvote()
comment = reddit.comment("dxolpyc")
comment.downvote()
.. seealso::
:meth:`.upvote`
"""
self._vote(direction=-1)
def upvote(self):
"""Upvote the object.
.. 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 manipulation. [`Ref
<https://www.reddit.com/dev/api#POST_api_vote>`_]
Example usage:
.. code-block:: python
submission = reddit.submission("5or86n")
submission.upvote()
comment = reddit.comment("dxolpyc")
comment.upvote()
.. seealso::
:meth:`.downvote`
"""
self._vote(direction=1)

View File

@@ -0,0 +1,344 @@
"""Provide models for new modmail."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ...const import API_PATH
from ...util import _deprecate_args, snake_case_keys
from .base import RedditBase
if TYPE_CHECKING: # pragma: no cover
import praw
class ModmailObject(RedditBase):
"""A base class for objects within a modmail conversation."""
AUTHOR_ATTRIBUTE = "author"
STR_FIELD = "id"
def __setattr__(self, attribute: str, value: Any):
"""Objectify the AUTHOR_ATTRIBUTE attribute."""
if attribute == self.AUTHOR_ATTRIBUTE:
value = self._reddit._objector.objectify(value)
super().__setattr__(attribute, value)
class ModmailConversation(RedditBase):
"""A class for modmail conversations.
.. include:: ../../typical_attributes.rst
==================== ===============================================================
Attribute Description
==================== ===============================================================
``authors`` Provides an ordered list of :class:`.Redditor` instances. The
authors of each message in the modmail conversation.
``id`` The ID of the :class:`.ModmailConversation`.
``is_highlighted`` Whether or not the :class:`.ModmailConversation` is
highlighted.
``is_internal`` Whether or not the :class:`.ModmailConversation` is a private
mod conversation.
``last_mod_update`` Time of the last mod message reply, represented in the `ISO
8601`_ standard with timezone.
``last_updated`` Time of the last message reply, represented in the `ISO 8601`_
standard with timezone.
``last_user_update`` Time of the last user message reply, represented in the `ISO
8601`_ standard with timezone.
``num_messages`` The number of messages in the :class:`.ModmailConversation`.
``obj_ids`` Provides a list of dictionaries representing mod actions on the
:class:`.ModmailConversation`. Each dict contains attributes of
``"key"`` and ``"id"``. The key can be either ``""messages"``
or ``"ModAction"``. ``"ModAction"`` represents
archiving/highlighting etc.
``owner`` Provides an instance of :class:`.Subreddit`. The subreddit that
the :class:`.ModmailConversation` belongs to.
``participant`` Provides an instance of :class:`.Redditor`. The participating
user in the :class:`.ModmailConversation`.
``subject`` The subject of the :class:`.ModmailConversation`.
==================== ===============================================================
.. _iso 8601: https://en.wikipedia.org/wiki/ISO_8601
"""
STR_FIELD = "id"
@staticmethod
def _convert_conversation_objects(data: dict[str, Any], reddit: praw.Reddit):
"""Convert messages and mod actions to PRAW objects."""
result = {"messages": [], "modActions": []}
for thing in data["objIds"]:
key = thing["key"]
thing_data = data[key][thing["id"]]
result[key].append(reddit._objector.objectify(thing_data))
data.update(result)
@staticmethod
def _convert_user_summary(data: dict[str, Any], reddit: praw.Reddit):
"""Convert dictionaries of recent user history to PRAW objects."""
parsers = {
"recentComments": reddit._objector.parsers[reddit.config.kinds["comment"]],
"recentConvos": ModmailConversation,
"recentPosts": reddit._objector.parsers[reddit.config.kinds["submission"]],
}
for kind, parser in parsers.items():
objects = []
for thing_id, summary in data[kind].items():
thing = parser(reddit, id=thing_id.rsplit("_", 1)[-1])
if parser is not ModmailConversation:
del summary["permalink"]
for key, value in summary.items():
setattr(thing, key, value)
objects.append(thing)
# Sort by id, oldest to newest
data[kind] = sorted(objects, key=lambda x: int(x.id, base=36), reverse=True)
@classmethod
def parse(
cls,
data: dict[str, Any],
reddit: praw.Reddit,
) -> ModmailConversation:
"""Return an instance of :class:`.ModmailConversation` from ``data``.
:param data: The structured data.
:param reddit: An instance of :class:`.Reddit`.
"""
data["authors"] = [
reddit._objector.objectify(author) for author in data["authors"]
]
for entity in "owner", "participant":
data[entity] = reddit._objector.objectify(data[entity])
if data.get("user"):
cls._convert_user_summary(data["user"], reddit)
data["user"] = reddit._objector.objectify(data["user"])
data = snake_case_keys(data)
return cls(reddit, _data=data)
def __init__(
self,
reddit: praw.Reddit,
id: str | None = None,
mark_read: bool = False,
_data: dict[str, Any] | None = None,
):
"""Initialize a :class:`.ModmailConversation` instance.
:param mark_read: If ``True``, conversation is marked as read (default:
``False``).
"""
if bool(id) == bool(_data):
msg = "Either 'id' or '_data' must be provided."
raise TypeError(msg)
if id:
self.id = id
super().__init__(reddit, _data=_data)
self._info_params = {"markRead": True} if mark_read else None
def _build_conversation_list(
self, other_conversations: list[ModmailConversation]
) -> str:
"""Return a comma-separated list of conversation IDs."""
conversations = [self] + (other_conversations or [])
return ",".join(conversation.id for conversation in conversations)
def _fetch(self):
data = self._fetch_data()
other = self._reddit._objector.objectify(data)
self.__dict__.update(other.__dict__)
super()._fetch()
def _fetch_info(self):
return "modmail_conversation", {"id": self.id}, self._info_params
def archive(self):
"""Archive the conversation.
For example:
.. code-block:: python
reddit.subreddit("test").modmail("2gmz").archive()
"""
self._reddit.post(API_PATH["modmail_archive"].format(id=self.id))
def highlight(self):
"""Highlight the conversation.
For example:
.. code-block:: python
reddit.subreddit("test").modmail("2gmz").highlight()
"""
self._reddit.post(API_PATH["modmail_highlight"].format(id=self.id))
@_deprecate_args("num_days")
def mute(self, *, num_days: int = 3):
"""Mute the non-mod user associated with the conversation.
:param num_days: Duration of mute in days. Valid options are ``3``, ``7``, or
``28`` (default: ``3``).
For example:
.. code-block:: python
reddit.subreddit("test").modmail("2gmz").mute()
To mute for 7 days:
.. code-block:: python
reddit.subreddit("test").modmail("2gmz").mute(num_days=7)
"""
params = {"num_hours": num_days * 24} if num_days != 3 else {}
self._reddit.request(
method="POST",
params=params,
path=API_PATH["modmail_mute"].format(id=self.id),
)
@_deprecate_args("other_conversations")
def read(self, *, other_conversations: list[ModmailConversation] | None = None):
"""Mark the conversation(s) as read.
:param other_conversations: A list of other conversations to mark (default:
``None``).
For example, to mark the conversation as read along with other recent
conversations from the same user:
.. code-block:: python
subreddit = reddit.subreddit("test")
conversation = subreddit.modmail.conversation("2gmz")
conversation.read(other_conversations=conversation.user.recent_convos)
"""
data = {"conversationIds": self._build_conversation_list(other_conversations)}
self._reddit.post(API_PATH["modmail_read"], data=data)
@_deprecate_args("body", "author_hidden", "internal")
def reply(
self, *, author_hidden: bool = False, body: str, internal: bool = False
) -> ModmailMessage:
"""Reply to the conversation.
:param author_hidden: When ``True``, author is hidden from non-moderators
(default: ``False``).
:param body: The Markdown formatted content for a message.
:param internal: When ``True``, message is a private moderator note, hidden from
non-moderators (default: ``False``).
:returns: A :class:`.ModmailMessage` object for the newly created message.
For example, to reply to the non-mod user while hiding your username:
.. code-block:: python
conversation = reddit.subreddit("test").modmail("2gmz")
conversation.reply(body="Message body", author_hidden=True)
To create a private moderator note on the conversation:
.. code-block:: python
conversation.reply(body="Message body", internal=True)
"""
data = {
"body": body,
"isAuthorHidden": author_hidden,
"isInternal": internal,
}
response = self._reddit.post(
API_PATH["modmail_conversation"].format(id=self.id), data=data
)
if isinstance(response, dict):
# Reddit recently changed the response format, so we need to handle both in case they change it back
message_id = response["conversation"]["objIds"][-1]["id"]
message_data = response["messages"][message_id]
return self._reddit._objector.objectify(message_data)
for message in response.messages: # noqa: RET503
if message.id == response.obj_ids[-1]["id"]:
return message
def unarchive(self):
"""Unarchive the conversation.
For example:
.. code-block:: python
reddit.subreddit("test").modmail("2gmz").unarchive()
"""
self._reddit.post(API_PATH["modmail_unarchive"].format(id=self.id))
def unhighlight(self):
"""Un-highlight the conversation.
For example:
.. code-block:: python
reddit.subreddit("test").modmail("2gmz").unhighlight()
"""
self._reddit.delete(API_PATH["modmail_highlight"].format(id=self.id))
def unmute(self):
"""Unmute the non-mod user associated with the conversation.
For example:
.. code-block:: python
reddit.subreddit("test").modmail("2gmz").unmute()
"""
self._reddit.request(
method="POST", path=API_PATH["modmail_unmute"].format(id=self.id)
)
@_deprecate_args("other_conversations")
def unread(self, *, other_conversations: list[ModmailConversation] | None = None):
"""Mark the conversation(s) as unread.
:param other_conversations: A list of other conversations to mark (default:
``None``).
For example, to mark the conversation as unread along with other recent
conversations from the same user:
.. code-block:: python
subreddit = reddit.subreddit("test")
conversation = subreddit.modmail.conversation("2gmz")
conversation.unread(other_conversations=conversation.user.recent_convos)
"""
data = {"conversationIds": self._build_conversation_list(other_conversations)}
self._reddit.post(API_PATH["modmail_unread"], data=data)
class ModmailAction(ModmailObject):
"""A class for moderator actions on modmail conversations."""
class ModmailMessage(ModmailObject):
"""A class for modmail messages."""

View File

@@ -0,0 +1,83 @@
"""Provide the MoreComments class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ...const import API_PATH
from ...util import _deprecate_args
from ..base import PRAWBase
if TYPE_CHECKING: # pragma: no cover
import praw.models
class MoreComments(PRAWBase):
"""A class indicating there are more comments."""
def __eq__(self, other: str | MoreComments) -> bool:
"""Return ``True`` if these :class:`.MoreComments` instances are the same."""
if isinstance(other, self.__class__):
return self.count == other.count and self.children == other.children
return super().__eq__(other)
def __init__(self, reddit: praw.Reddit, _data: dict[str, Any]):
"""Initialize a :class:`.MoreComments` instance."""
self.count = self.parent_id = None
self.children = []
super().__init__(reddit, _data=_data)
self._comments = None
self.submission = None
def __lt__(self, other: MoreComments) -> bool:
"""Provide a sort order on the :class:`.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.
return self.count > other.count
def __repr__(self) -> str:
"""Return an object initialization representation of the instance."""
children = self.children[:4]
if len(self.children) > 4:
children[-1] = "..."
return f"<{self.__class__.__name__} count={self.count}, children={children!r}>"
def _continue_comments(self, update: bool):
assert not self.children, "Please file a bug report with PRAW."
parent = self._load_comment(self.parent_id.split("_", 1)[1])
self._comments = parent.replies
if update:
for comment in self._comments:
comment.submission = self.submission
return self._comments
def _load_comment(self, comment_id: str):
path = f"{API_PATH['submission'].format(id=self.submission.id)}_/{comment_id}"
_, comments = self._reddit.get(
path,
params={
"limit": self.submission.comment_limit,
"sort": self.submission.comment_sort,
},
)
assert len(comments.children) == 1, "Please file a bug report with PRAW."
return comments.children[0]
@_deprecate_args("update")
def comments(self, *, update: bool = True) -> list[praw.models.Comment]:
"""Fetch and return the comments for a single :class:`.MoreComments` object."""
if self._comments is None:
if self.count == 0: # Handle "continue this thread"
return self._continue_comments(update)
assert self.children, "Please file a bug report with PRAW."
data = {
"children": ",".join(self.children),
"link_id": self.submission.fullname,
"sort": self.submission.comment_sort,
}
self._comments = self._reddit.post(API_PATH["morechildren"], data=data)
if update:
for comment in self._comments:
comment.submission = self.submission
return self._comments

View File

@@ -0,0 +1,240 @@
"""Provide the Multireddit class."""
from __future__ import annotations
import re
from json import dumps
from typing import TYPE_CHECKING, Any
from ...const import API_PATH
from ...util import _deprecate_args, cachedproperty
from ..listing.mixins import SubredditListingMixin
from .base import RedditBase
from .redditor import Redditor
from .subreddit import Subreddit, SubredditStream
if TYPE_CHECKING: # pragma: no cover
import praw.models
class Multireddit(SubredditListingMixin, RedditBase):
r"""A class for users' multireddits.
This is referred to as a "Custom Feed" on the Reddit UI.
.. include:: ../../typical_attributes.rst
==================== ==============================================================
Attribute Description
==================== ==============================================================
``can_edit`` A ``bool`` representing whether or not the authenticated user
may edit the multireddit.
``copied_from`` The multireddit that the multireddit was copied from, if it
exists, otherwise ``None``.
``created_utc`` When the multireddit was created, in `Unix Time`_.
``description_html`` The description of the multireddit, as HTML.
``description_md`` The description of the multireddit, as Markdown.
``display_name`` The display name of the multireddit.
``name`` The name of the multireddit.
``over_18`` A ``bool`` representing whether or not the multireddit is
restricted for users over 18.
``subreddits`` A list of :class:`.Subreddit`\ s that make up the multireddit.
``visibility`` The visibility of the multireddit, either ``"private"``,
``"public"``, or ``"hidden"``.
==================== ==============================================================
.. _unix time: https://en.wikipedia.org/wiki/Unix_time
"""
STR_FIELD = "path"
RE_INVALID = re.compile(r"[\W_]+", re.UNICODE)
@staticmethod
def sluggify(title: str) -> str:
"""Return a slug version of the title.
:param title: The title to make a slug of.
Adapted from Reddit's utils.py.
"""
title = Multireddit.RE_INVALID.sub("_", title).strip("_").lower()
if len(title) > 21: # truncate to nearest word
title = title[:21]
last_word = title.rfind("_")
if last_word > 0:
title = title[:last_word]
return title or "_"
@cachedproperty
def stream(self) -> SubredditStream:
"""Provide an instance of :class:`.SubredditStream`.
Streams can be used to indefinitely retrieve new comments made to a multireddit,
like:
.. code-block:: python
for comment in reddit.multireddit(redditor="spez", name="fun").stream.comments():
print(comment)
Additionally, new submissions can be retrieved via the stream. In the following
example all new submissions to the multireddit are fetched:
.. code-block:: python
for submission in reddit.multireddit(
redditor="bboe", name="games"
).stream.submissions():
print(submission)
"""
return SubredditStream(self)
def __init__(self, reddit: praw.Reddit, _data: dict[str, Any]):
"""Initialize a :class:`.Multireddit` instance."""
self.path = None
super().__init__(reddit, _data=_data)
self._author = Redditor(reddit, self.path.split("/", 3)[2])
self._path = API_PATH["multireddit"].format(multi=self.name, user=self._author)
self.path = f"/{self._path[:-1]}" # Prevent requests for path
if "subreddits" in self.__dict__:
self.subreddits = [Subreddit(reddit, x["name"]) for x in self.subreddits]
def _fetch(self):
data = self._fetch_data()
data = data["data"]
other = type(self)(self._reddit, _data=data)
self.__dict__.update(other.__dict__)
super()._fetch()
def _fetch_info(self):
return (
"multireddit_api",
{"multi": self.name, "user": self._author.name},
None,
)
def add(self, subreddit: praw.models.Subreddit):
"""Add a subreddit to this multireddit.
:param subreddit: The subreddit to add to this multi.
For example, to add r/test to multireddit ``bboe/test``:
.. code-block:: python
subreddit = reddit.subreddit("test")
reddit.multireddit(redditor="bboe", name="test").add(subreddit)
"""
url = API_PATH["multireddit_update"].format(
multi=self.name, user=self._author, subreddit=subreddit
)
self._reddit.put(url, data={"model": dumps({"name": str(subreddit)})})
self._reset_attributes("subreddits")
@_deprecate_args("display_name")
def copy(self, *, display_name: str | None = None) -> praw.models.Multireddit:
"""Copy this multireddit and return the new multireddit.
:param display_name: The display name for the copied multireddit. Reddit will
generate the ``name`` field from this display name. When not provided the
copy will use the same display name and name as this multireddit.
To copy the multireddit ``bboe/test`` with a name of ``"testing"``:
.. code-block:: python
reddit.multireddit(redditor="bboe", name="test").copy(display_name="testing")
"""
if display_name:
name = self.sluggify(display_name)
else:
display_name = self.display_name
name = self.name
data = {
"display_name": display_name,
"from": self.path,
"to": API_PATH["multireddit"].format(
multi=name, user=self._reddit.user.me()
),
}
return self._reddit.post(API_PATH["multireddit_copy"], data=data)
def delete(self):
"""Delete this multireddit.
For example, to delete multireddit ``bboe/test``:
.. code-block:: python
reddit.multireddit(redditor="bboe", name="test").delete()
"""
path = API_PATH["multireddit_api"].format(
multi=self.name, user=self._author.name
)
self._reddit.delete(path)
def remove(self, subreddit: praw.models.Subreddit):
"""Remove a subreddit from this multireddit.
:param subreddit: The subreddit to remove from this multi.
For example, to remove r/test from multireddit ``bboe/test``:
.. code-block:: python
subreddit = reddit.subreddit("test")
reddit.multireddit(redditor="bboe", name="test").remove(subreddit)
"""
url = API_PATH["multireddit_update"].format(
multi=self.name, user=self._author, subreddit=subreddit
)
self._reddit.delete(url, data={"model": dumps({"name": str(subreddit)})})
self._reset_attributes("subreddits")
def update(
self,
**updated_settings: str | list[str | praw.models.Subreddit | dict[str, str]],
):
"""Update this multireddit.
Keyword arguments are passed for settings that should be updated. They can any
of:
:param display_name: The display name for this multireddit. Must be no longer
than 50 characters.
:param subreddits: Subreddits for this multireddit.
:param description_md: Description for this multireddit, formatted in Markdown.
:param icon_name: Can be one of: ``"art and design"``, ``"ask"``, ``"books"``,
``"business"``, ``"cars"``, ``"comics"``, ``"cute animals"``, ``"diy"``,
``"entertainment"``, ``"food and drink"``, ``"funny"``, ``"games"``,
``"grooming"``, ``"health"``, ``"life advice"``, ``"military"``, ``"models
pinup"``, ``"music"``, ``"news"``, ``"philosophy"``, ``"pictures and
gifs"``, ``"science"``, ``"shopping"``, ``"sports"``, ``"style"``,
``"tech"``, ``"travel"``, ``"unusual stories"``, ``"video"``, or ``None``.
:param key_color: RGB hex color code of the form ``"#FFFFFF"``.
:param visibility: Can be one of: ``"hidden"``, ``"private"``, or ``"public"``.
:param weighting_scheme: Can be one of: ``"classic"`` or ``"fresh"``.
For example, to rename multireddit ``"bboe/test"`` to ``"bboe/testing"``:
.. code-block:: python
reddit.multireddit(redditor="bboe", name="test").update(display_name="testing")
"""
if "subreddits" in updated_settings:
updated_settings["subreddits"] = [
{"name": str(sub)} for sub in updated_settings["subreddits"]
]
path = API_PATH["multireddit_api"].format(
multi=self.name, user=self._author.name
)
new = self._reddit.put(path, data={"model": dumps(updated_settings)})
self.__dict__.update(new.__dict__)

View File

@@ -0,0 +1,112 @@
"""Provide poll-related classes."""
from __future__ import annotations
from typing import Any
from ...util import cachedproperty
from ..base import PRAWBase
class PollOption(PRAWBase):
"""Class to represent one option of a poll.
If ``submission`` is a poll :class:`.Submission`, access the poll's options like so:
.. code-block:: python
poll_data = submission.poll_data
# By index -- print the first option
print(poll_data.options[0])
# By ID -- print the option with ID "576797"
print(poll_data.option("576797"))
.. include:: ../../typical_attributes.rst
============== =================================================
Attribute Description
============== =================================================
``id`` ID of the poll option.
``text`` The text of the poll option.
``vote_count`` The number of votes the poll option has received.
============== =================================================
"""
def __repr__(self) -> str:
"""Return an object initialization representation of the instance."""
return f"PollOption(id={self.id!r})"
def __str__(self) -> str:
"""Return a string version of the PollData, its text."""
return self.text
class PollData(PRAWBase):
"""Class to represent poll data on a poll submission.
If ``submission`` is a poll :class:`.Submission`, access the poll data like so:
.. code-block:: python
poll_data = submission.poll_data
print(f"There are {poll_data.total_vote_count} votes total.")
print("The options are:")
for option in poll_data.options:
print(f"{option} ({option.vote_count} votes)")
print(f"I voted for {poll_data.user_selection}.")
.. include:: ../../typical_attributes.rst
======================== =========================================================
Attribute Description
======================== =========================================================
``options`` A list of :class:`.PollOption` of the poll.
``total_vote_count`` The total number of votes cast in the poll.
``user_selection`` The poll option selected by the authenticated user
(possibly ``None``).
``voting_end_timestamp`` Time the poll voting closes, represented in `Unix Time`_.
======================== =========================================================
.. _unix time: https://en.wikipedia.org/wiki/Unix_time
"""
@cachedproperty
def user_selection(self) -> PollOption | None:
"""Get the user's selection in this poll, if any.
:returns: The user's selection as a :class:`.PollOption`, or ``None`` if there
is no choice.
"""
if self._user_selection is None:
return None
return self.option(self._user_selection)
def __setattr__(self, attribute: str, value: Any):
"""Objectify the options attribute, and save user_selection."""
if attribute == "options" and isinstance(value, list):
value = [PollOption(self._reddit, option) for option in value]
elif attribute == "user_selection":
attribute = "_user_selection"
super().__setattr__(attribute, value)
def option(self, option_id: str) -> PollOption:
"""Get the option with the specified ID.
:param option_id: The ID of a poll option, as a ``str``.
:returns: The specified :class:`.PollOption`.
:raises: :py:class:`KeyError` if no option exists with the specified ID.
"""
for option in self.options:
if option.id == option_id:
return option
msg = f"No poll option with ID {option_id!r}."
raise KeyError(msg)

View File

@@ -0,0 +1,494 @@
"""Provide the Redditor class."""
from __future__ import annotations
from json import dumps
from typing import TYPE_CHECKING, Any, Generator
from ...const import API_PATH
from ...util import _deprecate_args
from ...util.cache import cachedproperty
from ..listing.mixins import RedditorListingMixin
from ..util import stream_generator
from .base import RedditBase
from .mixins import FullnameMixin, MessageableMixin
if TYPE_CHECKING: # pragma: no cover
import praw.models
class Redditor(MessageableMixin, RedditorListingMixin, FullnameMixin, RedditBase):
"""A class representing the users of Reddit.
.. include:: ../../typical_attributes.rst
.. note::
Shadowbanned accounts are treated the same as non-existent accounts, meaning
that they will not have any attributes.
.. note::
Suspended/banned accounts will only return the ``name`` and ``is_suspended``
attributes.
=================================== ================================================
Attribute Description
=================================== ================================================
``comment_karma`` The comment karma for the :class:`.Redditor`.
``comments`` Provide an instance of :class:`.SubListing` for
comment access.
``submissions`` Provide an instance of :class:`.SubListing` for
submission access.
``created_utc`` Time the account was created, represented in
`Unix Time`_.
``has_verified_email`` Whether or not the :class:`.Redditor` has
verified their email.
``icon_img`` The url of the Redditors' avatar.
``id`` The ID of the :class:`.Redditor`.
``is_employee`` Whether or not the :class:`.Redditor` is a
Reddit employee.
``is_friend`` Whether or not the :class:`.Redditor` is friends
with the authenticated user.
``is_mod`` Whether or not the :class:`.Redditor` mods any
subreddits.
``is_gold`` Whether or not the :class:`.Redditor` has active
Reddit Premium status.
``is_suspended`` Whether or not the :class:`.Redditor` is
currently suspended.
``link_karma`` The link karma for the :class:`.Redditor`.
``name`` The Redditor's username.
``subreddit`` If the :class:`.Redditor` has created a
user-subreddit, provides a dictionary of
additional attributes. See below.
``subreddit["banner_img"]`` The URL of the user-subreddit banner.
``subreddit["name"]`` The fullname of the user-subreddit.
``subreddit["over_18"]`` Whether or not the user-subreddit is NSFW.
``subreddit["public_description"]`` The public description of the user-subreddit.
``subreddit["subscribers"]`` The number of users subscribed to the
user-subreddit.
``subreddit["title"]`` The title of the user-subreddit.
=================================== ================================================
.. _unix time: https://en.wikipedia.org/wiki/Unix_time
"""
STR_FIELD = "name"
@classmethod
def from_data(cls, reddit: praw.Reddit, data: dict[str, Any]) -> Redditor | None:
"""Return an instance of :class:`.Redditor`, or ``None`` from ``data``."""
if data == "[deleted]":
return None
return cls(reddit, data)
@cachedproperty
def notes(self) -> praw.models.RedditorModNotes:
"""Provide an instance of :class:`.RedditorModNotes`.
This provides an interface for managing moderator notes for a redditor.
.. note::
The authenticated user must be a moderator of the provided subreddit(s).
For example, all the notes for u/spez in r/test can be iterated through like so:
.. code-block:: python
redditor = reddit.redditor("spez")
for note in redditor.notes.subreddits("test"):
print(f"{note.label}: {note.note}")
"""
from praw.models.mod_notes import RedditorModNotes
return RedditorModNotes(self._reddit, self)
@cachedproperty
def stream(self) -> praw.models.reddit.redditor.RedditorStream:
"""Provide an instance of :class:`.RedditorStream`.
Streams can be used to indefinitely retrieve new comments made by a redditor,
like:
.. code-block:: python
for comment in reddit.redditor("spez").stream.comments():
print(comment)
Additionally, new submissions can be retrieved via the stream. In the following
example all submissions are fetched via the redditor u/spez:
.. code-block:: python
for submission in reddit.redditor("spez").stream.submissions():
print(submission)
"""
return RedditorStream(self)
@property
def _kind(self) -> str:
"""Return the class's kind."""
return self._reddit.config.kinds["redditor"]
@property
def _path(self) -> str:
return API_PATH["user"].format(user=self)
def __init__(
self,
reddit: praw.Reddit,
name: str | None = None,
fullname: str | None = None,
_data: dict[str, Any] | None = None,
):
"""Initialize a :class:`.Redditor` instance.
:param reddit: An instance of :class:`.Reddit`.
:param name: The name of the redditor.
:param fullname: The fullname of the redditor, starting with ``t2_``.
Exactly one of ``name``, ``fullname`` or ``_data`` must be provided.
"""
if (name, fullname, _data).count(None) != 2:
msg = "Exactly one of 'name', 'fullname', or '_data' must be provided."
raise TypeError(msg)
if _data:
assert ( # noqa: PT018
isinstance(_data, dict) and "name" in _data
), "Please file a bug with PRAW."
self._listing_use_sort = True
if name:
self.name = name
elif fullname:
self._fullname = fullname
super().__init__(reddit, _data=_data, _extra_attribute_to_check="_fullname")
def __setattr__(self, name: str, value: Any):
"""Objectify the subreddit attribute."""
if name == "subreddit" and value:
from .user_subreddit import UserSubreddit
value = UserSubreddit(reddit=self._reddit, _data=value)
super().__setattr__(name, value)
def _fetch(self):
data = self._fetch_data()
data = data["data"]
other = type(self)(self._reddit, _data=data)
self.__dict__.update(other.__dict__)
super()._fetch()
def _fetch_info(self):
if hasattr(self, "_fullname"):
self.name = self._fetch_username(self._fullname)
return "user_about", {"user": self.name}, None
def _fetch_username(self, fullname: str):
return self._reddit.get(API_PATH["user_by_fullname"], params={"ids": fullname})[
fullname
]["name"]
def _friend(self, *, data: dict[str, Any], method: str):
url = API_PATH["friend_v1"].format(user=self)
self._reddit.request(data=dumps(data), method=method, path=url)
def block(self):
"""Block the :class:`.Redditor`.
For example, to block :class:`.Redditor` u/spez:
.. code-block:: python
reddit.redditor("spez").block()
.. note::
Blocking a trusted user will remove that user from your trusted list.
.. seealso::
:meth:`.trust`
"""
self._reddit.post(API_PATH["block_user"], params={"name": self.name})
def distrust(self):
"""Remove the :class:`.Redditor` from your whitelist of trusted users.
For example, to remove :class:`.Redditor` u/spez from your whitelist:
.. code-block:: python
reddit.redditor("spez").distrust()
.. seealso::
:meth:`.trust`
"""
self._reddit.post(API_PATH["remove_whitelisted"], data={"name": self.name})
@_deprecate_args("note")
def friend(self, *, note: str = None):
"""Friend the :class:`.Redditor`.
:param note: A note to save along with the relationship. Requires Reddit Premium
(default: ``None``).
Calling this method subsequent times will update the note.
For example, to friend u/spez:
.. code-block:: python
reddit.redditor("spez").friend()
To add a note to the friendship (requires Reddit Premium):
.. code-block:: python
reddit.redditor("spez").friend(note="My favorite admin")
"""
self._friend(data={"note": note} if note else {}, method="PUT")
def friend_info(self) -> praw.models.Redditor:
"""Return a :class:`.Redditor` instance with specific friend-related attributes.
:returns: A :class:`.Redditor` instance with fields ``date``, ``id``, and
possibly ``note`` if the authenticated user has Reddit Premium.
For example, to get the friendship information of :class:`.Redditor` u/spez:
.. code-block:: python
info = reddit.redditor("spez").friend_info
friend_data = info.date
"""
return self._reddit.get(API_PATH["friend_v1"].format(user=self))
@_deprecate_args("months")
def gild(self, *, months: int = 1):
"""Gild the :class:`.Redditor`.
:param months: Specifies the number of months to gild up to 36 (default: ``1``).
For example, to gild :class:`.Redditor` u/spez for 1 month:
.. code-block:: python
reddit.redditor("spez").gild(months=1)
"""
if months < 1 or months > 36:
msg = "months must be between 1 and 36"
raise TypeError(msg)
self._reddit.post(
API_PATH["gild_user"].format(username=self), data={"months": months}
)
def moderated(self) -> list[praw.models.Subreddit]:
"""Return a list of the redditor's moderated subreddits.
:returns: A list of :class:`.Subreddit` objects. Return ``[]`` if the redditor
has no moderated subreddits.
:raises: ``prawcore.ServerError`` in certain circumstances. See the note below.
.. note::
The redditor's own user profile subreddit will not be returned, but other
user profile subreddits they moderate will be returned.
Usage:
.. code-block:: python
for subreddit in reddit.redditor("spez").moderated():
print(subreddit.display_name)
print(subreddit.title)
.. note::
A ``prawcore.ServerError`` exception may be raised if the redditor moderates
a large number of subreddits. If that happens, try switching to
:ref:`read-only mode <read_only_application>`. For example,
.. code-block:: python
reddit.read_only = True
for subreddit in reddit.redditor("reddit").moderated():
print(str(subreddit))
It is possible that requests made in read-only mode will also raise a
``prawcore.ServerError`` exception.
When used in read-only mode, this method does not retrieve information about
subreddits that require certain special permissions to access, e.g., private
subreddits and premium-only subreddits.
.. seealso::
:meth:`.User.moderator_subreddits`
"""
return self._reddit.get(API_PATH["moderated"].format(user=self)) or []
def multireddits(self) -> list[praw.models.Multireddit]:
"""Return a list of the redditor's public multireddits.
For example, to to get :class:`.Redditor` u/spez's multireddits:
.. code-block:: python
multireddits = reddit.redditor("spez").multireddits()
"""
return self._reddit.get(API_PATH["multireddit_user"].format(user=self))
def trophies(self) -> list[praw.models.Trophy]:
"""Return a list of the redditor's trophies.
:returns: A list of :class:`.Trophy` objects. Return ``[]`` if the redditor has
no trophies.
:raises: :class:`.RedditAPIException` if the redditor doesn't exist.
Usage:
.. code-block:: python
for trophy in reddit.redditor("spez").trophies():
print(trophy.name)
print(trophy.description)
"""
return list(self._reddit.get(API_PATH["trophies"].format(user=self)))
def trust(self):
"""Add the :class:`.Redditor` to your whitelist of trusted users.
Trusted users will always be able to send you PMs.
Example usage:
.. code-block:: python
reddit.redditor("AaronSw").trust()
Use the ``accept_pms`` parameter of :meth:`.Preferences.update` to toggle your
``accept_pms`` setting between ``"everyone"`` and ``"whitelisted"``. For
example:
.. code-block:: python
# Accept private messages from everyone:
reddit.user.preferences.update(accept_pms="everyone")
# Only accept private messages from trusted users:
reddit.user.preferences.update(accept_pms="whitelisted")
You may trust a user even if your ``accept_pms`` setting is switched to
``"everyone"``.
.. note::
You are allowed to have a user on your blocked list and your friends list at
the same time. However, you cannot trust a user who is on your blocked list.
.. seealso::
- :meth:`.distrust`
- :meth:`.Preferences.update`
- :meth:`.trusted`
"""
self._reddit.post(API_PATH["add_whitelisted"], data={"name": self.name})
def unblock(self):
"""Unblock the :class:`.Redditor`.
For example, to unblock :class:`.Redditor` u/spez:
.. code-block:: python
reddit.redditor("spez").unblock()
"""
data = {
"container": self._reddit.user.me().fullname,
"name": str(self),
"type": "enemy",
}
url = API_PATH["unfriend"].format(subreddit="all")
self._reddit.post(url, data=data)
def unfriend(self):
"""Unfriend the :class:`.Redditor`.
For example, to unfriend :class:`.Redditor` u/spez:
.. code-block:: python
reddit.redditor("spez").unfriend()
"""
self._friend(data={"id": str(self)}, method="DELETE")
class RedditorStream:
"""Provides submission and comment streams."""
def __init__(self, redditor: praw.models.Redditor):
"""Initialize a :class:`.RedditorStream` instance.
:param redditor: The redditor associated with the streams.
"""
self.redditor = redditor
def comments(
self, **stream_options: str | int | dict[str, str]
) -> Generator[praw.models.Comment, None, None]:
"""Yield new comments as they become available.
Comments are yielded oldest first. Up to 100 historical comments will initially
be returned.
Keyword arguments are passed to :func:`.stream_generator`.
For example, to retrieve all new comments made by redditor u/spez, try:
.. code-block:: python
for comment in reddit.redditor("spez").stream.comments():
print(comment)
"""
return stream_generator(self.redditor.comments.new, **stream_options)
def submissions(
self, **stream_options: str | int | dict[str, str]
) -> Generator[praw.models.Submission, None, None]:
"""Yield new submissions as they become available.
Submissions are yielded oldest first. Up to 100 historical submissions will
initially be returned.
Keyword arguments are passed to :func:`.stream_generator`.
For example, to retrieve all new submissions made by redditor u/spez, try:
.. code-block:: python
for submission in reddit.redditor("spez").stream.submissions():
print(submission)
"""
return stream_generator(self.redditor.submissions.new, **stream_options)

View File

@@ -0,0 +1,255 @@
"""Provide the Removal Reason class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Iterator
from warnings import warn
from ...const import API_PATH
from ...exceptions import ClientException
from ...util import _deprecate_args, cachedproperty
from .base import RedditBase
if TYPE_CHECKING: # pragma: no cover
import praw
class RemovalReason(RedditBase):
"""An individual Removal Reason object.
.. include:: ../../typical_attributes.rst
=========== ==================================
Attribute Description
=========== ==================================
``id`` The ID of the removal reason.
``message`` The message of the removal reason.
``title`` The title of the removal reason.
=========== ==================================
"""
STR_FIELD = "id"
@staticmethod
def _warn_reason_id(
*, id_value: str | None, reason_id_value: str | None
) -> str | None:
"""Reason ID param is deprecated. Warns if it's used.
:param id_value: Returns the actual value of parameter ``id`` is parameter
``reason_id`` is not used.
:param reason_id_value: The value passed as parameter ``reason_id``.
"""
if reason_id_value is not None:
warn(
"Parameter 'reason_id' is deprecated. Either use positional arguments"
' (e.g., reason_id="x" -> "x") or change the parameter name to \'id\''
' (e.g., reason_id="x" -> id="x"). This parameter will be removed in'
" PRAW 8.",
category=DeprecationWarning,
stacklevel=3,
)
return reason_id_value
return id_value
def __eq__(self, other: str | RemovalReason) -> bool:
"""Return whether the other instance equals the current."""
if isinstance(other, str):
return other == str(self)
return isinstance(other, self.__class__) and str(self) == str(other)
def __hash__(self) -> int:
"""Return the hash of the current instance."""
return hash(self.__class__.__name__) ^ hash(str(self))
def __init__(
self,
reddit: praw.Reddit,
subreddit: praw.models.Subreddit,
id: str | None = None,
reason_id: str | None = None,
_data: dict[str, Any] | None = None,
):
"""Initialize a :class:`.RemovalReason` instance.
:param reddit: An instance of :class:`.Reddit`.
:param subreddit: An instance of :class:`.Subreddit`.
:param id: The ID of the removal reason.
:param reason_id: The original name of the ``id`` parameter. Used for backwards
compatibility. This parameter should not be used.
"""
reason_id = self._warn_reason_id(id_value=id, reason_id_value=reason_id)
if (reason_id, _data).count(None) != 1:
msg = "Either id or _data needs to be given."
raise ValueError(msg)
if reason_id:
self.id = reason_id
self.subreddit = subreddit
super().__init__(reddit, _data=_data)
def _fetch(self):
for removal_reason in self.subreddit.mod.removal_reasons:
if removal_reason.id == self.id:
self.__dict__.update(removal_reason.__dict__)
super()._fetch()
return
msg = f"Subreddit {self.subreddit} does not have the removal reason {self.id}"
raise ClientException(msg)
def delete(self):
"""Delete a removal reason from this subreddit.
To delete ``"141vv5c16py7d"`` from r/test try:
.. code-block:: python
reddit.subreddit("test").mod.removal_reasons["141vv5c16py7d"].delete()
"""
url = API_PATH["removal_reason"].format(subreddit=self.subreddit, id=self.id)
self._reddit.delete(url)
@_deprecate_args("message", "title")
def update(self, *, message: str | None = None, title: str | None = None):
"""Update the removal reason from this subreddit.
.. note::
Existing values will be used for any unspecified arguments.
:param message: The removal reason's new message.
:param title: The removal reason's new title.
To update ``"141vv5c16py7d"`` from r/test try:
.. code-block:: python
reddit.subreddit("test").mod.removal_reasons["141vv5c16py7d"].update(
title="New title", message="New message"
)
"""
url = API_PATH["removal_reason"].format(subreddit=self.subreddit, id=self.id)
data = {
name: getattr(self, name) if value is None else value
for name, value in {"message": message, "title": title}.items()
}
self._reddit.put(url, data=data)
class SubredditRemovalReasons:
"""Provide a set of functions to a :class:`.Subreddit`'s removal reasons."""
@cachedproperty
def _removal_reason_list(self) -> list[RemovalReason]:
"""Get a list of Removal Reason objects.
:returns: A list of instances of :class:`.RemovalReason`.
"""
response = self._reddit.get(
API_PATH["removal_reasons_list"].format(subreddit=self.subreddit)
)
return [
RemovalReason(
self._reddit, self.subreddit, _data=response["data"][reason_id]
)
for reason_id in response["order"]
]
def __getitem__(self, reason_id: str | int | slice) -> RemovalReason:
"""Return the Removal Reason with the ID/number/slice ``reason_id``.
:param reason_id: The ID or index of the removal reason
.. note::
Removal reasons fetched using a specific rule name are lazily loaded, so you
might have to access an attribute to get all the expected attributes.
This method is to be used to fetch a specific removal reason, like so:
.. code-block:: python
reason_id = "141vv5c16py7d"
reason = reddit.subreddit("test").mod.removal_reasons[reason_id]
print(reason)
You can also use indices to get a numbered removal reason. Since Python uses
0-indexing, the first removal reason is index 0, and so on.
.. note::
Both negative indices and slices can be used to interact with the removal
reasons.
:raises: :py:class:`IndexError` if a removal reason of a specific number does
not exist.
For example, to get the second removal reason of r/test:
.. code-block:: python
reason = reddit.subreddit("test").mod.removal_reasons[1]
To get the last three removal reasons in a subreddit:
.. code-block:: python
reasons = reddit.subreddit("test").mod.removal_reasons[-3:]
for reason in reasons:
print(reason)
"""
if not isinstance(reason_id, str):
return self._removal_reason_list[reason_id]
return RemovalReason(self._reddit, self.subreddit, reason_id)
def __init__(self, subreddit: praw.models.Subreddit):
"""Initialize a :class:`.SubredditRemovalReasons` instance.
:param subreddit: The subreddit whose removal reasons to work with.
"""
self.subreddit = subreddit
self._reddit = subreddit._reddit
def __iter__(self) -> Iterator[RemovalReason]:
"""Return a list of Removal Reasons for the subreddit.
This method is used to discover all removal reasons for a subreddit:
.. code-block:: python
for removal_reason in reddit.subreddit("test").mod.removal_reasons:
print(removal_reason)
"""
return iter(self._removal_reason_list)
@_deprecate_args("message", "title")
def add(self, *, message: str, title: str) -> RemovalReason:
"""Add a removal reason to this subreddit.
:param message: The message associated with the removal reason.
:param title: The title of the removal reason.
:returns: The :class:`.RemovalReason` added.
The message will be prepended with ``Hi u/username,`` automatically.
To add ``"Test"`` to r/test try:
.. code-block:: python
reddit.subreddit("test").mod.removal_reasons.add(title="Test", message="Foobar")
"""
data = {"message": message, "title": title}
url = API_PATH["removal_reasons_list"].format(subreddit=self.subreddit)
reason_id = self._reddit.post(url, data=data)
return RemovalReason(self._reddit, self.subreddit, reason_id)

View File

@@ -0,0 +1,452 @@
"""Provide the Rule class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Iterator
from urllib.parse import quote
from warnings import warn
from ...const import API_PATH
from ...exceptions import ClientException
from ...util import _deprecate_args, cachedproperty
from .base import RedditBase
if TYPE_CHECKING: # pragma: no cover
import praw.models
class Rule(RedditBase):
"""An individual :class:`.Rule` object.
.. include:: ../../typical_attributes.rst
==================== =============================================================
Attribute Description
==================== =============================================================
``created_utc`` Time the rule was created, represented in `Unix Time`_.
``description`` The description of the rule, if provided, otherwise a blank
string.
``kind`` The kind of rule. Can be ``"link"``, ``comment"``, or
``"all"``.
``priority`` Represents where the rule is ranked. For example, the first
rule is at priority ``0``. Serves as an index number on the
list of rules.
``short_name`` The name of the rule.
``violation_reason`` The reason that is displayed on the report menu for the rule.
==================== =============================================================
.. _unix time: https://en.wikipedia.org/wiki/Unix_time
"""
STR_FIELD = "short_name"
@cachedproperty
def mod(self) -> praw.models.reddit.rules.RuleModeration:
"""Contain methods used to moderate rules.
To delete ``"No spam"`` from r/test try:
.. code-block:: python
reddit.subreddit("test").rules["No spam"].mod.delete()
To update ``"No spam"`` from r/test try:
.. code-block:: python
reddit.subreddit("test").removal_reasons["No spam"].mod.update(
description="Don't do this!", violation_reason="Spam post"
)
"""
return RuleModeration(self)
def __getattribute__(self, attribute: str) -> Any:
"""Get the value of an attribute."""
value = super().__getattribute__(attribute)
if attribute == "subreddit" and value is None:
msg = "The Rule is missing a subreddit. File a bug report at PRAW."
raise ValueError(msg)
return value
def __init__(
self,
reddit: praw.Reddit,
subreddit: praw.models.Subreddit | None = None,
short_name: str | None = None,
_data: dict[str, str] | None = None,
):
"""Initialize a :class:`.Rule` instance."""
if (short_name, _data).count(None) != 1:
msg = "Either short_name or _data needs to be given."
raise ValueError(msg)
if short_name:
self.short_name = short_name
# Note: The subreddit parameter can be None, because the objector does not know
# this info. In that case, it is the responsibility of the caller to set the
# `subreddit` property on the returned value.
self.subreddit = subreddit
super().__init__(reddit, _data=_data)
def _fetch(self):
for rule in self.subreddit.rules:
if rule.short_name == self.short_name:
self.__dict__.update(rule.__dict__)
super()._fetch()
return
msg = f"Subreddit {self.subreddit} does not have the rule {self.short_name}"
raise ClientException(msg)
class RuleModeration:
"""Contain methods used to moderate rules.
To delete ``"No spam"`` from r/test try:
.. code-block:: python
reddit.subreddit("test").rules["No spam"].mod.delete()
To update ``"No spam"`` from r/test try:
.. code-block:: python
reddit.subreddit("test").removal_reasons["No spam"].mod.update(
description="Don't do this!", violation_reason="Spam post"
)
"""
def __init__(self, rule: praw.models.Rule):
"""Initialize a :class:`.RuleModeration` instance."""
self.rule = rule
def delete(self):
"""Delete a rule from this subreddit.
To delete ``"No spam"`` from r/test try:
.. code-block:: python
reddit.subreddit("test").rules["No spam"].mod.delete()
"""
data = {
"r": str(self.rule.subreddit),
"short_name": self.rule.short_name,
}
self.rule._reddit.post(API_PATH["remove_subreddit_rule"], data=data)
@_deprecate_args("description", "kind", "short_name", "violation_reason")
def update(
self,
*,
description: str | None = None,
kind: str | None = None,
short_name: str | None = None,
violation_reason: str | None = None,
) -> praw.models.Rule:
"""Update the rule from this subreddit.
.. note::
Existing values will be used for any unspecified arguments.
:param description: The new description for the rule. Can be empty.
:param kind: The kind of item that the rule applies to. One of ``"link"``,
``"comment"``, or ``"all"``.
:param short_name: The name of the rule.
:param violation_reason: The reason that is shown on the report menu.
:returns: A Rule object containing the updated values.
To update ``"No spam"`` from r/test try:
.. code-block:: python
reddit.subreddit("test").removal_reasons["No spam"].mod.update(
description="Don't do this!", violation_reason="Spam post"
)
"""
data = {
"r": str(self.rule.subreddit),
"old_short_name": self.rule.short_name,
}
for name, value in {
"description": description,
"kind": kind,
"short_name": short_name,
"violation_reason": violation_reason,
}.items():
data[name] = getattr(self.rule, name) if value is None else value
updated_rule = self.rule._reddit.post(
API_PATH["update_subreddit_rule"], data=data
)[0]
updated_rule.subreddit = self.rule.subreddit
return updated_rule
class SubredditRules:
"""Provide a set of functions to access a :class:`.Subreddit`'s rules.
For example, to list all the rules for a subreddit:
.. code-block:: python
for rule in reddit.subreddit("test").rules:
print(rule)
Moderators can also add rules to the subreddit. For example, to make a rule called
``"No spam"`` in r/test:
.. code-block:: python
reddit.subreddit("test").rules.mod.add(
short_name="No spam", kind="all", description="Do not spam. Spam bad"
)
"""
@cachedproperty
def _rule_list(self) -> list[Rule]:
"""Get a list of :class:`.Rule` objects.
:returns: A list of instances of :class:`.Rule`.
"""
rule_list = self._reddit.get(API_PATH["rules"].format(subreddit=self.subreddit))
for rule in rule_list:
rule.subreddit = self.subreddit
return rule_list
@cachedproperty
def mod(self) -> SubredditRulesModeration:
"""Contain methods to moderate subreddit rules as a whole.
To add rule ``"No spam"`` to r/test try:
.. code-block:: python
reddit.subreddit("test").rules.mod.add(
short_name="No spam", kind="all", description="Do not spam. Spam bad"
)
To move the fourth rule to the first position, and then to move the prior first
rule to where the third rule originally was in r/test:
.. code-block:: python
subreddit = reddit.subreddit("test")
rules = list(subreddit.rules)
new_rules = rules[3:4] + rules[1:3] + rules[0:1] + rules[4:]
# Alternate: [rules[3]] + rules[1:3] + [rules[0]] + rules[4:]
new_rule_list = subreddit.rules.mod.reorder(new_rules)
"""
return SubredditRulesModeration(self)
def __call__(self) -> list[praw.models.Rule]:
r"""Return a list of :class:`.Rule`\ s (Deprecated).
:returns: A list of instances of :class:`.Rule`.
.. deprecated:: 7.1
Use the iterator by removing the call to :class:`.SubredditRules`. For
example, in order to use the iterator:
.. code-block:: python
for rule in reddit.subreddit("test").rules:
print(rule)
"""
warn(
"Calling SubredditRules to get a list of rules is deprecated. Remove the"
" parentheses to use the iterator. View the PRAW documentation on how to"
" change the code in order to use the iterator"
" (https://praw.readthedocs.io/en/latest/code_overview/other/subredditrules.html#praw.models.reddit.rules.SubredditRules.__call__).",
category=DeprecationWarning,
stacklevel=2,
)
return self._reddit.request(
method="GET", path=API_PATH["rules"].format(subreddit=self.subreddit)
)
def __getitem__(self, short_name: str | int | slice) -> praw.models.Rule:
"""Return the :class:`.Rule` for the subreddit with short_name ``short_name``.
:param short_name: The short_name of the rule, or the rule number.
.. note::
Rules fetched using a specific rule name are lazily loaded, so you might
have to access an attribute to get all the expected attributes.
This method is to be used to fetch a specific rule, like so:
.. code-block:: python
rule_name = "No spam"
rule = reddit.subreddit("test").rules[rule_name]
print(rule)
You can also fetch a numbered rule of a subreddit.
Rule numbers start at ``0``, so the first rule is at index ``0``, and the second
rule is at index ``1``, and so on.
:raises: :py:class:`IndexError` if a rule of a specific number does not exist.
.. note::
You can use negative indexes, such as ``-1``, to get the last rule. You can
also use slices, to get a subset of rules, such as the last three rules with
``rules[-3:]``.
For example, to fetch the second rule of r/test:
.. code-block:: python
rule = reddit.subreddit("test").rules[1]
"""
if not isinstance(short_name, str):
return self._rule_list[short_name]
return Rule(self._reddit, subreddit=self.subreddit, short_name=short_name)
def __init__(self, subreddit: praw.models.Subreddit):
"""Initialize a :class:`.SubredditRules` instance.
:param subreddit: The subreddit whose rules to work with.
"""
self.subreddit = subreddit
self._reddit = subreddit._reddit
def __iter__(self) -> Iterator[praw.models.Rule]:
"""Iterate through the rules of the subreddit.
:returns: An iterator containing all the rules of a subreddit.
This method is used to discover all rules for a subreddit.
For example, to get the rules for r/test:
.. code-block:: python
for rule in reddit.subreddit("test").rules:
print(rule)
"""
return iter(self._rule_list)
class SubredditRulesModeration:
"""Contain methods to moderate subreddit rules as a whole.
To add rule ``"No spam"`` to r/test try:
.. code-block:: python
reddit.subreddit("test").rules.mod.add(
short_name="No spam", kind="all", description="Do not spam. Spam bad"
)
To move the fourth rule to the first position, and then to move the prior first rule
to where the third rule originally was in r/test:
.. code-block:: python
subreddit = reddit.subreddit("test")
rules = list(subreddit.rules)
new_rules = rules[3:4] + rules[1:3] + rules[0:1] + rules[4:]
# Alternate: [rules[3]] + rules[1:3] + [rules[0]] + rules[4:]
new_rule_list = subreddit.rules.mod.reorder(new_rules)
"""
def __init__(self, subreddit_rules: SubredditRules):
"""Initialize a :class:`.SubredditRulesModeration` instance."""
self.subreddit_rules = subreddit_rules
@_deprecate_args("short_name", "kind", "description", "violation_reason")
def add(
self,
*,
description: str = "",
kind: str,
short_name: str,
violation_reason: str | None = None,
) -> praw.models.Rule:
"""Add a removal reason to this subreddit.
:param description: The description for the rule.
:param kind: The kind of item that the rule applies to. One of ``"link"``,
``"comment"``, or ``"all"``.
:param short_name: The name of the rule.
:param violation_reason: The reason that is shown on the report menu. If a
violation reason is not specified, the short name will be used as the
violation reason.
:returns: The added :class:`.Rule`.
To add rule ``"No spam"`` to r/test try:
.. code-block:: python
reddit.subreddit("test").rules.mod.add(
short_name="No spam", kind="all", description="Do not spam. Spam bad"
)
"""
data = {
"r": str(self.subreddit_rules.subreddit),
"description": description,
"kind": kind,
"short_name": short_name,
"violation_reason": (
short_name if violation_reason is None else violation_reason
),
}
new_rule = self.subreddit_rules._reddit.post(
API_PATH["add_subreddit_rule"], data=data
)[0]
new_rule.subreddit = self.subreddit_rules.subreddit
return new_rule
def reorder(self, rule_list: list[praw.models.Rule]) -> list[praw.models.Rule]:
"""Reorder the rules of a subreddit.
:param rule_list: The list of rules, in the wanted order. Each index of the list
indicates the position of the rule.
:returns: A list containing the rules in the specified order.
For example, to move the fourth rule to the first position, and then to move the
prior first rule to where the third rule originally was in r/test:
.. code-block:: python
subreddit = reddit.subreddit("test")
rules = list(subreddit.rules)
new_rules = rules[3:4] + rules[1:3] + rules[0:1] + rules[4:]
# Alternate: [rules[3]] + rules[1:3] + [rules[0]] + rules[4:]
new_rule_list = subreddit.rules.mod.reorder(new_rules)
"""
order_string = quote(
",".join([rule.short_name for rule in rule_list]), safe=","
)
data = {
"r": str(self.subreddit_rules.subreddit),
"new_rule_order": order_string,
}
response = self.subreddit_rules._reddit.post(
API_PATH["reorder_subreddit_rules"], data=data
)
for rule in response:
rule.subreddit = self.subreddit_rules.subreddit
return response

View File

@@ -0,0 +1,951 @@
"""Provide the Submission class."""
from __future__ import annotations
import re
from json import dumps
from typing import TYPE_CHECKING, Any, Generator
from urllib.parse import urljoin
from warnings import warn
from prawcore import Conflict
from ...const import API_PATH
from ...exceptions import InvalidURL
from ...util import _deprecate_args, cachedproperty
from ..comment_forest import CommentForest
from ..listing.listing import Listing
from ..listing.mixins import SubmissionListingMixin
from .base import RedditBase
from .mixins import FullnameMixin, ModNoteMixin, ThingModerationMixin, UserContentMixin
from .poll import PollData
from .redditor import Redditor
from .subreddit import Subreddit
if TYPE_CHECKING: # pragma: no cover
import praw.models
INLINE_MEDIA_PATTERN = re.compile(
r"\n\n!?(\[.*?])?\(?((https://((preview|i)\.redd\.it|reddit.com/link).*?)|(?!https)([a-zA-Z0-9]+( \".*?\")?))\)?"
)
MEDIA_TYPE_MAPPING = {
"Image": "img",
"RedditVideo": "video",
"AnimatedImage": "gif",
}
class SubmissionFlair:
"""Provide a set of functions pertaining to :class:`.Submission` flair."""
def __init__(self, submission: praw.models.Submission):
"""Initialize a :class:`.SubmissionFlair` instance.
:param submission: The :class:`.Submission` associated with the flair functions.
"""
self.submission = submission
def choices(self) -> list[dict[str, bool | list | str]]:
"""Return list of available flair choices.
Choices are required in order to use :meth:`.select`.
For example:
.. code-block:: python
choices = submission.flair.choices()
"""
url = API_PATH["flairselector"].format(subreddit=self.submission.subreddit)
return self.submission._reddit.post(
url, data={"link": self.submission.fullname}
)["choices"]
@_deprecate_args("flair_template_id", "text")
def select(self, flair_template_id: str, *, text: str | None = None):
"""Select flair for submission.
:param flair_template_id: The flair template to select. The possible values can
be discovered through :meth:`.choices`.
:param text: If the template's ``flair_text_editable`` value is ``True``, this
value will set a custom text (default: ``None``).
For example, to select an arbitrary editable flair text (assuming there is one)
and set a custom value try:
.. code-block:: python
choices = submission.flair.choices()
template_id = next(x for x in choices if x["flair_text_editable"])["flair_template_id"]
submission.flair.select(template_id, text="my custom value")
"""
data = {
"flair_template_id": flair_template_id,
"link": self.submission.fullname,
"text": text,
}
url = API_PATH["select_flair"].format(subreddit=self.submission.subreddit)
self.submission._reddit.post(url, data=data)
class SubmissionModeration(ThingModerationMixin, ModNoteMixin):
"""Provide a set of functions pertaining to :class:`.Submission` moderation.
Example usage:
.. code-block:: python
submission = reddit.submission("8dmv8z")
submission.mod.approve()
"""
REMOVAL_MESSAGE_API = "removal_link_message"
def __init__(self, submission: praw.models.Submission):
"""Initialize a :class:`.SubmissionModeration` instance.
:param submission: The submission to moderate.
"""
self.thing = submission
@_deprecate_args("state")
def contest_mode(self, *, state: bool = True):
"""Set contest mode for the comments of this submission.
:param state: ``True`` enables contest mode and ``False`` disables (default:
``True``).
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.
Example usage:
.. code-block:: python
submission = reddit.submission("5or86n")
submission.mod.contest_mode()
"""
self.thing._reddit.post(
API_PATH["contest_mode"], data={"id": self.thing.fullname, "state": state}
)
@_deprecate_args("text", "css_class", "flair_template_id")
def flair(
self,
*,
css_class: str = "",
flair_template_id: str | None = None,
text: str = "",
):
"""Set flair for the submission.
:param css_class: The css class to associate with the flair html (default:
``""``).
:param flair_template_id: The flair template ID to use when flairing.
:param text: The flair text to associate with the :class:`.Submission` (default:
``""``).
This method can only be used by an authenticated user who is a moderator of the
submission's :class:`.Subreddit`.
Example usage:
.. code-block:: python
submission = reddit.submission("5or86n")
submission.mod.flair(text="PRAW", css_class="bot")
"""
data = {
"css_class": css_class,
"link": self.thing.fullname,
"text": text,
}
url = API_PATH["flair"].format(subreddit=self.thing.subreddit)
if flair_template_id is not None:
data["flair_template_id"] = flair_template_id
url = API_PATH["select_flair"].format(subreddit=self.thing.subreddit)
self.thing._reddit.post(url, data=data)
def nsfw(self):
"""Mark as not safe for work.
This method can be used both by the submission author and moderators of the
subreddit that the submission belongs to.
Example usage:
.. code-block:: python
submission = reddit.subreddit("test").submit("nsfw test", selftext="nsfw")
submission.mod.nsfw()
.. seealso::
:meth:`.sfw`
"""
self.thing._reddit.post(API_PATH["marknsfw"], data={"id": self.thing.fullname})
def set_original_content(self):
"""Mark as original content.
This method can be used by moderators of the subreddit that the submission
belongs to. If the subreddit has enabled the Original Content beta feature in
settings, then the submission's author can use it as well.
Example usage:
.. code-block:: python
submission = reddit.subreddit("test").submit("oc test", selftext="original")
submission.mod.set_original_content()
.. seealso::
:meth:`.unset_original_content`
"""
data = {
"id": self.thing.id,
"fullname": self.thing.fullname,
"should_set_oc": True,
"executed": False,
"r": self.thing.subreddit,
}
self.thing._reddit.post(API_PATH["set_original_content"], data=data)
def sfw(self):
"""Mark as safe for work.
This method can be used both by the submission author and moderators of the
subreddit that the submission belongs to.
Example usage:
.. code-block:: python
submission = reddit.submission("5or86n")
submission.mod.sfw()
.. seealso::
:meth:`.nsfw`
"""
self.thing._reddit.post(
API_PATH["unmarknsfw"], data={"id": self.thing.fullname}
)
def spoiler(self):
"""Indicate that the submission contains spoilers.
This method can be used both by the submission author and moderators of the
subreddit that the submission belongs to.
Example usage:
.. code-block:: python
submission = reddit.submission("5or86n")
submission.mod.spoiler()
.. seealso::
:meth:`.unspoiler`
"""
self.thing._reddit.post(API_PATH["spoiler"], data={"id": self.thing.fullname})
@_deprecate_args("state", "bottom")
def sticky(
self, *, bottom: bool = True, state: bool = True
) -> praw.models.Submission:
"""Set the submission's sticky state in its subreddit.
:param bottom: When ``True``, set the submission as the bottom sticky. If no top
sticky exists, this submission will become the top sticky regardless
(default: ``True``).
:param state: ``True`` sets the sticky for the submission and ``False`` unsets
(default: ``True``).
:returns: The stickied submission object.
.. note::
When a submission is stickied two or more times, the Reddit API responds
with a 409 error that is raised as a ``Conflict`` by prawcore. This method
suppresses these ``Conflict`` errors.
This submission will replace the second stickied submission if one exists.
For example:
.. code-block:: python
submission = reddit.submission("5or86n")
submission.mod.sticky()
"""
data = {"id": self.thing.fullname, "state": state}
if not bottom:
data["num"] = 1
try:
return self.thing._reddit.post(API_PATH["sticky_submission"], data=data)
except Conflict:
pass
@_deprecate_args("sort")
def suggested_sort(self, *, sort: str = "blank"):
"""Set the suggested sort for the comments of the submission.
:param sort: Can be one of: ``"confidence"``, ``"top"``, ``"new"``,
``"controversial"``, ``"old"``, ``"random"``, ``"qa"``, or ``"blank"``
(default: ``"blank"``).
"""
self.thing._reddit.post(
API_PATH["suggested_sort"], data={"id": self.thing.fullname, "sort": sort}
)
def unset_original_content(self):
"""Indicate that the submission is not original content.
This method can be used by moderators of the subreddit that the submission
belongs to. If the subreddit has enabled the Original Content beta feature in
settings, then the submission's author can use it as well.
Example usage:
.. code-block:: python
submission = reddit.subreddit("test").submit("oc test", selftext="original")
submission.mod.unset_original_content()
.. seealso::
:meth:`.set_original_content`
"""
data = {
"id": self.thing.id,
"fullname": self.thing.fullname,
"should_set_oc": False,
"executed": False,
"r": self.thing.subreddit,
}
self.thing._reddit.post(API_PATH["set_original_content"], data=data)
def unspoiler(self):
"""Indicate that the submission does not contain spoilers.
This method can be used both by the submission author and moderators of the
subreddit that the submission belongs to.
For example:
.. code-block:: python
submission = reddit.subreddit("test").submit("not spoiler", selftext="spoiler")
submission.mod.unspoiler()
.. seealso::
:meth:`.spoiler`
"""
self.thing._reddit.post(API_PATH["unspoiler"], data={"id": self.thing.fullname})
def update_crowd_control_level(self, level: int):
"""Change the Crowd Control level of the submission.
:param level: An integer between 0 and 3.
**Level Descriptions**
===== ======== ================================================================
Level Name Description
===== ======== ================================================================
0 Off Crowd Control will not action any of the submission's comments.
1 Lenient Comments from users who have negative karma in the subreddit are
automatically collapsed.
2 Moderate Comments from new users and users with negative karma in the
subreddit are automatically collapsed.
3 Strict Comments from users who havent joined the subreddit, new users,
and users with negative karma in the subreddit are automatically
collapsed.
===== ======== ================================================================
Example usage:
.. code-block:: python
submission = reddit.submission("745ryj")
submission.mod.update_crowd_control_level(2)
.. seealso::
:meth:`~.CommentModeration.show`
"""
self.thing._reddit.post(
API_PATH["update_crowd_control"],
data={"id": self.thing.fullname, "level": level},
)
class Submission(SubmissionListingMixin, UserContentMixin, FullnameMixin, RedditBase):
"""A class for submissions to Reddit.
.. include:: ../../typical_attributes.rst
========================== =========================================================
Attribute Description
========================== =========================================================
``author`` Provides an instance of :class:`.Redditor`.
``author_flair_text`` The text content of the author's flair, or ``None`` if
not flaired.
``clicked`` Whether or not the submission has been clicked by the
client.
``comments`` Provides an instance of :class:`.CommentForest`.
``created_utc`` Time the submission was created, represented in `Unix
Time`_.
``distinguished`` Whether or not the submission is distinguished.
``edited`` Whether or not the submission has been edited.
``id`` ID of the submission.
``is_original_content`` Whether or not the submission has been set as original
content.
``is_self`` Whether or not the submission is a selfpost (text-only).
``link_flair_template_id`` The link flair's ID.
``link_flair_text`` The link flair's text content, or ``None`` if not
flaired.
``locked`` Whether or not the submission has been locked.
``name`` Fullname of the submission.
``num_comments`` The number of comments on the submission.
``over_18`` Whether or not the submission has been marked as NSFW.
``permalink`` A permalink for the submission.
``poll_data`` A :class:`.PollData` object representing the data of this
submission, if it is a poll submission.
``saved`` Whether or not the submission is saved.
``score`` The number of upvotes for the submission.
``selftext`` The submissions' selftext - an empty string if a link
post.
``spoiler`` Whether or not the submission has been marked as a
spoiler.
``stickied`` Whether or not the submission is stickied.
``subreddit`` Provides an instance of :class:`.Subreddit`.
``title`` The title of the submission.
``upvote_ratio`` The percentage of upvotes from all votes on the
submission.
``url`` The URL the submission links to, or the permalink if a
selfpost.
========================== =========================================================
.. _unix time: https://en.wikipedia.org/wiki/Unix_time
"""
STR_FIELD = "id"
@staticmethod
def id_from_url(url: str) -> str:
"""Return the ID contained within a submission URL.
:param url: A url to a submission in one of the following formats (http urls
will also work):
- ``"https://redd.it/2gmzqe"``
- ``"https://reddit.com/comments/2gmzqe/"``
- ``"https://www.reddit.com/r/redditdev/comments/2gmzqe/praw_https/"``
- ``"https://www.reddit.com/gallery/2gmzqe"``
:raises: :class:`.InvalidURL` if ``url`` is not a valid submission URL.
"""
parts = RedditBase._url_parts(url)
if "comments" not in parts and "gallery" not in parts:
submission_id = parts[-1]
if "r" in parts:
raise InvalidURL(
url, message="Invalid URL (subreddit, not submission): {}"
)
elif "gallery" in parts:
submission_id = parts[parts.index("gallery") + 1]
elif parts[-1] == "comments":
raise InvalidURL(url, message="Invalid URL (submission ID not present): {}")
else:
submission_id = parts[parts.index("comments") + 1]
if not submission_id.isalnum():
raise InvalidURL(url)
return submission_id
@cachedproperty
def flair(self) -> SubmissionFlair:
"""Provide an instance of :class:`.SubmissionFlair`.
This attribute is used to work with flair as a regular user of the subreddit the
submission belongs to. Moderators can directly use :meth:`.flair`.
For example, to select an arbitrary editable flair text (assuming there is one)
and set a custom value try:
.. code-block:: python
choices = submission.flair.choices()
template_id = next(x for x in choices if x["flair_text_editable"])["flair_template_id"]
submission.flair.select(template_id, text="my custom value")
"""
return SubmissionFlair(self)
@cachedproperty
def mod(self) -> SubmissionModeration:
"""Provide an instance of :class:`.SubmissionModeration`.
Example usage:
.. code-block:: python
submission = reddit.submission("8dmv8z")
submission.mod.approve()
"""
return SubmissionModeration(self)
@property
def _kind(self) -> str:
"""Return the class's kind."""
return self._reddit.config.kinds["submission"]
@property
def comments(self) -> CommentForest:
"""Provide an instance of :class:`.CommentForest`.
This attribute can be used, for example, to obtain a flat list of comments, with
any :class:`.MoreComments` removed:
.. code-block:: python
submission.comments.replace_more(limit=0)
comments = submission.comments.list()
Sort order and comment limit can be set with the ``comment_sort`` and
``comment_limit`` attributes before comments are fetched, including any call to
:meth:`.replace_more`:
.. code-block:: python
submission.comment_sort = "new"
comments = submission.comments.list()
.. note::
The appropriate values for ``"comment_sort"`` include ``"confidence"``,
``"controversial"``, ``"new"``, ``"old"``, ``"q&a"``, and ``"top"``
See :ref:`extracting_comments` for more on working with a
:class:`.CommentForest`.
"""
# This assumes _comments is set so that _fetch is called when it's not.
return self._comments
@property
def shortlink(self) -> str:
"""Return a shortlink to the submission.
For example, https://redd.it/eorhm is a shortlink for
https://www.reddit.com/r/announcements/comments/eorhm/reddit_30_less_typing/.
"""
return urljoin(self._reddit.config.short_url, self.id)
def __init__(
self,
reddit: praw.Reddit,
id: str | None = None,
url: str | None = None,
_data: dict[str, Any] | None = None,
):
"""Initialize a :class:`.Submission` instance.
:param reddit: An instance of :class:`.Reddit`.
:param id: A reddit base36 submission ID, e.g., ``"2gmzqe"``.
:param url: A URL supported by :meth:`.id_from_url`.
Either ``id`` or ``url`` can be provided, but not both.
"""
if (id, url, _data).count(None) != 2:
msg = "Exactly one of 'id', 'url', or '_data' must be provided."
raise TypeError(msg)
self.comment_limit = 2048
# Specify the sort order for ``comments``
self.comment_sort = "confidence"
if id:
self.id = id
elif url:
self.id = self.id_from_url(url)
super().__init__(reddit, _data=_data)
self._additional_fetch_params = {}
self._comments_by_id = {}
def __setattr__(self, attribute: str, value: Any):
"""Objectify author, subreddit, and poll data attributes."""
if attribute == "author":
value = Redditor.from_data(self._reddit, value)
elif attribute == "subreddit":
value = Subreddit(self._reddit, value)
elif attribute == "poll_data":
value = PollData(self._reddit, value)
elif (
attribute == "comment_sort"
and hasattr(self, "_fetched")
and self._fetched
and hasattr(self, "_reddit")
and self._reddit.config.warn_comment_sort
):
warn(
"The comments for this submission have already been fetched, so the"
" updated comment_sort will not have any effect.",
stacklevel=2,
)
super().__setattr__(attribute, value)
def _chunk(
self,
*,
chunk_size: int,
other_submissions: list[praw.models.Submission] | None,
) -> Generator[str, None, None]:
all_submissions = [self.fullname]
if other_submissions:
all_submissions += [x.fullname for x in other_submissions]
for position in range(0, len(all_submissions), chunk_size):
yield ",".join(all_submissions[position : position + 50])
def _edit_experimental(
self,
body: str,
*,
preserve_inline_media: bool = False,
inline_media: dict[str, praw.models.InlineMedia] | None = None,
) -> praw.models.Submission:
"""Replace the body of the object with ``body``.
:param body: The Markdown formatted content for the updated object.
:param preserve_inline_media: Attempt to preserve inline media in ``body``.
.. danger::
This method is experimental. It is reliant on undocumented API endpoints
and may result in existing inline media not displaying correctly and/or
creating a malformed body. Use at your own risk. This method may be
removed in the future without warning.
:param inline_media: A dict of :class:`.InlineMedia` objects where the key is
the placeholder name in ``body``.
:returns: The current instance after updating its attributes.
Example usage:
.. code-block:: python
from praw.models import InlineGif, InlineImage, InlineVideo
submission = reddit.submission("5or86n")
gif = InlineGif(path="path/to/image.gif", caption="optional caption")
image = InlineImage(path="path/to/image.jpg", caption="optional caption")
video = InlineVideo(path="path/to/video.mp4", caption="optional caption")
body = "New body with a gif {gif1} an image {image1} and a video {video1} inline"
media = {"gif1": gif, "image1": image, "video1": video}
submission._edit_experimental(submission.selftext + body, inline_media=media)
"""
data = {
"thing_id": self.fullname,
"validate_on_submit": self._reddit.validate_on_submit,
}
is_richtext_json = False
if INLINE_MEDIA_PATTERN.search(body) and self.media_metadata:
is_richtext_json = True
if inline_media:
body = body.format(
**{
placeholder: self.subreddit._upload_inline_media(media)
for placeholder, media in inline_media.items()
}
)
is_richtext_json = True
if is_richtext_json:
richtext_json = self.subreddit._convert_to_fancypants(body)
if preserve_inline_media:
self._replace_richtext_links(richtext_json)
data["richtext_json"] = dumps(richtext_json)
else:
data["text"] = body
updated = self._reddit.post(API_PATH["edit"], data=data)
if not is_richtext_json:
updated = updated[0]
for attribute in [
"_fetched",
"_reddit",
"_submission",
"replies",
"subreddit",
]:
if attribute in updated.__dict__:
delattr(updated, attribute)
self.__dict__.update(updated.__dict__)
else:
self.__dict__.update(updated)
return self
def _fetch(self):
data = self._fetch_data()
submission_listing, comment_listing = data
comment_listing = Listing(self._reddit, _data=comment_listing["data"])
submission_data = submission_listing["data"]["children"][0]["data"]
submission = type(self)(self._reddit, _data=submission_data)
delattr(submission, "comment_limit")
delattr(submission, "comment_sort")
submission._comments = CommentForest(self)
self.__dict__.update(submission.__dict__)
self.comments._update(comment_listing.children)
super()._fetch()
def _fetch_data(self):
name, fields, params = self._fetch_info()
params.update(self._additional_fetch_params.copy())
path = API_PATH[name].format(**fields)
return self._reddit.request(method="GET", params=params, path=path)
def _fetch_info(self):
return (
"submission",
{"id": self.id},
{"limit": self.comment_limit, "sort": self.comment_sort},
)
def _replace_richtext_links(self, richtext_json: dict):
parsed_media_types = {
media_id: MEDIA_TYPE_MAPPING[value["e"]]
for media_id, value in self.media_metadata.items()
}
for index, element in enumerate(richtext_json["document"][:]):
element_items = element.get("c")
if isinstance(element_items, str):
assert element.get("e") in ["gif", "img", "video"], (
"Unexpected richtext JSON schema. Please file a bug report with"
" PRAW."
) # make sure this is an inline element
continue # pragma: no cover
for item in element.get("c"):
if item.get("e") == "link":
ids = set(parsed_media_types)
# remove extra bits from the url
url = item["u"].split("https://")[1].split("?")[0]
# the id is in the url somewhere, so we split by '/' and '.'
matched_id = ids.intersection(re.split(r"[./]", url))
if matched_id:
matched_id = matched_id.pop()
correct_element = {
"e": parsed_media_types[matched_id],
"id": matched_id,
}
if item.get("t") != item.get("u"): # add caption if it exists
correct_element["c"] = item["t"]
richtext_json["document"][index] = correct_element
def add_fetch_param(self, key: str, value: str):
"""Add a parameter to be used for the next fetch.
:param key: The key of the fetch parameter.
:param value: The value of the fetch parameter.
For example, to fetch a submission with the ``rtjson`` attribute populated:
.. code-block:: python
submission = reddit.submission("mcqjl8")
submission.add_fetch_param("rtj", "all")
print(submission.rtjson)
"""
if (
hasattr(self, "_fetched")
and self._fetched
and hasattr(self, "_reddit")
and self._reddit.config.warn_additional_fetch_params
):
warn(
f"This {self.__class__.__name__.lower()} has already been fetched, so"
" adding additional fetch parameters will not have any effect.",
stacklevel=2,
)
self._additional_fetch_params[key] = value
@_deprecate_args(
"subreddit",
"title",
"send_replies",
"flair_id",
"flair_text",
"nsfw",
"spoiler",
)
def crosspost(
self,
subreddit: praw.models.Subreddit,
*,
flair_id: str | None = None,
flair_text: str | None = None,
nsfw: bool = False,
send_replies: bool = True,
spoiler: bool = False,
title: str | None = None,
) -> praw.models.Submission:
"""Crosspost the submission to a subreddit.
.. note::
Be aware you have to be subscribed to the target subreddit.
:param subreddit: Name of the subreddit or :class:`.Subreddit` object to
crosspost into.
:param flair_id: The flair template to select (default: ``None``).
:param flair_text: If the template's ``flair_text_editable`` value is ``True``,
this value will set a custom text (default: ``None``).
:param nsfw: Whether the submission should be marked NSFW (default: ``False``).
:param send_replies: When ``True``, messages will be sent to the created
submission's author when comments are made to the submission (default:
``True``).
:param spoiler: Whether the submission should be marked as a spoiler (default:
``False``).
:param title: Title of the submission. Will use this submission's title if
``None`` (default: ``None``).
:returns: A :class:`.Submission` object for the newly created submission.
Example usage:
.. code-block:: python
submission = reddit.submission("5or86n")
cross_post = submission.crosspost("learnprogramming", send_replies=False)
.. seealso::
:meth:`.hide`
"""
if title is None:
title = self.title
data = {
"sr": str(subreddit),
"title": title,
"sendreplies": bool(send_replies),
"kind": "crosspost",
"crosspost_fullname": self.fullname,
"nsfw": bool(nsfw),
"spoiler": bool(spoiler),
}
for key, value in (("flair_id", flair_id), ("flair_text", flair_text)):
if value is not None:
data[key] = value
return self._reddit.post(API_PATH["submit"], data=data)
@_deprecate_args("other_submissions")
def hide(self, *, other_submissions: list[praw.models.Submission] | None = None):
"""Hide :class:`.Submission`.
:param other_submissions: When provided, additionally hide this list of
:class:`.Submission` instances as part of a single request (default:
``None``).
Example usage:
.. code-block:: python
submission = reddit.submission("5or86n")
submission.hide()
.. seealso::
:meth:`.unhide`
"""
for submissions in self._chunk(
chunk_size=50, other_submissions=other_submissions
):
self._reddit.post(API_PATH["hide"], data={"id": submissions})
def mark_visited(self):
"""Mark submission as visited.
This method requires a subscription to reddit premium.
Example usage:
.. code-block:: python
submission = reddit.submission("5or86n")
submission.mark_visited()
"""
data = {"links": self.fullname}
self._reddit.post(API_PATH["store_visits"], data=data)
@_deprecate_args("other_submissions")
def unhide(self, *, other_submissions: list[praw.models.Submission] | None = None):
"""Unhide :class:`.Submission`.
:param other_submissions: When provided, additionally unhide this list of
:class:`.Submission` instances as part of a single request (default:
``None``).
Example usage:
.. code-block:: python
submission = reddit.submission("5or86n")
submission.unhide()
.. seealso::
:meth:`.hide`
"""
for submissions in self._chunk(
chunk_size=50, other_submissions=other_submissions
):
self._reddit.post(API_PATH["unhide"], data={"id": submissions})
Subreddit._submission_class = Submission

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,242 @@
"""Provide the :class:`.UserSubreddit` class."""
from __future__ import annotations
import inspect
from typing import TYPE_CHECKING, Any, Callable
from warnings import warn
from ...util.cache import cachedproperty
from .subreddit import Subreddit, SubredditModeration
if TYPE_CHECKING: # pragma: no cover
import praw.models
class UserSubreddit(Subreddit):
"""A class for :class:`.User` Subreddits.
To obtain an instance of this class execute:
.. code-block:: python
subreddit = reddit.user.me().subreddit
.. include:: ../../typical_attributes.rst
========================= ==========================================================
Attribute Description
========================= ==========================================================
``can_assign_link_flair`` Whether users can assign their own link flair.
``can_assign_user_flair`` Whether users can assign their own user flair.
``created_utc`` Time the subreddit was created, represented in `Unix
Time`_.
``description`` Subreddit description, in Markdown.
``description_html`` Subreddit description, in HTML.
``display_name`` Name of the subreddit.
``id`` ID of the subreddit.
``name`` Fullname of the subreddit.
``over18`` Whether the subreddit is NSFW.
``public_description`` Description of the subreddit, shown in searches and on the
"You must be invited to visit this community" page (if
applicable).
``spoilers_enabled`` Whether the spoiler tag feature is enabled.
``subscribers`` Count of subscribers. This will be ``0`` unless unless the
authenticated user is a moderator.
``user_is_banned`` Whether the authenticated user is banned.
``user_is_moderator`` Whether the authenticated user is a moderator.
``user_is_subscriber`` Whether the authenticated user is subscribed.
========================= ==========================================================
.. _unix time: https://en.wikipedia.org/wiki/Unix_time
"""
@staticmethod
def _dict_deprecated_wrapper(func: Callable) -> Callable:
"""Show deprecation notice for dict only methods."""
def wrapper(*args: Any, **kwargs: Any):
warn(
"'Redditor.subreddit' is no longer a dict and is now an UserSubreddit"
f" object. Using '{func.__name__}' is deprecated and will be removed in"
" PRAW 8.",
category=DeprecationWarning,
stacklevel=2,
)
return func(*args, **kwargs)
return wrapper
@cachedproperty
def mod(self) -> praw.models.reddit.user_subreddit.UserSubredditModeration:
"""Provide an instance of :class:`.UserSubredditModeration`.
For example, to update the authenticated user's display name:
.. code-block:: python
reddit.user.me().subreddit.mod.update(title="New display name")
"""
return UserSubredditModeration(self)
def __getitem__(self, item: str) -> Any:
"""Show deprecation notice for dict method ``__getitem__``."""
warn(
"'Redditor.subreddit' is no longer a dict and is now an UserSubreddit"
" object. Accessing attributes using string indices is deprecated.",
category=DeprecationWarning,
stacklevel=2,
)
return getattr(self, item)
def __init__(self, reddit: praw.Reddit, *args: Any, **kwargs: Any):
"""Initialize an :class:`.UserSubreddit` instance.
:param reddit: An instance of :class:`.Reddit`.
.. note::
This class should not be initialized directly. Instead, obtain an instance
via: ``reddit.user.me().subreddit`` or
``reddit.redditor("redditor_name").subreddit``.
"""
def predicate(item: str):
name = getattr(item, "__name__", None)
return name not in dir(object) + dir(Subreddit) and name in dir(dict)
for name, _member in inspect.getmembers(dict, predicate=predicate):
if name != "__getitem__":
setattr(
self,
name,
self._dict_deprecated_wrapper(getattr(self.__dict__, name)),
)
super().__init__(reddit, *args, **kwargs)
# noinspection PyIncorrectDocstring
class UserSubredditModeration(SubredditModeration):
"""Provides a set of moderation functions to a :class:`.UserSubreddit`.
For example, to accept a moderation invite from the user subreddit of u/spez:
.. code-block:: python
reddit.subreddit("test").mod.accept_invite()
"""
def update(self, **settings: str | int | bool) -> dict[str, str | int | bool]:
"""Update the :class:`.Subreddit`'s settings.
:param all_original_content: Mandate all submissions to be original content
only.
:param allow_chat_post_creation: Allow users to create chat submissions.
:param allow_images: Allow users to upload images using the native image
hosting.
:param allow_polls: Allow users to post polls to the subreddit.
:param allow_post_crossposts: Allow users to crosspost submissions from other
subreddits.
:param allow_top: Allow the subreddit to appear on r/all as well as the default
and trending lists.
:param allow_videos: Allow users to upload videos using the native image
hosting.
:param collapse_deleted_comments: Collapse deleted and removed comments on
comments pages by default.
:param crowd_control_chat_level: Controls the crowd control level for chat
rooms. Goes from 0-3.
:param crowd_control_level: Controls the crowd control level for submissions.
Goes from 0-3.
:param crowd_control_mode: Enables/disables crowd control.
:param comment_score_hide_mins: The number of minutes to hide comment scores.
:param description: Shown in the sidebar of your subreddit.
:param disable_contributor_requests: Specifies whether redditors may send
automated modmail messages requesting approval as a submitter.
:param exclude_banned_modqueue: Exclude posts by site-wide banned users from
modqueue/unmoderated.
:param free_form_reports: Allow users to specify custom reasons in the report
menu.
:param header_hover_text: The text seen when hovering over the snoo.
:param hide_ads: Don't show ads within this subreddit. Only applies to
Premium-user only subreddits.
:param key_color: A 6-digit rgb hex color (e.g., ``"#AABBCC"``), used as a
thematic color for your subreddit on mobile.
:param lang: A valid IETF language tag (underscore separated).
:param link_type: The types of submissions users can make. One of ``"any"``,
``"link"``, or ``"self"``.
:param original_content_tag_enabled: Enables the use of the ``original content``
label for submissions.
:param over_18: Viewers must be over 18 years old (i.e., NSFW).
:param public_description: Public description blurb. Appears in search results
and on the landing page for private subreddits.
:param public_traffic: Make the traffic stats page public.
:param restrict_commenting: Specifies whether approved users have the ability to
comment.
:param restrict_posting: Specifies whether approved users have the ability to
submit posts.
:param show_media: Show thumbnails on submissions.
:param show_media_preview: Expand media previews on comments pages.
:param spam_comments: Spam filter strength for comments. One of ``"all"``,
``"low"``, or ``"high"``.
:param spam_links: Spam filter strength for links. One of ``"all"``, ``"low"``,
or ``"high"``.
:param spam_selfposts: Spam filter strength for selfposts. One of ``"all"``,
``"low"``, or ``"high"``.
:param spoilers_enabled: Enable marking posts as containing spoilers.
:param submit_link_label: Custom label for submit link button (None for
default).
:param submit_text: Text to show on submission page.
:param submit_text_label: Custom label for submit text post button (None for
default).
:param subreddit_type: The string ``"user"``.
:param suggested_comment_sort: All comment threads will use this sorting method
by default. Leave ``None``, or choose one of ``confidence``,
``"controversial"``, ``"live"``, ``"new"``, ``"old"``, ``"qa"``,
``"random"``, or ``"top"``.
:param title: The title of the subreddit.
:param welcome_message_enabled: Enables the subreddit welcome message.
:param welcome_message_text: The text to be used as a welcome message. A welcome
message is sent to all new subscribers by a Reddit bot.
:param wiki_edit_age: Account age, in days, required to edit and create wiki
pages.
:param wiki_edit_karma: Subreddit karma required to edit and create wiki pages.
:param wikimode: One of ``"anyone"``, ``"disabled"``, or ``"modonly"``.
Additional keyword arguments can be provided to handle new settings as Reddit
introduces them.
Settings that are documented here and aren't explicitly set by you in a call to
:meth:`.SubredditModeration.update` should retain their current value. If they
do not please file a bug.
.. warning::
Undocumented settings, or settings that were very recently documented, may
not retain their current value when updating. This often occurs when Reddit
adds a new setting but forgets to add that setting to the API endpoint that
is used to fetch the current settings.
"""
current_settings = self.settings()
# These attributes come out using different names than they go in.
remap = {
"allow_top": "default_set",
"header_title": "header_hover_text",
"lang": "language",
"link_type": "content_options",
"sr": "subreddit_id",
"type": "subreddit_type",
}
for new, old in remap.items():
current_settings[new] = current_settings.pop(old)
current_settings.update(settings)
return UserSubreddit._create_or_update(
_reddit=self.subreddit._reddit, **current_settings
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,346 @@
"""Provide the WikiPage class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Generator, Iterator
from ...const import API_PATH
from ...util import _deprecate_args
from ...util.cache import cachedproperty
from ..listing.generator import ListingGenerator
from .base import RedditBase
from .redditor import Redditor
if TYPE_CHECKING: # pragma: no cover
import praw.models
class WikiPageModeration:
"""Provides a set of moderation functions for a :class:`.WikiPage`.
For example, to add u/spez as an editor on the wikipage ``"praw_test"`` try:
.. code-block:: python
reddit.subreddit("test").wiki["praw_test"].mod.add("spez")
"""
def __init__(self, wikipage: WikiPage):
"""Initialize a :class:`.WikiPageModeration` instance.
:param wikipage: The wikipage to moderate.
"""
self.wikipage = wikipage
def add(self, redditor: praw.models.Redditor):
"""Add an editor to this :class:`.WikiPage`.
:param redditor: A redditor name or :class:`.Redditor` instance.
To add u/spez as an editor on the wikipage ``"praw_test"`` try:
.. code-block:: python
reddit.subreddit("test").wiki["praw_test"].mod.add("spez")
"""
data = {"page": self.wikipage.name, "username": str(redditor)}
url = API_PATH["wiki_page_editor"].format(
subreddit=self.wikipage.subreddit, method="add"
)
self.wikipage._reddit.post(url, data=data)
def remove(self, redditor: praw.models.Redditor):
"""Remove an editor from this :class:`.WikiPage`.
:param redditor: A redditor name or :class:`.Redditor` instance.
To remove u/spez as an editor on the wikipage ``"praw_test"`` try:
.. code-block:: python
reddit.subreddit("test").wiki["praw_test"].mod.remove("spez")
"""
data = {"page": self.wikipage.name, "username": str(redditor)}
url = API_PATH["wiki_page_editor"].format(
subreddit=self.wikipage.subreddit, method="del"
)
self.wikipage._reddit.post(url, data=data)
def revert(self):
"""Revert a wikipage back to a specific revision.
To revert the page ``"praw_test"`` in r/test to revision ``"1234abc"``, try
.. code-block:: python
reddit.subreddit("test").wiki["praw_test"].revision("1234abc").mod.revert()
.. note::
When you attempt to revert the page ``config/stylesheet``, Reddit checks to
see if the revision being reverted to passes the CSS filter. If the check
fails, then the revision attempt will also fail, and a
``prawcore.Forbidden`` exception will be raised. For example, you can't
revert to a revision that contains a link to ``url(%%PRAW%%)`` if there is
no image named ``PRAW`` on the current stylesheet.
Here is an example of how to look for this type of error:
.. code-block:: python
from prawcore.exceptions import Forbidden
try:
reddit.subreddit("test").wiki["config/stylesheet"].revision("1234abc").mod.revert()
except Forbidden as exception:
try:
exception.response.json()
except ValueError:
exception.response.text
If the error occurs, the output will look something like
.. code-block:: python
{"reason": "INVALID_CSS", "message": "Forbidden", "explanation": "%(css_error)s"}
"""
self.wikipage._reddit.post(
API_PATH["wiki_revert"].format(subreddit=self.wikipage.subreddit),
data={
"page": self.wikipage.name,
"revision": self.wikipage._revision,
},
)
def settings(self) -> dict[str, Any]:
"""Return the settings for this :class:`.WikiPage`."""
url = API_PATH["wiki_page_settings"].format(
subreddit=self.wikipage.subreddit, page=self.wikipage.name
)
return self.wikipage._reddit.get(url)["data"]
@_deprecate_args("listed", "permlevel")
def update(
self, *, listed: bool, permlevel: int, **other_settings: Any
) -> dict[str, Any]:
"""Update the settings for this :class:`.WikiPage`.
:param listed: Show this page on page list.
:param permlevel: Who can edit this page? ``0`` use subreddit wiki permissions,
``1`` only approved wiki contributors for this page may edit (see
:meth:`.WikiPageModeration.add`), ``2`` only mods may edit and view.
:param other_settings: Additional keyword arguments to pass.
:returns: The updated WikiPage settings.
To set the wikipage ``"praw_test"`` in r/test to mod only and disable it from
showing in the page list, try:
.. code-block:: python
reddit.subreddit("test").wiki["praw_test"].mod.update(listed=False, permlevel=2)
"""
other_settings.update({"listed": listed, "permlevel": permlevel})
url = API_PATH["wiki_page_settings"].format(
subreddit=self.wikipage.subreddit, page=self.wikipage.name
)
return self.wikipage._reddit.post(url, data=other_settings)["data"]
class WikiPage(RedditBase):
"""An individual :class:`.WikiPage` object.
.. include:: ../../typical_attributes.rst
================= =================================================================
Attribute Description
================= =================================================================
``content_html`` The contents of the wiki page, as HTML.
``content_md`` The contents of the wiki page, as Markdown.
``may_revise`` A ``bool`` representing whether or not the authenticated user may
edit the wiki page.
``name`` The name of the wiki page.
``revision_by`` The :class:`.Redditor` who authored this revision of the wiki
page.
``revision_date`` The time of this revision, in `Unix Time`_.
``subreddit`` The :class:`.Subreddit` this wiki page belongs to.
================= =================================================================
.. _unix time: https://en.wikipedia.org/wiki/Unix_time
"""
__hash__ = RedditBase.__hash__
@staticmethod
def _revision_generator(
*,
generator_kwargs: dict[str, Any],
subreddit: praw.models.Subreddit,
url: str,
) -> Generator[
dict[str, Redditor | WikiPage | str | int | bool | None], None, None
]:
for revision in ListingGenerator(subreddit._reddit, url, **generator_kwargs):
if revision["author"] is not None:
revision["author"] = Redditor(
subreddit._reddit, _data=revision["author"]["data"]
)
revision["page"] = WikiPage(
subreddit._reddit, subreddit, revision["page"], revision["id"]
)
yield revision
@cachedproperty
def mod(self) -> WikiPageModeration:
"""Provide an instance of :class:`.WikiPageModeration`.
For example, to add u/spez as an editor on the wikipage ``"praw_test"`` try:
.. code-block:: python
reddit.subreddit("test").wiki["praw_test"].mod.add("spez")
"""
return WikiPageModeration(self)
def __init__(
self,
reddit: praw.Reddit,
subreddit: praw.models.Subreddit,
name: str,
revision: str | None = None,
_data: dict[str, Any] | None = None,
):
"""Initialize a :class:`.WikiPage` instance.
:param revision: A specific revision ID to fetch. By default, fetches the most
recent revision.
"""
self.name = name
self._revision = revision
self.subreddit = subreddit
super().__init__(reddit, _data=_data, _str_field=False)
def __repr__(self) -> str:
"""Return an object initialization representation of the instance."""
return (
f"{self.__class__.__name__}(subreddit={self.subreddit!r},"
f" name={self.name!r})"
)
def __str__(self) -> str:
"""Return a string representation of the instance."""
return f"{self.subreddit}/{self.name}"
def _fetch(self):
data = self._fetch_data()
data = data["data"]
if data["revision_by"] is not None:
data["revision_by"] = Redditor(
self._reddit, _data=data["revision_by"]["data"]
)
self.__dict__.update(data)
super()._fetch()
def _fetch_info(self):
return (
"wiki_page",
{"subreddit": self.subreddit, "page": self.name},
{"v": self._revision} if self._revision else None,
)
def discussions(self, **generator_kwargs: Any) -> Iterator[praw.models.Submission]:
"""Return a :class:`.ListingGenerator` for discussions of a wiki page.
Discussions are site-wide links to a wiki page.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
To view the titles of discussions of the page ``"praw_test"`` in r/test, try:
.. code-block:: python
for submission in reddit.subreddit("test").wiki["praw_test"].discussions():
print(submission.title)
"""
return ListingGenerator(
self._reddit,
API_PATH["wiki_discussions"].format(
subreddit=self.subreddit, page=self.name
),
**generator_kwargs,
)
@_deprecate_args("content", "reason")
def edit(self, *, content: str, reason: str | None = None, **other_settings: Any):
"""Edit this wiki page's contents.
:param content: The updated Markdown content of the page.
:param reason: The reason for the revision.
:param other_settings: Additional keyword arguments to pass.
For example, to replace the first wiki page of r/test with the phrase ``"test
wiki page"``:
.. code-block:: python
page = next(iter(reddit.subreddit("test").wiki))
page.edit(content="test wiki page")
"""
other_settings.update({"content": content, "page": self.name, "reason": reason})
self._reddit.post(
API_PATH["wiki_edit"].format(subreddit=self.subreddit), data=other_settings
)
def revision(self, revision: str) -> WikiPage:
"""Return a specific version of this page by revision ID.
To view revision ``"1234abc"`` of ``"praw_test"`` in r/test:
.. code-block:: python
page = reddit.subreddit("test").wiki["praw_test"].revision("1234abc")
"""
return WikiPage(self.subreddit._reddit, self.subreddit, self.name, revision)
def revisions(
self, **generator_kwargs: str | int | dict[str, str]
) -> Generator[WikiPage, None, None]:
"""Return a :class:`.ListingGenerator` for page revisions.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
To view the wiki revisions for ``"praw_test"`` in r/test try:
.. code-block:: python
for item in reddit.subreddit("test").wiki["praw_test"].revisions():
print(item)
To get :class:`.WikiPage` objects for each revision:
.. code-block:: python
for item in reddit.subreddit("test").wiki["praw_test"].revisions():
print(item["page"])
"""
url = API_PATH["wiki_page_revisions"].format(
subreddit=self.subreddit, page=self.name
)
return self._revision_generator(
generator_kwargs=generator_kwargs, subreddit=self.subreddit, url=url
)