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 @@
"""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)