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,13 @@
"""Python Reddit API Wrapper.
PRAW, an acronym for "Python Reddit API Wrapper", is a python package that allows for
simple access to Reddit's API. PRAW aims to be as easy to use as possible and is
designed to follow all of Reddit's API rules. You have to give a useragent, everything
else is handled by PRAW so you needn't worry about violating them.
More information about PRAW can be found at https://github.com/praw-dev/praw.
"""
from .const import __version__
from .reddit import Reddit

View File

@@ -0,0 +1,178 @@
"""Provides the code to load PRAW's configuration file ``praw.ini``."""
from __future__ import annotations
import configparser
import os
import sys
from pathlib import Path
from threading import Lock
from typing import Any
from .exceptions import ClientException
class _NotSet:
def __bool__(self) -> bool:
return False
__nonzero__ = __bool__
def __str__(self) -> str:
return "NotSet"
class Config:
"""A class containing the configuration for a Reddit site."""
CONFIG = None
CONFIG_NOT_SET = _NotSet() # Represents a config value that is not set.
LOCK = Lock()
INTERPOLATION_LEVEL = {
"basic": configparser.BasicInterpolation,
"extended": configparser.ExtendedInterpolation,
}
@staticmethod
def _config_boolean(item: bool | str) -> bool:
if isinstance(item, bool):
return item
return item.lower() in {"1", "yes", "true", "on"}
@classmethod
def _load_config(cls, *, config_interpolation: str | None = None):
"""Attempt to load settings from various praw.ini files."""
if config_interpolation is not None:
interpolator_class = cls.INTERPOLATION_LEVEL[config_interpolation]()
else:
interpolator_class = None
config = configparser.ConfigParser(interpolation=interpolator_class)
module_dir = Path(sys.modules[__name__].__file__).parent
if "APPDATA" in os.environ: # Windows
os_config_path = Path(os.environ["APPDATA"])
elif "XDG_CONFIG_HOME" in os.environ: # Modern Linux
os_config_path = Path(os.environ["XDG_CONFIG_HOME"])
elif "HOME" in os.environ: # Legacy Linux
os_config_path = Path(os.environ["HOME"]) / ".config"
else:
os_config_path = None
locations = [str(module_dir / "praw.ini"), "praw.ini"]
if os_config_path is not None:
locations.insert(1, str(os_config_path / "praw.ini"))
config.read(locations)
cls.CONFIG = config
@property
def short_url(self) -> str:
"""Return the short url.
:raises: :class:`.ClientException` if it is not set.
"""
if self._short_url is self.CONFIG_NOT_SET:
msg = "No short domain specified."
raise ClientException(msg)
return self._short_url
def __init__(
self,
site_name: str,
config_interpolation: str | None = None,
**settings: str,
):
"""Initialize a :class:`.Config` instance."""
with Config.LOCK:
if Config.CONFIG is None:
self._load_config(config_interpolation=config_interpolation)
self._settings = settings
self.custom = dict(Config.CONFIG.items(site_name), **settings)
self.client_id = self.client_secret = self.oauth_url = None
self.reddit_url = self.refresh_token = self.redirect_uri = None
self.password = self.user_agent = self.username = None
self._initialize_attributes()
def _fetch(self, key: str) -> Any:
value = self.custom[key]
del self.custom[key]
return value
def _fetch_default(
self, key: str, *, default: bool | float | str | None = None
) -> Any:
if key not in self.custom:
return default
return self._fetch(key)
def _fetch_or_not_set(self, key: str) -> Any | _NotSet:
if key in self._settings: # Passed in values have the highest priority
return self._fetch(key)
env_value = os.getenv(f"praw_{key}")
ini_value = self._fetch_default(key) # Needed to remove from custom
# Environment variables have higher priority than praw.ini settings
return env_value or ini_value or self.CONFIG_NOT_SET
def _initialize_attributes(self):
self._short_url = self._fetch_default("short_url") or self.CONFIG_NOT_SET
self.check_for_async = self._config_boolean(
self._fetch_default("check_for_async", default=True)
)
self.check_for_updates = self._config_boolean(
self._fetch_or_not_set("check_for_updates")
)
self.warn_comment_sort = self._config_boolean(
self._fetch_default("warn_comment_sort", default=True)
)
self.warn_additional_fetch_params = self._config_boolean(
self._fetch_default("warn_additional_fetch_params", default=True)
)
self.window_size = self._fetch_default("window_size", default=600)
self.kinds = {
x: self._fetch(f"{x}_kind")
for x in [
"comment",
"message",
"redditor",
"submission",
"subreddit",
"trophy",
]
}
for attribute in (
"client_id",
"client_secret",
"redirect_uri",
"refresh_token",
"password",
"user_agent",
"username",
):
setattr(self, attribute, self._fetch_or_not_set(attribute))
for required_attribute in (
"oauth_url",
"ratelimit_seconds",
"reddit_url",
"timeout",
):
setattr(self, required_attribute, self._fetch(required_attribute))
for attribute, conversion in {
"ratelimit_seconds": int,
"timeout": int,
}.items():
try:
setattr(self, attribute, conversion(getattr(self, attribute)))
except ValueError:
msg = f"An incorrect config type was given for option {attribute}. The expected type is {conversion.__name__}, but the given value is {getattr(self, attribute)}."
raise ValueError(msg) from None

View File

@@ -0,0 +1,14 @@
"""PRAW constants."""
from .endpoints import API_PATH # noqa: F401
__version__ = "7.8.1"
USER_AGENT_FORMAT = f"{{}} PRAW/{__version__}"
MAX_IMAGE_SIZE = 512000
MIN_JPEG_SIZE = 128
MIN_PNG_SIZE = 67
JPEG_HEADER = b"\xff\xd8\xff"
PNG_HEADER = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a"

View File

@@ -0,0 +1,235 @@
"""List of API endpoints PRAW knows about."""
# fmt: off
API_PATH = {
"about_edited": "r/{subreddit}/about/edited/",
"about_log": "r/{subreddit}/about/log/",
"about_modqueue": "r/{subreddit}/about/modqueue/",
"about_reports": "r/{subreddit}/about/reports/",
"about_spam": "r/{subreddit}/about/spam/",
"about_sticky": "r/{subreddit}/about/sticky/",
"about_stylesheet": "r/{subreddit}/about/stylesheet/",
"about_traffic": "r/{subreddit}/about/traffic/",
"about_unmoderated": "r/{subreddit}/about/unmoderated/",
"accept_mod_invite": "r/{subreddit}/api/accept_moderator_invite",
"add_subreddit_rule": "api/add_subreddit_rule",
"add_whitelisted": "api/add_whitelisted",
"approve": "api/approve/",
"award_thing": "api/v2/gold/gild",
"block": "api/block",
"block_user": "/api/block_user/",
"blocked": "prefs/blocked/",
"collapse": "api/collapse_message/",
"collection": "api/v1/collections/collection",
"collection_add_post": "api/v1/collections/add_post_to_collection",
"collection_create": "api/v1/collections/create_collection",
"collection_delete": "api/v1/collections/delete_collection",
"collection_desc": "api/v1/collections/update_collection_description",
"collection_layout": "api/v1/collections/update_collection_display_layout",
"collection_follow": "api/v1/collections/follow_collection",
"collection_remove_post": "api/v1/collections/remove_post_in_collection",
"collection_reorder": "api/v1/collections/reorder_collection",
"collection_subreddit": "api/v1/collections/subreddit_collections",
"collection_title": "api/v1/collections/update_collection_title",
"comment": "api/comment/",
"comment_replies": "message/comments/",
"compose": "api/compose/",
"contest_mode": "api/set_contest_mode/",
"convert_rte_body": "api/convert_rte_body_format",
"del": "api/del/",
"delete_message": "api/del_msg",
"delete_sr_banner": "r/{subreddit}/api/delete_sr_banner",
"delete_sr_header": "r/{subreddit}/api/delete_sr_header",
"delete_sr_icon": "r/{subreddit}/api/delete_sr_icon",
"delete_sr_image": "r/{subreddit}/api/delete_sr_img",
"deleteflair": "r/{subreddit}/api/deleteflair",
"distinguish": "api/distinguish/",
"draft": "api/v1/draft",
"drafts": "api/v1/drafts",
"domain": "domain/{domain}/",
"duplicates": "duplicates/{submission_id}/",
"edit": "api/editusertext/",
"emoji_delete": "api/v1/{subreddit}/emoji/{emoji_name}",
"emoji_lease": "api/v1/{subreddit}/emoji_asset_upload_s3.json",
"emoji_list": "api/v1/{subreddit}/emojis/all",
"emoji_update": "api/v1/{subreddit}/emoji_permissions",
"emoji_upload": "api/v1/{subreddit}/emoji.json",
"flair": "r/{subreddit}/api/flair/",
"flairconfig": "r/{subreddit}/api/flairconfig/",
"flaircsv": "r/{subreddit}/api/flaircsv/",
"flairlist": "r/{subreddit}/api/flairlist/",
"flairselector": "r/{subreddit}/api/flairselector/",
"flairtemplate_v2": "r/{subreddit}/api/flairtemplate_v2",
"flairtemplateclear": "r/{subreddit}/api/clearflairtemplates/",
"flairtemplatedelete": "r/{subreddit}/api/deleteflairtemplate/",
"flairtemplatereorder": "r/{subreddit}/api/flair_template_order",
"friend": "r/{subreddit}/api/friend/",
"friend_v1": "api/v1/me/friends/{user}",
"friends": "api/v1/me/friends/",
"gild_user": "api/v1/gold/give/{username}/",
"hide": "api/hide/",
"ignore_reports": "api/ignore_reports/",
"inbox": "message/inbox/",
"info": "api/info/",
"karma": "api/v1/me/karma",
"leavecontributor": "api/leavecontributor",
"link_flair": "r/{subreddit}/api/link_flair_v2",
"list_banned": "r/{subreddit}/about/banned/",
"list_contributor": "r/{subreddit}/about/contributors/",
"list_moderator": "r/{subreddit}/about/moderators/",
"list_invited_moderator": "/api/v1/{subreddit}/moderators_invited",
"list_muted": "r/{subreddit}/about/muted/",
"list_wikibanned": "r/{subreddit}/about/wikibanned/",
"list_wikicontributor": "r/{subreddit}/about/wikicontributors/",
"live_accept_invite": "api/live/{id}/accept_contributor_invite",
"live_add_update": "api/live/{id}/update",
"live_close": "api/live/{id}/close_thread",
"live_contributors": "live/{id}/contributors",
"live_discussions": "live/{id}/discussions",
"live_focus": "live/{thread_id}/updates/{update_id}",
"live_info": "api/live/by_id/{ids}",
"live_invite": "api/live/{id}/invite_contributor",
"live_leave": "api/live/{id}/leave_contributor",
"live_now": "api/live/happening_now",
"live_remove_contrib": "api/live/{id}/rm_contributor",
"live_remove_invite": "api/live/{id}/rm_contributor_invite",
"live_remove_update": "api/live/{id}/delete_update",
"live_report": "api/live/{id}/report",
"live_strike": "api/live/{id}/strike_update",
"live_update_perms": "api/live/{id}/set_contributor_permissions",
"live_update_thread": "api/live/{id}/edit",
"live_updates": "live/{id}",
"liveabout": "api/live/{id}/about/",
"livecreate": "api/live/create",
"lock": "api/lock/",
"marknsfw": "api/marknsfw/",
"me": "api/v1/me",
"media_asset": "api/media/asset.json",
"mentions": "message/mentions",
"message": "message/messages/{id}/",
"messages": "message/messages/",
"mod_notes": "api/mod/notes",
"mod_notes_bulk": "api/mod/notes/recent",
"moderated": "user/{user}/moderated_subreddits/",
"moderator_messages": "r/{subreddit}/message/moderator/",
"moderator_unread": "r/{subreddit}/message/moderator/unread/",
"modmail_archive": "api/mod/conversations/{id}/archive",
"modmail_bulk_read": "api/mod/conversations/bulk/read",
"modmail_conversation": "api/mod/conversations/{id}",
"modmail_conversations": "api/mod/conversations/",
"modmail_highlight": "api/mod/conversations/{id}/highlight",
"modmail_mute": "api/mod/conversations/{id}/mute",
"modmail_read": "api/mod/conversations/read",
"modmail_subreddits": "api/mod/conversations/subreddits",
"modmail_unarchive": "api/mod/conversations/{id}/unarchive",
"modmail_unmute": "api/mod/conversations/{id}/unmute",
"modmail_unread": "api/mod/conversations/unread",
"modmail_unread_count": "api/mod/conversations/unread/count",
"morechildren": "api/morechildren/",
"multireddit": "user/{user}/m/{multi}/",
"multireddit_api": "api/multi/user/{user}/m/{multi}/",
"multireddit_base": "api/multi/",
"multireddit_copy": "api/multi/copy/",
"multireddit_rename": "api/multi/rename/",
"multireddit_update": "api/multi/user/{user}/m/{multi}/r/{subreddit}",
"multireddit_user": "api/multi/user/{user}/",
"mute_sender": "api/mute_message_author/",
"my_contributor": "subreddits/mine/contributor/",
"my_moderator": "subreddits/mine/moderator/",
"my_multireddits": "api/multi/mine/",
"my_subreddits": "subreddits/mine/subscriber/",
"post_requirements": "api/v1/{subreddit}/post_requirements",
"preferences": "api/v1/me/prefs",
"quarantine_opt_in": "api/quarantine_optin",
"quarantine_opt_out": "api/quarantine_optout",
"read_all_messages": "api/read_all_messages",
"read_message": "api/read_message/",
"removal_comment_message": "api/v1/modactions/removal_comment_message",
"removal_link_message": "api/v1/modactions/removal_link_message",
"removal_reasons": "api/v1/modactions/removal_reasons",
"removal_reason": "api/v1/{subreddit}/removal_reasons/{id}",
"removal_reasons_list": "api/v1/{subreddit}/removal_reasons",
"remove_subreddit_rule": "api/remove_subreddit_rule",
"remove_whitelisted": "api/remove_whitelisted",
"remove": "api/remove/",
"reorder_subreddit_rules": "api/reorder_subreddit_rules",
"report": "api/report/",
"rules": "r/{subreddit}/about/rules",
"save": "api/save/",
"search": "r/{subreddit}/search/",
"select_flair": "r/{subreddit}/api/selectflair/",
"sendreplies": "api/sendreplies",
"sent": "message/sent/",
"set_original_content": "api/set_original_content",
"setpermissions": "r/{subreddit}/api/setpermissions/",
"show_comment": "api/show_comment",
"site_admin": "api/site_admin/",
"spoiler": "api/spoiler/",
"sticky_submission": "api/set_subreddit_sticky/",
"store_visits": "api/store_visits",
"structured_styles": "api/v1/structured_styles/{subreddit}",
"style_asset_lease": "api/v1/style_asset_upload_s3/{subreddit}",
"sub_recommended": "api/recommend/sr/{subreddits}",
"submission": "comments/{id}/",
"submission_replies": "message/selfreply/",
"submit": "api/submit/",
"submit_gallery_post": "api/submit_gallery_post.json",
"submit_poll_post": "api/submit_poll_post",
"subreddit": "r/{subreddit}/",
"subreddit_about": "r/{subreddit}/about/",
"subreddit_filter": "api/filter/user/{user}/f/{special}/r/{subreddit}",
"subreddit_filter_list": "api/filter/user/{user}/f/{special}",
"subreddit_random": "r/{subreddit}/random/",
"subreddit_settings": "r/{subreddit}/about/edit/",
"subreddit_stylesheet": "r/{subreddit}/api/subreddit_stylesheet/",
"subreddits_by_topic": "api/subreddits_by_topic",
"subreddits_default": "subreddits/default/",
"subreddits_gold": "subreddits/premium/",
"subreddits_name_search": "api/search_reddit_names/",
"subreddits_new": "subreddits/new/",
"subreddits_popular": "subreddits/popular/",
"subreddits_search": "subreddits/search/",
"subscribe": "api/subscribe/",
"suggested_sort": "api/set_suggested_sort/",
"trophies": "api/v1/user/{user}/trophies",
"trusted": "prefs/trusted",
"unblock_subreddit": "api/unblock_subreddit",
"uncollapse": "api/uncollapse_message/",
"unfriend": "r/{subreddit}/api/unfriend/",
"unhide": "api/unhide/",
"unignore_reports": "api/unignore_reports/",
"unlock": "api/unlock/",
"unmarknsfw": "api/unmarknsfw/",
"unmute_sender": "api/unmute_message_author/",
"unread": "message/unread/",
"unread_message": "api/unread_message/",
"unsave": "api/unsave/",
"unspoiler": "api/unspoiler/",
"update_crowd_control": "api/update_crowd_control_level",
"update_settings": "api/v1/subreddit/update_settings",
"update_subreddit_rule": "api/update_subreddit_rule",
"upload_image": "r/{subreddit}/api/upload_sr_img",
"user": "user/{user}/",
"user_about": "user/{user}/about/",
"user_by_fullname": "/api/user_data_by_account_ids",
"user_flair": "r/{subreddit}/api/user_flair_v2",
"username_available": "api/username_available",
"users_new": "users/new",
"users_popular": "users/popular",
"users_search": "users/search",
"vote": "api/vote/",
"widget_create": "r/{subreddit}/api/widget",
"widget_lease": "r/{subreddit}/api/widget_image_upload_s3",
"widget_modify": "r/{subreddit}/api/widget/{widget_id}",
"widget_order": "r/{subreddit}/api/widget_order/{section}",
"widgets": "r/{subreddit}/api/widgets",
"wiki_discussions": "r/{subreddit}/wiki/discussions/{page}",
"wiki_edit": "r/{subreddit}/api/wiki/edit",
"wiki_page": "r/{subreddit}/wiki/{page}",
"wiki_page_editor": "r/{subreddit}/api/wiki/alloweditor/{method}",
"wiki_page_revisions": "r/{subreddit}/wiki/revisions/{page}",
"wiki_page_settings": "r/{subreddit}/wiki/settings/{page}",
"wiki_pages": "r/{subreddit}/wiki/pages/",
"wiki_revert": "r/{subreddit}/api/wiki/revert",
"wiki_revisions": "r/{subreddit}/wiki/revisions/",
}

View File

