From 51ca5d9ffed250b0ff270d6d5f00168bd349e589 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Sun, 17 Jan 2021 16:34:17 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20issue=20with=20missed=20co?= =?UTF-8?q?mments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- instapi/client_api/client.py | 2 + instapi/client_api/comments.py | 29 +++++++++ instapi/models/comment.py | 32 +++++++++- instapi/models/feed.py | 25 +------- instapi/utils.py | 2 +- tests/unit_tests/client_api/test_comments.py | 66 ++++++++++++++++++++ tests/unit_tests/models/test_feed.py | 4 +- 7 files changed, 132 insertions(+), 28 deletions(-) create mode 100644 instapi/client_api/comments.py create mode 100644 tests/unit_tests/client_api/test_comments.py diff --git a/instapi/client_api/client.py b/instapi/client_api/client.py index 74142d9..7b024c8 100644 --- a/instapi/client_api/client.py +++ b/instapi/client_api/client.py @@ -1,8 +1,10 @@ +from .comments import CommentsEndpoint from .direct import DirectEndpoint class Client( DirectEndpoint, + CommentsEndpoint, ): pass diff --git a/instapi/client_api/comments.py b/instapi/client_api/comments.py new file mode 100644 index 0000000..64c3680 --- /dev/null +++ b/instapi/client_api/comments.py @@ -0,0 +1,29 @@ +from typing import Any, Iterator, Union + +from ..types import StrDict +from .base import BaseClient + + +class CommentsEndpoint(BaseClient): + def media_comments_gen(self, media_id: Union[int, str], **kwargs: Any) -> Iterator[StrDict]: + kwargs.setdefault("can_support_threading", "false") + + results = self.media_comments(media_id, **kwargs) + yield from results["comments"] + + while any( + ( + results.get("has_more_comments") and results.get("next_max_id"), + results.get("has_more_headload_comments") and results.get("next_min_id"), + ) + ): + if results.get("has_more_comments"): + kwargs["max_id"] = results.get("next_max_id") + else: + kwargs["min_id"] = results.get("next_min_id") + + results = self.media_comments(media_id, **kwargs) + yield from results["comments"] + + +__all__ = ["CommentsEndpoint"] diff --git a/instapi/models/comment.py b/instapi/models/comment.py index d8798ab..0c6b1b6 100644 --- a/instapi/models/comment.py +++ b/instapi/models/comment.py @@ -1,7 +1,10 @@ +from __future__ import annotations + from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Iterable, List, Optional from ..client import client +from ..utils import to_list from .media import Media if TYPE_CHECKING: @@ -15,7 +18,7 @@ class Comment(Media): """ text: str - user: "User" + user: User def like(self) -> None: """ @@ -34,6 +37,31 @@ def unlike(self) -> None: client.comment_unlike(self.pk) +class CommentsBoundMixin: + pk: int + + def iter_comments(self) -> Iterable[Comment]: + """ + Create generator for iteration over comments, which was attached to the media + + :return: generator with comments + """ + for c in client.media_comments_gen(self.pk): + from instapi.models import User + + yield Comment.create({**c, "user": User.create(c["user"])}) + + def comments(self, limit: Optional[int] = None) -> List[Comment]: + """ + Generate list of comments, which was attached to the media + + :param limit: number of comments, which will be added to the list + :return: list with comments + """ + return to_list(self.iter_comments(), limit=limit) + + __all__ = [ "Comment", + "CommentsBoundMixin", ] diff --git a/instapi/models/feed.py b/instapi/models/feed.py index 7d0e74e..20cd48b 100644 --- a/instapi/models/feed.py +++ b/instapi/models/feed.py @@ -5,13 +5,13 @@ from ..client import client from ..types import StrDict from ..utils import process_many, to_list -from .comment import Comment +from .comment import CommentsBoundMixin from .resource import ResourceContainer from .user import User @dataclass(frozen=True) -class Feed(ResourceContainer): +class Feed(CommentsBoundMixin, ResourceContainer): """ This class represent Instagram's feed. It gives opportunity to: - Get posts from feed @@ -120,27 +120,6 @@ def unlike(self) -> None: """ client.delete_like(self.pk) - def iter_comments(self) -> Iterable["Comment"]: - """ - Create generator for iteration over comments, which was attached to the post - - :return: generator with comments - """ - for result in process_many(client.media_comments, self.pk): - for c in result["comments"]: - c["user"] = User.create(c["user"]) - - yield from map(Comment.create, result["comments"]) - - def comments(self, limit: Optional[int] = None) -> List["Comment"]: - """ - Generate list of comments, which was attached to the post - - :param limit: number of comments, which will be added to the list - :return: list with comments - """ - return to_list(self.iter_comments(), limit=limit) - __all__ = [ "Feed", diff --git a/instapi/utils.py b/instapi/utils.py index 43950fd..01cf01e 100644 --- a/instapi/utils.py +++ b/instapi/utils.py @@ -59,7 +59,7 @@ def to_list(iterable: Iterable[T], limit: Optional[int] = None) -> List[T]: return [*limited(iterable, limit=limit)] -def flat(source: List[Iterable[T]]) -> List[T]: +def flat(source: Iterable[Iterable[T]]) -> List[T]: """ Unpack list of iterable into single list diff --git a/tests/unit_tests/client_api/test_comments.py b/tests/unit_tests/client_api/test_comments.py new file mode 100644 index 0000000..4982acb --- /dev/null +++ b/tests/unit_tests/client_api/test_comments.py @@ -0,0 +1,66 @@ +from pytest import fixture, mark + +from instapi.client_api.comments import CommentsEndpoint +from instapi.utils import flat + +from ..conftest import random_int, random_string + + +@fixture +def comments_endpoint(mocker): + mocker.patch("instagram_private_api.client.Client.login", return_value=None) + return CommentsEndpoint(random_string(), random_string()) + + +def test_comments_gen_no_more_comments(comments_endpoint, mocker): + mock = mocker.patch.object( + comments_endpoint, "media_comments", autospec=True, return_value={"comments": []} + ) + + media_id = random_int() + + assert not [*comments_endpoint.media_comments_gen(media_id)] + + mock.assert_called_once_with(media_id, can_support_threading="false") + + +@mark.parametrize( + "indicator,key,next_key", + [ + ("has_more_comments", "max_id", "next_max_id"), + ("has_more_headload_comments", "min_id", "next_min_id"), + ], + ids=["max_id", "min_id"], +) +def test_comments_gen_next_comments(comments_endpoint, mocker, indicator, key, next_key): + side_effects = [ + { + "comments": [mocker.Mock() for _ in range(10)], + indicator: True, + next_key: mocker.Mock(), + } + for _ in range(10) + ] + side_effects[-1][indicator] = False + + mock = mocker.patch.object( + comments_endpoint, "media_comments", autospec=True, side_effect=side_effects + ) + + comments = [*flat(r["comments"] for r in side_effects)] + + media_id = random_int() + + assert [*comments_endpoint.media_comments_gen(media_id)] == comments + + calls = [mocker.call(media_id, can_support_threading="false")] + calls.extend( + mocker.call( + media_id, + can_support_threading="false", + **{key: r[next_key]}, + ) + for r in side_effects[:-1] + ) + + mock.assert_has_calls(calls) diff --git a/tests/unit_tests/models/test_feed.py b/tests/unit_tests/models/test_feed.py index 85c82ad..f6cf024 100644 --- a/tests/unit_tests/models/test_feed.py +++ b/tests/unit_tests/models/test_feed.py @@ -15,8 +15,8 @@ def mock_likers(self, mocker, users): @fixture def mock_comments(self, mocker, comments): return mocker.patch( - "instapi.client.client.media_comments", - return_value={"comments": as_dicts(comments)}, + "instapi.client.client.media_comments_gen", + return_value=as_dicts(comments), ) @fixture