@@ -0,0 +1,307 @@
"""PRAW exception classes.
Includes two main exceptions: :class:`.RedditAPIException` for when something goes wrong
on the server side, and :class:`.ClientException` when something goes wrong on the
client side. Both of these classes extend :class:`.PRAWException`.
All other exceptions are subclassed from :class:`.ClientException`.
"""
from __future__ import annotations
from typing import Any
from warnings import warn
from .util import _deprecate_args
class PRAWException(Exception):
"""The base PRAW Exception that all other exception classes extend."""
class RedditErrorItem:
"""Represents a single error returned from Reddit's API."""
@property
def error_message(self) -> str:
"""Get the completed error message string."""
error_str = self.error_type
if self.message:
error_str += f": {self.message!r}"
if self.field:
error_str += f" on field {self.field!r}"
return error_str
def __eq__(self, other: RedditErrorItem | list[str]) -> bool:
"""Check for equality."""
if isinstance(other, RedditErrorItem):
return (self.error_type, self.message, self.field) == (
other.error_type,
other.message,
other.field,
)
return super().__eq__(other)
@_deprecate_args("error_type", "message", "field")
def __init__(
self,
error_type: str,
*,
field: str | None = None,
message: str | None = None,
):
"""Initialize a :class:`.RedditErrorItem` instance.
:param error_type: The error type set on Reddit's end.
:param field: The input field associated with the error, if available.
:param message: The associated message for the error.
"""
self.error_type = error_type
self.message = message
self.field = field
def __repr__(self) -> str:
"""Return an object initialization representation of the instance."""
return (
f"{self.__class__.__name__}(error_type={self.error_type!r},"
f" message={self.message!r}, field={self.field!r})"
)
def __str__(self) -> str:
"""Get the message returned from str(self)."""
return self.error_message
class ClientException(PRAWException):
"""Indicate exceptions that don't involve interaction with Reddit's API."""
class DuplicateReplaceException(ClientException):
"""Indicate exceptions that involve the replacement of :class:`.MoreComments`."""
def __init__(self):
"""Initialize a :class:`.DuplicateReplaceException` instance."""
super().__init__(
"A duplicate comment has been detected. Are you attempting to call"
" 'replace_more_comments' more than once?"
)
class InvalidFlairTemplateID(ClientException):
"""Indicate exceptions where an invalid flair template ID is given."""
def __init__(self, template_id: str):
"""Initialize an :class:`.InvalidFlairTemplateID` instance."""
super().__init__(
f"The flair template ID '{template_id}' is invalid. If you are trying to"
" create a flair, please use the 'add' method."
)
class InvalidImplicitAuth(ClientException):
"""Indicate exceptions where an implicit auth type is used incorrectly."""
def __init__(self):
"""Initialize an :class:`.InvalidImplicitAuth` instance."""
super().__init__("Implicit authorization can only be used with installed apps.")
class InvalidURL(ClientException):
"""Indicate exceptions where an invalid URL is entered."""
@_deprecate_args("url", "message")
def __init__(self, url: str, *, message: str = "Invalid URL: {}"):
"""Initialize an :class:`.InvalidURL` instance.
:param url: The invalid URL.
:param message: The message to display. Must contain a format identifier (``{}``
or ``{0}``) (default: ``"Invalid URL: {}"``).
"""
super().__init__(message.format(url))
class MissingRequiredAttributeException(ClientException):
"""Indicate exceptions caused by not including a required attribute."""
class ReadOnlyException(ClientException):
"""Raised when a method call requires :attr:`.read_only` mode to be disabled."""
class TooLargeMediaException(ClientException):
"""Indicate exceptions from uploading media that's too large."""
@_deprecate_args("maximum_size", "actual")
def __init__(self, *, actual: int, maximum_size: int):
"""Initialize a :class:`.TooLargeMediaException` instance.
:param actual: The actual size of the uploaded media.
:param maximum_size: The maximum size of the uploaded media.
"""
self.maximum_size = maximum_size
self.actual = actual
super().__init__(
f"The media that you uploaded was too large (maximum size is {maximum_size}"
f" bytes, uploaded {actual} bytes)"
)
class WebSocketException(ClientException):
"""Indicate exceptions caused by use of WebSockets."""
@property
def original_exception(self) -> Exception:
"""Access the ``original_exception`` attribute (now deprecated)."""
warn(
"Accessing the attribute 'original_exception' is deprecated. Please rewrite"
" your code in such a way that this attribute does not need to be used. It"
" will be removed in PRAW 8.0.",
category=DeprecationWarning,
stacklevel=2,
)
return self._original_exception
@original_exception.setter
def original_exception(self, value: Exception):
self._original_exception = value
@original_exception.deleter
def original_exception(self):
del self._original_exception
def __init__(self, message: str, exception: Exception | None):
"""Initialize a :class:`.WebSocketException` instance.
:param message: The exception message.
:param exception: The exception thrown by the websocket library.
.. note::
This parameter is deprecated. It will be removed in PRAW 8.0.
"""
super().__init__(message)
self._original_exception = exception
class MediaPostFailed(WebSocketException):
"""Indicate exceptions where media uploads failed.."""
def __init__(self):
"""Initialize a :class:`.MediaPostFailed` instance."""
super().__init__(
"The attempted media upload action has failed. Possible causes include the"
" corruption of media files. Check that the media file can be opened on"
" your local machine.",
None,
)
class APIException(PRAWException):
"""Old class preserved for alias purposes.
.. deprecated:: 7.0
Class :class:`.APIException` has been deprecated in favor of
:class:`.RedditAPIException`. This class will be removed in PRAW 8.0.
"""
@staticmethod
def parse_exception_list(
exceptions: list[RedditErrorItem | list[str]],
) -> list[RedditErrorItem]:
"""Covert an exception list into a :class:`.RedditErrorItem` list."""
return [
(
exception
if isinstance(exception, RedditErrorItem)
else RedditErrorItem(
error_type=exception[0],
field=exception[2] if bool(exception[2]) else "",
message=exception[1] if bool(exception[1]) else "",
)
)
for exception in exceptions
]
@property
def error_type(self) -> str:
"""Get error_type.
.. deprecated:: 7.0
Accessing attributes through instances of :class:`.RedditAPIException` is
deprecated. This behavior will be removed in PRAW 8.0. Check out the
:ref:`PRAW 7 Migration tutorial <Exception_Handling>` on how to migrate code
from this behavior.
"""
return self._get_old_attr("error_type")
@property
def field(self) -> str:
"""Get field.
.. deprecated:: 7.0
Accessing attributes through instances of :class:`.RedditAPIException` is
deprecated. This behavior will be removed in PRAW 8.0. Check out the
:ref:`PRAW 7 Migration tutorial <Exception_Handling>` on how to migrate code
from this behavior.
"""
return self._get_old_attr("field")
@property
def message(self) -> str:
"""Get message.
.. deprecated:: 7.0
Accessing attributes through instances of :class:`.RedditAPIException` is
deprecated. This behavior will be removed in PRAW 8.0. Check out the
:ref:`PRAW 7 Migration tutorial <Exception_Handling>` on how to migrate code
from this behavior.
"""
return self._get_old_attr("message")
def __init__(
self,
items: list[RedditErrorItem | list[str] | str] | str,
*optional_args: str,
):
"""Initialize a :class:`.RedditAPIException` instance.
:param items: Either a list of instances of :class:`.RedditErrorItem` or a list
containing lists of unformed errors.
:param optional_args: Takes the second and third arguments that
:class:`.APIException` used to take.
"""
if isinstance(items, str):
items = [[items, *optional_args]]
elif isinstance(items, list) and isinstance(items[0], str):
items = [items]
self.items = self.parse_exception_list(items)
super().__init__(*self.items)
def _get_old_attr(self, attrname: str) -> Any:
warn(
f"Accessing attribute '{attrname}' through APIException is deprecated."
" This behavior will be removed in PRAW 8.0. Check out"
" https://praw.readthedocs.io/en/latest/package_info/praw7_migration.html"
" to learn how to migrate your code.",
category=DeprecationWarning,
stacklevel=3,
)
return getattr(self.items[0], attrname)
class RedditAPIException(APIException):
"""Container for error messages from Reddit's API."""

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,65 @@
"""Provide the PRAW models."""
from .auth import Auth
from .front import Front
from .helpers import DraftHelper, LiveHelper, MultiredditHelper, SubredditHelper
from .inbox import Inbox
from .list.draft import DraftList
from .list.moderated import ModeratedList
from .list.redditor import RedditorList
from .list.trophy import TrophyList
from .listing.domain import DomainListing
from .listing.generator import ListingGenerator
from .listing.listing import Listing, ModeratorListing, ModmailConversationsListing
from .mod_action import ModAction
from .mod_note import ModNote
from .mod_notes import RedditModNotes, RedditorModNotes, SubredditModNotes
from .preferences import Preferences
from .reddit.collections import Collection
from .reddit.comment import Comment
from .reddit.draft import Draft
from .reddit.emoji import Emoji
from .reddit.inline_media import InlineGif, InlineImage, InlineMedia, InlineVideo
from .reddit.live import LiveThread, LiveUpdate
from .reddit.message import Message, SubredditMessage
from .reddit.modmail import ModmailAction, ModmailConversation, ModmailMessage
from .reddit.more import MoreComments
from .reddit.multi import Multireddit
from .reddit.poll import PollData, PollOption
from .reddit.redditor import Redditor
from .reddit.removal_reasons import RemovalReason
from .reddit.rules import Rule
from .reddit.submission import Submission
from .reddit.subreddit import Subreddit
from .reddit.user_subreddit import UserSubreddit
from .reddit.widgets import (
Button,
ButtonWidget,
Calendar,
CalendarConfiguration,
CommunityList,
CustomWidget,
Hover,
IDCard,
Image,
ImageData,
ImageWidget,
Menu,
MenuLink,
ModeratorsWidget,
PostFlairWidget,
RulesWidget,
Styles,
Submenu,
SubredditWidgets,
SubredditWidgetsModeration,
TextArea,
Widget,
WidgetModeration,
)
from .reddit.wikipage import WikiPage
from .redditors import Redditors
from .stylesheet import Stylesheet
from .subreddits import Subreddits
from .trophy import Trophy
from .user import User

View File

@@ -0,0 +1,140 @@
"""Provide the Auth class."""
from __future__ import annotations
from prawcore import Authorizer, ImplicitAuthorizer, UntrustedAuthenticator, session
from ..exceptions import InvalidImplicitAuth, MissingRequiredAttributeException
from ..util import _deprecate_args
from .base import PRAWBase
class Auth(PRAWBase):
"""Auth provides an interface to Reddit's authorization."""
@property
def limits(self) -> dict[str, str | int | None]:
"""Return a dictionary containing the rate limit info.
The keys are:
:remaining: The number of requests remaining to be made in the current rate
limit window.
:reset_timestamp: A unix timestamp providing an upper bound on when the rate
limit counters will reset.
:used: The number of requests made in the current rate limit window.
All values are initially ``None`` as these values are set in response to issued
requests.
The ``reset_timestamp`` value is an upper bound as the real timestamp is
computed on Reddit's end in preparation for sending the response. This value may
change slightly within a given window due to slight changes in response times
and rounding.
"""
data = self._reddit._core._rate_limiter
return {
"remaining": data.remaining,
"reset_timestamp": data.reset_timestamp,
"used": data.used,
}
def authorize(self, code: str) -> str | None:
"""Complete the web authorization flow and return the refresh token.
:param code: The code obtained through the request to the redirect uri.
:returns: The obtained refresh token, if available, otherwise ``None``.
The session's active authorization will be updated upon success.
"""
authenticator = self._reddit._read_only_core._authorizer._authenticator
authorizer = Authorizer(authenticator)
authorizer.authorize(code)
authorized_session = session(
authorizer=authorizer, window_size=self._reddit.config.window_size
)
self._reddit._core = self._reddit._authorized_core = authorized_session
return authorizer.refresh_token
@_deprecate_args("access_token", "expires_in", "scope")
def implicit(self, *, access_token: str, expires_in: int, scope: str):
"""Set the active authorization to be an implicit authorization.
:param access_token: The access_token obtained from Reddit's callback.
:param expires_in: The number of seconds the ``access_token`` is valid for. The
origin of this value was returned from Reddit's callback. You may need to
subtract an offset before passing in this number to account for a delay
between when Reddit prepared the response, and when you make this function
call.
:param scope: A space-delimited string of Reddit OAuth2 scope names as returned
from Reddit's callback.
:raises: :class:`.InvalidImplicitAuth` if :class:`.Reddit` was initialized for a
non-installed application type.
"""
authenticator = self._reddit._read_only_core._authorizer._authenticator
if not isinstance(authenticator, UntrustedAuthenticator):
raise InvalidImplicitAuth
implicit_session = session(
authorizer=ImplicitAuthorizer(
authenticator, access_token, expires_in, scope
),
window_size=self._reddit.config.window_size,
)
self._reddit._core = self._reddit._authorized_core = implicit_session
def scopes(self) -> set[str]:
"""Return a set of scopes included in the current authorization.
For read-only authorizations this should return ``{"*"}``.
"""
authorizer = self._reddit._core._authorizer
if not authorizer.is_valid():
authorizer.refresh()
return authorizer.scopes
@_deprecate_args("scopes", "state", "duration", "implicit")
def url(
self,
*,
duration: str = "permanent",
implicit: bool = False,
scopes: list[str],
state: str,
) -> str:
"""Return the URL used out-of-band to grant access to your application.
:param duration: Either ``"permanent"`` or ``"temporary"`` (default:
``"permanent"``). ``"temporary"`` authorizations generate access tokens that
last only 1 hour. ``"permanent"`` authorizations additionally generate a
refresh token that expires 1 year after the last use and can be used
indefinitely to generate new hour-long access tokens. This value is ignored
when ``implicit=True``.
:param implicit: For **installed** applications, this value can be set to use
the implicit, rather than the code flow. When ``True``, the ``duration``
argument has no effect as only temporary tokens can be retrieved.
:param scopes: A list of OAuth scopes to request authorization for.
:param state: A string that will be reflected in the callback to
``redirect_uri``. This value should be temporarily unique to the client for
whom the URL was generated for.
"""
authenticator = self._reddit._read_only_core._authorizer._authenticator
if authenticator.redirect_uri is self._reddit.config.CONFIG_NOT_SET:
msg = "redirect_uri must be provided"
raise MissingRequiredAttributeException(msg)
if isinstance(authenticator, UntrustedAuthenticator):
return authenticator.authorize_url(
"temporary" if implicit else duration,
scopes,
state,
implicit=implicit,
)
if implicit:
raise InvalidImplicitAuth
return authenticator.authorize_url(duration, scopes, state)

View File

@@ -0,0 +1,49 @@
"""Provide the PRAWBase superclass."""
from __future__ import annotations
from copy import deepcopy
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING: # pragma: no cover
import praw
class PRAWBase:
"""Superclass for all models in PRAW."""
@staticmethod
def _safely_add_arguments(
*, arguments: dict[str, Any], key: str, **new_arguments: Any
):
"""Replace arguments[key] with a deepcopy and update.
This method is often called when new parameters need to be added to a request.
By calling this method and adding the new or updated parameters we can insure we
don't modify the dictionary passed in by the caller.
"""
value = deepcopy(arguments[key]) if key in arguments else {}
value.update(new_arguments)
arguments[key] = value
@classmethod
def parse(cls, data: dict[str, Any], reddit: praw.Reddit) -> Any:
"""Return an instance of ``cls`` from ``data``.
:param data: The structured data.
:param reddit: An instance of :class:`.Reddit`.
"""
return cls(reddit, _data=data)
def __init__(self, reddit: praw.Reddit, _data: dict[str, Any] | None):
"""Initialize a :class:`.PRAWBase` instance.
:param reddit: An instance of :class:`.Reddit`.
"""
self._reddit = reddit
if _data:
for attribute, value in _data.items():
setattr(self, attribute, value)

View File

@@ -0,0 +1,209 @@
"""Provide CommentForest for submission comments."""
from __future__ import annotations
from heapq import heappop, heappush
from typing import TYPE_CHECKING
from ..exceptions import DuplicateReplaceException
from ..util import _deprecate_args
from .reddit.more import MoreComments
if TYPE_CHECKING: # pragma: no cover
import praw.models
class CommentForest:
"""A forest of comments starts with multiple top-level comments.
Each of these comments can be a tree of replies.
"""
def __getitem__(self, index: int) -> praw.models.Comment:
"""Return the comment at position ``index`` in the list.
This method is to be used like an array access, such as:
.. code-block:: python
first_comment = submission.comments[0]
Alternatively, the presence of this method enables one to iterate over all top
level comments, like so:
.. code-block:: python
for comment in submission.comments:
print(comment.body)
"""
return self._comments[index]
def __len__(self) -> int:
"""Return the number of top-level comments in the forest."""
return len(self._comments)
def _insert_comment(self, comment: praw.models.Comment):
if comment.name in self._submission._comments_by_id:
raise DuplicateReplaceException
comment.submission = self._submission
if isinstance(comment, MoreComments) or comment.is_root:
self._comments.append(comment)
else:
assert comment.parent_id in self._submission._comments_by_id, (
"PRAW Error occurred. Please file a bug report and include the code"
" that caused the error."
)
parent = self._submission._comments_by_id[comment.parent_id]
parent.replies._comments.append(comment)
def list( # noqa: A003
self,
) -> list[praw.models.Comment | praw.models.MoreComments]:
"""Return a flattened list of all comments.
This list may contain :class:`.MoreComments` instances if :meth:`.replace_more`
was not called first.
"""
comments = []
queue = list(self)
while queue:
comment = queue.pop(0)
comments.append(comment)
if not isinstance(comment, MoreComments):
queue.extend(comment.replies)
return comments
@staticmethod
def _gather_more_comments(
tree: list[praw.models.MoreComments],
*,
parent_tree: list[praw.models.MoreComments] | None = None,
) -> list[MoreComments]:
"""Return a list of :class:`.MoreComments` objects obtained from tree."""
more_comments = []
queue = [(None, x) for x in tree]
while queue:
parent, comment = queue.pop(0)
if isinstance(comment, MoreComments):
heappush(more_comments, comment)
if parent:
comment._remove_from = parent.replies._comments
else:
comment._remove_from = parent_tree or tree
else:
for item in comment.replies:
queue.append((comment, item))
return more_comments
def __init__(
self,
submission: praw.models.Submission,
comments: list[praw.models.Comment] | None = None,
):
"""Initialize a :class:`.CommentForest` instance.
:param submission: An instance of :class:`.Submission` that is the parent of the
comments.
:param comments: Initialize the forest with a list of comments (default:
``None``).
"""
self._comments = comments
self._submission = submission
def _update(self, comments: list[praw.models.Comment]):
self._comments = comments
for comment in comments:
comment.submission = self._submission
@_deprecate_args("limit", "threshold")
def replace_more(
self, *, limit: int | None = 32, threshold: int = 0
) -> list[praw.models.MoreComments]:
"""Update the comment forest by resolving instances of :class:`.MoreComments`.
:param limit: The maximum number of :class:`.MoreComments` instances to replace.
Each replacement requires 1 API request. Set to ``None`` to have no limit,
or to ``0`` to remove all :class:`.MoreComments` instances without
additional requests (default: ``32``).
:param threshold: The minimum number of children comments a
:class:`.MoreComments` instance must have in order to be replaced.
:class:`.MoreComments` instances that represent "continue this thread" links
unfortunately appear to have 0 children (default: ``0``).
:returns: A list of :class:`.MoreComments` instances that were not replaced.
:raises: ``prawcore.TooManyRequests`` when used concurrently.
For example, to replace up to 32 :class:`.MoreComments` instances of a
submission try:
.. code-block:: python
submission = reddit.submission("3hahrw")
submission.comments.replace_more()
Alternatively, to replace :class:`.MoreComments` instances within the replies of
a single comment try:
.. code-block:: python
comment = reddit.comment("d8r4im1")
comment.refresh()
comment.replies.replace_more()
.. note::
This method can take a long time as each replacement will discover at most
100 new :class:`.Comment` instances. As a result, consider looping and
handling exceptions until the method returns successfully. For example:
.. code-block:: python
while True:
try:
submission.comments.replace_more()
break
except PossibleExceptions:
print("Handling replace_more exception")
sleep(1)
.. warning::
If this method is called, and the comments are refreshed, calling this
method again will result in a :class:`.DuplicateReplaceException`.
"""
remaining = limit
more_comments = self._gather_more_comments(self._comments)
skipped = []
# Fetch largest more_comments until reaching the limit or the threshold
while more_comments:
item = heappop(more_comments)
if remaining is not None and remaining <= 0 or item.count < threshold:
skipped.append(item)
item._remove_from.remove(item)
continue
new_comments = item.comments(update=False)
if remaining is not None:
remaining -= 1
# Add new MoreComment objects to the heap of more_comments
for more in self._gather_more_comments(
new_comments, parent_tree=self._comments
):
more.submission = self._submission
heappush(more_comments, more)
# Insert all items into the tree
for comment in new_comments:
self._insert_comment(comment)
# Remove from forest
item._remove_from.remove(item)
return more_comments + skipped

View File

@@ -0,0 +1,32 @@
"""Provide the Front class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Iterator
from urllib.parse import urljoin
from .listing.generator import ListingGenerator
from .listing.mixins import SubredditListingMixin
if TYPE_CHECKING: # pragma: no cover
import praw.models
class Front(SubredditListingMixin):
"""Front is a Listing class that represents the front page."""
def __init__(self, reddit: praw.Reddit):
"""Initialize a :class:`.Front` instance."""
super().__init__(reddit, _data=None)
self._path = "/"
def best(self, **generator_kwargs: str | int) -> Iterator[praw.models.Submission]:
"""Return a :class:`.ListingGenerator` for best items.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
"""
return ListingGenerator(
self._reddit, urljoin(self._path, "best"), **generator_kwargs
)

View File

@@ -0,0 +1,379 @@
"""Provide the helper classes."""
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 .base import PRAWBase
from .reddit.draft import Draft
from .reddit.live import LiveThread
from .reddit.multi import Multireddit, Subreddit
if TYPE_CHECKING: # pragma: no cover
import praw.models
class DraftHelper(PRAWBase):
r"""Provide a set of functions to interact with :class:`.Draft` instances.
.. note::
The methods provided by this class will only work on the currently authenticated
user's :class:`.Draft`\ s.
"""
def __call__(
self, draft_id: str | None = None
) -> list[praw.models.Draft] | praw.models.Draft:
"""Return a list of :class:`.Draft` instances.
:param draft_id: When provided, this returns a :class:`.Draft` instance
(default: ``None``).
:returns: A :class:`.Draft` instance if ``draft_id`` is provided. Otherwise, a
list of :class:`.Draft` objects.
.. note::
Drafts fetched using a specific draft ID are lazily loaded, so you might
have to access an attribute to get all the expected attributes.
This method can be used to fetch a specific draft by ID, like so:
.. code-block:: python
draft_id = "124862bc-e1e9-11eb-aa4f-e68667a77cbb"
draft = reddit.drafts(draft_id)
print(draft)
"""
if draft_id is not None:
return Draft(self._reddit, id=draft_id)
return self._draft_list()
def _draft_list(self) -> list[praw.models.Draft]:
"""Get a list of :class:`.Draft` instances.
:returns: A list of :class:`.Draft` instances.
"""
return self._reddit.get(API_PATH["drafts"], params={"md_body": True})
def create(
self,
*,
flair_id: str | None = None,
flair_text: str | None = None,
is_public_link: bool = False,
nsfw: bool = False,
original_content: bool = False,
selftext: str | None = None,
send_replies: bool = True,
spoiler: bool = False,
subreddit: (
str | praw.models.Subreddit | praw.models.UserSubreddit | None
) = None,
title: str | None = None,
url: str | None = None,
**draft_kwargs: Any,
) -> praw.models.Draft:
"""Create a new :class:`.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 is_public_link: Whether to enable public viewing of the draft before it
is submitted (default: ``False``).
:param nsfw: Whether the draft should be marked NSFW (default: ``False``).
:param original_content: Whether the submission should be marked as original
content (default: ``False``).
:param selftext: The Markdown formatted content for a text submission draft. Use
``None`` to make a title-only submission draft (default: ``None``).
``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 (default: ``True``).
:param spoiler: Whether the submission should be marked as a spoiler (default:
``False``).
:param subreddit: The subreddit to create the draft for. This accepts a
subreddit display name, :class:`.Subreddit` object, or
:class:`.UserSubreddit` object. If ``None``, the :class:`.UserSubreddit` of
currently authenticated user will be used (default: ``None``).
:param title: The title of the draft (default: ``None``).
:param url: The URL for a ``link`` submission draft (default: ``None``). ``url``
can not be provided if ``selftext`` is provided.
Additional keyword arguments can be provided to handle new parameters as Reddit
introduces them.
:returns: The new :class:`.Draft` object.
"""
if selftext and url:
msg = "Exactly one of 'selftext' or 'url' must be provided."
raise TypeError(msg)
if isinstance(subreddit, str):
subreddit = self._reddit.subreddit(subreddit)
data = Draft._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,
)
return self._reddit.post(API_PATH["draft"], data=data)
class LiveHelper(PRAWBase):
r"""Provide a set of functions to interact with :class:`.LiveThread`\ s."""
def __call__(self, id: str) -> praw.models.LiveThread:
"""Return a new lazy instance of :class:`.LiveThread`.
This method is intended to be used as:
.. code-block:: python
livethread = reddit.live("ukaeu1ik4sw5")
:param id: A live thread ID, e.g., ``ukaeu1ik4sw5``.
"""
return LiveThread(self._reddit, id=id)
@_deprecate_args("title", "description", "nsfw", "resources")
def create(
self,
title: str,
*,
description: str | None = None,
nsfw: bool = False,
resources: str = None,
) -> praw.models.LiveThread:
"""Create a new :class:`.LiveThread`.
:param title: The title of the new :class:`.LiveThread`.
:param description: The new :class:`.LiveThread`'s description.
:param nsfw: Indicate whether this thread is not safe for work (default:
``False``).
:param resources: Markdown formatted information that is useful for the
:class:`.LiveThread`.
:returns: The new :class:`.LiveThread` object.
"""
return self._reddit.post(
API_PATH["livecreate"],
data={
"description": description,
"nsfw": nsfw,
"resources": resources,
"title": title,
},
)
def info(self, ids: list[str]) -> Generator[praw.models.LiveThread, None, None]:
"""Fetch information about each live thread in ``ids``.
:param ids: A list of IDs for a live thread.
:returns: A generator that yields :class:`.LiveThread` instances.
:raises: ``prawcore.ServerError`` if invalid live threads are requested.
Requests will be issued in batches for each 100 IDs.
.. note::
This method doesn't support IDs for live updates.
.. warning::
Unlike :meth:`.Reddit.info`, the output of this method may not reflect the
order of input.
Usage:
.. code-block:: python
ids = ["3rgnbke2rai6hen7ciytwcxadi", "sw7bubeycai6hey4ciytwamw3a", "t8jnufucss07"]
for thread in reddit.live.info(ids):
print(thread.title)
"""
if not isinstance(ids, list):
msg = "ids must be a list"
raise TypeError(msg)
def generator():
for position in range(0, len(ids), 100):
ids_chunk = ids[position : position + 100]
url = API_PATH["live_info"].format(ids=",".join(ids_chunk))
params = {"limit": 100} # 25 is used if not specified
yield from self._reddit.get(url, params=params)
return generator()
def now(self) -> praw.models.LiveThread | None:
"""Get the currently featured live thread.
:returns: The :class:`.LiveThread` object, or ``None`` if there is no currently
featured live thread.
Usage:
.. code-block:: python
thread = reddit.live.now() # LiveThread object or None
"""
return self._reddit.get(API_PATH["live_now"])
class MultiredditHelper(PRAWBase):
"""Provide a set of functions to interact with multireddits."""
@_deprecate_args("redditor", "name")
def __call__(
self, *, name: str, redditor: str | praw.models.Redditor
) -> praw.models.Multireddit:
"""Return a lazy instance of :class:`.Multireddit`.
:param name: The name of the multireddit.
:param redditor: A redditor name or :class:`.Redditor` instance who owns the
multireddit.
"""
path = f"/user/{redditor}/m/{name}"
return Multireddit(self._reddit, _data={"name": name, "path": path})
@_deprecate_args(
"display_name",
"subreddits",
"description_md",
"icon_name",
"key_color",
"visibility",
"weighting_scheme",
)
def create(
self,
*,
description_md: str | None = None,
display_name: str,
icon_name: str | None = None,
key_color: str | None = None,
subreddits: str | praw.models.Subreddit,
visibility: str = "private",
weighting_scheme: str = "classic",
) -> praw.models.Multireddit:
"""Create a new :class:`.Multireddit`.
:param display_name: The display name for the new multireddit.
:param subreddits: Subreddits to add to the new multireddit. Can be a list of
either :class:`.Subreddit` instances or subreddit display names.
:param description_md: Description for the new 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"``
(default: ``"private"``).
:param weighting_scheme: Can be one of: ``"classic"`` or ``"fresh"`` (default:
``"classic"``).
:returns: The new :class:`.Multireddit` object.
"""
model = {
"description_md": description_md,
"display_name": display_name,
"icon_name": icon_name,
"key_color": key_color,
"subreddits": [{"name": str(sub)} for sub in subreddits],
"visibility": visibility,
"weighting_scheme": weighting_scheme,
}
return self._reddit.post(
API_PATH["multireddit_base"], data={"model": dumps(model)}
)
class SubredditHelper(PRAWBase):
"""Provide a set of functions to interact with Subreddits."""
def __call__(self, display_name: str) -> praw.models.Subreddit:
"""Return a lazy instance of :class:`.Subreddit`.
:param display_name: The name of the subreddit.
"""
lower_name = display_name.lower()
if lower_name == "random":
return self._reddit.random_subreddit()
if lower_name == "randnsfw":
return self._reddit.random_subreddit(nsfw=True)
return Subreddit(self._reddit, display_name=display_name)
@_deprecate_args("name", "title", "link_type", "subreddit_type", "wikimode")
def create(
self,
name: str,
*,
link_type: str = "any",
subreddit_type: str = "public",
title: str | None = None,
wikimode: str = "disabled",
**other_settings: str | None,
) -> praw.models.Subreddit:
"""Create a new :class:`.Subreddit`.
:param name: The name for the new subreddit.
:param link_type: The types of submissions users can make. One of ``"any"``,
``"link"``, or ``"self"`` (default: ``"any"``).
:param subreddit_type: One of ``"archived"``, ``"employees_only"``,
``"gold_only"``, ``"gold_restricted"``, ``"private"``, ``"public"``, or
``"restricted"`` (default: ``"public"``).
:param title: The title of the subreddit. When ``None`` or ``""`` use the value
of ``name``.
:param wikimode: One of ``"anyone"``, ``"disabled"``, or ``"modonly"`` (default:
``"disabled"``).
Any keyword parameters not provided, or set explicitly to ``None``, will take on
a default value assigned by the Reddit server.
.. seealso::
:meth:`~.SubredditModeration.update` for documentation of other available
settings.
"""
Subreddit._create_or_update(
_reddit=self._reddit,
link_type=link_type,
name=name,
subreddit_type=subreddit_type,
title=title or name,
wikimode=wikimode,
**other_settings,
)
return self(name)

View File

@@ -0,0 +1,342 @@
"""Provide the Front class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Iterator
from ..const import API_PATH
from ..util import _deprecate_args
from .base import PRAWBase
from .listing.generator import ListingGenerator
from .util import stream_generator
if TYPE_CHECKING: # pragma: no cover
import praw.models
class Inbox(PRAWBase):
"""Inbox is a Listing class that represents the inbox."""
def all( # noqa: A003
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Message | praw.models.Comment]:
"""Return a :class:`.ListingGenerator` for all inbox comments and messages.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
To output the type and ID of all items available via this listing do:
.. code-block:: python
for item in reddit.inbox.all(limit=None):
print(repr(item))
"""
return ListingGenerator(self._reddit, API_PATH["inbox"], **generator_kwargs)
def collapse(self, items: list[praw.models.Message]):
"""Mark an inbox message as collapsed.
:param items: A list containing instances of :class:`.Message`.
Requests are batched at 25 items (reddit limit).
For example, to collapse all unread Messages, try:
.. code-block:: python
from praw.models import Message
unread_messages = []
for item in reddit.inbox.unread(limit=None):
if isinstance(item, Message):
unread_messages.append(item)
reddit.inbox.collapse(unread_messages)
.. seealso::
:meth:`.Message.uncollapse`
"""
while items:
data = {"id": ",".join(x.fullname for x in items[:25])}
self._reddit.post(API_PATH["collapse"], data=data)
items = items[25:]
def comment_replies(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Comment]:
"""Return a :class:`.ListingGenerator` for comment replies.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
To output the author of one request worth of comment replies try:
.. code-block:: python
for reply in reddit.inbox.comment_replies():
print(reply.author)
"""
return ListingGenerator(
self._reddit, API_PATH["comment_replies"], **generator_kwargs
)
def mark_all_read(self):
"""Mark all messages as read with just one API call.
Example usage:
.. code-block:: python
reddit.inbox.mark_all_read()
.. note::
This method returns after Reddit acknowledges your request, instead of after
the request has been fulfilled.
"""
self._reddit.post(API_PATH["read_all_messages"])
def mark_read(self, items: list[praw.models.Comment | praw.models.Message]):
"""Mark Comments or Messages as read.
:param items: A list containing instances of :class:`.Comment` and/or
:class:`.Message` to be marked as read relative to the authorized user's
inbox.
Requests are batched at 25 items (reddit limit).
For example, to mark all unread Messages as read, try:
.. code-block:: python
from praw.models import Message
unread_messages = []
for item in reddit.inbox.unread(limit=None):
if isinstance(item, Message):
unread_messages.append(item)
reddit.inbox.mark_read(unread_messages)
.. seealso::
- :meth:`.Comment.mark_read`
- :meth:`.Message.mark_read`
"""
while items:
data = {"id": ",".join(x.fullname for x in items[:25])}
self._reddit.post(API_PATH["read_message"], data=data)
items = items[25:]
def mark_unread(self, items: list[praw.models.Comment | praw.models.Message]):
"""Unmark Comments or Messages as read.
:param items: A list containing instances of :class:`.Comment` and/or
:class:`.Message` to be marked as unread relative to the authorized user's
inbox.
Requests are batched at 25 items (Reddit limit).
For example, to mark the first 10 items as unread try:
.. code-block:: python
to_unread = list(reddit.inbox.all(limit=10))
reddit.inbox.mark_unread(to_unread)
.. seealso::
- :meth:`.Comment.mark_unread`
- :meth:`.Message.mark_unread`
"""
while items:
data = {"id": ",".join(x.fullname for x in items[:25])}
self._reddit.post(API_PATH["unread_message"], data=data)
items = items[25:]
def mentions(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Comment]:
r"""Return a :class:`.ListingGenerator` for mentions.
A mention is :class:`.Comment` in which the authorized redditor is named in its
body like u/spez.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
For example, to output the author and body of the first 25 mentions try:
.. code-block:: python
for mention in reddit.inbox.mentions(limit=25):
print(f"{mention.author}\\n{mention.body}\\n")
"""
return ListingGenerator(self._reddit, API_PATH["mentions"], **generator_kwargs)
def message(self, message_id: str) -> praw.models.Message:
"""Return a :class:`.Message` corresponding to ``message_id``.
:param message_id: The base36 ID of a message.
For example:
.. code-block:: python
message = reddit.inbox.message("7bnlgu")
"""
listing = self._reddit.get(API_PATH["message"].format(id=message_id))
messages = {
message.fullname: message for message in [listing[0]] + listing[0].replies
}
for _fullname, message in messages.items():
message.parent = messages.get(message.parent_id, None)
return messages[f"t4_{message_id.lower()}"]
def messages(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Message]:
"""Return a :class:`.ListingGenerator` for inbox messages.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
For example, to output the subject of the most recent 5 messages try:
.. code-block:: python
for message in reddit.inbox.messages(limit=5):
print(message.subject)
"""
return ListingGenerator(self._reddit, API_PATH["messages"], **generator_kwargs)
def sent(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Message]:
"""Return a :class:`.ListingGenerator` for sent messages.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
For example, to output the recipient of the most recent 15 messages try:
.. code-block:: python
for message in reddit.inbox.sent(limit=15):
print(message.dest)
"""
return ListingGenerator(self._reddit, API_PATH["sent"], **generator_kwargs)
def stream(
self, **stream_options: str | int | dict[str, str]
) -> Iterator[praw.models.Comment | praw.models.Message]:
"""Yield new inbox items as they become available.
Items are yielded oldest first. Up to 100 historical items will initially be
returned.
Keyword arguments are passed to :func:`.stream_generator`.
For example, to retrieve all new inbox items, try:
.. code-block:: python
for item in reddit.inbox.stream():
print(item)
"""
return stream_generator(self.unread, **stream_options)
def submission_replies(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Comment]:
"""Return a :class:`.ListingGenerator` for submission replies.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
To output the author of one request worth of submission replies try:
.. code-block:: python
for reply in reddit.inbox.submission_replies():
print(reply.author)
"""
return ListingGenerator(
self._reddit, API_PATH["submission_replies"], **generator_kwargs
)
def uncollapse(self, items: list[praw.models.Message]):
"""Mark an inbox message as uncollapsed.
:param items: A list containing instances of :class:`.Message`.
Requests are batched at 25 items (reddit limit).
For example, to uncollapse all unread Messages, try:
.. code-block:: python
from praw.models import Message
unread_messages = []
for item in reddit.inbox.unread(limit=None):
if isinstance(item, Message):
unread_messages.append(item)
reddit.inbox.uncollapse(unread_messages)
.. seealso::
:meth:`.Message.collapse`
"""
while items:
data = {"id": ",".join(x.fullname for x in items[:25])}
self._reddit.post(API_PATH["uncollapse"], data=data)
items = items[25:]
@_deprecate_args("mark_read")
def unread(
self,
*,
mark_read: bool = False,
**generator_kwargs: str | int | dict[str, str],
) -> Iterator[praw.models.Comment | praw.models.Message]:
"""Return a :class:`.ListingGenerator` for unread comments and messages.
:param mark_read: Marks the inbox as read (default: ``False``).
.. note::
This only marks the inbox as read not the messages. Use
:meth:`.Inbox.mark_read` to mark the messages.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
For example, to output the author of unread comments try:
.. code-block:: python
from praw.models import Comment
for item in reddit.inbox.unread(limit=None):
if isinstance(item, Comment):
print(item.author)
"""
self._safely_add_arguments(
arguments=generator_kwargs, key="params", mark=mark_read
)
return ListingGenerator(self._reddit, API_PATH["unread"], **generator_kwargs)

View File

@@ -0,0 +1 @@
"""Package providing models that act like a list."""

View File

@@ -0,0 +1,52 @@
"""Provide the BaseList class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Iterator
from ..base import PRAWBase
if TYPE_CHECKING: # pragma: no cover
import praw
class BaseList(PRAWBase):
"""An abstract class to coerce a list into a :class:`.PRAWBase`."""
CHILD_ATTRIBUTE = None
def __contains__(self, item: Any) -> bool:
"""Test if item exists in the list."""
return item in getattr(self, self.CHILD_ATTRIBUTE)
def __getitem__(self, index: int) -> Any:
"""Return the item at position index in the list."""
return getattr(self, self.CHILD_ATTRIBUTE)[index]
def __init__(self, reddit: praw.Reddit, _data: dict[str, Any]):
"""Initialize a :class:`.BaseList` instance.
:param reddit: An instance of :class:`.Reddit`.
"""
super().__init__(reddit, _data=_data)
if self.CHILD_ATTRIBUTE is None:
msg = "BaseList must be extended."
raise NotImplementedError(msg)
child_list = getattr(self, self.CHILD_ATTRIBUTE)
for index, item in enumerate(child_list):
child_list[index] = reddit._objector.objectify(item)
def __iter__(self) -> Iterator[Any]:
"""Return an iterator to the list."""
return getattr(self, self.CHILD_ATTRIBUTE).__iter__()
def __len__(self) -> int:
"""Return the number of items in the list."""
return len(getattr(self, self.CHILD_ATTRIBUTE))
def __str__(self) -> str:
"""Return a string representation of the list."""
return str(getattr(self, self.CHILD_ATTRIBUTE))

View File

@@ -0,0 +1,9 @@
"""Provide the DraftList class."""
from .base import BaseList
class DraftList(BaseList):
"""A list of :class:`.Draft` objects. Works just like a regular list."""
CHILD_ATTRIBUTE = "drafts"

View File

@@ -0,0 +1,9 @@
"""Provide the ModeratedList class."""
from .base import BaseList
class ModeratedList(BaseList):
"""A list of moderated :class:`.Subreddit` objects. Works just like a regular list."""
CHILD_ATTRIBUTE = "data"

View File

@@ -0,0 +1,9 @@
"""Provide the RedditorList class."""
from .base import BaseList
class RedditorList(BaseList):
"""A list of :class:`.Redditor` objects. Works just like a regular list."""
CHILD_ATTRIBUTE = "children"

View File

@@ -0,0 +1,14 @@
"""Provide the TrophyList class."""
from .base import BaseList
class TrophyList(BaseList):
"""A list of :class:`.Trophy` objects. Works just like a regular list.
This class is solely used to parse responses from Reddit, so end users should not
use this class directly.
"""
CHILD_ATTRIBUTE = "trophies"

View File

@@ -0,0 +1 @@
"""Package providing models and mixins pertaining to Reddit listings."""

View File

@@ -0,0 +1,25 @@
"""Provide the DomainListing class."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ...const import API_PATH
from .mixins import BaseListingMixin, RisingListingMixin
if TYPE_CHECKING: # pragma: no cover
import praw
class DomainListing(BaseListingMixin, RisingListingMixin):
"""Provide a set of functions to interact with domain listings."""
def __init__(self, reddit: praw.Reddit, domain: str):
"""Initialize a :class:`.DomainListing` instance.
:param reddit: An instance of :class:`.Reddit`.
:param domain: The domain for which to obtain listings.
"""
super().__init__(reddit, _data=None)
self._path = API_PATH["domain"].format(domain=domain)

View File

@@ -0,0 +1,102 @@
"""Provide the ListingGenerator class."""
from __future__ import annotations
from copy import deepcopy
from typing import TYPE_CHECKING, Any, Iterator
from ..base import PRAWBase
from .listing import FlairListing, ModNoteListing
if TYPE_CHECKING: # pragma: no cover
import praw
class ListingGenerator(PRAWBase, Iterator):
"""Instances of this class generate :class:`.RedditBase` instances.
.. warning::
This class should not be directly utilized. Instead, you will find a number of
methods that return instances of the class here_.
.. _here: https://praw.readthedocs.io/en/latest/search.html?q=ListingGenerator
"""
def __init__(
self,
reddit: praw.Reddit,
url: str,
limit: int = 100,
params: dict[str, str | int] | None = None,
):
"""Initialize a :class:`.ListingGenerator` instance.
:param reddit: An instance of :class:`.Reddit`.
:param url: A URL returning a Reddit listing.
:param limit: The number of content entries to fetch. If ``limit`` is ``None``,
then fetch as many entries as possible. Most of Reddit's listings contain a
maximum of 1000 items, and are returned 100 at a time. This class will
automatically issue all necessary requests (default: ``100``).
:param params: A dictionary containing additional query string parameters to
send with the request.
"""
super().__init__(reddit, _data=None)
self._exhausted = False
self._listing = None
self._list_index = None
self.limit = limit
self.params = deepcopy(params) if params else {}
self.params["limit"] = limit or 1024
self.url = url
self.yielded = 0
def __iter__(self) -> Any:
"""Permit :class:`.ListingGenerator` to operate as an iterator."""
return self
def __next__(self) -> Any:
"""Permit :class:`.ListingGenerator` to operate as a generator."""
if self.limit is not None and self.yielded >= self.limit:
raise StopIteration
if self._listing is None or self._list_index >= len(self._listing):
self._next_batch()
self._list_index += 1
self.yielded += 1
return self._listing[self._list_index - 1]
def _extract_sublist(self, listing: dict[str, Any] | list[Any]):
if isinstance(listing, list):
return listing[1] # for submission duplicates
if isinstance(listing, dict):
classes = [FlairListing, ModNoteListing]
for listing_type in classes:
if listing_type.CHILD_ATTRIBUTE in listing:
return listing_type(self._reddit, listing)
else: # noqa: PLW0120
msg = "The generator returned a dictionary PRAW didn't recognize. File a bug report at PRAW."
raise ValueError(msg)
return listing
def _next_batch(self):
if self._exhausted:
raise StopIteration
self._listing = self._reddit.get(self.url, params=self.params)
self._listing = self._extract_sublist(self._listing)
self._list_index = 0
if not self._listing:
raise StopIteration
if self._listing.after and self._listing.after != self.params.get(
self._listing.AFTER_PARAM
):
self.params[self._listing.AFTER_PARAM] = self._listing.after
else:
self._exhausted = True

View File

@@ -0,0 +1,73 @@
"""Provide the Listing class."""
from __future__ import annotations
from typing import Any
from ..base import PRAWBase
class Listing(PRAWBase):
"""A listing is a collection of :class:`.RedditBase` instances."""
AFTER_PARAM = "after"
CHILD_ATTRIBUTE = "children"
def __getitem__(self, index: int) -> Any:
"""Return the item at position index in the list."""
return getattr(self, self.CHILD_ATTRIBUTE)[index]
def __len__(self) -> int:
"""Return the number of items in the Listing."""
return len(getattr(self, self.CHILD_ATTRIBUTE))
def __setattr__(self, attribute: str, value: Any):
"""Objectify the ``CHILD_ATTRIBUTE`` attribute."""
if attribute == self.CHILD_ATTRIBUTE:
value = self._reddit._objector.objectify(value)
super().__setattr__(attribute, value)
class FlairListing(Listing):
"""Special Listing for handling flair lists."""
CHILD_ATTRIBUTE = "users"
@property
def after(self) -> Any | None:
"""Return the next attribute or ``None``."""
return getattr(self, "next", None)
class ModNoteListing(Listing):
"""Special Listing for handling :class:`.ModNote` lists."""
AFTER_PARAM = "before"
CHILD_ATTRIBUTE = "mod_notes"
@property
def after(self) -> Any | None:
"""Return the next attribute or None."""
if not getattr(self, "has_next_page", True):
return None
return getattr(self, "end_cursor", None)
class ModeratorListing(Listing):
"""Special Listing for handling moderator lists."""
CHILD_ATTRIBUTE = "moderators"
class ModmailConversationsListing(Listing):
"""Special Listing for handling :class:`.ModmailConversation` lists."""
CHILD_ATTRIBUTE = "conversations"
@property
def after(self) -> str | None:
"""Return the next attribute or ``None``."""
try:
return self.conversations[-1].id
except IndexError:
return None

View File

@@ -0,0 +1,7 @@
"""Package providing models that pertain to listing mixins."""
from .base import BaseListingMixin
from .redditor import RedditorListingMixin
from .rising import RisingListingMixin
from .submission import SubmissionListingMixin
from .subreddit import SubredditListingMixin

View File

@@ -0,0 +1,155 @@
"""Provide the BaseListingMixin class."""
from __future__ import annotations
from typing import Any, Iterator
from urllib.parse import urljoin
from ....util import _deprecate_args
from ...base import PRAWBase
from ..generator import ListingGenerator
class BaseListingMixin(PRAWBase):
"""Adds minimum set of methods that apply to all listing objects."""
VALID_TIME_FILTERS = {"all", "day", "hour", "month", "week", "year"}
@staticmethod
def _validate_time_filter(time_filter: str):
"""Validate ``time_filter``.
:raises: :py:class:`ValueError` if ``time_filter`` is not valid.
"""
if time_filter not in BaseListingMixin.VALID_TIME_FILTERS:
valid_time_filters = ", ".join(
map("{!r}".format, BaseListingMixin.VALID_TIME_FILTERS)
)
msg = f"'time_filter' must be one of: {valid_time_filters}"
raise ValueError(msg)
def _prepare(self, *, arguments: dict[str, Any], sort: str) -> str:
"""Fix for :class:`.Redditor` methods that use a query param rather than subpath."""
if self.__dict__.get("_listing_use_sort"):
self._safely_add_arguments(arguments=arguments, key="params", sort=sort)
return self._path
return urljoin(self._path, sort)
@_deprecate_args("time_filter")
def controversial(
self,
*,
time_filter: str = "all",
**generator_kwargs: str | int | dict[str, str],
) -> Iterator[Any]:
"""Return a :class:`.ListingGenerator` for controversial items.
:param time_filter: Can be one of: ``"all"``, ``"day"``, ``"hour"``,
``"month"``, ``"week"``, or ``"year"`` (default: ``"all"``).
:raises: :py:class:`ValueError` if ``time_filter`` is invalid.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
This method can be used like:
.. code-block:: python
reddit.domain("imgur.com").controversial(time_filter="week")
reddit.multireddit(redditor="samuraisam", name="programming").controversial(
time_filter="day"
)
reddit.redditor("spez").controversial(time_filter="month")
reddit.redditor("spez").comments.controversial(time_filter="year")
reddit.redditor("spez").submissions.controversial(time_filter="all")
reddit.subreddit("all").controversial(time_filter="hour")
"""
self._validate_time_filter(time_filter)
self._safely_add_arguments(
arguments=generator_kwargs, key="params", t=time_filter
)
url = self._prepare(arguments=generator_kwargs, sort="controversial")
return ListingGenerator(self._reddit, url, **generator_kwargs)
def hot(self, **generator_kwargs: str | int | dict[str, str]) -> Iterator[Any]:
"""Return a :class:`.ListingGenerator` for hot items.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
This method can be used like:
.. code-block:: python
reddit.domain("imgur.com").hot()
reddit.multireddit(redditor="samuraisam", name="programming").hot()
reddit.redditor("spez").hot()
reddit.redditor("spez").comments.hot()
reddit.redditor("spez").submissions.hot()
reddit.subreddit("all").hot()
"""
generator_kwargs.setdefault("params", {})
url = self._prepare(arguments=generator_kwargs, sort="hot")
return ListingGenerator(self._reddit, url, **generator_kwargs)
def new(self, **generator_kwargs: str | int | dict[str, str]) -> Iterator[Any]:
"""Return a :class:`.ListingGenerator` for new items.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
This method can be used like:
.. code-block:: python
reddit.domain("imgur.com").new()
reddit.multireddit(redditor="samuraisam", name="programming").new()
reddit.redditor("spez").new()
reddit.redditor("spez").comments.new()
reddit.redditor("spez").submissions.new()
reddit.subreddit("all").new()
"""
generator_kwargs.setdefault("params", {})
url = self._prepare(arguments=generator_kwargs, sort="new")
return ListingGenerator(self._reddit, url, **generator_kwargs)
@_deprecate_args("time_filter")
def top(
self,
*,
time_filter: str = "all",
**generator_kwargs: str | int | dict[str, str],
) -> Iterator[Any]:
"""Return a :class:`.ListingGenerator` for top items.
:param time_filter: Can be one of: ``"all"``, ``"day"``, ``"hour"``,
``"month"``, ``"week"``, or ``"year"`` (default: ``"all"``).
:raises: :py:class:`ValueError` if ``time_filter`` is invalid.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
This method can be used like:
.. code-block:: python
reddit.domain("imgur.com").top(time_filter="week")
reddit.multireddit(redditor="samuraisam", name="programming").top(time_filter="day")
reddit.redditor("spez").top(time_filter="month")
reddit.redditor("spez").comments.top(time_filter="year")
reddit.redditor("spez").submissions.top(time_filter="all")
reddit.subreddit("all").top(time_filter="hour")
"""
self._validate_time_filter(time_filter)
self._safely_add_arguments(
arguments=generator_kwargs, key="params", t=time_filter
)
url = self._prepare(arguments=generator_kwargs, sort="top")
return ListingGenerator(self._reddit, url, **generator_kwargs)

View File

@@ -0,0 +1,31 @@
"""Provide the GildedListingMixin class."""
from __future__ import annotations
from typing import Any, Iterator
from urllib.parse import urljoin
from ...base import PRAWBase
from ..generator import ListingGenerator
class GildedListingMixin(PRAWBase):
"""Mixes in the gilded method."""
def gilded(self, **generator_kwargs: str | int | dict[str, str]) -> Iterator[Any]:
"""Return a :class:`.ListingGenerator` for gilded items.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
For example, to get gilded items in r/test:
.. code-block:: python
for item in reddit.subreddit("test").gilded():
print(item.id)
"""
return ListingGenerator(
self._reddit, urljoin(self._path, "gilded"), **generator_kwargs
)

View File

@@ -0,0 +1,224 @@
"""Provide the RedditorListingMixin class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Iterator
from urllib.parse import urljoin
from ....util.cache import cachedproperty
from ..generator import ListingGenerator
from .base import BaseListingMixin
from .gilded import GildedListingMixin
if TYPE_CHECKING: # pragma: no cover
import praw.models
class SubListing(BaseListingMixin):
"""Helper class for generating :class:`.ListingGenerator` objects."""
def __init__(self, reddit: praw.Reddit, base_path: str, subpath: str):
"""Initialize a :class:`.SubListing` instance.
:param reddit: An instance of :class:`.Reddit`.
:param base_path: The path to the object up to this point.
:param subpath: The additional path to this sublisting.
"""
super().__init__(reddit, _data=None)
self._listing_use_sort = True
self._reddit = reddit
self._path = urljoin(base_path, subpath)
class RedditorListingMixin(BaseListingMixin, GildedListingMixin):
"""Adds additional methods pertaining to :class:`.Redditor` instances."""
@cachedproperty
def comments(self) -> SubListing:
r"""Provide an instance of :class:`.SubListing` for comment access.
For example, to output the first line of all new comments by u/spez try:
.. code-block:: python
for comment in reddit.redditor("spez").comments.new(limit=None):
print(comment.body.split("\\n", 1)[0][:79])
"""
return SubListing(self._reddit, self._path, "comments")
@cachedproperty
def submissions(self) -> SubListing:
"""Provide an instance of :class:`.SubListing` for submission access.
For example, to output the title's of top 100 of all time submissions for u/spez
try:
.. code-block:: python
for submission in reddit.redditor("spez").submissions.top(time_filter="all"):
print(submission.title)
"""
return SubListing(self._reddit, self._path, "submitted")
def downvoted(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Comment | praw.models.Submission]:
"""Return a :class:`.ListingGenerator` for items the user has downvoted.
:returns: A :class:`.ListingGenerator` object which yields :class:`.Comment` or
:class:`.Submission` objects the user has downvoted.
:raises: ``prawcore.Forbidden`` if the user is not authorized to access the
list.
.. note::
Since this function returns a :class:`.ListingGenerator` the exception
may not occur until sometime after this function has returned.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
For example, to get all downvoted items of the authenticated user:
.. code-block:: python
for item in reddit.user.me().downvoted():
print(item.id)
"""
return ListingGenerator(
self._reddit, urljoin(self._path, "downvoted"), **generator_kwargs
)
def gildings(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Comment | praw.models.Submission]:
"""Return a :class:`.ListingGenerator` for items the user has gilded.
:returns: A :class:`.ListingGenerator` object which yields :class:`.Comment` or
:class:`.Submission` objects the user has gilded.
:raises: ``prawcore.Forbidden`` if the user is not authorized to access the
list.
.. note::
Since this function returns a :class:`.ListingGenerator` the exception
may not occur until sometime after this function has returned.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
For example, to get all gilded items of the authenticated user:
.. code-block:: python
for item in reddit.user.me().gildings():
print(item.id)
"""
return ListingGenerator(
self._reddit, urljoin(self._path, "gilded/given"), **generator_kwargs
)
def hidden(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Comment | praw.models.Submission]:
"""Return a :class:`.ListingGenerator` for items the user has hidden.
:returns: A :class:`.ListingGenerator` object which yields :class:`.Comment` or
:class:`.Submission` objects the user has hid.
:raises: ``prawcore.Forbidden`` if the user is not authorized to access the
list.
.. note::
Since this function returns a :class:`.ListingGenerator` the exception
may not occur until sometime after this function has returned.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
For example, to get all hidden items of the authenticated user:
.. code-block:: python
for item in reddit.user.me().hidden():
print(item.id)
"""
return ListingGenerator(
self._reddit, urljoin(self._path, "hidden"), **generator_kwargs
)
def saved(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Comment | praw.models.Submission]:
"""Return a :class:`.ListingGenerator` for items the user has saved.
:returns: A :class:`.ListingGenerator` object which yields :class:`.Comment` or
:class:`.Submission` objects the user has saved.
:raises: ``prawcore.Forbidden`` if the user is not authorized to access the
list.
.. note::
Since this function returns a :class:`.ListingGenerator` the exception
may not occur until sometime after this function has returned.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
For example, to get all saved items of the authenticated user:
.. code-block:: python
for item in reddit.user.me().saved(limit=None):
print(item.id)
"""
return ListingGenerator(
self._reddit, urljoin(self._path, "saved"), **generator_kwargs
)
def upvoted(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Comment | praw.models.Submission]:
"""Return a :class:`.ListingGenerator` for items the user has upvoted.
:returns: A :class:`.ListingGenerator` object which yields :class:`.Comment` or
:class:`.Submission` objects the user has upvoted.
:raises: ``prawcore.Forbidden`` if the user is not authorized to access the
list.
.. note::
Since this function returns a :class:`.ListingGenerator` the exception
may not occur until sometime after this function has returned.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
For example, to get all upvoted items of the authenticated user:
.. code-block:: python
for item in reddit.user.me().upvoted():
print(item.id)
"""
return ListingGenerator(
self._reddit, urljoin(self._path, "upvoted"), **generator_kwargs
)

View File

@@ -0,0 +1,56 @@
"""Provide the RisingListingMixin class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Iterator
from urllib.parse import urljoin
from ...base import PRAWBase
from ..generator import ListingGenerator
if TYPE_CHECKING: # pragma: no cover
import praw.models
class RisingListingMixin(PRAWBase):
"""Mixes in the rising methods."""
def random_rising(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Submission]:
"""Return a :class:`.ListingGenerator` for random rising submissions.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
For example, to get random rising submissions for r/test:
.. code-block:: python
for submission in reddit.subreddit("test").random_rising():
print(submission.title)
"""
return ListingGenerator(
self._reddit, urljoin(self._path, "randomrising"), **generator_kwargs
)
def rising(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Submission]:
"""Return a :class:`.ListingGenerator` for rising submissions.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
For example, to get rising submissions for r/test:
.. code-block:: python
for submission in reddit.subreddit("test").rising():
print(submission.title)
"""
return ListingGenerator(
self._reddit, urljoin(self._path, "rising"), **generator_kwargs
)

View File

@@ -0,0 +1,42 @@
"""Provide the SubmissionListingMixin class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Iterator
from ....const import API_PATH
from ...base import PRAWBase
from ..generator import ListingGenerator
if TYPE_CHECKING: # pragma: no cover
import praw.models
class SubmissionListingMixin(PRAWBase):
"""Adds additional methods pertaining to :class:`.Submission` instances."""
def duplicates(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Submission]:
"""Return a :class:`.ListingGenerator` for the submission's duplicates.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
Example usage:
.. code-block:: python
submission = reddit.submission("5or86n")
for duplicate in submission.duplicates():
# process each duplicate
...
.. seealso::
:meth:`.upvote`
"""
url = API_PATH["duplicates"].format(submission_id=self.id)
return ListingGenerator(self._reddit, url, **generator_kwargs)

View File

@@ -0,0 +1,74 @@
"""Provide the SubredditListingMixin class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Iterator
from urllib.parse import urljoin
from ....util.cache import cachedproperty
from ...base import PRAWBase
from ..generator import ListingGenerator
from .base import BaseListingMixin
from .gilded import GildedListingMixin
from .rising import RisingListingMixin
if TYPE_CHECKING: # pragma: no cover
import praw.models
class CommentHelper(PRAWBase):
"""Provide a set of functions to interact with a :class:`.Subreddit`'s comments."""
@property
def _path(self) -> str:
return urljoin(self.subreddit._path, "comments/")
def __call__(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Comment]:
"""Return a :class:`.ListingGenerator` for the :class:`.Subreddit`'s comments.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
This method should be used in a way similar to the example below:
.. code-block:: python
for comment in reddit.subreddit("test").comments(limit=25):
print(comment.author)
"""
return ListingGenerator(self._reddit, self._path, **generator_kwargs)
def __init__(self, subreddit: praw.models.Subreddit | SubredditListingMixin):
"""Initialize a :class:`.CommentHelper` instance."""
super().__init__(subreddit._reddit, _data=None)
self.subreddit = subreddit
class SubredditListingMixin(BaseListingMixin, GildedListingMixin, RisingListingMixin):
"""Adds additional methods pertaining to subreddit-like instances."""
@cachedproperty
def comments(self) -> CommentHelper:
"""Provide an instance of :class:`.CommentHelper`.
For example, to output the author of the 25 most recent comments of r/test
execute:
.. code-block:: python
for comment in reddit.subreddit("test").comments(limit=25):
print(comment.author)
"""
return CommentHelper(self)
def __init__(self, reddit: praw.Reddit, _data: dict[str, Any] | None):
"""Initialize a :class:`.SubredditListingMixin` instance.
:param reddit: An instance of :class:`.Reddit`.
"""
super().__init__(reddit, _data=_data)

View File

@@ -0,0 +1,23 @@
"""Provide the ModAction class."""
from __future__ import annotations
from typing import TYPE_CHECKING
from .base import PRAWBase
if TYPE_CHECKING: # pragma: no cover
import praw.models
class ModAction(PRAWBase):
"""Represent a moderator action."""
@property
def mod(self) -> praw.models.Redditor:
"""Return the :class:`.Redditor` who the action was issued by."""
return self._reddit.redditor(self._mod)
@mod.setter
def mod(self, value: str | praw.models.Redditor):
self._mod = value

View File

@@ -0,0 +1,72 @@
"""Provide the ModNote class."""
from __future__ import annotations
from ..endpoints import API_PATH
from .base import PRAWBase
class ModNote(PRAWBase):
"""Represent a moderator note.
.. include:: ../../typical_attributes.rst
=============== ====================================================================
Attribute Description
=============== ====================================================================
``action`` If this note represents a moderator action, this field indicates the
type of action. For example, ``"banuser"`` if the action was banning
a user.
``created_at`` Time the moderator note was created, represented in `Unix Time`_.
``description`` If this note represents a moderator action, this field indicates the
description of the action. For example, if the action was banning
the user, this is the ban reason.
``details`` If this note represents a moderator action, this field indicates the
details of the action. For example, if the action was banning the
user, this is the duration of the ban.
``id`` The ID of the moderator note.
``label`` The label applied to the note, currently one of:
``"ABUSE_WARNING"``, ``"BAN"``, ``"BOT_BAN"``, ``"HELPFUL_USER"``,
``"PERMA_BAN"``, ``"SOLID_CONTRIBUTOR"``, ``"SPAM_WARNING"``,
``"SPAM_WATCH"``, or ``None``.
``moderator`` The moderator who created the note.
``note`` The text of the note.
``reddit_id`` The fullname of the object this note is attributed to, or ``None``
if not set. If this note represents a moderators action, this is the
fullname of the object the action was performed on.
``subreddit`` The subreddit this note belongs to.
``type`` The type of note, currently one of: ``"APPROVAL"``, ``"BAN"``,
``"CONTENT_CHANGE"``, ``"INVITE"``, ``"MUTE"``, ``"NOTE"``,
``"REMOVAL"``, or ``"SPAM"``.
``user`` The redditor the note is for.
=============== ====================================================================
.. _unix time: https://en.wikipedia.org/wiki/Unix_time
"""
def __eq__(self, other: ModNote) -> bool:
"""Return whether the other instance equals the current."""
if isinstance(other, self.__class__):
return self.id == other.id
if isinstance(other, str):
return self.id == other
return super().__eq__(other)
def delete(self):
"""Delete this note.
For example, to delete the last note for u/spez from r/test, try:
.. code-block:: python
for note in reddit.subreddit("test").mod.notes("spez"):
note.delete()
"""
params = {
"user": str(self.user),
"subreddit": str(self.subreddit),
"note_id": self.id,
}
self._reddit.delete(API_PATH["mod_notes"], params=params)

View File

@@ -0,0 +1,696 @@
"""Provides classes for interacting with moderator notes."""
from itertools import islice
from typing import TYPE_CHECKING, Any, Generator, List, Optional, Tuple, Union
from ..const import API_PATH
from .base import PRAWBase
from .listing.generator import ListingGenerator
from .reddit.comment import Comment
from .reddit.redditor import Redditor
from .reddit.submission import Submission
if TYPE_CHECKING: # pragma: no cover
import praw.models
RedditorType = Union[Redditor, str]
SubredditType = Union["praw.models.Subreddit", str]
ThingType = Union[Comment, Submission]
class BaseModNotes:
"""Provides base methods to interact with moderator notes."""
def __init__(
self,
reddit: "praw.Reddit",
):
"""Initialize a :class:`.BaseModNotes` instance.
:param reddit: An instance of :class:`.Reddit`.
"""
self._reddit = reddit
def _all_generator(
self,
redditor: RedditorType,
subreddit: SubredditType,
**generator_kwargs: Any,
):
PRAWBase._safely_add_arguments(
arguments=generator_kwargs,
key="params",
subreddit=subreddit,
user=redditor,
)
return ListingGenerator(self._reddit, API_PATH["mod_notes"], **generator_kwargs)
def _bulk_generator(
self, redditors: List[RedditorType], subreddits: List[SubredditType]
) -> Generator["praw.models.ModNote", None, None]:
subreddits_iter = iter(subreddits)
redditors_iter = iter(redditors)
while True:
subreddits_chunk = list(islice(subreddits_iter, 500))
users_chunk = list(islice(redditors_iter, 500))
if not any([subreddits_chunk, users_chunk]):
break
params = {
"subreddits": ",".join(map(str, subreddits_chunk)),
"users": ",".join(map(str, users_chunk)),
}
response = self._reddit.get(API_PATH["mod_notes_bulk"], params=params)
for note_dict in response["mod_notes"]:
yield self._reddit._objector.objectify(note_dict)
def _ensure_attribute(self, error_message: str, **attributes: Any) -> Any:
attribute, _value = attributes.popitem()
value = _value or getattr(self, attribute, None)
if value is None:
raise TypeError(error_message)
return value
def _notes(
self,
all_notes: bool,
redditors: List[RedditorType],
subreddits: List[SubredditType],
**generator_kwargs: Any,
) -> Generator["praw.models.ModNote", None, None]:
if all_notes:
for subreddit in subreddits:
for redditor in redditors:
yield from self._all_generator(
redditor, subreddit, **generator_kwargs
)
else:
yield from self._bulk_generator(redditors, subreddits)
def create(
self,
*,
label: Optional[str] = None,
note: str,
redditor: Optional[RedditorType] = None,
subreddit: Optional[SubredditType] = None,
thing: Optional[Union[Comment, Submission, str]] = None,
**other_settings: Any,
) -> "praw.models.ModNote":
"""Create a :class:`.ModNote` for a redditor in the specified subreddit.
: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 redditor: The redditor to create the note for (default: ``None``).
.. note::
This parameter is required if ``thing`` is not provided or this is not
called from a :class:`.Redditor` instance (e.g.,
``reddit.redditor.notes``).
:param subreddit: The subreddit associated with the note (default: ``None``).
.. note::
This parameter is required if ``thing`` is not provided or this is not
called from a :class:`.Subreddit` instance (e.g.,
``reddit.subreddit.mod``).
:param thing: Either the fullname of a comment/submission, a :class:`.Comment`,
or a :class:`.Submission` to associate with the note.
:param other_settings: Additional keyword arguments can be provided to handle
new parameters as Reddit introduces them.
:returns: The new :class:`.ModNote` object.
For example, to create a note for u/spez in r/test:
.. code-block:: python
reddit.subreddit("test").mod.notes.create(
label="HELPFUL_USER", note="Test note", redditor="spez"
)
# or
reddit.redditor("spez").mod.notes.create(
label="HELPFUL_USER", note="Test note", subreddit="test"
)
# or
reddit.notes.create(
label="HELPFUL_USER", note="Test note", redditor="spez", subreddit="test"
)
"""
reddit_id = None
if thing:
if isinstance(thing, str):
reddit_id = thing
# this is to minimize the number of requests
if not (
getattr(self, "redditor", redditor)
and getattr(self, "subreddit", subreddit)
):
# only fetch if we are missing either redditor or subreddit
thing = next(self._reddit.info(fullnames=[thing]))
else:
reddit_id = thing.fullname
redditor = getattr(self, "redditor", redditor) or thing.author
subreddit = getattr(self, "subreddit", subreddit) or thing.subreddit
redditor = self._ensure_attribute(
"Either the 'redditor' or 'thing' parameters must be provided or this"
" method must be called from a Redditor instance (e.g., 'redditor.notes').",
redditor=redditor,
)
subreddit = self._ensure_attribute(
"Either the 'subreddit' or 'thing' parameters must be provided or this"
" method must be called from a Subreddit instance (e.g.,"
" 'subreddit.mod.notes').",
subreddit=subreddit,
)
data = {
"user": str(redditor),
"subreddit": str(subreddit),
"note": note,
}
if label:
data["label"] = label
if reddit_id:
data["reddit_id"] = reddit_id
data.update(other_settings)
return self._reddit.post(API_PATH["mod_notes"], data=data)
def delete(
self,
*,
delete_all: bool = False,
note_id: Optional[str] = None,
redditor: Optional[RedditorType] = None,
subreddit: Optional[SubredditType] = None,
):
"""Delete note(s) for a redditor.
:param delete_all: When ``True``, delete all notes for the specified redditor in
the specified subreddit (default: ``False``).
.. note::
This will make a request for each note.
:param note_id: The ID of the note to delete. This parameter is ignored if
``delete_all`` is ``True``.
:param redditor: The redditor to delete the note(s) for (default: ``None``). Can
be a :class:`.Redditor` instance or a redditor name.
.. note::
This parameter is required if this method is **not** called from a
:class:`.Redditor` instance (e.g., ``redditor.notes``).
:param subreddit: The subreddit to delete the note(s) from (default: ``None``).
Can be a :class:`.Subreddit` instance or a subreddit name.
.. note::
This parameter is required if this method is **not** called from a
:class:`.Subreddit` instance (e.g., ``reddit.subreddit.mod``).
For example, to delete a note with the ID
``"ModNote_d324b280-5ecc-435d-8159-3e259e84e339"``, try:
.. code-block:: python
reddit.subreddit("test").mod.notes.delete(
note_id="ModNote_d324b280-5ecc-435d-8159-3e259e84e339", redditor="spez"
)
# or
reddit.redditor("spez").notes.delete(
note_id="ModNote_d324b280-5ecc-435d-8159-3e259e84e339", subreddit="test"
)
# or
reddit.notes.delete(
note_id="ModNote_d324b280-5ecc-435d-8159-3e259e84e339",
subreddit="test",
redditor="spez",
)
To delete all notes for u/spez, try:
.. code-block:: python
reddit.subreddit("test").mod.notes.delete(delete_all=True, redditor="spez")
# or
reddit.redditor("spez").notes.delete(delete_all=True, subreddit="test")
# or
reddit.notes.delete(delete_all=True, subreddit="test", redditor="spez")
"""
redditor = self._ensure_attribute(
"Either the 'redditor' parameter must be provided or this method must be"
" called from a Redditor instance (e.g., 'redditor.notes').",
redditor=redditor,
)
subreddit = self._ensure_attribute(
"Either the 'subreddit' parameter must be provided or this method must be"
" called from a Subreddit instance (e.g., 'subreddit.mod.notes').",
subreddit=subreddit,
)
if not delete_all and note_id is None:
msg = "Either 'note_id' or 'delete_all' must be provided."
raise TypeError(msg)
if delete_all:
for note in self._notes(True, [redditor], [subreddit]):
note.delete()
else:
params = {
"user": str(redditor),
"subreddit": str(subreddit),
"note_id": note_id,
}
self._reddit.delete(API_PATH["mod_notes"], params=params)
class RedditorModNotes(BaseModNotes):
"""Provides methods to interact with moderator notes at the redditor level.
.. 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}")
"""
def __init__(self, reddit: "praw.Reddit", redditor: RedditorType):
"""Initialize a :class:`.RedditorModNotes` instance.
:param reddit: An instance of :class:`.Reddit`.
:param redditor: An instance of :class:`.Redditor`.
"""
super().__init__(reddit)
self.redditor = redditor
def subreddits(
self,
*subreddits: SubredditType,
all_notes: Optional[bool] = None,
**generator_kwargs: Any,
) -> Generator["praw.models.ModNote", None, None]:
"""Return notes for this :class:`.Redditor` from one or more subreddits.
:param subreddits: One or more subreddits to retrieve the notes from. Must be
either a :class:`.Subreddit` or a subreddit name.
:param all_notes: Whether to return all notes or only the latest note (default:
``True`` if only one subreddit is provided otherwise ``False``).
.. note::
Setting this to ``True`` will result in a request for each subreddit.
:returns: A generator that yields the most recent :class:`.ModNote` (or ``None``
if this redditor doesn't have any notes) per subreddit in their relative
order. If ``all_notes`` is ``True``, this will yield all notes or ``None``
from each subreddit for this redditor.
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}")
For example, the latest note for u/spez from r/test and r/redditdev can be
iterated through like so:
.. code-block:: python
redditor = reddit.redditor("spez")
subreddit = reddit.subreddit("redditdev")
for note in redditor.notes.subreddits("test", subreddit):
print(f"{note.label}: {note.note}")
For example, **all** the notes for u/spez in r/test and r/redditdev can be
iterated through like so:
.. code-block:: python
redditor = reddit.redditor("spez")
subreddit = reddit.subreddit("redditdev")
for note in redditor.notes.subreddits("test", subreddit, all_notes=True):
print(f"{note.label}: {note.note}")
"""
if len(subreddits) == 0:
msg = "At least 1 subreddit must be provided."
raise ValueError(msg)
if all_notes is None:
all_notes = len(subreddits) == 1
return self._notes(
all_notes,
[self.redditor] * len(subreddits),
list(subreddits),
**generator_kwargs,
)
class SubredditModNotes(BaseModNotes):
"""Provides methods to interact with moderator notes at the subreddit level.
.. note::
The authenticated user must be a moderator of this subreddit.
For example, all the notes for u/spez in r/test can be iterated through like so:
.. code-block:: python
subreddit = reddit.subreddit("test")
for note in subreddit.mod.notes.redditors("spez"):
print(f"{note.label}: {note.note}")
"""
def __init__(self, reddit: "praw.Reddit", subreddit: SubredditType):
"""Initialize a :class:`.SubredditModNotes` instance.
:param reddit: An instance of :class:`.Reddit`.
:param subreddit: An instance of :class:`.Subreddit`.
"""
super().__init__(reddit)
self.subreddit = subreddit
def redditors(
self,
*redditors: RedditorType,
all_notes: Optional[bool] = None,
**generator_kwargs: Any,
) -> Generator["praw.models.ModNote", None, None]:
"""Return notes from this :class:`.Subreddit` for one or more redditors.
:param redditors: One or more redditors to retrieve notes for. Must be either a
:class:`.Redditor` or a redditor name.
:param all_notes: Whether to return all notes or only the latest note (default:
``True`` if only one redditor is provided otherwise ``False``).
.. note::
Setting this to ``True`` will result in a request for each redditor.
:returns: A generator that yields the most recent :class:`.ModNote` (or ``None``
if the user doesn't have any notes in this subreddit) per redditor in their
relative order. If ``all_notes`` is ``True``, this will yield all notes for
each redditor.
For example, all the notes for u/spez in r/test can be iterated through like so:
.. code-block:: python
subreddit = reddit.subreddit("test")
for note in subreddit.mod.notes.redditors("spez"):
print(f"{note.label}: {note.note}")
For example, the latest note for u/spez and u/bboe from r/test can be iterated
through like so:
.. code-block:: python
subreddit = reddit.subreddit("test")
redditor = reddit.redditor("bboe")
for note in subreddit.mod.notes.redditors("spez", redditor):
print(f"{note.label}: {note.note}")
For example, **all** the notes for both u/spez and u/bboe in r/test can be
iterated through like so:
.. code-block:: python
subreddit = reddit.subreddit("test")
redditor = reddit.redditor("bboe")
for note in subreddit.mod.notes.redditors("spez", redditor, all_notes=True):
print(f"{note.label}: {note.note}")
"""
if len(redditors) == 0:
msg = "At least 1 redditor must be provided."
raise ValueError(msg)
if all_notes is None:
all_notes = len(redditors) == 1
return self._notes(
all_notes,
list(redditors),
[self.subreddit] * len(redditors),
**generator_kwargs,
)
class RedditModNotes(BaseModNotes):
"""Provides methods to interact with moderator notes at a global level.
.. note::
The authenticated user must be a moderator of the provided subreddit(s).
For example, the latest note for u/spez in r/redditdev and r/test, and for u/bboe in
r/redditdev can be iterated through like so:
.. code-block:: python
redditor = reddit.redditor("bboe")
subreddit = reddit.subreddit("redditdev")
pairs = [(subreddit, "spez"), ("test", "spez"), (subreddit, redditor)]
for note in reddit.notes(pairs=pairs):
print(f"{note.label}: {note.note}")
"""
def __call__(
self,
*,
all_notes: bool = False,
pairs: Optional[List[Tuple[SubredditType, RedditorType]]] = None,
redditors: Optional[List[RedditorType]] = None,
subreddits: Optional[List[SubredditType]] = None,
things: Optional[List[ThingType]] = None,
**generator_kwargs: Any,
) -> Generator["praw.models.ModNote", None, None]:
"""Get note(s) for each subreddit/user pair, or ``None`` if they don't have any.
:param all_notes: Whether to return all notes or only the latest note for each
subreddit/redditor pair (default: ``False``).
.. note::
Setting this to ``True`` will result in a request for each unique
subreddit/redditor pair. If ``subreddits`` and ``redditors`` are
provided, this will make a request equivalent to number of redditors
multiplied by the number of subreddits.
:param pairs: A list of subreddit/redditor tuples.
.. note::
Required if ``subreddits``, ``redditors``, nor ``things`` are provided.
:param redditors: A list of redditors to return notes for. This parameter is
used in tandem with ``subreddits`` to get notes from multiple subreddits for
each of the provided redditors.
.. note::
Required if ``items`` or ``things`` is not provided or if ``subreddits``
**is** provided.
:param subreddits: A list of subreddits to return notes for. This parameter is
used in tandem with ``redditors`` to get notes for multiple redditors from
each of the provided subreddits.
.. note::
Required if ``items`` or ``things`` is not provided or if ``redditors``
**is** provided.
:param things: A list of comments and/or submissions to return notes for.
:param generator_kwargs: Additional keyword arguments passed to the generator.
This parameter is ignored when ``all_notes`` is ``False``.
:returns: A generator that yields the most recent :class:`.ModNote` (or ``None``
if the user doesn't have any notes) per entry in their relative order. If
``all_notes`` is ``True``, this will yield all notes for each entry.
.. note::
This method will merge the subreddits and redditors provided from ``pairs``,
``redditors``, ``subreddits``, and ``things``.
.. note::
This method accepts :class:`.Redditor` instances or redditor names and
:class:`.Subreddit` instances or subreddit names where applicable.
For example, the latest note for u/spez in r/redditdev and r/test, and for
u/bboe in r/redditdev can be iterated through like so:
.. code-block:: python
redditor = reddit.redditor("bboe")
subreddit = reddit.subreddit("redditdev")
pairs = [(subreddit, "spez"), ("test", "spez"), (subreddit, redditor)]
for note in reddit.notes(pairs=pairs):
print(f"{note.label}: {note.note}")
For example, **all** the notes for u/spez and u/bboe in r/announcements,
r/redditdev, and r/test can be iterated through like so:
.. code-block:: python
redditor = reddit.redditor("bboe")
subreddit = reddit.subreddit("redditdev")
for note in reddit.notes(
redditors=["spez", redditor],
subreddits=["announcements", subreddit, "test"],
all_notes=True,
):
print(f"{note.label}: {note.note}")
For example, the latest note for the authors of the last 5 comments and
submissions from r/test can be iterated through like so:
.. code-block:: python
submissions = list(reddit.subreddit("test").new(limit=5))
comments = list(reddit.subreddit("test").comments(limit=5))
for note in reddit.notes(things=submissions + comments):
print(f"{note.label}: {note.note}")
.. note::
Setting ``all_notes`` to ``True`` will make a request for each redditor and
subreddit combination. The previous example will make 6 requests.
"""
if pairs is None:
pairs = []
if redditors is None:
redditors = []
if subreddits is None:
subreddits = []
if things is None:
things = []
if not (pairs + redditors + subreddits + things):
msg = "Either the 'pairs', 'redditors', 'subreddits', or 'things' parameters must be provided."
raise TypeError(msg)
if (
len(redditors) * len(subreddits) == 0
and len(redditors) + len(subreddits) > 0
):
raise TypeError(
"'redditors' must be non-empty if 'subreddits' is not empty."
if len(subreddits) > 0
else "'subreddits' must be non-empty if 'redditors' is not empty."
)
merged_redditors = []
merged_subreddits = []
items = (
pairs
+ [
(subreddit, redditor)
for redditor in set(redditors)
for subreddit in set(subreddits)
]
+ things
)
for item in items:
if isinstance(item, (Comment, Submission)):
merged_redditors.append(item.author.name)
merged_subreddits.append(item.subreddit.display_name)
elif isinstance(item, Tuple):
subreddit, redditor = item
merged_redditors.append(redditor)
merged_subreddits.append(subreddit)
else:
msg = f"Cannot get subreddit and author fields from type {type(item)}"
raise ValueError(msg)
return self._notes(
all_notes, merged_redditors, merged_subreddits, **generator_kwargs
)
def things(
self,
*things: ThingType,
all_notes: Optional[bool] = None,
**generator_kwargs: Any,
) -> Generator["praw.models.ModNote", None, None]:
"""Return notes associated with the author of a :class:`.Comment` or :class:`.Submission`.
:param things: One or more things to return notes on. Must be a
:class:`.Comment` or :class:`.Submission`.
:param all_notes: Whether to return all notes, or only the latest (default:
``True`` if only one thing is provided otherwise ``False``).
.. note::
Setting this to ``True`` will result in a request for each thing.
:returns: A generator that yields the most recent :class:`.ModNote` (or ``None``
if the user doesn't have any notes) per entry in their relative order. If
``all_notes`` is ``True``, this will yield all notes for each entry.
For example, to get the latest note for the authors of the top 25 submissions in
r/test:
.. code-block:: python
submissions = reddit.subreddit("test").top(limit=25)
for note in reddit.notes.things(*submissions):
print(f"{note.label}: {note.note}")
For example, to get the latest note for the authors of the last 25 comments in
r/test:
.. code-block:: python
comments = reddit.subreddit("test").comments(limit=25)
for note in reddit.notes.things(*comments):
print(f"{note.label}: {note.note}")
"""
subreddits = []
redditors = []
for thing in things:
subreddits.append(thing.subreddit)
redditors.append(thing.author)
if all_notes is None:
all_notes = len(things) == 1
return self._notes(all_notes, redditors, subreddits, **generator_kwargs)

View File

@@ -0,0 +1,208 @@
"""Provide the Preferences class."""
from __future__ import annotations
from json import dumps
from typing import TYPE_CHECKING
from ..const import API_PATH
if TYPE_CHECKING: # pragma: no cover
import praw
class Preferences:
"""A class for Reddit preferences.
The :class:`.Preferences` class provides access to the Reddit preferences of the
currently authenticated user.
"""
def __call__(self) -> dict[str, bool | int | str]:
"""Return the preference settings of the authenticated user as a dict.
This method is intended to be accessed as ``reddit.user.preferences()`` like so:
.. code-block:: python
preferences = reddit.user.preferences()
print(preferences["show_link_flair"])
See https://www.reddit.com/dev/api#GET_api_v1_me_prefs for the list of possible
values.
"""
return self._reddit.get(API_PATH["preferences"])
def __init__(self, reddit: praw.Reddit):
"""Initialize a :class:`.Preferences` instance.
:param reddit: The :class:`.Reddit` instance.
"""
self._reddit = reddit
def update(self, **preferences: bool | int | str) -> dict[str, bool | int | str]:
"""Modify the specified settings.
:param accept_pms: Who can send you personal messages (one of ``"everyone"`` or
``"whitelisted"``).
:param activity_relevant_ads: Allow Reddit to use your activity on Reddit to
show you more relevant advertisements.
:param allow_clicktracking: Allow Reddit to log my outbound clicks for
personalization.
:param beta: I would like to beta test features for Reddit.
:param clickgadget: Show me links I've recently viewed.
:param collapse_read_messages: Collapse messages after I've read them.
:param compress: Compress the link display.
:param creddit_autorenew: Use a creddit to automatically renew my gold if it
expires.
:param default_comment_sort: Default comment sort (one of ``"confidence"``,
``"top"``, ``"new"``, ``"controversial"``, ``"old"``, ``"random"``,
``"qa"``, or ``"live"``).
:param bool domain_details: Show additional details in the domain text when
available, such as the source subreddit or the content author's url/name.
:param bool email_chat_request: Send chat requests as emails.
:param bool email_comment_reply: Send comment replies as emails.
:param bool email_digests: Send email digests.
:param bool email_messages: Send messages as emails.
:param bool email_post_reply: Send post replies as emails.
:param bool email_private_message: Send private messages as emails.
:param bool email_unsubscribe_all: Unsubscribe from all emails.
:param bool email_upvote_comment: Send comment upvote updates as emails.
:param bool email_upvote_post: Send post upvote updates as emails.
:param bool email_user_new_follower: Send new follower alerts as emails.
:param bool email_username_mention: Send username mentions as emails.
:param bool enable_default_themes: Use Reddit theme.
:param bool feed_recommendations_enabled: Enable feed recommendations.
:param str geopopular: Location (one of ``"GLOBAL"``, ``"AR"``, ``"AU"``,
``"BG"``, ``"CA"``, ``"CL"``, ``"CO"``, ``"CZ"``, ``"FI"``, ``"GB"``,
``"GR"``, ``"HR"``, ``"HU"``, ``"IE"``, ``"IN"``, ``"IS"``, ``"JP"``,
``"MX"``, ``"MY"``, ``"NZ"``, ``"PH"``, ``"PL"``, ``"PR"``, ``"PT"``,
``"RO"``, ``"RS"``, ``"SE"``, ``"SG"``, ``"TH"``, ``"TR"``, ``"TW"``,
``"US"``, ``"US_AK"``, ``"US_AL"``, ``"US_AR"``, ``"US_AZ"``, ``"US_CA"``,
``"US_CO"``, ``"US_CT"``, ``"US_DC"``, ``"US_DE"``, ``"US_FL"``,
``"US_GA"``, ``"US_HI"``, ``"US_IA"``, ``"US_ID"``, ``"US_IL"``,
``"US_IN"``, ``"US_KS"``, ``"US_KY"``, ``"US_LA"``, ``"US_MA"``,
``"US_MD"``, ``"US_ME"``, ``"US_MI"``, ``"US_MN"``, ``"US_MO"``,
``"US_MS"``, ``"US_MT"``, ``"US_NC"``, ``"US_ND"``, ``"US_NE"``,
``"US_NH"``, ``"US_NJ"``, ``"US_NM"``, ``"US_NV"``, ``"US_NY"``,
``"US_OH"``, ``"US_OK"``, ``"US_OR"``, ``"US_PA"``, ``"US_RI"``,
``"US_SC"``, ``"US_SD"``, ``"US_TN"``, ``"US_TX"``, ``"US_UT"``,
``"US_VA"``, ``"US_VT"``, ``"US_WA"``, ``"US_WI"``, ``"US_WV"``, or
``"US_WY"``).
:param bool hide_ads: Hide ads.
:param bool hide_downs: Don't show me submissions after I've downvoted them,
except my own.
:param bool hide_from_robots: Don't allow search engines to index my user
profile.
:param bool hide_ups: Don't show me submissions after I've upvoted them, except
my own.
:param bool highlight_controversial: Show a dagger on comments voted
controversial.
:param bool highlight_new_comments: Highlight new comments.
:param bool ignore_suggested_sort: Ignore suggested sorts.
:param bool in_redesign_beta: In redesign beta.
:param bool label_nsfw: Label posts that are not safe for work.
:param str lang: Interface language (IETF language tag, underscore separated).
:param bool legacy_search: Show legacy search page.
:param bool live_orangereds: Send message notifications in my browser.
:param bool mark_messages_read: Mark messages as read when I open my inbox.
:param str media: Thumbnail preference (one of ``"on"``, ``"off"``, or
``"subreddit"``).
:param str media_preview: Media preview preference (one of ``"on"``, ``"off"``,
or ``"subreddit"``).
:param int min_comment_score: Don't show me comments with a score less than this
number (between ``-100`` and ``100``).
:param int min_link_score: Don't show me submissions with a score less than this
number (between ``-100`` and ``100``).
:param bool monitor_mentions: Notify me when people say my username.
:param bool newwindow: Open links in a new window.
:param bool nightmode: Enable night mode.
:param bool no_profanity: Don't show thumbnails or media previews for anything
labeled NSFW.
:param int num_comments: Display this many comments by default (between ``1``
and ``500``).
:param int numsites: Number of links to display at once (between ``1`` and
``100``).
:param bool organic: Show the spotlight box on the home feed.
:param str other_theme: Subreddit theme to use (subreddit name).
:param bool over_18: I am over eighteen years old and willing to view adult
content.
:param bool private_feeds: Enable private RSS feeds.
:param bool profile_opt_out: View user profiles on desktop using legacy mode.
:param bool public_votes: Make my votes public.
:param bool research: Allow my data to be used for research purposes.
:param bool search_include_over_18: Include not safe for work (NSFW) search
results in searches.
:param bool send_crosspost_messages: Send crosspost messages.
:param bool send_welcome_messages: Send welcome messages.
:param bool show_flair: Show user flair.
:param bool show_gold_expiration: Show how much gold you have remaining on your
userpage.
:param bool show_link_flair: Show link flair.
:param bool show_location_based_recommendations: Show location based
recommendations.
:param bool show_presence: Show presence.
:param bool show_promote: Show promote.
:param bool show_stylesheets: Allow subreddits to show me custom themes.
:param bool show_trending: Show trending subreddits on the home feed.
:param bool show_twitter: Show a link to your Twitter account on your profile.
:param bool store_visits: Store visits.
:param bool theme_selector: Theme selector (subreddit name).
:param bool third_party_data_personalized_ads: Allow Reddit to use data provided
by third-parties to show you more relevant advertisements on Reddit.
:param bool third_party_personalized_ads: Allow personalization of
advertisements.
:param bool third_party_site_data_personalized_ads: Allow personalization of
advertisements using third-party website data.
:param bool third_party_site_data_personalized_content: Allow personalization of
content using third-party website data.
:param bool threaded_messages: Show message conversations in the inbox.
:param bool threaded_modmail: Enable threaded modmail display.
:param bool top_karma_subreddits: Top karma subreddits.
:param bool use_global_defaults: Use global defaults.
:param bool video_autoplay: Autoplay Reddit videos on the desktop comments page.
Additional keyword arguments can be provided to handle new settings as Reddit
introduces them.
See https://www.reddit.com/dev/api#PATCH_api_v1_me_prefs for the most up-to-date
list of possible parameters.
This is intended to be used like so:
.. code-block:: python
reddit.user.preferences.update(show_link_flair=True)
This method returns the new state of the preferences as a ``dict``, which can be
used to check whether a change went through.
.. code-block:: python
original_preferences = reddit.user.preferences()
new_preferences = reddit.user.preferences.update(invalid_param=123)
print(original_preferences == new_preferences) # True, no change
.. warning::
Passing an unknown parameter name or an illegal value (such as an int when a
boolean is expected) does not result in an error from the Reddit API. As a
consequence, any invalid input will fail silently. To verify that changes
have been made, use the return value of this method, which is a dict of the
preferences after the update action has been performed.
Some preferences have names that are not valid keyword arguments in Python. To
update these, construct a ``dict`` and use ``**`` to unpack it as keyword
arguments:
.. code-block:: python
reddit.user.preferences.update(**{"third_party_data_personalized_ads": False})
"""
return self._reddit.patch(
API_PATH["preferences"], data={"json": dumps(preferences)}
)

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
)

View File

@@ -0,0 +1,114 @@
"""Provide the Redditors class."""
from __future__ import annotations
from itertools import islice
from types import SimpleNamespace
from typing import TYPE_CHECKING, Iterable, Iterator
import prawcore
from ..const import API_PATH
from .base import PRAWBase
from .listing.generator import ListingGenerator
from .util import stream_generator
if TYPE_CHECKING: # pragma: no cover
import praw.models
class PartialRedditor(SimpleNamespace):
"""A namespace object that provides a subset of :class:`.Redditor` attributes."""
class Redditors(PRAWBase):
"""Redditors is a Listing class that provides various :class:`.Redditor` lists."""
def new(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Subreddit]:
"""Return a :class:`.ListingGenerator` for new :class:`.Redditors`.
:returns: :class:`.Redditor` profiles, which are a type of :class:`.Subreddit`.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
"""
return ListingGenerator(self._reddit, API_PATH["users_new"], **generator_kwargs)
def partial_redditors(self, ids: Iterable[str]) -> Iterator[PartialRedditor]:
"""Get user summary data by redditor IDs.
:param ids: An iterable of redditor fullname IDs.
:returns: A iterator producing :class:`.PartialRedditor` objects.
Each ID must be prefixed with ``t2_``.
Invalid IDs are ignored by the server.
"""
iterable = iter(ids)
while True:
chunk = list(islice(iterable, 100))
if not chunk:
break
params = {"ids": ",".join(chunk)}
try:
results = self._reddit.get(API_PATH["user_by_fullname"], params=params)
except prawcore.exceptions.NotFound:
# None of the given IDs matched any Redditor.
continue
for fullname, user_data in results.items():
yield PartialRedditor(fullname=fullname, **user_data)
def popular(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Subreddit]:
"""Return a :class:`.ListingGenerator` for popular :class:`.Redditors`.
:returns: :class:`.Redditor` profiles, which are a type of :class:`.Subreddit`.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
"""
return ListingGenerator(
self._reddit, API_PATH["users_popular"], **generator_kwargs
)
def search(
self, query: str, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Subreddit]:
r"""Return a :class:`.ListingGenerator` of Redditors for ``query``.
:param query: The query string to filter Redditors by.
:returns: :class:`.Redditor`\ s.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
"""
self._safely_add_arguments(arguments=generator_kwargs, key="params", q=query)
return ListingGenerator(
self._reddit, API_PATH["users_search"], **generator_kwargs
)
def stream(
self, **stream_options: str | int | dict[str, str]
) -> Iterator[praw.models.Subreddit]:
"""Yield new Redditors as they are created.
Redditors are yielded oldest first. Up to 100 historical Redditors will
initially be returned.
Keyword arguments are passed to :func:`.stream_generator`.
:returns: :class:`.Redditor` profiles, which are a type of :class:`.Subreddit`.
"""
return stream_generator(self.new, **stream_options)

View File

@@ -0,0 +1,18 @@
"""Provide the Stylesheet class."""
from .base import PRAWBase
class Stylesheet(PRAWBase):
"""Represent a stylesheet.
.. include:: ../../typical_attributes.rst
============== ========================================
Attribute Description
============== ========================================
``images`` A list of images used by the stylesheet.
``stylesheet`` The contents of the stylesheet, as CSS.
============== ========================================
"""

View File

@@ -0,0 +1,183 @@
"""Provide the Subreddits class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Iterator
from warnings import warn
from ..const import API_PATH
from ..util import _deprecate_args
from . import Subreddit
from .base import PRAWBase
from .listing.generator import ListingGenerator
from .util import stream_generator
if TYPE_CHECKING: # pragma: no cover
import praw.models
class Subreddits(PRAWBase):
"""Subreddits is a Listing class that provides various subreddit lists."""
@staticmethod
def _to_list(subreddit_list: list[str | praw.models.Subreddit]) -> str:
return ",".join([str(x) for x in subreddit_list])
def default(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Subreddit]:
"""Return a :class:`.ListingGenerator` for default subreddits.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
"""
return ListingGenerator(
self._reddit, API_PATH["subreddits_default"], **generator_kwargs
)
def gold(self, **generator_kwargs: Any) -> Iterator[praw.models.Subreddit]:
"""Alias for :meth:`.premium` to maintain backwards compatibility."""
warn(
"'subreddits.gold' has be renamed to 'subreddits.premium'.",
category=DeprecationWarning,
stacklevel=2,
)
return self.premium(**generator_kwargs)
def new(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Subreddit]:
"""Return a :class:`.ListingGenerator` for new subreddits.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
"""
return ListingGenerator(
self._reddit, API_PATH["subreddits_new"], **generator_kwargs
)
def popular(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Subreddit]:
"""Return a :class:`.ListingGenerator` for popular subreddits.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
"""
return ListingGenerator(
self._reddit, API_PATH["subreddits_popular"], **generator_kwargs
)
def premium(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Subreddit]:
"""Return a :class:`.ListingGenerator` for premium subreddits.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
"""
return ListingGenerator(
self._reddit, API_PATH["subreddits_gold"], **generator_kwargs
)
def recommended(
self,
subreddits: list[str | praw.models.Subreddit],
omit_subreddits: list[str | praw.models.Subreddit] | None = None,
) -> list[praw.models.Subreddit]:
"""Return subreddits recommended for the given list of subreddits.
:param subreddits: A list of :class:`.Subreddit` instances and/or subreddit
names.
:param omit_subreddits: A list of :class:`.Subreddit` instances and/or subreddit
names to exclude from the results (Reddit's end may not work as expected).
"""
if not isinstance(subreddits, list):
msg = "subreddits must be a list"
raise TypeError(msg)
if omit_subreddits is not None and not isinstance(omit_subreddits, list):
msg = "omit_subreddits must be a list or None"
raise TypeError(msg)
params = {"omit": self._to_list(omit_subreddits or [])}
url = API_PATH["sub_recommended"].format(subreddits=self._to_list(subreddits))
return [
Subreddit(self._reddit, sub["sr_name"])
for sub in self._reddit.get(url, params=params)
]
def search(
self, query: str, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Subreddit]:
"""Return a :class:`.ListingGenerator` of subreddits matching ``query``.
Subreddits are searched by both their title and description.
:param query: The query string to filter subreddits by.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
.. seealso::
:meth:`.search_by_name` to search by subreddit names
"""
self._safely_add_arguments(arguments=generator_kwargs, key="params", q=query)
return ListingGenerator(
self._reddit, API_PATH["subreddits_search"], **generator_kwargs
)
@_deprecate_args("query", "include_nsfw", "exact")
def search_by_name(
self,
query: str,
*,
include_nsfw: bool = True,
exact: bool = False,
) -> list[praw.models.Subreddit]:
r"""Return list of :class:`.Subreddit`\ s whose names begin with ``query``.
:param query: Search for subreddits beginning with this string.
:param exact: Return only exact matches to ``query`` (default: ``False``).
:param include_nsfw: Include subreddits labeled NSFW (default: ``True``).
"""
result = self._reddit.post(
API_PATH["subreddits_name_search"],
data={"include_over_18": include_nsfw, "exact": exact, "query": query},
)
return [self._reddit.subreddit(x) for x in result["names"]]
def search_by_topic(self, query: str) -> list[praw.models.Subreddit]:
"""Return list of Subreddits whose topics match ``query``.
:param query: Search for subreddits relevant to the search topic.
.. note::
As of 09/01/2020, this endpoint always returns 404.
"""
result = self._reddit.get(
API_PATH["subreddits_by_topic"], params={"query": query}
)
return [self._reddit.subreddit(x["name"]) for x in result if x.get("name")]
def stream(
self, **stream_options: str | int | dict[str, str]
) -> Iterator[praw.models.Subreddit]:
"""Yield new subreddits as they are created.
Subreddits are yielded oldest first. Up to 100 historical subreddits will
initially be returned.
Keyword arguments are passed to :func:`.stream_generator`.
"""
return stream_generator(self.new, **stream_options)

View File

@@ -0,0 +1,58 @@
"""Represent the :class:`.Trophy` class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from .base import PRAWBase
if TYPE_CHECKING: # pragma: no cover
import praw
class Trophy(PRAWBase):
"""Represent a trophy.
End users should not instantiate this class directly. :meth:`.Redditor.trophies` can
be used to get a list of the redditor's trophies.
.. include:: ../../typical_attributes.rst
=============== ===================================================
Attribute Description
=============== ===================================================
``award_id`` The ID of the trophy (sometimes ``None``).
``description`` The description of the trophy (sometimes ``None``).
``icon_40`` The URL of a 41x41 px icon for the trophy.
``icon_70`` The URL of a 71x71 px icon for the trophy.
``name`` The name of the trophy.
``url`` A relevant URL (sometimes ``None``).
=============== ===================================================
"""
def __eq__(self, other: Trophy | Any) -> bool:
"""Check if two Trophies are equal."""
if isinstance(other, self.__class__):
return self.name == other.name
return super().__eq__(other)
def __init__(self, reddit: praw.Reddit, _data: dict[str, Any]):
"""Initialize a :class:`.Trophy` instance.
:param reddit: An instance of :class:`.Reddit`.
:param _data: The structured data, assumed to be a dict and key ``"name"`` must
be provided.
"""
assert isinstance(_data, dict)
assert "name" in _data
super().__init__(reddit, _data=_data)
def __repr__(self) -> str:
"""Return an object initialization representation of the instance."""
return f"{self.__class__.__name__}(name={self.name!r})"
def __str__(self) -> str:
"""Return a name of the trophy."""
return self.name

View File

@@ -0,0 +1,293 @@
"""Provides the User class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Iterator
from warnings import warn
from prawcore import Conflict
from ..const import API_PATH
from ..exceptions import ReadOnlyException
from ..models import Preferences
from ..util import _deprecate_args
from ..util.cache import cachedproperty
from .base import PRAWBase
from .listing.generator import ListingGenerator
from .reddit.redditor import Redditor
from .reddit.subreddit import Subreddit
if TYPE_CHECKING: # pragma: no cover
import praw.models
class User(PRAWBase):
"""The :class:`.User` class provides methods for the currently authenticated user."""
@cachedproperty
def preferences(self) -> praw.models.Preferences:
"""Get an instance of :class:`.Preferences`.
The preferences can be accessed as a ``dict`` like so:
.. code-block:: python
preferences = reddit.user.preferences()
print(preferences["show_link_flair"])
Preferences can be updated via:
.. code-block:: python
reddit.user.preferences.update(show_link_flair=True)
The :meth:`.Preferences.update` method returns the new state of the preferences
as a ``dict``, which can be used to check whether a change went through. Changes
with invalid types or parameter names fail silently.
.. code-block:: python
original_preferences = reddit.user.preferences()
new_preferences = reddit.user.preferences.update(invalid_param=123)
print(original_preferences == new_preferences) # True, no change
"""
return Preferences(self._reddit)
def __init__(self, reddit: praw.Reddit):
"""Initialize an :class:`.User` instance.
This class is intended to be interfaced with through ``reddit.user``.
"""
super().__init__(reddit, _data=None)
def blocked(self) -> list[praw.models.Redditor]:
r"""Return a :class:`.RedditorList` of blocked :class:`.Redditor`\ s."""
return self._reddit.get(API_PATH["blocked"])
def contributor_subreddits(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Subreddit]:
r"""Return a :class:`.ListingGenerator` of contributor :class:`.Subreddit`\ s.
These are subreddits in which the user is an approved user.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
To print a list of the subreddits that you are an approved user in, try:
.. code-block:: python
for subreddit in reddit.user.contributor_subreddits(limit=None):
print(str(subreddit))
"""
return ListingGenerator(
self._reddit, API_PATH["my_contributor"], **generator_kwargs
)
@_deprecate_args("user")
def friends(
self, *, user: str | praw.models.Redditor | None = None
) -> list[praw.models.Redditor] | praw.models.Redditor:
r"""Return a :class:`.RedditorList` of friends or a :class:`.Redditor` in the friends list.
:param user: Checks to see if you are friends with the redditor. Either an
instance of :class:`.Redditor` or a string can be given.
:returns: A list of :class:`.Redditor`\ s, or a single :class:`.Redditor` if
``user`` is specified. The :class:`.Redditor` instance(s) returned also has
friend attributes.
:raises: An instance of :class:`.RedditAPIException` if you are not friends with
the specified :class:`.Redditor`.
"""
endpoint = (
API_PATH["friends"]
if user is None
else API_PATH["friend_v1"].format(user=str(user))
)
return self._reddit.get(endpoint)
def karma(self) -> dict[praw.models.Subreddit, dict[str, int]]:
r"""Return a dictionary mapping :class:`.Subreddit`\ s to their karma.
The returned dict contains subreddits as keys. Each subreddit key contains a
sub-dict that have keys for ``comment_karma`` and ``link_karma``. The dict is
sorted in descending karma order.
.. note::
Each key of the main dict is an instance of :class:`.Subreddit`. It is
recommended to iterate over the dict in order to retrieve the values,
preferably through :py:meth:`dict.items`.
"""
karma_map = {}
for row in self._reddit.get(API_PATH["karma"])["data"]:
subreddit = Subreddit(self._reddit, row["sr"])
del row["sr"]
karma_map[subreddit] = row
return karma_map
@_deprecate_args("use_cache")
def me(self, *, use_cache: bool = True) -> praw.models.Redditor | None:
"""Return a :class:`.Redditor` instance for the authenticated user.
:param use_cache: When ``True``, and if this function has been previously
called, returned the cached version (default: ``True``).
.. note::
If you change the :class:`.Reddit` instance's authorization, you might want
to refresh the cached value. Prefer using separate :class:`.Reddit`
instances, however, for distinct authorizations.
.. deprecated:: 7.2
In :attr:`.read_only` mode this method returns ``None``. In PRAW 8 this
method will raise :class:`.ReadOnlyException` when called in
:attr:`.read_only` mode. To operate in PRAW 8 mode, set the config variable
``praw8_raise_exception_on_me`` to ``True``.
"""
if self._reddit.read_only:
if not self._reddit.config.custom.get("praw8_raise_exception_on_me"):
warn(
"The 'None' return value is deprecated, and will raise a"
" ReadOnlyException beginning with PRAW 8. See documentation for"
" forward compatibility options.",
category=DeprecationWarning,
stacklevel=2,
)
return None
msg = "`user.me()` does not work in read_only mode"
raise ReadOnlyException(msg)
if "_me" not in self.__dict__ or not use_cache:
user_data = self._reddit.get(API_PATH["me"])
self._me = Redditor(self._reddit, _data=user_data)
return self._me
def moderator_subreddits(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Subreddit]:
"""Return a :class:`.ListingGenerator` subreddits that the user moderates.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
To print a list of the names of the subreddits you moderate, try:
.. code-block:: python
for subreddit in reddit.user.moderator_subreddits(limit=None):
print(str(subreddit))
.. seealso::
:meth:`.Redditor.moderated`
"""
return ListingGenerator(
self._reddit, API_PATH["my_moderator"], **generator_kwargs
)
def multireddits(self) -> list[praw.models.Multireddit]:
r"""Return a list of :class:`.Multireddit`\ s belonging to the user."""
return self._reddit.get(API_PATH["my_multireddits"])
def pin(
self, submission: praw.models.Submission, *, num: int = None, state: bool = True
) -> praw.models.Submission:
"""Set the pin state of a submission on the authenticated user's profile.
:param submission: An instance of :class:`.Submission` that will be
pinned/unpinned.
:param num: If specified, the slot in which the submission will be pinned into.
If there is a submission already in the specified slot, it will be replaced.
If ``None`` or there is not a submission in the specified slot, the first
available slot will be used (default: ``None``). If all slots are used the
following will occur:
- Old Reddit:
1. The submission in the last slot will be unpinned.
2. The remaining pinned submissions will be shifted down a slot.
3. The new submission will be pinned in the first slot.
- New Reddit:
1. The submission in the first slot will be unpinned.
2. The remaining pinned submissions will be shifted up a slot.
3. The new submission will be pinned in the last slot.
.. note::
At the time of writing (10/22/2021), there are 4 pin slots available and
pins are in reverse order on old Reddit. If ``num`` is an invalid value,
Reddit will ignore it and the same behavior will occur as if ``num`` is
``None``.
:param state: ``True`` pins the submission, ``False`` unpins (default:
``True``).
:returns: The pinned submission.
:raises: ``prawcore.BadRequest`` when pinning a removed or deleted submission.
:raises: ``prawcore.Forbidden`` when pinning a submission the authenticated user
is not the author of.
.. code-block:: python
submission = next(reddit.user.me().submissions.new())
reddit.user.pin(submission)
"""
data = {
"id": submission.fullname,
"num": num,
"state": state,
"to_profile": True,
}
try:
return self._reddit.post(API_PATH["sticky_submission"], data=data)
except Conflict:
pass
def subreddits(
self, **generator_kwargs: str | int | dict[str, str]
) -> Iterator[praw.models.Subreddit]:
r"""Return a :class:`.ListingGenerator` of :class:`.Subreddit`\ s the user is subscribed to.
Additional keyword arguments are passed in the initialization of
:class:`.ListingGenerator`.
To print a list of the subreddits that you are subscribed to, try:
.. code-block:: python
for subreddit in reddit.user.subreddits(limit=None):
print(str(subreddit))
"""
return ListingGenerator(
self._reddit, API_PATH["my_subreddits"], **generator_kwargs
)
def trusted(self) -> list[praw.models.Redditor]:
r"""Return a :class:`.RedditorList` of trusted :class:`.Redditor`\ s.
To display the usernames of your trusted users and the times at which you
decided to trust them, try:
.. code-block:: python
trusted_users = reddit.user.trusted()
for user in trusted_users:
print(f"User: {user.name}, time: {user.date}")
"""
return self._reddit.get(API_PATH["trusted"])

View File

@@ -0,0 +1,230 @@
"""Provide helper classes used by other models."""
from __future__ import annotations
import random
import time
from collections import OrderedDict
from typing import Any, Callable, Generator
from ..util import _deprecate_args
@_deprecate_args("permissions", "known_permissions")
def permissions_string(
*, known_permissions: set[str], permissions: list[str] | None
) -> str:
"""Return a comma separated string of permission changes.
:param known_permissions: A set of strings representing the available permissions.
:param permissions: A list of strings, or ``None``. These strings can exclusively
contain ``+`` or ``-`` prefixes, or contain no prefixes at all. When prefixed,
the resulting string will simply be the joining of these inputs. When not
prefixed, all permissions are considered to be additions, and all permissions in
the ``known_permissions`` set that aren't provided are considered to be
removals. When ``None``, the result is ``"+all"``.
"""
if permissions is None:
to_set = ["+all"]
else:
to_set = ["-all"]
omitted = sorted(known_permissions - set(permissions))
to_set.extend(f"-{x}" for x in omitted)
to_set.extend(f"+{x}" for x in permissions)
return ",".join(to_set)
@_deprecate_args(
"function",
"pause_after",
"skip_existing",
"attribute_name",
"exclude_before",
"continue_after_id",
)
def stream_generator(
function: Callable,
*,
attribute_name: str = "fullname",
continue_after_id: str | None = None,
exclude_before: bool = False,
pause_after: int | None = None,
skip_existing: bool = False,
**function_kwargs: Any,
) -> Generator[Any, None, None]:
"""Yield new items from ``function`` as they become available.
:param function: A callable that returns a :class:`.ListingGenerator`, e.g.,
:meth:`.Subreddit.comments` or :meth:`.Subreddit.new`.
:param attribute_name: The field to use as an ID (default: ``"fullname"``).
:param exclude_before: When ``True`` does not pass ``params`` to ``function``
(default: ``False``).
:param pause_after: An integer representing the number of requests that result in no
new items before this function yields ``None``, effectively introducing a pause
into the stream. A negative value yields ``None`` after items from a single
response have been yielded, regardless of number of new items obtained in that
response. A value of ``0`` yields ``None`` after every response resulting in no
new items, and a value of ``None`` never introduces a pause (default: ``None``).
:param skip_existing: When ``True``, this does not yield any results from the first
request thereby skipping any items that existed in the stream prior to starting
the stream (default: ``False``).
:param continue_after_id: The initial item ID value to use for ``before`` in
``params``. The stream will continue from the item following this one (default:
``None``).
Additional keyword arguments will be passed to ``function``.
.. note::
This function internally uses an exponential delay with jitter between
subsequent responses that contain no new results, up to a maximum delay of just
over 16 seconds. In practice, that means that the time before pause for
``pause_after=N+1`` is approximately twice the time before pause for
``pause_after=N``.
For example, to create a stream of comment replies, try:
.. code-block:: python
reply_function = reddit.inbox.comment_replies
for reply in praw.models.util.stream_generator(reply_function):
print(reply)
To pause a comment stream after six responses with no new comments, try:
.. code-block:: python
subreddit = reddit.subreddit("test")
for comment in subreddit.stream.comments(pause_after=6):
if comment is None:
break
print(comment)
To resume fetching comments after a pause, try:
.. code-block:: python
subreddit = reddit.subreddit("test")
comment_stream = subreddit.stream.comments(pause_after=5)
for comment in comment_stream:
if comment is None:
break
print(comment)
# Do any other processing, then try to fetch more data
for comment in comment_stream:
if comment is None:
break
print(comment)
To bypass the internal exponential backoff, try the following. This approach is
useful if you are monitoring a subreddit with infrequent activity, and you want to
consistently learn about new items from the stream as soon as possible, rather than
up to a delay of just over sixteen seconds.
.. code-block:: python
subreddit = reddit.subreddit("test")
for comment in subreddit.stream.comments(pause_after=0):
if comment is None:
continue
print(comment)
"""
before_attribute = continue_after_id
exponential_counter = ExponentialCounter(max_counter=16)
seen_attributes = BoundedSet(301)
without_before_counter = 0
responses_without_new = 0
valid_pause_after = pause_after is not None
while True:
found = False
newest_attribute = None
limit = 100
if before_attribute is None:
limit -= without_before_counter
without_before_counter = (without_before_counter + 1) % 30
if not exclude_before:
function_kwargs["params"] = {"before": before_attribute}
for item in reversed(list(function(limit=limit, **function_kwargs))):
attribute = getattr(item, attribute_name)
if attribute in seen_attributes:
continue
found = True
seen_attributes.add(attribute)
newest_attribute = attribute
if not skip_existing:
yield item
before_attribute = newest_attribute
skip_existing = False
if valid_pause_after and pause_after < 0:
yield None
elif found:
exponential_counter.reset()
responses_without_new = 0
else:
responses_without_new += 1
if valid_pause_after and responses_without_new > pause_after:
exponential_counter.reset()
responses_without_new = 0
yield None
else:
time.sleep(exponential_counter.counter())
class BoundedSet:
"""A set with a maximum size that evicts the oldest items when necessary.
This class does not implement the complete set interface.
"""
def __contains__(self, item: Any) -> bool:
"""Test if the :class:`.BoundedSet` contains item."""
self._access(item)
return item in self._set
def __init__(self, max_items: int):
"""Initialize a :class:`.BoundedSet` instance."""
self.max_items = max_items
self._set = OrderedDict()
def _access(self, item: Any):
if item in self._set:
self._set.move_to_end(item)
def add(self, item: Any):
"""Add an item to the set discarding the oldest item if necessary."""
self._access(item)
self._set[item] = None
if len(self._set) > self.max_items:
self._set.popitem(last=False)
class ExponentialCounter:
"""A class to provide an exponential counter with jitter."""
def __init__(self, max_counter: int):
"""Initialize an :class:`.ExponentialCounter` instance.
:param max_counter: The maximum base value.
.. note::
The computed value may be 3.125% higher due to jitter.
"""
self._base = 1
self._max = max_counter
def counter(self) -> int | float:
"""Increment the counter and return the current value with jitter."""
max_jitter = self._base / 16.0
value = self._base + random.random() * max_jitter - max_jitter / 2 # noqa: S311
self._base = min(self._base * 2, self._max)
return value
def reset(self):
"""Reset the counter to 1."""
self._base = 1

View File

@@ -0,0 +1,286 @@
"""Provides the Objector class."""
from __future__ import annotations
from datetime import datetime
from json import loads
from typing import TYPE_CHECKING, Any
from .exceptions import ClientException, RedditAPIException
from .util import snake_case_keys
if TYPE_CHECKING: # pragma: no cover
import praw
from .models.reddit.base import RedditBase
class Objector:
"""The objector builds :class:`.RedditBase` objects."""
@classmethod
def check_error(cls, data: list[Any] | dict[str, dict[str, str]]):
"""Raise an error if the argument resolves to an error object."""
error = cls.parse_error(data)
if error:
raise error
@classmethod
def parse_error(
cls, data: list[Any] | dict[str, dict[str, str]]
) -> RedditAPIException | None:
"""Convert JSON response into an error object.
:param data: The dict to be converted.
:returns: An instance of :class:`.RedditAPIException`, or ``None`` if ``data``
doesn't fit this model.
"""
if isinstance(data, list):
# Fetching a Submission returns a list (of two items). Although it's handled
# manually in `Submission._fetch()`, assume it's a possibility here.
return None
errors = data.get("json", {}).get("errors")
if errors is None:
return None
if len(errors) < 1:
# See `Collection._fetch()`.
msg = "successful error response"
raise ClientException(msg, data)
return RedditAPIException(errors)
def __init__(self, reddit: praw.Reddit, parsers: dict[str, Any] | None = None):
"""Initialize an :class:`.Objector` instance.
:param reddit: An instance of :class:`.Reddit`.
"""
self.parsers = {} if parsers is None else parsers
self._reddit = reddit
def _objectify_dict( # noqa: PLR0912,PLR0915
self, data: dict[str, Any]
) -> RedditBase:
"""Create :class:`.RedditBase` objects from dicts.
:param data: The structured data, assumed to be a dict.
:returns: An instance of :class:`.RedditBase`.
"""
if {"messages", "modActions"}.issubset(data) and {
"conversations",
"conversation",
}.intersection(data):
# fetched conversation
data.update(
data.pop("conversation")
if "conversation" in data
else data.pop("conversations")
)
parser = self.parsers["ModmailConversation"]
parser._convert_conversation_objects(data, self._reddit)
elif {"messages", "modActions"}.issubset(data) or {
"legacyFirstMessageId",
"state",
}.issubset(data):
# not fetched conversation i.e., from conversations()
del data["objIds"] # delete objIds since it could be missing data
parser = self.parsers["ModmailConversation"]
elif {"conversationIds", "conversations", "messages"}.issubset(data):
# modmail conversations
conversations = []
for conversation_id in data["conversationIds"]:
conversation = data["conversations"][conversation_id]
# set if the numMessages is same as number of messages in objIds
if conversation["numMessages"] == len(
[obj for obj in conversation["objIds"] if obj["key"] == "messages"]
):
conversation["messages"] = [
self.objectify(data["messages"][obj_id["id"]])
for obj_id in conversation["objIds"]
]
conversations.append(conversation)
data["conversations"] = conversations
data = snake_case_keys(data)
parser = self.parsers["ModmailConversations-list"]
elif {"actionTypeId", "author", "date"}.issubset(data):
# Modmail mod action
data = snake_case_keys(data)
parser = self.parsers["ModmailAction"]
elif {"bodyMarkdown", "isInternal"}.issubset(data):
# Modmail message
data = snake_case_keys(data)
parser = self.parsers["ModmailMessage"]
elif {"kind", "short_name", "violation_reason"}.issubset(data):
# This is a Rule
parser = self.parsers["rule"]
elif {"isAdmin", "isDeleted"}.issubset(data):
# Modmail author
data = snake_case_keys(data)
# Prevent clobbering base-36 id
del data["id"]
data["is_subreddit_mod"] = data.pop("is_mod")
parser = self.parsers[self._reddit.config.kinds["redditor"]]
elif {"banStatus", "muteStatus", "recentComments"}.issubset(data):
# Modmail user
data = snake_case_keys(data)
data["created_string"] = data.pop("created")
parser = self.parsers[self._reddit.config.kinds["redditor"]]
elif {"displayName", "id", "type"}.issubset(data):
# Modmail subreddit
data = snake_case_keys(data)
parser = self.parsers[self._reddit.config.kinds[data["type"]]]
elif {"date", "id", "name"}.issubset(data) or {
"id",
"name",
"permissions",
}.issubset(data):
parser = self.parsers[self._reddit.config.kinds["redditor"]]
elif {"text", "url"}.issubset(data):
if "color" in data or "linkUrl" in data:
parser = self.parsers["Button"]
else:
parser = self.parsers["MenuLink"]
elif {"children", "text"}.issubset(data):
parser = self.parsers["Submenu"]
elif {"height", "url", "width"}.issubset(data):
parser = self.parsers["Image"]
elif {"isSubscribed", "name", "subscribers"}.issubset(data):
# discards icon and subscribed information
return self._reddit.subreddit(data["name"])
elif {"authorFlairType", "name"}.issubset(data):
# discards flair information
return self._reddit.redditor(data["name"])
elif {"parent_id"}.issubset(data):
parser = self.parsers[self._reddit.config.kinds["comment"]]
elif "collection_id" in data:
parser = self.parsers["Collection"]
elif {"moderators", "moderatorIds", "allUsersLoaded", "subredditId"}.issubset(
data
):
data = snake_case_keys(data)
moderators = []
for mod_id in data["moderator_ids"]:
mod = snake_case_keys(data["moderators"][mod_id])
mod["mod_permissions"] = list(mod["mod_permissions"].keys())
moderators.append(mod)
data["moderators"] = moderators
parser = self.parsers["moderator-list"]
elif "username" in data:
data["name"] = data.pop("username")
parser = self.parsers[self._reddit.config.kinds["redditor"]]
elif {"mod_permissions", "name", "sr", "subscribers"}.issubset(data):
data["display_name"] = data["sr"]
parser = self.parsers[self._reddit.config.kinds["subreddit"]]
elif {"drafts", "subreddits"}.issubset(data): # Draft list
subreddit_parser = self.parsers[self._reddit.config.kinds["subreddit"]]
user_subreddit_parser = self.parsers["UserSubreddit"]
subreddits = {
subreddit["name"]: (
user_subreddit_parser.parse(subreddit, self._reddit)
if subreddit["display_name_prefixed"].startswith("u/")
else subreddit_parser.parse(subreddit, self._reddit)
)
for subreddit in data.pop("subreddits")
}
for draft in data["drafts"]:
if draft["subreddit"]:
draft["subreddit"] = subreddits[draft["subreddit"]]
draft["modified"] = datetime.fromtimestamp(
draft["modified"] / 1000
).astimezone()
parser = self.parsers["DraftList"]
elif {"mod_action_data", "user_note_data"}.issubset(data):
data["moderator"] = self._reddit.redditor(data["operator"])
data["subreddit"] = self._reddit.subreddit(data["subreddit"])
data["user"] = self._reddit.redditor(data["user"])
# move these sub dict values into the main dict for simplicity
data.update(data["mod_action_data"])
del data["mod_action_data"]
data.update(data["user_note_data"])
del data["user_note_data"]
parser = self.parsers["mod_note"]
elif (
"created" in data
and isinstance(data["created"], dict)
and {"mod_action_data", "user_note_data"}.issubset(data["created"])
):
data = data["created"]
return self._objectify_dict(data)
else:
if "user" in data:
parser = self.parsers[self._reddit.config.kinds["redditor"]]
data["user"] = parser.parse({"name": data["user"]}, self._reddit)
return data
return parser.parse(data, self._reddit)
def objectify( # noqa: PLR0911,PLR0912,PLR0915
self, data: dict[str, Any] | list[Any] | bool | None
) -> RedditBase | dict[str, Any] | list[Any] | bool | None:
"""Create :class:`.RedditBase` objects from data.
:param data: The structured data.
:returns: An instance of :class:`.RedditBase`, or ``None`` if given ``data`` is
``None``.
"""
if data is None: # 204 no content
return None
if isinstance(data, list):
return [self.objectify(item) for item in data]
if isinstance(data, bool): # Reddit.username_available
return data
if "json" in data and "errors" in data["json"]:
errors = data["json"]["errors"]
if len(errors) > 0:
raise RedditAPIException(errors)
if "kind" in data and (
"shortName" in data or data["kind"] in ("menu", "moderators")
):
# This is a widget
parser = self.parsers.get(data["kind"], self.parsers["widget"])
return parser.parse(data, self._reddit)
if {"kind", "data"}.issubset(data) and data["kind"] in self.parsers:
parser = self.parsers[data["kind"]]
if data["kind"] == "ModeratedList":
return parser.parse(data, self._reddit)
return parser.parse(data["data"], self._reddit)
if "json" in data and "data" in data["json"]:
if "websocket_url" in data["json"]["data"]:
return data
if "things" in data["json"]["data"]: # Submission.reply
return self.objectify(data["json"]["data"]["things"])
if "rules" in data["json"]["data"]:
return self.objectify(loads(data["json"]["data"]["rules"]))
if "drafts_count" in data["json"]["data"] and all(
key not in data["json"]["data"] for key in ["name", "url"]
): # Draft
data["json"]["data"].pop("drafts_count")
return self.parsers["Draft"].parse(data["json"]["data"], self._reddit)
if "url" in data["json"]["data"]: # Subreddit.submit
# The URL is the URL to the submission, so it's removed.
del data["json"]["data"]["url"]
parser = self.parsers[self._reddit.config.kinds["submission"]]
if data["json"]["data"]["id"].startswith(
f"{self._reddit.config.kinds['submission']}_"
):
# With polls, Reddit returns a fullname but calls it an "id". This
# fixes this by coercing the fullname into an id.
data["json"]["data"]["id"] = data["json"]["data"]["id"].split(
"_", 1
)[1]
else:
parser = self.parsers["LiveUpdateEvent"]
return parser.parse(data["json"]["data"], self._reddit)
if {"is_public_link", "title", "body"}.issubset(data):
parser = self.parsers["Draft"]
return parser.parse(data, self._reddit)
if "rules" in data:
return self.objectify(data["rules"])
if isinstance(data, dict):
return self._objectify_dict(data)
return data

View File

@@ -0,0 +1,26 @@
[DEFAULT]
# A boolean to indicate whether or not to check for package updates.
check_for_updates=True
# Object to kind mappings
comment_kind=t1
message_kind=t4
redditor_kind=t2
submission_kind=t3
subreddit_kind=t5
trophy_kind=t6
# The URL prefix for OAuth-related requests.
oauth_url=https://oauth.reddit.com
# The amount of seconds of ratelimit to sleep for upon encountering a specific type of 429 error.
ratelimit_seconds=5
# The URL prefix for regular requests.
reddit_url=https://www.reddit.com
# The URL prefix for short URLs.
short_url=https://redd.it
# The timeout for requests to Reddit in number of seconds
timeout=16

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
"""Package imports for utilities."""
from .cache import cachedproperty
from .deprecate_args import _deprecate_args
from .snake import camel_to_snake, snake_case_keys

View File

@@ -0,0 +1,52 @@
"""Caching utilities."""
from __future__ import annotations
from typing import Any, Callable
class cachedproperty: # noqa: N801
"""A decorator for caching a property's result.
Similar to :py:class:`property`, but the wrapped method's result is cached on the
instance. This is achieved by setting an entry in the object's instance dictionary
with the same name as the property. When the name is later accessed, the value in
the instance dictionary takes precedence over the (non-data descriptor) property.
This is useful for implementing lazy-loaded properties.
The cache can be invalidated via :py:meth:`delattr`, or by modifying ``__dict__``
directly. It will be repopulated on next access.
.. versionadded:: 6.3.0
"""
# This to make sphinx run properly
def __call__(self, *args: Any, **kwargs: Any): # pragma: no cover
"""Empty method to make sphinx run properly."""
def __get__(self, obj: Any | None, objtype: Any | None = None) -> Any:
"""Implement descriptor getter.
Calculate the property's value and then store it in the associated object's
instance dictionary.
"""
if obj is None:
return self
value = obj.__dict__[self.func.__name__] = self.func(obj)
return value
def __init__(self, func: Callable[[Any], Any], doc: str | None = None):
"""Initialize a :class:`.cachedproperty` instance."""
self.func = self.__wrapped__ = func
if doc is None:
doc = func.__doc__
self.__doc__ = doc
def __repr__(self) -> str:
"""Return an object initialization representation of the instance."""
return f"<{self.__class__.__name__} {self.func}>"

View File

@@ -0,0 +1,50 @@
"""Positional argument deprecation decorator."""
from __future__ import annotations
import inspect
from functools import wraps
from typing import Any, Callable
from warnings import warn
def _deprecate_args(*old_args: str) -> Callable:
def _generate_arg_string(used_args: tuple[str, ...]) -> str:
used_args = list(map(repr, used_args))
arg_count = len(used_args)
arg_string = (
" and ".join(used_args)
if arg_count < 3
else f"{', '.join(used_args[:-1])}, and {used_args[-1]}"
)
arg_string += f" as {'' if arg_count > 1 else 'a '}"
arg_string += "keyword argument"
return arg_string + ("s" if arg_count > 1 else "")
def wrapper(func: Callable):
@wraps(func)
def wrapped(*args: Any, **kwargs: Any):
signature = inspect.signature(func)
positional_args = [
name
for name, parameter in signature.parameters.items()
if parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD
]
_old_args = tuple(filter(lambda arg: arg not in positional_args, old_args))
if positional_args:
# remove the acceptable positional arguments like self or id for helpers
kwargs.update(zip(positional_args, args))
args = tuple(args[len(positional_args) :])
if args:
arg_string = _generate_arg_string(_old_args[: len(args)])
warn(
f"Positional arguments for {func.__qualname__!r} will no longer be"
f" supported in PRAW 8.\nCall this function with {arg_string}.",
DeprecationWarning,
stacklevel=2,
)
return func(**dict(zip(_old_args, args)), **kwargs)
return wrapped
return wrapper

View File

@@ -0,0 +1,22 @@
"""Contains functions dealing with snake case conversions."""
from __future__ import annotations
import re
from typing import Any
_re_camel_to_snake = re.compile(r"([a-z0-9](?=[A-Z])|[A-Z](?=[A-Z][a-z]))")
def camel_to_snake(name: str) -> str:
"""Convert ``name`` from camelCase to snake_case."""
return _re_camel_to_snake.sub(r"\1_", name).lower()
def snake_case_keys(dictionary: dict[str, Any]) -> dict[str, Any]:
"""Return a new dictionary with keys converted to snake_case.
:param dictionary: The dict to be corrected.
"""
return {camel_to_snake(k): v for k, v in dictionary.items()}

View File

@@ -0,0 +1,203 @@
"""Token Manager classes.
There should be a 1-to-1 mapping between an instance of a subclass of
:class:`.BaseTokenManager` and a :class:`.Reddit` instance.
A few proof of concept token manager classes are provided here, but it is expected that
PRAW users will create their own token manager classes suitable for their needs.
.. deprecated:: 7.4.0
Tokens managers have been deprecated and will be removed in the near future.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from pathlib import Path
from typing import TYPE_CHECKING
from . import _deprecate_args
if TYPE_CHECKING: # pragma: no cover
import prawcore
import praw
class BaseTokenManager(ABC):
"""An abstract class for all token managers."""
@abstractmethod
def post_refresh_callback(self, authorizer: prawcore.auth.BaseAuthorizer):
"""Handle callback that is invoked after a refresh token is used.
:param authorizer: The ``prawcore.Authorizer`` instance used containing
``access_token`` and ``refresh_token`` attributes.
This function will be called after refreshing the access and refresh tokens.
This callback can be used for saving the updated ``refresh_token``.
"""
@abstractmethod
def pre_refresh_callback(self, authorizer: prawcore.auth.BaseAuthorizer):
"""Handle callback that is invoked before refreshing PRAW's authorization.
:param authorizer: The ``prawcore.Authorizer`` instance used containing
``access_token`` and ``refresh_token`` attributes.
This callback can be used to inspect and modify the attributes of the
``prawcore.Authorizer`` instance, such as setting the ``refresh_token``.
"""
@property
def reddit(self) -> praw.Reddit:
"""Return the :class:`.Reddit` instance bound to the token manager."""
return self._reddit
@reddit.setter
def reddit(self, value: praw.Reddit):
if self._reddit is not None:
msg = "'reddit' can only be set once and is done automatically"
raise RuntimeError(msg)
self._reddit = value
def __init__(self):
"""Initialize a :class:`.BaseTokenManager` instance."""
self._reddit = None
class FileTokenManager(BaseTokenManager):
"""Provides a single-file based token manager.
It is expected that the file with the initial ``refresh_token`` is created prior to
use.
.. warning::
The same ``file`` should not be used by more than one instance of this class
concurrently. Doing so may result in data corruption. Consider using
:class:`.SQLiteTokenManager` if you want more than one instance of PRAW to
concurrently manage a specific ``refresh_token`` chain.
"""
def __init__(self, filename: str):
"""Initialize a :class:`.FileTokenManager` instance.
:param filename: The file the contains the refresh token.
"""
super().__init__()
self._filename = filename
def post_refresh_callback(self, authorizer: prawcore.auth.BaseAuthorizer):
"""Update the saved copy of the refresh token."""
with Path(self._filename).open("w") as fp:
fp.write(authorizer.refresh_token)
def pre_refresh_callback(self, authorizer: prawcore.auth.BaseAuthorizer):
"""Load the refresh token from the file."""
if authorizer.refresh_token is None:
with Path(self._filename).open() as fp:
authorizer.refresh_token = fp.read().strip()
class SQLiteTokenManager(BaseTokenManager):
"""Provides a SQLite3 based token manager.
Unlike, :class:`.FileTokenManager`, the initial database need not be created ahead
of time, as it'll automatically be created on first use. However, initial refresh
tokens will need to be registered via :meth:`.register` prior to use.
.. warning::
This class is untested on Windows because we encountered file locking issues in
the test environment.
"""
@_deprecate_args("database", "key")
def __init__(self, *, database: str, key: str):
"""Initialize a :class:`.SQLiteTokenManager` instance.
:param database: The path to the SQLite database.
:param key: The key used to locate the refresh token. This ``key`` can be
anything. You might use the ``client_id`` if you expect to have unique a
refresh token for each ``client_id``, or you might use a redditor's
``username`` if you're managing multiple users' authentications.
"""
super().__init__()
import sqlite3
self._connection = sqlite3.connect(database)
self._connection.execute(
"CREATE TABLE IF NOT EXISTS tokens (id, refresh_token, updated_at)"
)
self._connection.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS ux_tokens_id on tokens(id)"
)
self._connection.commit()
self.key = key
def _get(self):
cursor = self._connection.execute(
"SELECT refresh_token FROM tokens WHERE id=?", (self.key,)
)
result = cursor.fetchone()
if result is None:
raise KeyError
return result[0]
def _set(self, refresh_token: str):
"""Set the refresh token in the database.
This function will overwrite an existing value if the corresponding ``key``
already exists.
"""
self._connection.execute(
"REPLACE INTO tokens VALUES (?, ?, datetime('now'))",
(self.key, refresh_token),
)
self._connection.commit()
def is_registered(self) -> bool:
"""Return whether ``key`` already has a ``refresh_token``."""
cursor = self._connection.execute(
"SELECT refresh_token FROM tokens WHERE id=?", (self.key,)
)
return cursor.fetchone() is not None
def post_refresh_callback(self, authorizer: prawcore.auth.BaseAuthorizer):
"""Update the refresh token in the database."""
self._set(authorizer.refresh_token)
# While the following line is not strictly necessary, it ensures that the
# refresh token is not used elsewhere. And also forces the pre_refresh_callback
# to always load the latest refresh_token from the database.
authorizer.refresh_token = None
def pre_refresh_callback(self, authorizer: prawcore.auth.BaseAuthorizer):
"""Load the refresh token from the database."""
assert authorizer.refresh_token is None
authorizer.refresh_token = self._get()
def register(self, refresh_token: str) -> bool:
"""Register the initial refresh token in the database.
:returns: ``True`` if ``refresh_token`` is saved to the database, otherwise,
``False`` if there is already a ``refresh_token`` for the associated
``key``.
"""
cursor = self._connection.execute(
"INSERT OR IGNORE INTO tokens VALUES (?, ?, datetime('now'))",
(self.key, refresh_token),
)
self._connection.commit()
return cursor.rowcount == 1