├── tests ├── __init__.py └── unit_tests │ ├── __init__.py │ ├── models │ ├── __init__.py │ ├── test_comments.py │ ├── test_story.py │ ├── test_media.py │ ├── test_base.py │ ├── test_feed.py │ ├── test_direct.py │ ├── test_resource.py │ ├── conftest.py │ └── test_user.py │ ├── client_api │ ├── __init__.py │ ├── test_base.py │ └── test_direct.py │ ├── test_client.py │ ├── test_utils.py │ └── conftest.py ├── pytest.ini ├── instapi ├── client_api │ ├── __init__.py │ ├── client.py │ ├── base.py │ └── direct.py ├── exceptions.py ├── types.py ├── models │ ├── media.py │ ├── __init__.py │ ├── comment.py │ ├── story.py │ ├── base.py │ ├── direct.py │ ├── feed.py │ ├── user.py │ └── resource.py ├── __init__.py ├── client.py ├── utils.py └── cache.py ├── .github ├── dependabot.yml └── workflows │ ├── lint.yml │ └── test.yml ├── .mergify.yml ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── .gitignore ├── pyproject.toml ├── logo.svg └── poetry.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit_tests/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit_tests/client_api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | 3 | addopts = 4 | --cov=instapi 5 | --cov-report=term-missing 6 | -------------------------------------------------------------------------------- /instapi/client_api/__init__.py: -------------------------------------------------------------------------------- 1 | from instapi.client_api.client import Client 2 | 3 | __all__ = [ 4 | "Client", 5 | ] 6 | -------------------------------------------------------------------------------- /instapi/exceptions.py: -------------------------------------------------------------------------------- 1 | class ClientNotInitedException(Exception): 2 | pass 3 | 4 | 5 | __all__ = [ 6 | "ClientNotInitedException", 7 | ] 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "03:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /instapi/client_api/client.py: -------------------------------------------------------------------------------- 1 | from .direct import DirectEndpoint 2 | 3 | 4 | class Client( 5 | DirectEndpoint, 6 | ): 7 | pass 8 | 9 | 10 | __all__ = [ 11 | "Client", 12 | ] 13 | -------------------------------------------------------------------------------- /instapi/types.py: -------------------------------------------------------------------------------- 1 | from typing import Any, SupportsInt, TypeVar 2 | 3 | Credentials = tuple[str, str] 4 | StrDict = dict[str, Any] 5 | SupportsInt_co = TypeVar("SupportsInt_co", bound=SupportsInt, covariant=True) 6 | 7 | __all__ = [ 8 | "StrDict", 9 | "SupportsInt_co", 10 | "Credentials", 11 | ] 12 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: automatic merge when CI passes and 1 reviews 3 | conditions: 4 | - "#approved-reviews-by>=1" 5 | - status-success=Travis CI - Pull Request 6 | - status-success=codecov/patch 7 | - base=develop 8 | - label!=work-in-progress 9 | actions: 10 | merge: 11 | method: merge 12 | delete_head_branch: {} -------------------------------------------------------------------------------- /tests/unit_tests/client_api/test_base.py: -------------------------------------------------------------------------------- 1 | from instapi.client_api.base import BaseClient 2 | 3 | from ..conftest import random_string 4 | 5 | 6 | def test_redirect_to_base(mocker): 7 | mocker.patch("instagram_private_api.client.Client.__init__", return_value=None) 8 | mock = mocker.patch("instagram_private_api.client.Client._call_api", return_value=None) 9 | 10 | client = BaseClient(random_string(), random_string()) 11 | client._call_api(random_string()) 12 | 13 | mock.assert_called_once() 14 | -------------------------------------------------------------------------------- /instapi/models/media.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | from ..cache import cached 4 | from ..client import client 5 | from ..types import StrDict 6 | from .base import Entity 7 | 8 | 9 | class Media(Entity): 10 | @cached 11 | def _media_info(self) -> StrDict: 12 | items, *_ = client.media_info(self.pk)["items"] 13 | return cast(StrDict, items) 14 | 15 | def comment(self, text: str) -> None: 16 | client.post_comment(self.pk, text) 17 | 18 | 19 | __all__ = [ 20 | "Media", 21 | ] 22 | -------------------------------------------------------------------------------- /tests/unit_tests/models/test_comments.py: -------------------------------------------------------------------------------- 1 | def test_like_comment(comment, mocker): 2 | """Test for Comment.like method""" 3 | like_mock = mocker.patch("instapi.client.client.comment_like") 4 | 5 | comment.like() 6 | 7 | like_mock.assert_called_once_with(comment.pk) 8 | 9 | 10 | def test_unlike_comment(comment, mocker): 11 | """Test for Comment.unlike method""" 12 | unlike_mock = mocker.patch("instapi.client.client.comment_unlike") 13 | 14 | comment.unlike() 15 | 16 | unlike_mock.assert_called_once_with(comment.pk) 17 | -------------------------------------------------------------------------------- /tests/unit_tests/models/test_story.py: -------------------------------------------------------------------------------- 1 | class TestStory: 2 | def test_mark_seen(self, mocker, story): 3 | media_info = {} 4 | mocker.patch("instapi.models.Media._media_info", return_value=media_info) 5 | mock = mocker.patch("instapi.client.client.media_seen") 6 | 7 | story.mark_seen() 8 | 9 | mock.assert_called_once_with([media_info]) 10 | 11 | def test_as_dict(self, story): 12 | data = story.as_dict() 13 | 14 | assert "reel_mentions" in data 15 | assert data["reel_mentions"] == story.mentions 16 | -------------------------------------------------------------------------------- /instapi/__init__.py: -------------------------------------------------------------------------------- 1 | from instapi.client import bind 2 | from instapi.exceptions import ClientNotInitedException 3 | from instapi.models import ( 4 | Candidate, 5 | Comment, 6 | Direct, 7 | Feed, 8 | Image, 9 | Message, 10 | Resource, 11 | Resources, 12 | User, 13 | Video, 14 | ) 15 | 16 | __all__ = [ 17 | "bind", 18 | "ClientNotInitedException", 19 | "Comment", 20 | "Candidate", 21 | "Direct", 22 | "Feed", 23 | "Image", 24 | "Resource", 25 | "Resources", 26 | "User", 27 | "Video", 28 | "Message", 29 | ] 30 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.10"] 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | 20 | - name: Install dependencies 21 | run: | 22 | pip install poetry 23 | poetry install 24 | 25 | - name: Lint 26 | run: poetry run pre-commit run --all-files --show-diff-on-failure -------------------------------------------------------------------------------- /tests/unit_tests/models/test_media.py: -------------------------------------------------------------------------------- 1 | from ..conftest import random_string 2 | 3 | 4 | class TestMedia: 5 | """Tests for Media class""" 6 | 7 | def test_media_info(self, media, mocker): 8 | items = [[*range(100)]] 9 | data = {"items": items} 10 | 11 | media_info_mock = mocker.patch("instapi.client.client.media_info", return_value=data) 12 | 13 | assert media._media_info() == items[0] 14 | media_info_mock.assert_called_once_with(media.pk) 15 | 16 | def test_comment(self, mocker, media): 17 | comment_mock = mocker.patch("instapi.client.client.post_comment") 18 | text = random_string() 19 | 20 | media.comment(text) 21 | 22 | comment_mock.assert_called_once_with(media.pk, text) 23 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: ruff-format 5 | language: python 6 | name: ruff-format 7 | pass_filenames: false 8 | language_version: python3.10 9 | entry: poetry run ruff format instapi tests 10 | 11 | - repo: local 12 | hooks: 13 | - id: ruff 14 | language: python 15 | name: ruff 16 | pass_filenames: false 17 | language_version: python3.10 18 | entry: poetry run ruff check --fix --exit-non-zero-on-fix --show-fixes instapi 19 | 20 | - repo: local 21 | hooks: 22 | - id: mypy 23 | language: python 24 | name: mypy 25 | pass_filenames: false 26 | entry: poetry run mypy --show-error-codes instapi -------------------------------------------------------------------------------- /instapi/models/__init__.py: -------------------------------------------------------------------------------- 1 | from instapi.models.base import BaseModel, Entity 2 | from instapi.models.comment import Comment 3 | from instapi.models.direct import Direct, Message 4 | from instapi.models.feed import Feed 5 | from instapi.models.media import Media 6 | from instapi.models.resource import ( 7 | Candidate, 8 | Image, 9 | Resource, 10 | Resources, 11 | Video, 12 | ) 13 | from instapi.models.story import Story 14 | from instapi.models.user import User 15 | 16 | __all__ = [ 17 | "BaseModel", 18 | "Direct", 19 | "Entity", 20 | "Feed", 21 | "Media", 22 | "Message", 23 | "Comment", 24 | "Candidate", 25 | "Resource", 26 | "Resources", 27 | "Image", 28 | "Video", 29 | "Story", 30 | "User", 31 | ] 32 | -------------------------------------------------------------------------------- /instapi/models/comment.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from ..client import client 6 | from .media import Media 7 | 8 | if TYPE_CHECKING: 9 | from instapi.models import User # pragma: no cover 10 | 11 | 12 | class Comment(Media): 13 | """ 14 | This class represents a comment in Instagram 15 | """ 16 | 17 | text: str 18 | user: User 19 | 20 | def like(self) -> None: 21 | """ 22 | Like comment 23 | 24 | :return: None 25 | """ 26 | client.comment_like(self.pk) 27 | 28 | def unlike(self) -> None: 29 | """ 30 | Unlike comment 31 | 32 | :return: None 33 | """ 34 | client.comment_unlike(self.pk) 35 | 36 | 37 | __all__ = [ 38 | "Comment", 39 | ] 40 | -------------------------------------------------------------------------------- /instapi/client_api/base.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | from instagram_private_api import Client 4 | 5 | from ..types import StrDict 6 | 7 | 8 | class BaseClient(Client): # type: ignore[misc] 9 | def _call_api( 10 | self, 11 | endpoint: str, 12 | params: StrDict | None = None, 13 | query: StrDict | None = None, 14 | return_response: bool = False, 15 | unsigned: bool = False, 16 | version: str = "v1", 17 | ) -> StrDict: 18 | value = super()._call_api( 19 | endpoint=endpoint, 20 | params=params, 21 | query=query, 22 | return_response=return_response, 23 | unsigned=unsigned, 24 | version=version, 25 | ) 26 | 27 | return cast(StrDict, value) 28 | 29 | 30 | __all__ = [ 31 | "BaseClient", 32 | ] 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | python-version: ["3.10", "3.11", "3.12" ] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | 21 | - name: Install dependencies 22 | run: | 23 | pip install poetry 24 | poetry install -E pillow 25 | 26 | - name: Unit tests 27 | run: poetry run pytest tests --cov=instapi --cov-report=term-missing --cov-report=xml 28 | 29 | - name: Upload coverage to Codecov 30 | uses: codecov/codecov-action@v1 31 | with: 32 | fail_ci_if_error: true 33 | file: ./coverage.xml -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Yurii Karabas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | InstAPI 2 | ============= 3 | 4 | [![Code Style](https://img.shields.io/badge/Code%20Style-PEP%208-blueviolet)](https://www.python.org/dev/peps/pep-0008/) 5 | ![Contributions](https://img.shields.io/badge/Contributions-Welcome-brightgreen) 6 | [![License](https://img.shields.io/badge/License-MIT-lightgrey)](/LICENSE) 7 | [![Build Status](https://travis-ci.com/uriyyo/instapi.svg?branch=develop)](https://travis-ci.com/uriyyo/instapi) 8 | [![codecov](https://codecov.io/gh/uriyyo/instapi/branch/develop/graph/badge.svg)](https://codecov.io/gh/uriyyo/instapi) 9 | 10 |

11 | Instapi 12 |

13 | 14 | InstAPI - comfortable and easy to use Python's library for interaction with Instagram. 15 | 16 | Installation 17 | ------------ 18 | ```bash 19 | pip install inst-api 20 | ``` 21 | 22 | Usage 23 | ----- 24 | Example how to like all feeds for user 25 | ```python 26 | from instapi import bind 27 | from instapi import User 28 | 29 | bind('your_username_here', 'your_password_here') 30 | 31 | # Get user profile by username 32 | instagram_profile = User.from_username('username') 33 | 34 | # Like all posts 35 | for feed in instagram_profile.iter_feeds(): 36 | feed.like() 37 | ``` 38 | 39 | Contribute 40 | ---------- 41 | Contributions are always welcome! 42 | -------------------------------------------------------------------------------- /tests/unit_tests/test_client.py: -------------------------------------------------------------------------------- 1 | from instagram_private_api import Client 2 | from pytest import mark, raises 3 | 4 | from instapi.client import bind, client 5 | from instapi.exceptions import ClientNotInitedException 6 | from instapi.models import User 7 | 8 | from .conftest import random_string 9 | 10 | 11 | @mark.usefixtures("regular_client_mode") 12 | def test_client_not_initialized(): 13 | """Test for: bind function wasn't call""" 14 | with raises(ClientNotInitedException): 15 | User.self() 16 | 17 | 18 | @mark.usefixtures("regular_client_mode") 19 | def test_client_inited_after_bind(mocker): 20 | """Test for: bind function was called""" 21 | mocker.patch("instagram_private_api.client.Client.__init__", return_value=None) 22 | cookie_jar_mock = mocker.patch("instagram_private_api.client.Client.cookie_jar") 23 | get_from_cache_mock = mocker.patch("instapi.cache.get_from_cache", return_value=None) 24 | write_to_cache_mock = mocker.patch("instapi.cache.write_to_cache", return_value=None) 25 | 26 | username, password = random_string(), random_string() 27 | 28 | bind(username, password) 29 | 30 | # Check that proxy inited 31 | assert client.obj is not None 32 | Client.__init__.assert_called_once_with(username, password, cookie=None) 33 | 34 | get_from_cache_mock.assert_called_once_with((username, password)) 35 | write_to_cache_mock.assert_called_once_with((username, password), cookie_jar_mock) 36 | 37 | 38 | @mark.usefixtures("regular_client_mode") 39 | def test_bind_with_no_arguments(): 40 | with raises(ValueError): 41 | bind() 42 | -------------------------------------------------------------------------------- /instapi/client.py: -------------------------------------------------------------------------------- 1 | import os 2 | import ssl 3 | from dataclasses import dataclass 4 | from typing import Any, ClassVar, cast 5 | 6 | from instagram_private_api import ClientError 7 | 8 | from . import cache 9 | from .client_api import Client 10 | from .exceptions import ClientNotInitedException 11 | 12 | ssl._create_default_https_context = ssl._create_unverified_context 13 | 14 | ENV_USERNAME = os.environ.get("INSTAPI_USERNAME") 15 | ENV_PASSWORD = os.environ.get("INSTAPI_PASSWORD") 16 | 17 | 18 | @dataclass 19 | class ClientProxy: 20 | obj: Client | None = None 21 | 22 | # Used to return dummy implementation of methods 23 | is_testing: ClassVar[bool] = False 24 | 25 | def __getattr__(self, item: str) -> Any: 26 | if self.obj is None: 27 | if self.is_testing: 28 | return None 29 | 30 | raise ClientNotInitedException 31 | 32 | return getattr(self.obj, item) 33 | 34 | 35 | client: Client = cast(Client, ClientProxy()) 36 | 37 | 38 | def bind( 39 | username: str | None = ENV_USERNAME, 40 | password: str | None = ENV_PASSWORD, 41 | ) -> None: 42 | if username is None or password is None: 43 | raise ValueError("Both username and password should be passed") 44 | 45 | try: 46 | client.obj = Client(username, password, cookie=cache.get_from_cache((username, password))) 47 | except ClientError: # pragma: no cover 48 | client.obj = Client(username, password) 49 | 50 | cache.write_to_cache((username, password), client.obj.cookie_jar) 51 | 52 | 53 | __all__ = [ 54 | "bind", 55 | "client", 56 | "ClientProxy", 57 | ] 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # PyCharm 107 | .idea/ 108 | 109 | # InstAPI 110 | .instapi_cache/ -------------------------------------------------------------------------------- /instapi/models/story.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import field 4 | 5 | from typing_extensions import Self 6 | 7 | from ..client import client 8 | from ..types import StrDict 9 | from .resource import ResourceContainer 10 | from .user import User 11 | 12 | 13 | class Story(ResourceContainer): 14 | """ 15 | Class that represent user story 16 | """ 17 | 18 | # TODO: Add ability to send reactions on the story 19 | 20 | mentions: list[User] = field(hash=False) 21 | 22 | @classmethod 23 | def create(cls, data: StrDict) -> Self: 24 | """ 25 | Information about users mentions in a story located in reel_mentions. 26 | reel_mentions is a complicated name, move it to simple mentions. 27 | 28 | :param data: information about a story 29 | :return: Story instance 30 | """ 31 | return super().create( 32 | { 33 | **data, 34 | "mentions": [User.create(d["user"]) for d in data.get("reel_mentions", ())], 35 | } 36 | ) 37 | 38 | def as_dict(self) -> StrDict: 39 | """ 40 | Method should return dict with same structure as create method accepts. 41 | 42 | :return: dict with information about story 43 | """ 44 | data = super().as_dict() 45 | data["reel_mentions"] = [{"user": user} for user in data.pop("mentions")] 46 | 47 | return data 48 | 49 | def mark_seen(self) -> None: 50 | """ 51 | Mark story as seen, by default you can get story media 52 | and user will not know that you did that. In case when 53 | you want user know you watch the story this method should be called. 54 | 55 | :return None 56 | """ 57 | client.media_seen([self._media_info()]) 58 | 59 | 60 | __all__ = [ 61 | "Story", 62 | ] 63 | -------------------------------------------------------------------------------- /instapi/models/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import Field, asdict, dataclass, field, fields 4 | from typing import AbstractSet, Any 5 | 6 | from typing_extensions import Self, dataclass_transform 7 | 8 | from ..types import StrDict 9 | 10 | 11 | @dataclass_transform( 12 | field_specifiers=(field, Field), 13 | ) 14 | class BaseModelMeta(type): 15 | def __new__(mcs, *args: Any, **kwargs: Any) -> Any: 16 | cls = super().__new__(mcs, *args, **kwargs) 17 | 18 | try: 19 | dataclass_kwargs = cls.Config.dataclass_kwargs 20 | except AttributeError: 21 | dataclass_kwargs = {} 22 | 23 | return dataclass(**{"frozen": True, **dataclass_kwargs})(cls) 24 | 25 | 26 | class BaseModel(metaclass=BaseModelMeta): 27 | @classmethod 28 | def fields(cls) -> AbstractSet[str]: 29 | return {f.name for f in fields(cls)} - {"__dataclass_fields__"} 30 | 31 | @classmethod 32 | def create(cls, data: Any) -> Self: 33 | # noinspection PyArgumentList 34 | return cls(**{k: data[k] for k in cls.fields() if k in data}) 35 | 36 | def as_dict(self) -> StrDict: 37 | """ 38 | Convert model into native instagram representation. 39 | Should be overridden at delivered classes if model 40 | has specific representation. 41 | 42 | :return: native instagram representation 43 | """ 44 | return {key: value.as_dict() if isinstance(value, BaseModel) else value for key, value in asdict(self).items()} 45 | 46 | 47 | class Entity(BaseModel): 48 | pk: int = field(repr=False) 49 | 50 | def __hash__(self) -> int: 51 | return hash(self.pk) 52 | 53 | def __int__(self) -> int: 54 | return self.pk 55 | 56 | @classmethod 57 | def create(cls, data: StrDict) -> Self: 58 | return super().create(data) 59 | 60 | 61 | __all__ = [ 62 | "BaseModel", 63 | "Entity", 64 | ] 65 | -------------------------------------------------------------------------------- /instapi/utils.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable, Iterable 2 | from functools import partial 3 | from itertools import chain 4 | from typing import Any, TypeVar 5 | from uuid import uuid1 6 | 7 | T = TypeVar("T") 8 | 9 | 10 | def fetch_key(source: dict[Any, Any], key_path: str) -> Any: 11 | for key in key_path.split("."): 12 | if key in source: 13 | source = source[key] 14 | else: 15 | return None 16 | 17 | return source 18 | 19 | 20 | def process_many( 21 | fetcher: Callable[..., Any], 22 | pk: int | None = None, 23 | with_rank_token: bool = False, 24 | key: str = "max_id", 25 | key_path: str = "next_max_id", 26 | ) -> Iterable[Any]: 27 | next_value = None 28 | 29 | if pk is not None: 30 | fetcher = partial(fetcher, pk) 31 | 32 | if with_rank_token: 33 | fetcher = partial(fetcher, rank_token=str(uuid1())) 34 | 35 | while True: 36 | if next_value is not None: 37 | result = fetcher(**{key: next_value}) 38 | else: 39 | result = fetcher() 40 | 41 | yield result 42 | 43 | next_value = fetch_key(result, key_path) 44 | 45 | if not next_value: 46 | break 47 | 48 | 49 | def limited(iterable: Iterable[T], limit: int | None = None) -> Iterable[T]: 50 | if limit is None: 51 | yield from iterable 52 | else: 53 | if limit < 0: 54 | raise ValueError("Limited can't handle negative numbers") 55 | 56 | yield from (i for _, i in zip(range(limit), iterable)) 57 | 58 | 59 | def to_list(iterable: Iterable[T], limit: int | None = None) -> list[T]: 60 | return [*limited(iterable, limit=limit)] 61 | 62 | 63 | def flat(source: list[Iterable[T]]) -> list[T]: 64 | """ 65 | Unpack list of iterable into single list 66 | 67 | :param source: list of iterable 68 | :return: unpacked list 69 | """ 70 | return [*chain.from_iterable(source)] 71 | 72 | 73 | def join(iterable: Iterable[Any], separator: str = ",") -> str: 74 | return separator.join(str(s) for s in iterable) 75 | 76 | 77 | __all__ = [ 78 | "process_many", 79 | "limited", 80 | "to_list", 81 | "flat", 82 | "join", 83 | ] 84 | -------------------------------------------------------------------------------- /tests/unit_tests/models/test_base.py: -------------------------------------------------------------------------------- 1 | from pytest import fixture, raises 2 | 3 | from instapi.models.base import BaseModel 4 | from tests.unit_tests.conftest import random_int, random_string 5 | 6 | 7 | class TestBaseModel: 8 | """Tests for BaseModel class""" 9 | 10 | @fixture() 11 | def model_cls(self): 12 | class TempModel(BaseModel): 13 | a: int 14 | b: int 15 | c: int 16 | 17 | return TempModel 18 | 19 | @fixture() 20 | def mocked_fields(self, mocker): 21 | fields = {"a", "b", "c"} 22 | mocker.patch("instapi.models.base.BaseModel.fields", return_value=fields) 23 | data = {key: random_int() for key in fields} 24 | 25 | return data 26 | 27 | def test_fields(self, model_cls): 28 | """Test for BaseModel.fields classmethod""" 29 | 30 | assert model_cls.fields() == {"a", "b", "c"} 31 | 32 | def test_fields_with_inheritance(self): 33 | """Test for BaseModel.fields classmethod""" 34 | 35 | class First(BaseModel): 36 | a: int 37 | 38 | assert First.fields() == {"a"} 39 | 40 | class Two(First): 41 | b: int 42 | 43 | assert Two.fields() == {"a", "b"} 44 | 45 | class Three(Two): 46 | c: int 47 | 48 | assert Three.fields() == {"a", "b", "c"} 49 | 50 | def test_create(self, model_cls, mocked_fields): 51 | assert model_cls.create(mocked_fields) == model_cls(**mocked_fields) 52 | 53 | def test_create_data_has_more_fields(self, model_cls, mocked_fields): 54 | more_fields = {**mocked_fields, random_string(): random_int()} 55 | 56 | assert model_cls.create(more_fields) == model_cls(**mocked_fields) 57 | 58 | def test_create_data_has_less_fields(self, model_cls, mocked_fields): 59 | less_fields = {key: mocked_fields[key] for key in [*mocked_fields][:-1]} 60 | 61 | with raises(Exception): 62 | model_cls.create(less_fields) 63 | 64 | 65 | class TestEntity: 66 | """Tests for Entity class""" 67 | 68 | def test_entity_hash(self, entity): 69 | # Entity hash should be calculated base on pk field 70 | assert hash(entity) == hash(entity.pk) 71 | 72 | def test_entity_support_int(self, entity): 73 | assert entity.pk == int(entity) 74 | -------------------------------------------------------------------------------- /instapi/cache.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | from collections.abc import Callable 3 | from contextvars import ContextVar 4 | from dataclasses import dataclass 5 | from functools import partial, wraps 6 | from hashlib import md5 7 | from itertools import chain 8 | from pathlib import Path 9 | from time import time 10 | from typing import Any, Deque, TypeVar 11 | 12 | from instagram_private_api.http import ClientCookieJar 13 | 14 | from instapi.types import Credentials 15 | 16 | 17 | def _get_cache_root() -> Path: # pragma: no cover 18 | cwd = Path.cwd() 19 | 20 | for p in chain([cwd], cwd.parents): 21 | cache = p / ".instapi_cache" 22 | 23 | if cache.exists(): 24 | return cache 25 | 26 | return cwd / ".instapi_cache" 27 | 28 | 29 | _CACHE_ROOT = _get_cache_root() 30 | _CACHE_ROOT.mkdir(parents=True, exist_ok=True) 31 | 32 | 33 | def _get_hash(credentials: Credentials) -> str: # pragma: no cover 34 | return md5(":".join(credentials).encode()).hexdigest() 35 | 36 | 37 | # TODO: add tests for cache logic 38 | def get_from_cache(credentials: Credentials) -> bytes | None: # pragma: no cover 39 | cache = _CACHE_ROOT / _get_hash(credentials) 40 | return cache.read_bytes() if cache.exists() else None 41 | 42 | 43 | def write_to_cache(credentials: Credentials, cookie: ClientCookieJar) -> None: # pragma: no cover 44 | cache = _CACHE_ROOT / _get_hash(credentials) 45 | cache.write_bytes(cookie.dump()) 46 | 47 | 48 | CACHED_TIME = ContextVar("CACHED_TIME", default=60) 49 | CacheKey = tuple[tuple[Any, ...], tuple[Any, ...]] 50 | 51 | T = TypeVar("T") 52 | 53 | 54 | @dataclass 55 | class _CacheInfo: 56 | cache: dict[CacheKey, Any] 57 | keys: Deque[tuple[CacheKey, float]] 58 | 59 | 60 | def cached(func: Callable[..., T]) -> Callable[..., T]: 61 | cache: dict[CacheKey, Any] = {} 62 | keys: Deque[tuple[CacheKey, float]] = deque() 63 | 64 | def _delete_expired_keys() -> None: # pragma: no cover 65 | while keys: 66 | key, expired = keys[0] 67 | 68 | if expired > time(): 69 | break 70 | 71 | keys.popleft() 72 | del cache[key] 73 | 74 | def _add_key(key: CacheKey) -> None: 75 | keys.append((key, time() + CACHED_TIME.get())) 76 | 77 | @wraps(func) 78 | def wrapper(*args: Any, **kwargs: Any) -> Any: 79 | _delete_expired_keys() 80 | 81 | key: CacheKey = (args, tuple(kwargs.items())) 82 | 83 | if key not in cache: 84 | cache[key] = func(*args, **kwargs) 85 | _add_key(key) 86 | 87 | return cache[key] 88 | 89 | wrapper.info: Callable[..., _CacheInfo] = partial(_CacheInfo, cache, keys) # type: ignore 90 | 91 | return wrapper 92 | 93 | 94 | __all__ = [ 95 | "CACHED_TIME", 96 | "cached", 97 | "get_from_cache", 98 | "write_to_cache", 99 | ] 100 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "inst-api" 3 | version = "0.3.0" 4 | description = "InstAPI - comfortable and easy to use Python's library for interaction with Instagram" 5 | authors = ["Yurii Karabas <1998uriyyo@gmail.com>"] 6 | packages = [ 7 | { include = "instapi" } 8 | ] 9 | 10 | license = "MIT" 11 | readme = "README.md" 12 | repository = "https://github.com/uriyyo/instapi" 13 | 14 | classifiers = [ 15 | 'Operating System :: OS Independent', 16 | 'Programming Language :: Python', 17 | 'Programming Language :: Python :: 3', 18 | 'Programming Language :: Python :: 3.10', 19 | 'Programming Language :: Python :: 3.11', 20 | 'Programming Language :: Python :: 3.12', 21 | 'Programming Language :: Python :: 3 :: Only', 22 | ] 23 | 24 | [tool.poetry.dependencies] 25 | python = "^3.10" 26 | instagram-private-api = "^1.6.0" 27 | httpx = "^0.27.0" 28 | pillow = "^10.2.0" 29 | pydantic = "^2.6.4" 30 | typing-extensions = "^4.10.0" 31 | 32 | [tool.poetry.dev-dependencies] 33 | pytest = "^7.4.0" 34 | pre-commit = "^2.21.0" 35 | pytest-mock = "^3.11.1" 36 | pytest-cov = "^4.1.0" 37 | ruff = "^0.3.4" 38 | mypy = "^1.9.0" 39 | types-requests = "^2.31.0.20240311" 40 | 41 | [tool.poetry.extras] 42 | pillow = [ 43 | "pillow", 44 | ] 45 | 46 | [build-system] 47 | requires = ["poetry>=0.12"] 48 | build-backend = "poetry.masonry.api" 49 | 50 | [tool.ruff] 51 | line-length = 120 52 | target-version = "py38" 53 | 54 | [tool.ruff.lint] 55 | select = [ 56 | "E", # pycodestyle errors 57 | "W", # pycodestyle warnings 58 | "F", # pyflakes 59 | "I", # isort 60 | "C", # flake8-comprehensions 61 | "B", # flake8-bugbear 62 | "S", # flake8-bandit 63 | "G", # flake8-logging-format 64 | "PIE", # flake8-pie 65 | "COM", # flake8-commas 66 | # "PT", # flake8-pytest-style 67 | "Q", # flake8-quotes 68 | "RSE", # flake8-raise 69 | "RET", # flake8-return 70 | "SIM", # flake8-simplify 71 | "TRY", # tryceratops 72 | "RUF", # ruff specific rules 73 | ] 74 | ignore = [ 75 | "TRY003", # too long exception message 76 | "S101", # use of assert detected 77 | "S324", # use of insecure random number generator 78 | "S113", # use requests without timeout 79 | "SIM108", # use inline if 80 | "COM812", # will be handled by ruff format 81 | ] 82 | exclude = [ 83 | ".bzr", 84 | ".direnv", 85 | ".eggs", 86 | ".git", 87 | ".hg", 88 | ".mypy_cache", 89 | ".nox", 90 | ".pants.d", 91 | ".ruff_cache", 92 | ".svn", 93 | ".tox", 94 | ".venv", 95 | "__pypackages__", 96 | "_build", 97 | "buck-out", 98 | "build", 99 | "dist", 100 | "node_modules", 101 | "venv", 102 | ".venv", 103 | ] 104 | per-file-ignores = {} 105 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 106 | 107 | [tool.ruff.lint.mccabe] 108 | max-complexity = 10 109 | 110 | [tool.mypy] 111 | python_version = "3.10" 112 | strict = true 113 | follow_imports = "normal" 114 | ignore_missing_imports = true 115 | no_implicit_reexport = false 116 | show_column_numbers= true 117 | show_error_codes= true 118 | -------------------------------------------------------------------------------- /tests/unit_tests/client_api/test_direct.py: -------------------------------------------------------------------------------- 1 | from instagram_private_api import ClientError 2 | from pytest import fixture, mark, raises 3 | from requests import Response 4 | 5 | from instapi.client_api.direct import DirectEndpoint 6 | 7 | from ..conftest import random_string 8 | 9 | 10 | @fixture() 11 | def direct_endpoint(mocker): 12 | mocker.patch("instagram_private_api.client.Client.login", return_value=None) 13 | return DirectEndpoint(random_string(), random_string()) 14 | 15 | 16 | @mark.parametrize( 17 | "data,expected", 18 | [ 19 | [1, "[[1]]"], 20 | [[1, 2, 3], "[[1,2,3]]"], 21 | ], 22 | ) 23 | def test_convert_recipient(data, expected): 24 | assert DirectEndpoint._convert_recipient_users(data) == expected 25 | 26 | 27 | class TestSendItem: 28 | @fixture() 29 | def response(self): 30 | r = Response() 31 | r._content = b'{"key": "value"}' 32 | return r 33 | 34 | @fixture() 35 | def mock_post(self, mocker, response): 36 | return mocker.patch("requests.post", return_value=response) 37 | 38 | def test_send_item(self, mock_post, direct_endpoint, response): 39 | response.status_code = 200 40 | 41 | data = direct_endpoint.direct_v2_send_item( 42 | recipient_users=1, 43 | item_type="text", 44 | item_data={}, 45 | ) 46 | 47 | assert data == {"key": "value"} 48 | 49 | _, kwargs = mock_post.call_args 50 | assert "thread_ids" not in kwargs["data"] 51 | 52 | def test_send_item_with_thread_id(self, mock_post, direct_endpoint, response): 53 | response.status_code = 200 54 | 55 | data = direct_endpoint.direct_v2_send_item( 56 | recipient_users=1, 57 | thread_id=1, 58 | item_type="text", 59 | item_data={}, 60 | ) 61 | 62 | assert data == {"key": "value"} 63 | 64 | _, kwargs = mock_post.call_args 65 | assert "thread_ids" in kwargs["data"] 66 | 67 | def test_send_item_raise_exception(self, mock_post, direct_endpoint, response): 68 | response.status_code = 400 69 | 70 | with raises(ClientError): 71 | direct_endpoint.direct_v2_send_item( 72 | recipient_users=1, 73 | thread_id=1, 74 | item_type="text", 75 | item_data={}, 76 | ) 77 | 78 | 79 | @mark.parametrize( 80 | "method,kwargs", 81 | [ 82 | [DirectEndpoint.direct_v2_send_media_share, {"media_id": 1}], 83 | [DirectEndpoint.direct_v2_send_hashtag, {"hashtag": ""}], 84 | [DirectEndpoint.direct_v2_send_profile, {"profile_id": 1}], 85 | [DirectEndpoint.direct_v2_send_link, {"link": ""}], 86 | [DirectEndpoint.direct_v2_send_text, {"text": ""}], 87 | ], 88 | ) 89 | def test_custom_send(mocker, method, direct_endpoint, kwargs): 90 | mock = mocker.patch("instapi.client_api.direct.DirectEndpoint.direct_v2_send_item") 91 | method(direct_endpoint, **kwargs) 92 | 93 | mock.assert_called_once() 94 | 95 | 96 | @mark.parametrize( 97 | "method,kwargs", 98 | [ 99 | [DirectEndpoint.direct_v2_inbox, {}], 100 | [DirectEndpoint.direct_v2_get_by_participants, {"recipient_users": 1}], 101 | [DirectEndpoint.direct_v2_thread, {"thread_id": 1}], 102 | ], 103 | ) 104 | def test_direct_call_api(mocker, method, direct_endpoint, kwargs): 105 | mock = mocker.patch("instapi.client_api.direct.DirectEndpoint._call_api") 106 | method(direct_endpoint, **kwargs) 107 | 108 | mock.assert_called_once() 109 | -------------------------------------------------------------------------------- /tests/unit_tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from typing import Generator, List 2 | 3 | from pytest import fixture, mark, raises 4 | 5 | from instapi.utils import flat, join, limited, process_many, to_list 6 | 7 | from .conftest import random_int 8 | 9 | 10 | class TestFlat: 11 | """Test for flat function""" 12 | 13 | def test_flat_skip_empty(self): 14 | # should skip empty iterables 15 | assert flat([[1, 2, 3], [], [4, 5], [6, 7]]) == [1, 2, 3, 4, 5, 6, 7] 16 | 17 | def test_flat_empty_iterable(self): 18 | # should work for empty list 19 | assert flat([]) == [] 20 | 21 | def test_flat_dont_unpack_inner(self): 22 | assert flat([[1, [2]]]) == [1, [2]] 23 | 24 | 25 | @mark.parametrize( 26 | "func,ret_type", 27 | [ 28 | [limited, Generator], 29 | [to_list, List], 30 | ], 31 | ) 32 | class TestLimitedAndToList: 33 | """ 34 | Tests for: 35 | limited function 36 | to_list function 37 | """ 38 | 39 | @fixture() 40 | def arr(self): 41 | return [*range(100)] 42 | 43 | def test_return_type(self, arr, func, ret_type): 44 | assert isinstance(func(arr), ret_type) 45 | assert isinstance(func(arr, limit=10), ret_type) 46 | 47 | @mark.usefixtures("ret_type") 48 | def test_without_limit(self, arr, func): 49 | assert [*func(arr)] == arr 50 | 51 | @mark.usefixtures("ret_type") 52 | def test_limit_bigger(self, arr, func): 53 | assert [*func(arr, limit=len(arr) * 5)] == arr 54 | 55 | @mark.usefixtures("ret_type") 56 | def test_limit_less(self, arr, func): 57 | limit = len(arr) // 2 58 | assert [*func(arr, limit=limit)] == arr[:limit] 59 | 60 | @mark.usefixtures("ret_type") 61 | def test_limit_negative(self, arr, func): 62 | with raises(ValueError): 63 | _ = [*func(arr, limit=-10)] 64 | 65 | 66 | class TestProcessMany: 67 | """Test for process_many function""" 68 | 69 | def test_process_many_called_once(self, mocker): 70 | mock = mocker.Mock(return_value={}) 71 | 72 | _ = [*process_many(mock)] 73 | mock.assert_called_once_with() 74 | 75 | def test_process_many_with_pk(self, mocker): 76 | mock = mocker.Mock(return_value={}) 77 | 78 | pk = random_int() 79 | _ = [*process_many(mock, pk)] 80 | 81 | mock.assert_called_once_with(pk) 82 | 83 | def test_process_many_with_rank_token(self, mocker): 84 | mock = mocker.Mock(return_value={}) 85 | 86 | _ = [*process_many(mock, with_rank_token=True)] 87 | mock.assert_called_once() 88 | 89 | _, kwargs = mock.call_args 90 | assert "rank_token" in kwargs 91 | assert isinstance(kwargs["rank_token"], str) 92 | 93 | def test_process_many_called_multiple_times(self, mocker): 94 | max_ids = [*[{"next_max_id": random_int()} for _ in range(3)], {}] 95 | mock = mocker.Mock(side_effect=max_ids) 96 | 97 | _ = [*process_many(mock)] 98 | 99 | mock.assert_has_calls( 100 | [ 101 | mocker.call(), 102 | *[mocker.call(max_id=m["next_max_id"]) for m in max_ids[:-1]], 103 | ], 104 | ) 105 | 106 | def test_process_many_return_value(self, mocker): 107 | max_ids = [*[{"next_max_id": random_int()} for _ in range(3)], {}] 108 | mock = mocker.Mock(side_effect=max_ids) 109 | 110 | assert [*process_many(mock)] == max_ids 111 | 112 | 113 | class TestJoin: 114 | def test_join_not_str(self): 115 | assert join(range(4)) == "0,1,2,3" 116 | 117 | def test_join_str(self): 118 | assert join(["hello", "world"], "hello,world") 119 | 120 | def test_custom_separator(self): 121 | assert join(["hello", "world"], " ") == "hello world" 122 | -------------------------------------------------------------------------------- /instapi/models/direct.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Iterable 4 | from dataclasses import field 5 | from typing import Any 6 | 7 | from typing_extensions import Self 8 | 9 | from ..cache import cached 10 | from ..client import client 11 | from ..types import StrDict 12 | from ..utils import process_many, to_list 13 | from .base import BaseModel 14 | from .media import Media 15 | from .user import User 16 | 17 | 18 | class Message(BaseModel): 19 | item_id: int 20 | timestamp: int 21 | item_type: str 22 | user: User 23 | placeholder: StrDict = field(default_factory=dict) 24 | story_share: StrDict = field(default_factory=dict) 25 | 26 | @classmethod 27 | def create(cls, data: StrDict) -> Self: 28 | user_id = data.pop("user_id") 29 | return super().create({"user": User.get(user_id), **data}) 30 | 31 | def as_dict(self) -> StrDict: 32 | data = super().as_dict() 33 | data["user_id"] = data.pop("user")["pk"] 34 | 35 | return data 36 | 37 | 38 | class Direct(BaseModel): 39 | thread_title: str 40 | thread_type: str 41 | is_group: bool 42 | users: tuple[User] 43 | thread_id: int | None = None 44 | 45 | @classmethod 46 | def create(cls, data: Any) -> Self: 47 | return super().create( 48 | { 49 | **data, 50 | "users": tuple(map(User.create, data["users"])), 51 | } 52 | ) 53 | 54 | @classmethod 55 | def iter_directs(cls) -> Iterable[Direct]: 56 | for response in process_many(client.direct_v2_inbox, key="cursor", key_path="inbox.oldest_cursor"): 57 | yield from map(Direct.create, response["inbox"]["threads"]) 58 | 59 | @classmethod 60 | def directs(cls, limit: int | None = None) -> list[Direct]: 61 | return to_list(cls.iter_directs(), limit) 62 | 63 | @classmethod 64 | @cached 65 | def with_user(cls, user: User) -> Self: 66 | result = client.direct_v2_get_by_participants(user) 67 | 68 | try: 69 | return cls.create(result["thread"]) 70 | except KeyError: 71 | return cls(user.username, "private", False, (user,)) 72 | 73 | def iter_message(self) -> Iterable[Message]: 74 | for response in process_many( 75 | client.direct_v2_thread, 76 | self.thread_id, 77 | key="cursor", 78 | key_path="thread.oldest_cursor", 79 | ): 80 | yield from map(Message.create, response["thread"]["items"]) 81 | 82 | def messages(self, limit: int | None = None) -> list[Message]: 83 | return to_list(self.iter_message(), limit) 84 | 85 | @property 86 | def _send_args(self) -> StrDict: 87 | return { 88 | "recipient_users": self.users, 89 | "thread_id": self.thread_id, 90 | } 91 | 92 | def send_text(self, text: str) -> None: 93 | client.direct_v2_send_text( 94 | text=text, 95 | **self._send_args, 96 | ) 97 | 98 | def send_link(self, link: str, text: str = "") -> None: 99 | client.direct_v2_send_link( 100 | link=link, 101 | text=text, 102 | **self._send_args, 103 | ) 104 | 105 | def send_profile(self, user: User, text: str = "") -> None: 106 | client.direct_v2_send_profile( 107 | text=text, 108 | profile_id=user.pk, 109 | **self._send_args, 110 | ) 111 | 112 | def send_hashtag(self, hashtag: str, text: str = "") -> None: 113 | client.direct_v2_send_hashtag( 114 | hashtag=hashtag, 115 | text=text, 116 | **self._send_args, 117 | ) 118 | 119 | def send_media(self, media: Media, text: str = "") -> None: 120 | client.direct_v2_send_media_share( 121 | text=text, 122 | media_id=media.pk, 123 | **self._send_args, 124 | ) 125 | 126 | 127 | __all__ = [ 128 | "Direct", 129 | "Message", 130 | ] 131 | -------------------------------------------------------------------------------- /tests/unit_tests/conftest.py: -------------------------------------------------------------------------------- 1 | from dataclasses import is_dataclass 2 | from functools import partial 3 | from random import choice, randint 4 | from string import ascii_letters 5 | from typing import Any, Callable, Dict, List, Type, TypeVar, Union 6 | 7 | from pytest import fixture 8 | 9 | from instapi import models 10 | from instapi.client import ClientProxy 11 | 12 | T = TypeVar("T") 13 | 14 | TEST_USERNAME = "test-username" 15 | 16 | 17 | def pytest_configure(): 18 | # Turn on testing mode for ClientProxy 19 | ClientProxy.is_testing = True 20 | ClientProxy.username = TEST_USERNAME 21 | 22 | 23 | def random_bytes(count: int = 10) -> bytes: 24 | """ 25 | Generate random bytes 26 | 27 | :param count: how many bytes will be generated 28 | :return: random bytes 29 | """ 30 | return b"".join(bytes(choice(ascii_letters), "ascii") for _ in range(count)) 31 | 32 | 33 | def random_string(length: int = 10, source: str = ascii_letters) -> str: 34 | """ 35 | Generate random string from source string 36 | 37 | :param length: length of generated string 38 | :param source: source of characters to use 39 | :return: random string 40 | """ 41 | return "".join(choice(source) for _ in range(length)) 42 | 43 | 44 | def random_int(start: int = 1, end: int = 100) -> int: 45 | """ 46 | Generate a random int in range from start to end 47 | 48 | :param start: range start 49 | :param end: range end 50 | :return: a random int 51 | """ 52 | return randint(start, end) 53 | 54 | 55 | def random_url(extension: str = ".jpg"): 56 | """ 57 | Generate random url 58 | 59 | :param extension: extension to add to url 60 | :return: random url 61 | """ 62 | return f"http://{random_string()}.com/{random_string()}{extension}" 63 | 64 | 65 | # Define default actions to do for different types 66 | TYPE_TO_ACTION: Dict[Type, Callable] = { 67 | bool: partial(choice, [True, False]), 68 | int: random_int, 69 | str: random_string, 70 | } 71 | 72 | 73 | def _get_rand_type(field_type: Union[str, Type[T]]) -> T: 74 | """ 75 | Create random field value based on type 76 | 77 | :param field_type: field type 78 | :return: random field value 79 | """ 80 | if isinstance(field_type, str): 81 | field_type = eval(field_type, vars(models)) 82 | 83 | if field_type in TYPE_TO_ACTION: 84 | return TYPE_TO_ACTION[field_type]() 85 | else: 86 | return rand(field_type) 87 | 88 | 89 | def rand(cls: Type[T], **kwargs) -> T: 90 | """ 91 | Generate an object from class with random fields values based on their types. 92 | Cls object must be wrapped with dataclass decorator to have ability 93 | fetch information about field types 94 | 95 | :param cls: object class to generate 96 | :param kwargs: kwargs to use at an instance 97 | :return: instance of cls with random fields values 98 | """ 99 | if not is_dataclass(cls): 100 | raise TypeError("Can create random instances only of dataclass classes") 101 | 102 | fields_info = {f: cls.__dataclass_fields__[f] for f in cls.fields() if f not in kwargs} 103 | 104 | return cls( 105 | **{ 106 | **{name: _get_rand_type(field.type) for name, field in fields_info.items()}, 107 | **kwargs, 108 | }, 109 | ) 110 | 111 | 112 | def rands(cls: Type[T], length: int = 10, **kwargs: Callable[[], Any]) -> List[T]: 113 | """ 114 | Generate list of random objects 115 | 116 | :param cls: object type 117 | :param length: list size 118 | :param kwargs: fields overrides 119 | :return: list of random objects 120 | """ 121 | return [rand(cls, **{k: v() for k, v in kwargs.items()}) for _ in range(length)] 122 | 123 | 124 | @fixture() 125 | def regular_client_mode(): 126 | """ 127 | Fixture that disable ClientProxy testing mode for single test 128 | """ 129 | old_value = ClientProxy.is_testing 130 | ClientProxy.is_testing = False 131 | 132 | yield 133 | 134 | ClientProxy.is_testing = old_value 135 | -------------------------------------------------------------------------------- /tests/unit_tests/models/test_feed.py: -------------------------------------------------------------------------------- 1 | from pytest import fixture 2 | 3 | from ..conftest import random_int, random_string 4 | from .conftest import as_dicts 5 | 6 | 7 | class TestFeed: 8 | @fixture() 9 | def mock_likers(self, mocker, users): 10 | return mocker.patch( 11 | "instapi.client.client.media_likers", 12 | return_value={"users": as_dicts(users)}, 13 | ) 14 | 15 | @fixture() 16 | def mock_comments(self, mocker, comments): 17 | return mocker.patch( 18 | "instapi.client.client.media_comments", 19 | return_value={"comments": as_dicts(comments)}, 20 | ) 21 | 22 | @fixture() 23 | def mock_timeline(self, mocker, feeds): 24 | return mocker.patch( 25 | "instapi.client.client.feed_timeline", 26 | return_value={"feed_items": [{"media_or_ad": f.as_dict()} for f in feeds]}, 27 | ) 28 | 29 | def test_usertags_one_user(self, mocker, feed, user): 30 | mocker.patch( 31 | "instapi.models.feed.Feed._media_info", 32 | return_value={"usertags": {"in": [{"user": user.as_dict()}]}}, 33 | ) 34 | 35 | assert feed.user_tags() == [user] 36 | 37 | def test_usertags_many_users(self, mocker, feed, users): 38 | mocker.patch( 39 | "instapi.models.feed.Feed._media_info", 40 | return_value={"usertags": {"in": [{"user": u} for u in as_dicts(users)]}}, 41 | ) 42 | 43 | assert feed.user_tags() == users 44 | 45 | def test_usertags_no_users(self, mocker, feed): 46 | mocker.patch("instapi.models.feed.Feed._media_info", return_value={}) 47 | 48 | assert not feed.user_tags() 49 | 50 | def test_like(self, mocker, feed): 51 | like_mock = mocker.patch("instapi.client.client.post_like") 52 | 53 | feed.like() 54 | 55 | like_mock.assert_called_once_with(feed.pk) 56 | 57 | def test_unlike(self, mocker, feed): 58 | unlike_mock = mocker.patch("instapi.client.client.delete_like") 59 | 60 | feed.unlike() 61 | 62 | unlike_mock.assert_called_once_with(feed.pk) 63 | 64 | def test_liked_by_user_in_likers(self, mock_likers, feed, users): 65 | user, *_ = users 66 | 67 | assert feed.liked_by(user) 68 | 69 | def test_liked_by_user_not_in_likers(self, mock_likers, feed, users, user): 70 | assert not feed.liked_by(user) 71 | 72 | def test_iter_likes(self, mock_likers, feed, users): 73 | unpack = [*feed.iter_likes()] 74 | 75 | assert unpack == users 76 | 77 | def test_likes_without_limit(self, mock_likers, feed, users): 78 | likes = feed.likes() 79 | 80 | assert likes == users 81 | 82 | def test_likes_with_limit(self, mock_likers, feed, users): 83 | limit = random_int(0, len(users)) 84 | likes = feed.likes(limit=limit) 85 | 86 | assert likes == users[:limit] 87 | 88 | def test_iter_comments(self, mock_comments, feed, comments): 89 | unpack = [*feed.iter_comments()] 90 | 91 | assert unpack == comments 92 | 93 | def test_comments_without_limit(self, mock_comments, feed, comments): 94 | assert feed.comments() == comments 95 | 96 | def test_comments_with_limit(self, mock_comments, feed, comments): 97 | limit = random_int(0, len(comments)) 98 | 99 | assert feed.comments(limit=limit) == comments[:limit] 100 | 101 | def test_iter_timeline(self, mock_timeline, feed, feeds): 102 | assert [*feed.iter_timeline()] == feeds 103 | 104 | def test_timeline_without_limit(self, mock_timeline, feed, feeds): 105 | assert feed.timeline() == feeds 106 | 107 | def test_timeline_with_limit(self, mock_timeline, feed, feeds): 108 | limit = random_int(0, len(feeds)) 109 | feeds = feed.timeline(limit=limit) 110 | 111 | assert feeds == feeds[:limit] 112 | 113 | def test_caption(self, mocker, feed): 114 | caption = random_string() 115 | mocker.patch("instapi.models.feed.Feed._media_info", return_value={"caption": {"text": caption}}) 116 | 117 | assert feed.caption == caption 118 | -------------------------------------------------------------------------------- /instapi/models/feed.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Iterable 4 | from typing import cast 5 | 6 | from ..cache import cached 7 | from ..client import client 8 | from ..types import StrDict 9 | from ..utils import process_many, to_list 10 | from .comment import Comment 11 | from .resource import ResourceContainer 12 | from .user import User 13 | 14 | 15 | class Feed(ResourceContainer): 16 | """ 17 | This class represent Instagram's feed. It gives opportunity to: 18 | - Get posts from feed 19 | - Get info about post (comments, which was attached to the post; users, which has liked the post) 20 | - Like/Unlike posts 21 | - Get media (videos and images) from posts 22 | """ 23 | 24 | like_count: int 25 | comment_count: int = 0 26 | 27 | @property 28 | def caption(self) -> str: 29 | return cast(str, self._media_info()["caption"]["text"]) 30 | 31 | @classmethod 32 | def iter_timeline(cls) -> Iterable[Feed]: 33 | """ 34 | Create generator for iteration over posts from feed 35 | 36 | :return: generator with posts from feed 37 | """ 38 | for result in process_many(client.feed_timeline): 39 | yield from (Feed.create(data["media_or_ad"]) for data in result["feed_items"] if "media_or_ad" in data) 40 | 41 | @classmethod 42 | def timeline(cls, limit: int | None = None) -> list[Feed]: 43 | """ 44 | Generate list of posts from feed 45 | 46 | :param limit: number of posts, which will be added to the list 47 | :return: list with posts from feed 48 | """ 49 | return to_list(cls.iter_timeline(), limit=limit) 50 | 51 | def _resources(self) -> Iterable[StrDict]: 52 | """ 53 | Feed can contain multiple images and videos that located in carousel_media 54 | 55 | :return: source of videos or images 56 | """ 57 | media_info = self._media_info() 58 | return media_info.get("carousel_media", [media_info]) # type: ignore[no-any-return] 59 | 60 | @cached 61 | def user_tags(self) -> list[User]: 62 | """ 63 | Generate list of Users from Feed usertags 64 | 65 | :return: list of Users from usertags 66 | """ 67 | info = self._media_info() 68 | 69 | if "usertags" not in info: 70 | return [] 71 | 72 | return [User.create(u["user"]) for u in info["usertags"]["in"]] 73 | 74 | def iter_likes(self) -> Iterable[User]: 75 | """ 76 | Create generator for iteration over posts from feed 77 | 78 | :return: generator with users, which has liked a post 79 | """ 80 | for result in process_many(client.media_likers, self.pk): 81 | yield from map(User.create, result["users"]) 82 | 83 | def likes(self, limit: int | None = None) -> list[User]: 84 | """ 85 | Generate list of users, which has liked a post 86 | 87 | :param limit: number of users, which will be added to the list 88 | :return: list with users, which has liked a post 89 | """ 90 | return to_list(self.iter_likes(), limit=limit) 91 | 92 | def liked_by(self, user: User) -> bool: 93 | """ 94 | Check if post was liked by user 95 | 96 | :param user: user for checking 97 | :return: boolean value 98 | """ 99 | return any( 100 | any(user.pk == u["pk"] for u in result["users"]) for result in process_many(client.media_likers, self.pk) 101 | ) 102 | 103 | def like(self) -> None: 104 | """ 105 | Like post 106 | 107 | :return: none 108 | """ 109 | client.post_like(self.pk) 110 | 111 | def unlike(self) -> None: 112 | """ 113 | Unlike post 114 | 115 | :return: none 116 | """ 117 | client.delete_like(self.pk) 118 | 119 | def iter_comments(self) -> Iterable[Comment]: 120 | """ 121 | Create generator for iteration over comments, which was attached to the post 122 | 123 | :return: generator with comments 124 | """ 125 | for result in process_many(client.media_comments, self.pk): 126 | for c in result["comments"]: 127 | c["user"] = User.create(c["user"]) 128 | 129 | yield from map(Comment.create, result["comments"]) 130 | 131 | def comments(self, limit: int | None = None) -> list[Comment]: 132 | """ 133 | Generate list of comments, which was attached to the post 134 | 135 | :param limit: number of comments, which will be added to the list 136 | :return: list with comments 137 | """ 138 | return to_list(self.iter_comments(), limit=limit) 139 | 140 | 141 | __all__ = [ 142 | "Feed", 143 | ] 144 | -------------------------------------------------------------------------------- /tests/unit_tests/models/test_direct.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | 3 | from pytest import fixture 4 | 5 | from instapi import Direct, User 6 | from tests.unit_tests.models.conftest import as_dicts 7 | 8 | from ..conftest import random_int, random_string 9 | 10 | 11 | def test_direct_create(user): 12 | data = { 13 | "thread_title": random_string(), 14 | "thread_type": random_string(), 15 | "is_group": False, 16 | "users": [user.as_dict()], 17 | "thread_id": random_int(), 18 | } 19 | 20 | direct = Direct.create(data) 21 | 22 | assert {**data, "users": tuple(User.create(d) for d in data["users"])} == vars(direct) 23 | 24 | 25 | class TestDirects: 26 | @fixture() 27 | def inbox_data(self, directs): 28 | return {"inbox": {"threads": as_dicts(directs)}} 29 | 30 | @fixture() 31 | def mock_inbox(self, mocker, inbox_data): 32 | return mocker.patch("instapi.client.client.direct_v2_inbox", return_value=inbox_data) 33 | 34 | def test_iter_direct_return_type(self): 35 | assert isinstance(Direct.iter_directs(), Generator) 36 | 37 | def test_iter_direct(self, mock_inbox, directs): 38 | assert [*Direct.iter_directs()] == directs 39 | 40 | def test_direct(self, mock_inbox, directs): 41 | assert Direct.directs() == directs 42 | 43 | def test_direct_with_limit(self, mock_inbox, directs): 44 | limit = len(directs) // 2 45 | assert Direct.directs(limit) == directs[:limit] 46 | 47 | 48 | class TestMessages: 49 | @fixture() 50 | def message_data(self, direct, messages): 51 | return {"thread": {"items": as_dicts(messages)}} 52 | 53 | @fixture() 54 | def mock_user_get(self, messages, mocker): 55 | users = {m.user.pk: m.user for m in messages} 56 | 57 | def get(key): 58 | return users[key] 59 | 60 | mocker.patch("instapi.models.user.User.get", side_effect=get) 61 | 62 | @fixture() 63 | def mock_thread(self, mocker, message_data, mock_user_get): 64 | return mocker.patch("instapi.client.client.direct_v2_thread", return_value=message_data) 65 | 66 | def test_iter_direct_return_type(self, direct): 67 | assert isinstance(direct.iter_message(), Generator) 68 | 69 | def test_iter_direct(self, mock_thread, direct, messages): 70 | assert [*direct.iter_message()] == messages 71 | 72 | def test_direct(self, mock_thread, direct, messages): 73 | assert direct.messages() == messages 74 | 75 | def test_direct_with_limit(self, mock_thread, direct, messages): 76 | limit = len(messages) // 2 77 | assert direct.messages(limit) == messages[:limit] 78 | 79 | 80 | class TestWithUser: 81 | def test_with_user_thread_exists(self, mocker, direct, user): 82 | mocker.patch( 83 | "instapi.client.client.direct_v2_get_by_participants", 84 | return_value={"thread": direct.as_dict()}, 85 | ) 86 | assert Direct.with_user(user) == direct 87 | 88 | def test_with_user_thread_doesnt_exists(self, mocker, user): 89 | mocker.patch( 90 | "instapi.client.client.direct_v2_get_by_participants", 91 | return_value={}, 92 | ) 93 | 94 | direct = Direct.with_user(user) 95 | 96 | assert direct.thread_id is None 97 | assert direct.users == (user,) 98 | 99 | 100 | def test_send_text(mocker, direct): 101 | mock = mocker.patch("instapi.client.client.direct_v2_send_text") 102 | 103 | text = random_string() 104 | direct.send_text(text) 105 | 106 | mock.assert_called_once_with( 107 | **direct._send_args, 108 | text=text, 109 | ) 110 | 111 | 112 | def test_send_link(mocker, direct): 113 | mock = mocker.patch("instapi.client.client.direct_v2_send_link") 114 | 115 | link = random_string() 116 | text = random_string() 117 | direct.send_link(link, text) 118 | 119 | mock.assert_called_once_with( 120 | **direct._send_args, 121 | link=link, 122 | text=text, 123 | ) 124 | 125 | 126 | def test_send_profile(mocker, direct, user): 127 | mock = mocker.patch("instapi.client.client.direct_v2_send_profile") 128 | 129 | text = random_string() 130 | direct.send_profile(user, text) 131 | 132 | mock.assert_called_once_with( 133 | **direct._send_args, 134 | profile_id=user.pk, 135 | text=text, 136 | ) 137 | 138 | 139 | def test_send_hashtag(mocker, direct, user): 140 | mock = mocker.patch("instapi.client.client.direct_v2_send_hashtag") 141 | 142 | text = random_string() 143 | hashtag = random_string() 144 | direct.send_hashtag(hashtag, text) 145 | 146 | mock.assert_called_once_with( 147 | **direct._send_args, 148 | hashtag=hashtag, 149 | text=text, 150 | ) 151 | 152 | 153 | def test_send_media(mocker, direct, feed): 154 | mock = mocker.patch("instapi.client.client.direct_v2_send_media_share") 155 | 156 | text = random_string() 157 | direct.send_media(feed, text) 158 | 159 | mock.assert_called_once_with( 160 | **direct._send_args, 161 | media_id=feed.pk, 162 | text=text, 163 | ) 164 | -------------------------------------------------------------------------------- /tests/unit_tests/models/test_resource.py: -------------------------------------------------------------------------------- 1 | import io 2 | from pathlib import Path 3 | 4 | from pytest import fixture, mark, raises 5 | 6 | from instapi import Resource 7 | from instapi.models.resource import Candidate 8 | 9 | from ..conftest import random_bytes, random_string 10 | 11 | 12 | @fixture() 13 | def mock_content(mocker): 14 | content = random_bytes() 15 | mock = mocker.patch("instapi.Candidate.content", side_effect=lambda: io.BytesIO(content)) 16 | 17 | return mock, content 18 | 19 | 20 | class TestImage: 21 | """ 22 | Test for Image class 23 | """ 24 | 25 | @mark.usefixtures("mock_content") 26 | def test_image(self, mocker, image): 27 | open_mock = mocker.patch("PIL.Image.open") 28 | 29 | image.preview() 30 | 31 | open_mock.show.called_once() 32 | 33 | 34 | class TestCandidate: 35 | """ 36 | Test for Candidate class 37 | """ 38 | 39 | @fixture() 40 | def mock_requests_get(self, mocker): 41 | resource_content = random_bytes() 42 | get_mock = mocker.patch( 43 | "requests.get", 44 | return_value=mocker.Mock(raw=io.BytesIO(resource_content)), 45 | ) 46 | return get_mock, resource_content 47 | 48 | @mark.parametrize( 49 | "url,filename", 50 | [ 51 | ["http://instapi.com/sasha.jpg", "sasha.jpg"], 52 | [ 53 | "https://instapi/images/thumb/5/not-sasha.jpg/this-is-sasha.jpg", 54 | "this-is-sasha.jpg", 55 | ], 56 | ["https://instapi/images/sasha.jpg?age=too_old&for=school", "sasha.jpg"], 57 | ], 58 | ) 59 | def test_filename(self, resource, url, filename): 60 | r = Candidate(0, 0, url) 61 | assert r.filename == Path(filename) 62 | 63 | def test_candidate_content(self, mock_requests_get, candidate): 64 | get_mock, resource_content = mock_requests_get 65 | content = candidate.content() 66 | 67 | assert content.read() == resource_content 68 | 69 | get_mock.assert_called_with(candidate.url, stream=True) 70 | 71 | @mark.usefixtures("mock_content") 72 | def test_download_without_param(self, tmp_path, candidate): 73 | candidate.download() 74 | 75 | assert candidate.filename.exists() 76 | 77 | candidate.filename.unlink() 78 | 79 | @mark.usefixtures("mock_content") 80 | def test_download_with_path(self, tmp_path, candidate): 81 | candidate.download(tmp_path) 82 | result_path: Path = tmp_path / candidate.filename 83 | 84 | assert result_path.exists() 85 | 86 | @mark.usefixtures("mock_content") 87 | def test_download_with_path_and_filename(self, tmp_path, resource): 88 | rand_filename = random_string() + ".jpg" 89 | resource.download(tmp_path, rand_filename) 90 | result_path: Path = tmp_path / rand_filename 91 | 92 | assert result_path.exists() 93 | 94 | 95 | class TestResource: 96 | """ 97 | Test for Resource class 98 | """ 99 | 100 | @mark.parametrize( 101 | "data", 102 | [ 103 | [{}], 104 | [{"invalid_key": []}], 105 | ], 106 | ) 107 | def test_from_data_invalid_data(self, data): 108 | assert Resource.from_data(data) is None 109 | 110 | def test_resource_with_no_candidates(self): 111 | with raises(ValueError): 112 | Resource(candidates=()) 113 | 114 | 115 | class TestResourceContainer: 116 | @fixture() 117 | def mock_images(self, mocker, images): 118 | return mocker.patch( 119 | "instapi.models.media.Media._media_info", 120 | return_value=images[0].as_dict(), 121 | ) 122 | 123 | @fixture() 124 | def mock_videos(self, mocker, videos): 125 | return mocker.patch( 126 | "instapi.models.media.Media._media_info", 127 | return_value=videos[0].as_dict(), 128 | ) 129 | 130 | def test_resources(self, mocker, resource_container): 131 | mock = mocker.patch("instapi.models.media.Media._media_info") 132 | 133 | resource_container._resources() 134 | 135 | mock.assert_called_once() 136 | 137 | def test_iter_resources_videos_without_carusel(self, mock_videos, videos, feed): 138 | assert [*feed.iter_resources()] == videos 139 | 140 | def test_iter_resources_images_without_carusel(self, mock_images, images, feed): 141 | assert [*feed.iter_resources()] == images 142 | 143 | def test_resources_videos(self, mock_videos, videos, feed): 144 | assert feed.resources() == videos 145 | 146 | def test_resources_images(self, mock_images, images, feed): 147 | assert feed.resources() == images 148 | 149 | def test_iter_videos(self, mock_videos, videos, feed): 150 | assert [*feed.iter_videos()] == videos 151 | 152 | def test_videos(self, mock_videos, videos, feed): 153 | assert feed.videos() == videos 154 | 155 | def test_iter_images(self, mock_images, images, feed): 156 | assert [*feed.iter_images()] == images 157 | 158 | def test_images(self, mock_images, images, feed): 159 | assert feed.images() == images 160 | 161 | def test_video(self, mock_videos, videos, feed): 162 | assert feed.video() == videos[0] 163 | 164 | def test_image(self, mock_images, images, feed): 165 | assert feed.image() == images[0] 166 | -------------------------------------------------------------------------------- /instapi/client_api/direct.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | from typing import Any, SupportsInt, Union, cast 3 | 4 | import requests 5 | from instagram_private_api.errors import ClientError 6 | 7 | from ..types import StrDict 8 | from ..utils import join 9 | from .base import BaseClient 10 | 11 | RecipientUsers = Union[SupportsInt, Iterable[SupportsInt]] 12 | 13 | 14 | class DirectEndpoint(BaseClient): 15 | @staticmethod 16 | def _convert_recipient_users( 17 | recipient_users: RecipientUsers, 18 | braces_count: int = 2, 19 | ) -> str: 20 | try: 21 | value = join(map(int, cast(Iterable[Any], recipient_users))) 22 | except TypeError: 23 | value = str(int(cast(str, recipient_users))) 24 | 25 | return f'{"[" * braces_count}{value}{"]" * braces_count}' 26 | 27 | def direct_v2_send_item( 28 | self, 29 | recipient_users: RecipientUsers, 30 | thread_id: int | None = None, 31 | *, 32 | item_type: str, 33 | item_data: StrDict, 34 | version: str = "v1", 35 | ) -> StrDict: 36 | url = f"{self.api_url.format(version=version)}direct_v2/threads/broadcast/{item_type}/" 37 | 38 | data = { 39 | "action": "send_item", 40 | "recipient_users": self._convert_recipient_users(recipient_users), 41 | **item_data, 42 | } 43 | 44 | if thread_id: 45 | data["thread_ids"] = f"[{thread_id}]" 46 | 47 | response = requests.post( 48 | url, 49 | headers=self.default_headers, 50 | cookies=self.cookie_jar, 51 | data={ 52 | **self.authenticated_params, 53 | **data, 54 | }, 55 | ) 56 | 57 | try: 58 | response.raise_for_status() 59 | except requests.HTTPError as e: 60 | raise ClientError(str(e), response.status_code, response.text) from e 61 | 62 | return cast(StrDict, response.json()) 63 | 64 | def direct_v2_send_text( 65 | self, 66 | recipient_users: RecipientUsers = (), 67 | thread_id: int | None = None, 68 | *, 69 | text: str, 70 | ) -> StrDict: 71 | return self.direct_v2_send_item( 72 | recipient_users=recipient_users, 73 | thread_id=thread_id, 74 | item_type="text", 75 | item_data={"text": text}, 76 | ) 77 | 78 | def direct_v2_send_link( 79 | self, 80 | recipient_users: RecipientUsers = (), 81 | thread_id: int | None = None, 82 | *, 83 | text: str = "", 84 | link: str, 85 | ) -> StrDict: 86 | return self.direct_v2_send_item( 87 | recipient_users=recipient_users, 88 | thread_id=thread_id, 89 | item_type="link", 90 | item_data={ 91 | "link_text": text or link, 92 | "link_urls": f'["{link}"]', 93 | }, 94 | ) 95 | 96 | def direct_v2_send_media_share( 97 | self, 98 | recipient_users: RecipientUsers = (), 99 | thread_id: int | None = None, 100 | *, 101 | text: str = "", 102 | media_type: str = "photo", 103 | media_id: int, 104 | ) -> StrDict: 105 | return self.direct_v2_send_item( 106 | recipient_users=recipient_users, 107 | thread_id=thread_id, 108 | item_type="media_share", 109 | item_data={ 110 | "text": text, 111 | "media_type": media_type, 112 | "media_id": media_id, 113 | }, 114 | ) 115 | 116 | def direct_v2_send_hashtag( 117 | self, 118 | recipient_users: RecipientUsers = (), 119 | thread_id: int | None = None, 120 | *, 121 | text: str = "", 122 | hashtag: str, 123 | ) -> StrDict: 124 | return self.direct_v2_send_item( 125 | recipient_users=recipient_users, 126 | thread_id=thread_id, 127 | item_type="hashtag", 128 | item_data={ 129 | "text": text, 130 | "hashtag": hashtag, 131 | }, 132 | ) 133 | 134 | def direct_v2_send_profile( 135 | self, 136 | recipient_users: RecipientUsers = (), 137 | thread_id: int | None = None, 138 | *, 139 | text: str = "", 140 | profile_id: int, 141 | ) -> StrDict: 142 | return self.direct_v2_send_item( 143 | recipient_users=recipient_users, 144 | thread_id=thread_id, 145 | item_type="profile", 146 | item_data={ 147 | "text": text, 148 | "profile_user_id": profile_id, 149 | }, 150 | ) 151 | 152 | def direct_v2_inbox(self, **kwargs: str) -> StrDict: 153 | return self._call_api("direct_v2/inbox", query=kwargs) 154 | 155 | def direct_v2_get_by_participants(self, recipient_users: RecipientUsers) -> StrDict: 156 | return self._call_api( 157 | "direct_v2/threads/get_by_participants", 158 | query={ 159 | "recipient_users": self._convert_recipient_users(recipient_users, braces_count=1), 160 | }, 161 | ) 162 | 163 | def direct_v2_thread(self, thread_id: int, **kwargs: str) -> StrDict: 164 | return self._call_api(f"direct_v2/threads/{thread_id}", query=kwargs) 165 | 166 | 167 | __all__ = [ 168 | "DirectEndpoint", 169 | ] 170 | -------------------------------------------------------------------------------- /tests/unit_tests/models/conftest.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from random import shuffle 3 | from typing import Iterable, List, Tuple, Type, TypeVar 4 | 5 | from pytest import fixture 6 | 7 | from instapi import Direct 8 | from instapi.models import Comment, Entity, Feed, Media, User 9 | from instapi.models.direct import Message 10 | from instapi.models.resource import ( 11 | Candidate, 12 | Image, 13 | Resource, 14 | ResourceContainer, 15 | Video, 16 | ) 17 | from instapi.models.story import Story 18 | 19 | from ..conftest import rand, random_int, random_url, rands 20 | 21 | T = TypeVar("T") 22 | 23 | 24 | def as_dicts(models: Iterable[T]) -> List[T]: 25 | """ 26 | Convert models into list of their dict representations 27 | 28 | :param models: iterable of models 29 | :return: list of dicts 30 | """ 31 | return [m.as_dict() for m in models] 32 | 33 | 34 | def create_users(length: int = 10) -> List[User]: 35 | """ 36 | Generate list of dummy users 37 | 38 | :param length: length of list 39 | :return: list of dummy users 40 | """ 41 | return rands(User, length) 42 | 43 | 44 | def create_feeds(length: int = 10) -> List[Feed]: 45 | """ 46 | Generate list of dummy feeds 47 | 48 | :param length: length of list 49 | :return: list of dummy feeds 50 | """ 51 | return rands(Feed, length) 52 | 53 | 54 | def create_candidates(length: int = 10, extension: str = ".jpg") -> Tuple[Candidate]: 55 | """ 56 | Generate list of dummy resource candidates 57 | 58 | :param length: length of list 59 | :param extension: resource extension 60 | :return: list of dummy candidates 61 | """ 62 | return tuple(rand(Candidate, url=random_url(extension)) for _ in range(length)) 63 | 64 | 65 | def create_resource( 66 | length: int = 10, 67 | extension: str = ".jpg", 68 | resource_cls: Type[T] = Resource, 69 | ) -> List[T]: 70 | """ 71 | Generate list of dummy resources 72 | 73 | :param length: length of list 74 | :param extension: resource extension 75 | :param resource_cls: resource class 76 | :return: list of dummy resources 77 | """ 78 | return rands( 79 | resource_cls, 80 | length, 81 | candidates=partial(create_candidates, length=1, extension=extension), 82 | ) 83 | 84 | 85 | def create_images(length: int = 10) -> List[Image]: 86 | """ 87 | Generate list of dummy images 88 | 89 | :param length: length of list 90 | :return: list of dummy images 91 | """ 92 | return create_resource(resource_cls=Image, length=length) 93 | 94 | 95 | def create_comments(length: int = 10) -> List[Comment]: 96 | """ 97 | Generate list of dummy comments 98 | 99 | :param length: length of list 100 | :return: list of dummy comments 101 | """ 102 | return rands(Comment, length) 103 | 104 | 105 | def create_videos(length: int = 10) -> List[Video]: 106 | """ 107 | Generate list of dummy videos 108 | 109 | :param length: length of list 110 | :return: list of dummy videos 111 | """ 112 | return create_resource(resource_cls=Video, extension=".mp4", length=length) 113 | 114 | 115 | def create_directs(length: int = 10) -> List[Direct]: 116 | return rands( 117 | cls=Direct, 118 | length=length, 119 | thread_id=random_int, 120 | users=lambda: tuple([rand(User)]), 121 | ) 122 | 123 | 124 | def create_messages(length: int = 10) -> List[Message]: 125 | # Messages should not have users with same id 126 | # to avoid collision will pop id from predefined 127 | # array of ids 128 | ids = [*range(length)] 129 | shuffle(ids) 130 | 131 | def user(): 132 | return rand(User, pk=ids.pop()) 133 | 134 | return rands(Message, length, placeholder=dict, story_share=dict, user=user) 135 | 136 | 137 | def create_stories(length: int = 10) -> List[Story]: 138 | return rands(Story, length=length, mentions=list) 139 | 140 | 141 | @fixture() 142 | def user() -> User: 143 | """Fixture that return dummy user""" 144 | # User id must not be in range from 1 to 100 because 145 | # randomly generated users have same the range 146 | # so user fixture will return user with pk in range 147 | # from 101 to 200 to avoid fails at random tests 148 | return rand(User, pk=random_int(101, 200)) 149 | 150 | 151 | @fixture() 152 | def entity(): 153 | """Fixture that return dummy entity""" 154 | return rand(Entity) 155 | 156 | 157 | @fixture() 158 | def media(): 159 | """Fixture that return dummy media""" 160 | return rand(Media) 161 | 162 | 163 | @fixture() 164 | def image(): 165 | """Fixture that return dummy image""" 166 | (im,) = create_images(length=1) 167 | return im 168 | 169 | 170 | @fixture() 171 | def resource(): 172 | """Fixture that return dummy resource""" 173 | (resp,) = create_resource(length=1) 174 | return resp 175 | 176 | 177 | @fixture() 178 | def feed() -> Feed: 179 | """Fixture that return dummy feed""" 180 | (f,) = create_feeds(length=1) 181 | return f 182 | 183 | 184 | @fixture() 185 | def candidate() -> Candidate: 186 | """Fixture that return dummy candidate""" 187 | (c,) = create_candidates(length=1) 188 | return c 189 | 190 | 191 | @fixture() 192 | def comment(user) -> Comment: 193 | """Fixture that return comment with random content""" 194 | return rand(Comment) 195 | 196 | 197 | @fixture() 198 | def story() -> Story: 199 | return rand(Story, mentions=[]) 200 | 201 | 202 | @fixture() 203 | def resource_container() -> ResourceContainer: 204 | return rand(ResourceContainer) 205 | 206 | 207 | @fixture() 208 | def direct(user) -> Direct: 209 | return rand(Direct, users=(user,), thread_id=random_int()) 210 | 211 | 212 | @fixture() 213 | def message(user) -> Message: 214 | return rand(Message, user=user, placeholder={}, story_share={}) 215 | 216 | 217 | @fixture() 218 | def messages() -> List[Message]: 219 | return create_messages() 220 | 221 | 222 | @fixture() 223 | def directs() -> List[Direct]: 224 | return create_directs() 225 | 226 | 227 | @fixture() 228 | def users(): 229 | return create_users() 230 | 231 | 232 | @fixture() 233 | def comments(): 234 | return create_comments() 235 | 236 | 237 | @fixture() 238 | def feeds(): 239 | return create_feeds() 240 | 241 | 242 | @fixture() 243 | def videos(): 244 | return create_videos(length=1) 245 | 246 | 247 | @fixture() 248 | def images(): 249 | return create_images(length=1) 250 | 251 | 252 | @fixture() 253 | def stories(): 254 | return rands(Story, mentions=list) 255 | -------------------------------------------------------------------------------- /instapi/models/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import Counter 4 | from collections import Counter as RealCounter 5 | from collections.abc import Iterable 6 | from itertools import chain 7 | from typing import TYPE_CHECKING, cast 8 | 9 | from ..cache import cached 10 | from ..client import client 11 | from ..types import StrDict 12 | from ..utils import process_many, to_list 13 | from .base import Entity 14 | from .resource import Resource 15 | 16 | if TYPE_CHECKING: # pragma: no cover 17 | from .feed import Feed 18 | from .story import Story 19 | 20 | 21 | class User(Entity): 22 | username: str 23 | full_name: str 24 | is_private: bool 25 | is_verified: bool 26 | 27 | @classmethod 28 | @cached 29 | def get(cls, pk: int) -> User: 30 | """ 31 | Create User object from unique user's identifier 32 | 33 | :param pk: unique user's identifier 34 | :return: User object 35 | """ 36 | return cls.create(client.user_info(pk)["user"]) 37 | 38 | @classmethod 39 | @cached 40 | def from_username(cls, username: str) -> User: 41 | """ 42 | Create User object from username 43 | 44 | :param username: name of user 45 | :return: User object 46 | """ 47 | return cls.create(client.username_info(username)["user"]) 48 | 49 | @classmethod 50 | def match_username(cls, username: str, limit: int | None = None) -> list[User]: 51 | """ 52 | Search users by username 53 | 54 | :param username: username 55 | :param limit: size of resulting list 56 | :return: list of User objects 57 | """ 58 | response = client.search_users( 59 | query=username, 60 | **({"count": limit} if limit is not None else {}), 61 | ) 62 | 63 | return [cls.create(user) for user in response["users"]] 64 | 65 | @classmethod 66 | @cached 67 | def self(cls) -> User: 68 | """ 69 | Create User object from current user 70 | 71 | :return: User object 72 | """ 73 | return cls.from_username(client.username) 74 | 75 | @property 76 | def biography(self) -> str: 77 | """ 78 | Return biography of user 79 | 80 | :return: string 81 | """ 82 | return cast(str, self.user_detail()["biography"]) 83 | 84 | @property 85 | def media_count(self) -> int: 86 | """ 87 | Return user's count of post 88 | 89 | :return: number 90 | """ 91 | return cast(int, self.user_detail()["media_count"]) 92 | 93 | @property 94 | def follower_count(self) -> int: 95 | """ 96 | Return user's count of followers 97 | 98 | :return: number 99 | """ 100 | return cast(int, self.user_detail()["follower_count"]) 101 | 102 | @property 103 | def following_count(self) -> int: 104 | """ 105 | Return count of people, on which user followed 106 | 107 | :return: number 108 | """ 109 | return cast(int, self.user_detail()["following_count"]) 110 | 111 | def user_detail(self) -> StrDict: 112 | return cast(StrDict, self.full_info()["user_detail"]["user"]) 113 | 114 | @cached 115 | def full_info(self) -> StrDict: 116 | return cast(StrDict, client.user_detail_info(self.pk)) 117 | 118 | def follow(self, user: User) -> None: 119 | """ 120 | Follow on user 121 | 122 | :param user: User object 123 | :return: None 124 | """ 125 | if self != User.self(): 126 | raise ValueError 127 | 128 | client.friendships_create(user.pk) 129 | 130 | def unfollow(self, user: User) -> None: 131 | """ 132 | Unfollow from user 133 | 134 | :param user: User object 135 | :return: None 136 | """ 137 | if self != User.self(): 138 | raise ValueError 139 | 140 | client.friendships_destroy(user.pk) 141 | 142 | def iter_images(self) -> Iterable[Resource]: 143 | for feed in self.iter_feeds(): 144 | yield from feed.images() 145 | 146 | def images(self, limit: int | None = None) -> list[Resource]: 147 | return to_list(self.iter_images(), limit=limit) 148 | 149 | def iter_videos(self) -> Iterable[Resource]: 150 | for feed in self.feeds(): 151 | yield from feed.videos() 152 | 153 | def videos(self, limit: int | None = None) -> list[Resource]: 154 | return to_list(self.iter_videos(), limit=limit) 155 | 156 | def iter_resources(self) -> Iterable[Resource]: 157 | for feed in self.iter_feeds(): 158 | yield from feed.iter_resources() 159 | 160 | def resources(self, limit: int | None = None) -> list[Resource]: 161 | return to_list(self.iter_resources(), limit=limit) 162 | 163 | def iter_followers(self) -> Iterable[User]: 164 | for result in process_many(client.user_followers, self.pk, with_rank_token=True): 165 | yield from map(User.create, result["users"]) 166 | 167 | def followers(self, limit: int | None = None) -> list[User]: 168 | return to_list(self.iter_followers(), limit=limit) 169 | 170 | def iter_followings(self) -> Iterable[User]: 171 | """ 172 | Create generator for followers 173 | 174 | :return: generator with User objects 175 | """ 176 | for result in process_many(client.user_following, self.pk, with_rank_token=True): 177 | yield from map(User.create, result["users"]) 178 | 179 | def followings(self, limit: int | None = None) -> list[User]: 180 | """ 181 | Generate list of followers 182 | 183 | :param limit: number of images, which will be added to the list 184 | :return: list with User objects 185 | """ 186 | return to_list(self.iter_followings(), limit=limit) 187 | 188 | def iter_feeds(self) -> Iterable[Feed]: 189 | from instapi.models.feed import Feed 190 | 191 | for result in process_many(client.user_feed, self.pk): 192 | yield from map(Feed.create, result["items"]) 193 | 194 | def feeds(self, limit: int | None = None) -> list[Feed]: 195 | return to_list(self.iter_feeds(), limit=limit) 196 | 197 | def total_comments(self) -> int: 198 | return sum(feed.comment_count for feed in self.iter_feeds()) 199 | 200 | def total_likes(self) -> int: 201 | return sum(feed.like_count for feed in self.iter_feeds()) 202 | 203 | def likes_chain(self) -> Iterable[User]: 204 | return chain.from_iterable(feed.iter_likes() for feed in self.iter_feeds()) 205 | 206 | def likes_statistic(self) -> Counter[User]: 207 | return RealCounter(self.likes_chain()) 208 | 209 | def iter_liked_by_user(self, user: User) -> Iterable[Feed]: 210 | return (f for f in self.iter_feeds() if f.liked_by(user)) 211 | 212 | def liked_by_user(self, user: User, limit: int | None = None) -> list[Feed]: 213 | return to_list(self.iter_liked_by_user(user), limit=limit) 214 | 215 | def iter_stories(self) -> Iterable[Story]: 216 | from instapi.models.story import Story 217 | 218 | items = (client.user_story_feed(self.pk)["reel"] or {}).get("items", ()) 219 | yield from map(Story.create, items) 220 | 221 | def stories(self, limit: int | None = None) -> list[Story]: 222 | return to_list(self.iter_stories(), limit=limit) 223 | 224 | 225 | __all__ = [ 226 | "User", 227 | ] 228 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 16 | 17 | 18 | 19 | 20 | 21 | 23 | 26 | 29 | 30 | 31 | 32 | 36 | 37 | 38 | 39 | 40 | 45 | 46 | 47 | 48 | 49 | 51 | 52 | 53 | 54 | 55 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 79 | 80 | 81 | 82 | 83 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 107 | 108 | 109 | 110 | 120 | 121 | 122 | 126 | 127 | 128 | 129 | 130 | 132 | 133 | 134 | 135 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | -------------------------------------------------------------------------------- /instapi/models/resource.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import shutil 4 | from collections.abc import Iterable 5 | from dataclasses import field 6 | from pathlib import Path 7 | from typing import IO, Union, cast 8 | from urllib.parse import urlparse 9 | 10 | import requests 11 | from typing_extensions import Self 12 | 13 | from ..types import StrDict 14 | from ..utils import to_list 15 | from .base import BaseModel 16 | from .media import Media 17 | 18 | 19 | class Candidate(BaseModel): 20 | """ 21 | Represent a candidate for Resource 22 | """ 23 | 24 | width: int 25 | height: int 26 | url: str = field(compare=False) 27 | 28 | class Config: 29 | dataclass_kwargs = {"order": True} # noqa: RUF012 30 | 31 | @property 32 | def filename(self) -> Path: 33 | """ 34 | Return the name of image/video 35 | 36 | :return: path to file 37 | """ 38 | *_, filename = urlparse(self.url).path.split("/") 39 | return Path(filename) 40 | 41 | def content(self) -> IO[bytes]: 42 | """ 43 | File-like object, which contains candidate content 44 | 45 | :return: candidate content 46 | """ 47 | response = requests.get(self.url, stream=True) 48 | return cast(IO[bytes], response.raw) 49 | 50 | def download( 51 | self, 52 | directory: Path | None = None, 53 | filename: Path | str | None = None, 54 | ) -> None: 55 | """ 56 | Download image/video 57 | 58 | :param directory: path for storage file 59 | :param filename: name of file, which will be downloaded 60 | :return: None 61 | """ 62 | filename = filename or self.filename 63 | 64 | if directory: 65 | into = directory / filename 66 | else: 67 | into = Path(filename) 68 | 69 | with into.open(mode="wb") as f: 70 | shutil.copyfileobj(self.content(), f) 71 | 72 | 73 | class Resource(BaseModel): 74 | """ 75 | This class represents image or video, which contains in the post 76 | """ 77 | 78 | candidates: tuple[Candidate, ...] 79 | 80 | def __post_init__(self) -> None: 81 | if not self.candidates: 82 | raise ValueError("Candidates can't be empty") 83 | 84 | @classmethod 85 | def create(cls, data: Iterable[StrDict]) -> Self: 86 | """ 87 | Create Resource from iterable of candidates 88 | 89 | :param data: iterable of candidates 90 | :return: resources with given candidates 91 | """ 92 | candidates = tuple(Candidate.create(c) for c in data) 93 | return cls(candidates) 94 | 95 | @property 96 | def best_candidate(self) -> Candidate: 97 | """ 98 | Return the best available candidate for given resource 99 | 100 | :return: the best candidate 101 | """ 102 | return max(self.candidates) # type: ignore 103 | 104 | def download( 105 | self, 106 | directory: Path | None = None, 107 | filename: None | Path | str = None, 108 | candidate: Candidate | None = None, 109 | ) -> None: 110 | """ 111 | Download image/video 112 | 113 | :param candidate: candidate to use or None 114 | :param directory: path for storage file 115 | :param filename: name of file, which will be downloaded 116 | :return: None 117 | """ 118 | candidate = candidate or self.best_candidate 119 | candidate.download(directory, filename) 120 | 121 | @classmethod 122 | def create_resources( 123 | cls, 124 | resources_data: Iterable[StrDict], 125 | video: bool = True, 126 | image: bool = True, 127 | ) -> Iterable[Resources]: 128 | """ 129 | Create a generator for iteration over images/videos, which contains in the resources_data 130 | 131 | :param resources_data: iterable with information about resources 132 | :param video: true - add videos, false - ignore videos 133 | :param image: true - add images, false - ignore images 134 | :return: generator with images/videos 135 | """ 136 | for data in resources_data: 137 | if (video and cls.is_video_data(data)) or (image and cls.is_image_data(data)): 138 | resource = cls.from_data(data) 139 | 140 | if resource is not None: 141 | yield resource 142 | 143 | @classmethod 144 | def from_data( 145 | cls, 146 | data: StrDict, 147 | video: bool = True, 148 | image: bool = True, 149 | ) -> Resources | None: 150 | """ 151 | Create resource based on data fetched from api 152 | 153 | :param data: data from api 154 | :return: resource instance or None 155 | """ 156 | if video and cls.is_video_data(data): 157 | return Video.create(data["video_versions"]) 158 | if image and cls.is_image_data(data): 159 | return Image.create(data["image_versions2"]["candidates"]) 160 | 161 | return None 162 | 163 | @staticmethod 164 | def is_video_data(data: StrDict) -> bool: 165 | """ 166 | Check if given data contains information about video resource 167 | 168 | :param data: resource data 169 | :return: is given data contains information about video resource 170 | """ 171 | return "video_versions" in data 172 | 173 | @staticmethod 174 | def is_image_data(data: StrDict) -> bool: 175 | """ 176 | Check if given data contains information about image resource 177 | 178 | :param data: resource data 179 | :return: is given data contains information about image resource 180 | """ 181 | return "image_versions2" in data 182 | 183 | 184 | class Video(Resource): 185 | """ 186 | This class represents video resource 187 | """ 188 | 189 | def as_dict(self) -> StrDict: 190 | return {"video_versions": [c.as_dict() for c in self.candidates]} 191 | 192 | 193 | class Image(Resource): 194 | """ 195 | This class represents image resource 196 | """ 197 | 198 | def as_dict(self) -> StrDict: 199 | return {"image_versions2": {"candidates": [c.as_dict() for c in self.candidates]}} 200 | 201 | def preview(self, candidate: Candidate | None = None) -> None: 202 | """ 203 | Show preview of image 204 | 205 | :param candidate: candidate to preview or None 206 | :return: None 207 | """ 208 | try: 209 | from PIL import Image as PILImage 210 | except ImportError: # pragma: no cover 211 | raise RuntimeError("Inst-API is installed without pillow\npip install inst-api[pillow]") from None 212 | 213 | candidate = candidate or self.best_candidate 214 | 215 | img = PILImage.open(candidate.content()) 216 | img.show() 217 | 218 | 219 | class ResourceContainer(Media): 220 | """ 221 | The class represents media with resources 222 | """ 223 | 224 | def _resources(self) -> Iterable[StrDict]: 225 | """ 226 | Return source of videos or images 227 | 228 | :return: source of videos or images 229 | """ 230 | return [self._media_info()] 231 | 232 | def iter_resources(self, *, video: bool = True, image: bool = True) -> Iterable[Resources]: 233 | """ 234 | Create generator for iteration over images/videos, which contains in the media 235 | 236 | :param video: true - add videos, false - ignore videos 237 | :param image: true - add images, false - ignore images 238 | :return: generator with images/videos 239 | """ 240 | return Resource.create_resources(self._resources(), video=video, image=image) 241 | 242 | def resources(self, video: bool = True, image: bool = True, limit: int | None = None) -> list[Resources]: 243 | """ 244 | Generate list of images/videos, which contains in the media 245 | 246 | :param video: true - add videos, false - ignore videos 247 | :param image: true - add images, false - ignore images 248 | :param limit: number of images/videos, which will be added to the list 249 | :return: list with images/videos 250 | """ 251 | return to_list(self.iter_resources(video=video, image=image), limit=limit) 252 | 253 | def iter_videos(self) -> Iterable[Video]: 254 | """ 255 | Create generator for iteration over videos, which contains in the media 256 | 257 | :return: generator with videos, which contains in the post 258 | """ 259 | return cast(Iterable["Video"], self.iter_resources(video=True, image=False)) 260 | 261 | def videos(self, limit: int | None = None) -> list[Video]: 262 | """ 263 | Generate list of videos, which contains in the media 264 | 265 | :param limit: number of videos, which will be added to the list 266 | :return: list with videos 267 | """ 268 | return to_list(self.iter_videos(), limit=limit) 269 | 270 | def iter_images(self) -> Iterable[Image]: 271 | """ 272 | Create generator for iteration over images, which contains in the media 273 | 274 | :return: generator with images, which contains in the meia 275 | """ 276 | return cast(Iterable["Image"], self.iter_resources(video=False, image=True)) 277 | 278 | def images(self, limit: int | None = None) -> list[Image]: 279 | """ 280 | Generate list of images, which contains in the media 281 | 282 | :param limit: number of images, which will be added to the list 283 | :return: list with images 284 | """ 285 | return to_list(self.iter_images(), limit=limit) 286 | 287 | def image(self) -> Image | None: 288 | """ 289 | Return the first image from media if it exists. 290 | 291 | :return: image or None 292 | """ 293 | return next(iter(self.iter_images()), None) 294 | 295 | def video(self) -> Video | None: 296 | """ 297 | Return the first video from media if it exists 298 | 299 | :return: video or None 300 | """ 301 | return next(iter(self.iter_videos()), None) 302 | 303 | 304 | Resources = Union[Image, Video] 305 | 306 | __all__ = [ 307 | "Candidate", 308 | "Resource", 309 | "Resources", 310 | "ResourceContainer", 311 | "Video", 312 | "Image", 313 | ] 314 | -------------------------------------------------------------------------------- /tests/unit_tests/models/test_user.py: -------------------------------------------------------------------------------- 1 | from collections import Counter 2 | from typing import Iterable, List 3 | 4 | from pytest import fixture, raises 5 | 6 | from instapi.models import Feed, User 7 | from instapi.models.resource import Resources 8 | from instapi.utils import flat 9 | 10 | from ..conftest import random_int, random_string, TEST_USERNAME 11 | from .conftest import as_dicts, create_users 12 | 13 | 14 | @fixture() 15 | def mock_feeds_with_resources(mocker, feeds, images, videos): 16 | """ 17 | Fixture that mocks user feeds and feeds resources 18 | """ 19 | mocker.patch("instapi.models.User.iter_feeds", return_value=feeds) 20 | mocker.patch("instapi.models.Media._media_info", return_value={}) 21 | 22 | def mocked_resources(resource_data, video: bool = True, image: bool = True) -> Iterable[Resources]: 23 | if video: 24 | yield from videos 25 | if image: 26 | yield from images 27 | 28 | mocker.patch("instapi.Resource.create_resources", side_effect=mocked_resources) 29 | 30 | 31 | @fixture() 32 | def mock_feeds(mocker, feeds) -> List[Feed]: 33 | """ 34 | Fixture that mocks user feeds 35 | """ 36 | return mocker.patch( 37 | "instapi.client.client.user_feed", 38 | return_value={"items": as_dicts(feeds)}, 39 | ) 40 | 41 | 42 | def test_user_get(user, mocker): 43 | """Test for User.get classmethod""" 44 | user_info_mock = mocker.patch("instapi.client.client.user_info", return_value={"user": user.as_dict()}) 45 | 46 | assert User.get(user.pk) == user 47 | 48 | user_info_mock.assert_called_once_with(user.pk) 49 | 50 | 51 | def test_user_from_username(user, mocker): 52 | """Test for User.from_username classmethod""" 53 | username_info_mock = mocker.patch("instapi.client.client.username_info", return_value={"user": user.as_dict()}) 54 | 55 | assert User.from_username(user.username) == user 56 | 57 | username_info_mock.assert_called_once_with(user.username) 58 | 59 | 60 | def test_user_match_username(user, mocker): 61 | """Test for User.match_username classmethod""" 62 | list_of_users = create_users(length=50) 63 | search_mock = mocker.patch( 64 | "instapi.client.client.search_users", 65 | return_value={"users": as_dicts(list_of_users)}, 66 | ) 67 | 68 | assert User.match_username(user.username) == list_of_users 69 | 70 | search_mock.assert_called_once_with(query=user.username) 71 | search_mock.reset_mock() 72 | 73 | limit = 10 74 | search_mock.return_value = {"users": as_dicts(list_of_users[:limit])} 75 | 76 | assert User.match_username(user.username, limit=limit) == list_of_users[:limit] 77 | 78 | search_mock.assert_called_once_with(query=user.username, count=limit) 79 | 80 | 81 | def test_user_self(user, mocker): 82 | """Test for User.self classmethod""" 83 | user.__dict__["username"] = TEST_USERNAME 84 | user_info_mock = mocker.patch("instapi.client.client.username_info", return_value={"user": user.as_dict()}) 85 | 86 | assert User.self() == user 87 | 88 | user_info_mock.assert_called_once_with(user.username) 89 | 90 | 91 | def test_user_details(user, mocker): 92 | """ 93 | Test for: 94 | User.biography property 95 | User.media_count property 96 | User.follower_count property 97 | User.following_count property 98 | User.user_detail method 99 | User.full_info method 100 | """ 101 | user_details = { 102 | "biography": random_string(), 103 | "media_count": random_int(), 104 | "follower_count": random_int(), 105 | "following_count": random_int(), 106 | **user.as_dict(), 107 | } 108 | full_info = {"user_detail": {"user": user_details}} 109 | 110 | details_mock = mocker.patch("instapi.client.client.user_detail_info", return_value=full_info) 111 | 112 | assert user.biography == user_details["biography"] 113 | assert user.media_count == user_details["media_count"] 114 | assert user.follower_count == user_details["follower_count"] 115 | assert user.following_count == user_details["following_count"] 116 | assert user.user_detail() == user_details 117 | assert user.full_info() == full_info 118 | 119 | details_mock.assert_called_once_with(user.pk) 120 | 121 | 122 | def test_follow(user, mocker): 123 | """Test for User.follow method""" 124 | (self,) = create_users(length=1) 125 | 126 | friendships_mock = mocker.patch("instapi.client.client.friendships_create") 127 | mocker.patch("instapi.models.User.self", return_value=self) 128 | 129 | with raises(ValueError): 130 | user.follow(self) 131 | 132 | friendships_mock.assert_not_called() 133 | 134 | self.follow(user) 135 | 136 | friendships_mock.assert_called_once_with(user.pk) 137 | 138 | 139 | def test_unfollow(user, mocker): 140 | """Test for User.unfollow method""" 141 | (self,) = create_users(length=1) 142 | 143 | friendships_mock = mocker.patch("instapi.client.client.friendships_destroy") 144 | mocker.patch("instapi.models.User.self", return_value=self) 145 | 146 | with raises(ValueError): 147 | user.unfollow(self) 148 | 149 | friendships_mock.assert_not_called() 150 | 151 | self.unfollow(user) 152 | 153 | friendships_mock.assert_called_once_with(user.pk) 154 | 155 | 156 | def test_images(mock_feeds_with_resources, user, feeds, images): 157 | """ 158 | Test for: 159 | User.iter_images method 160 | User.images method 161 | """ 162 | 163 | expected = flat([images] * len(feeds)) 164 | 165 | assert [*user.iter_images()] == expected 166 | assert user.images() == expected 167 | 168 | limit = len(expected) - len(expected) // 2 169 | assert user.images(limit=limit) == expected[:limit] 170 | 171 | 172 | def test_videos(mock_feeds_with_resources, user, feeds, videos): 173 | """ 174 | Test for: 175 | User.iter_videos method 176 | User.videos method 177 | """ 178 | expected = flat([videos] * len(feeds)) 179 | 180 | assert [*user.iter_videos()] == expected 181 | assert user.videos() == expected 182 | 183 | limit = len(expected) - len(expected) // 2 184 | assert user.videos(limit=limit) == expected[:limit] 185 | 186 | 187 | def test_resources(mock_feeds_with_resources, user, videos, images, feeds): 188 | """ 189 | Test for: 190 | User.iter_resources method 191 | User.resources method 192 | """ 193 | expected = flat([videos, images] * len(feeds)) 194 | 195 | assert [*user.iter_resources()] == expected 196 | assert user.resources() == expected 197 | 198 | limit = len(expected) - len(expected) // 2 199 | assert user.resources(limit=limit) == expected[:limit] 200 | 201 | 202 | def test_followers(mocker, user, users): 203 | """ 204 | Test for: 205 | User.iter_followers method 206 | User.followers method 207 | """ 208 | follow_mock = mocker.patch("instapi.client.client.user_followers", return_value={"users": as_dicts(users)}) 209 | 210 | assert [*user.iter_followers()] == users 211 | follow_mock.assert_called_once() 212 | assert follow_mock.call_args[0][0] == user.pk 213 | 214 | follow_mock.reset_mock() 215 | 216 | assert user.followers() == users 217 | follow_mock.assert_called_once() 218 | assert follow_mock.call_args[0][0] == user.pk 219 | 220 | follow_mock.reset_mock() 221 | 222 | limit = len(users) - len(users) // 2 223 | assert user.followers(limit=limit) == users[:limit] 224 | 225 | 226 | def test_followings(mocker, user, users): 227 | """ 228 | Test for: 229 | User.iter_followings method 230 | User.followings method 231 | """ 232 | follow_mock = mocker.patch("instapi.client.client.user_following", return_value={"users": as_dicts(users)}) 233 | 234 | assert [*user.iter_followings()] == users 235 | follow_mock.assert_called_once() 236 | assert follow_mock.call_args[0][0] == user.pk 237 | 238 | follow_mock.reset_mock() 239 | 240 | assert user.followings() == users 241 | follow_mock.assert_called_once() 242 | assert follow_mock.call_args[0][0] == user.pk 243 | 244 | follow_mock.reset_mock() 245 | 246 | limit = len(users) - len(users) // 2 247 | assert user.followings(limit=limit) == users[:limit] 248 | 249 | 250 | def test_feeds(mock_feeds, user, feeds): 251 | """ 252 | Test for: 253 | User.iter_feeds 254 | User.feeds 255 | """ 256 | 257 | assert [*user.iter_feeds()] == feeds 258 | assert user.feeds() == feeds 259 | 260 | limit = len(feeds) - len(feeds) // 2 261 | assert user.feeds(limit=limit) == feeds[:limit] 262 | 263 | 264 | def test_total_comments_and_likes(mock_feeds, feeds, user): 265 | """ 266 | Test for: 267 | User.total_comments 268 | User.total_likes 269 | """ 270 | likes = sum(f.like_count for f in feeds) 271 | assert user.total_likes() == likes 272 | 273 | comments = sum(f.comment_count for f in feeds) 274 | assert user.total_comments() == comments 275 | 276 | 277 | def test_likes(mocker, mock_feeds, user, users, feeds): 278 | """ 279 | Test for: 280 | User.likes_chain 281 | User.likes_statistic 282 | """ 283 | mocker.patch("instapi.models.Feed.iter_likes", return_value=users) 284 | expected = flat([users] * len(feeds)) 285 | 286 | assert [*user.likes_chain()] == expected 287 | assert user.likes_statistic() == Counter(expected) 288 | 289 | 290 | def test_liked_by(mocker, mock_feeds, feeds, user, users): 291 | """ 292 | Test for: 293 | User.iter_liked_by_user 294 | User.liked_by_user 295 | """ 296 | liked_by_mock = mocker.patch("instapi.models.Feed.liked_by") 297 | 298 | def assert_method(like_user: User, expected: List[Feed]): 299 | liked_by_mock.reset_mock() 300 | 301 | assert [*user.iter_liked_by_user(like_user)] == expected 302 | assert liked_by_mock.call_count == len(users) 303 | 304 | liked_by_mock.reset_mock() 305 | 306 | assert user.liked_by_user(like_user) == expected 307 | assert liked_by_mock.call_count == len(users) 308 | 309 | for u in users: 310 | # Looks complicated, list contains cycle to mark first feed 311 | # as liked twice for iter_liked_by_user and liked_by_user 312 | liked_by_mock.side_effect = ([True] + [False] * (len(feeds) - 1)) * 2 313 | assert_method(u, [feeds[0]]) 314 | 315 | def _liked_by_all_liked(user: User) -> bool: 316 | return u == user 317 | 318 | liked_by_mock.side_effect = _liked_by_all_liked 319 | 320 | for u in users: 321 | assert_method(u, feeds) 322 | 323 | liked_by_mock.side_effect = None 324 | liked_by_mock.return_value = False 325 | 326 | for u in users: 327 | assert_method(u, []) 328 | 329 | 330 | def test_stories(user, mocker, stories): 331 | """ 332 | Test for: 333 | User.iter_stories 334 | User.stories 335 | """ 336 | 337 | return_value = {"reel": {"items": as_dicts(stories)}} 338 | 339 | story_mock = mocker.patch("instapi.client.client.user_story_feed", return_value=return_value) 340 | 341 | assert [*user.iter_stories()] == stories 342 | story_mock.assert_called_once_with(user.pk) 343 | 344 | story_mock.reset_mock() 345 | 346 | assert user.stories() == stories 347 | story_mock.assert_called_once_with(user.pk) 348 | 349 | story_mock.reset_mock() 350 | 351 | limit = len(stories) - len(stories) // 2 352 | 353 | assert user.stories(limit=limit) == stories[:limit] 354 | story_mock.assert_called_once_with(user.pk) 355 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "annotated-types" 5 | version = "0.6.0" 6 | description = "Reusable constraint types to use with typing.Annotated" 7 | optional = false 8 | python-versions = ">=3.8" 9 | files = [ 10 | {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, 11 | {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, 12 | ] 13 | 14 | [[package]] 15 | name = "anyio" 16 | version = "4.3.0" 17 | description = "High level compatibility layer for multiple asynchronous event loop implementations" 18 | optional = false 19 | python-versions = ">=3.8" 20 | files = [ 21 | {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"}, 22 | {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"}, 23 | ] 24 | 25 | [package.dependencies] 26 | exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} 27 | idna = ">=2.8" 28 | sniffio = ">=1.1" 29 | typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} 30 | 31 | [package.extras] 32 | doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] 33 | test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] 34 | trio = ["trio (>=0.23)"] 35 | 36 | [[package]] 37 | name = "certifi" 38 | version = "2024.2.2" 39 | description = "Python package for providing Mozilla's CA Bundle." 40 | optional = false 41 | python-versions = ">=3.6" 42 | files = [ 43 | {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, 44 | {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, 45 | ] 46 | 47 | [[package]] 48 | name = "cfgv" 49 | version = "3.4.0" 50 | description = "Validate configuration and produce human readable error messages." 51 | optional = false 52 | python-versions = ">=3.8" 53 | files = [ 54 | {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, 55 | {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, 56 | ] 57 | 58 | [[package]] 59 | name = "colorama" 60 | version = "0.4.6" 61 | description = "Cross-platform colored terminal text." 62 | optional = false 63 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 64 | files = [ 65 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 66 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 67 | ] 68 | 69 | [[package]] 70 | name = "coverage" 71 | version = "7.4.4" 72 | description = "Code coverage measurement for Python" 73 | optional = false 74 | python-versions = ">=3.8" 75 | files = [ 76 | {file = "coverage-7.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0be5efd5127542ef31f165de269f77560d6cdef525fffa446de6f7e9186cfb2"}, 77 | {file = "coverage-7.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ccd341521be3d1b3daeb41960ae94a5e87abe2f46f17224ba5d6f2b8398016cf"}, 78 | {file = "coverage-7.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fa497a8ab37784fbb20ab699c246053ac294d13fc7eb40ec007a5043ec91f8"}, 79 | {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1a93009cb80730c9bca5d6d4665494b725b6e8e157c1cb7f2db5b4b122ea562"}, 80 | {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:690db6517f09336559dc0b5f55342df62370a48f5469fabf502db2c6d1cffcd2"}, 81 | {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:09c3255458533cb76ef55da8cc49ffab9e33f083739c8bd4f58e79fecfe288f7"}, 82 | {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8ce1415194b4a6bd0cdcc3a1dfbf58b63f910dcb7330fe15bdff542c56949f87"}, 83 | {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b91cbc4b195444e7e258ba27ac33769c41b94967919f10037e6355e998af255c"}, 84 | {file = "coverage-7.4.4-cp310-cp310-win32.whl", hash = "sha256:598825b51b81c808cb6f078dcb972f96af96b078faa47af7dfcdf282835baa8d"}, 85 | {file = "coverage-7.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:09ef9199ed6653989ebbcaacc9b62b514bb63ea2f90256e71fea3ed74bd8ff6f"}, 86 | {file = "coverage-7.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f9f50e7ef2a71e2fae92774c99170eb8304e3fdf9c8c3c7ae9bab3e7229c5cf"}, 87 | {file = "coverage-7.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:623512f8ba53c422fcfb2ce68362c97945095b864cda94a92edbaf5994201083"}, 88 | {file = "coverage-7.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0513b9508b93da4e1716744ef6ebc507aff016ba115ffe8ecff744d1322a7b63"}, 89 | {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40209e141059b9370a2657c9b15607815359ab3ef9918f0196b6fccce8d3230f"}, 90 | {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a2b2b78c78293782fd3767d53e6474582f62443d0504b1554370bde86cc8227"}, 91 | {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:73bfb9c09951125d06ee473bed216e2c3742f530fc5acc1383883125de76d9cd"}, 92 | {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1f384c3cc76aeedce208643697fb3e8437604b512255de6d18dae3f27655a384"}, 93 | {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:54eb8d1bf7cacfbf2a3186019bcf01d11c666bd495ed18717162f7eb1e9dd00b"}, 94 | {file = "coverage-7.4.4-cp311-cp311-win32.whl", hash = "sha256:cac99918c7bba15302a2d81f0312c08054a3359eaa1929c7e4b26ebe41e9b286"}, 95 | {file = "coverage-7.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:b14706df8b2de49869ae03a5ccbc211f4041750cd4a66f698df89d44f4bd30ec"}, 96 | {file = "coverage-7.4.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:201bef2eea65e0e9c56343115ba3814e896afe6d36ffd37bab783261db430f76"}, 97 | {file = "coverage-7.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41c9c5f3de16b903b610d09650e5e27adbfa7f500302718c9ffd1c12cf9d6818"}, 98 | {file = "coverage-7.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d898fe162d26929b5960e4e138651f7427048e72c853607f2b200909794ed978"}, 99 | {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ea79bb50e805cd6ac058dfa3b5c8f6c040cb87fe83de10845857f5535d1db70"}, 100 | {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce4b94265ca988c3f8e479e741693d143026632672e3ff924f25fab50518dd51"}, 101 | {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:00838a35b882694afda09f85e469c96367daa3f3f2b097d846a7216993d37f4c"}, 102 | {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fdfafb32984684eb03c2d83e1e51f64f0906b11e64482df3c5db936ce3839d48"}, 103 | {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:69eb372f7e2ece89f14751fbcbe470295d73ed41ecd37ca36ed2eb47512a6ab9"}, 104 | {file = "coverage-7.4.4-cp312-cp312-win32.whl", hash = "sha256:137eb07173141545e07403cca94ab625cc1cc6bc4c1e97b6e3846270e7e1fea0"}, 105 | {file = "coverage-7.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:d71eec7d83298f1af3326ce0ff1d0ea83c7cb98f72b577097f9083b20bdaf05e"}, 106 | {file = "coverage-7.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d5ae728ff3b5401cc320d792866987e7e7e880e6ebd24433b70a33b643bb0384"}, 107 | {file = "coverage-7.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc4f1358cb0c78edef3ed237ef2c86056206bb8d9140e73b6b89fbcfcbdd40e1"}, 108 | {file = "coverage-7.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8130a2aa2acb8788e0b56938786c33c7c98562697bf9f4c7d6e8e5e3a0501e4a"}, 109 | {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf271892d13e43bc2b51e6908ec9a6a5094a4df1d8af0bfc360088ee6c684409"}, 110 | {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4cdc86d54b5da0df6d3d3a2f0b710949286094c3a6700c21e9015932b81447e"}, 111 | {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ae71e7ddb7a413dd60052e90528f2f65270aad4b509563af6d03d53e979feafd"}, 112 | {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:38dd60d7bf242c4ed5b38e094baf6401faa114fc09e9e6632374388a404f98e7"}, 113 | {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa5b1c1bfc28384f1f53b69a023d789f72b2e0ab1b3787aae16992a7ca21056c"}, 114 | {file = "coverage-7.4.4-cp38-cp38-win32.whl", hash = "sha256:dfa8fe35a0bb90382837b238fff375de15f0dcdb9ae68ff85f7a63649c98527e"}, 115 | {file = "coverage-7.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:b2991665420a803495e0b90a79233c1433d6ed77ef282e8e152a324bbbc5e0c8"}, 116 | {file = "coverage-7.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b799445b9f7ee8bf299cfaed6f5b226c0037b74886a4e11515e569b36fe310d"}, 117 | {file = "coverage-7.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b4d33f418f46362995f1e9d4f3a35a1b6322cb959c31d88ae56b0298e1c22357"}, 118 | {file = "coverage-7.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadacf9a2f407a4688d700e4ebab33a7e2e408f2ca04dbf4aef17585389eff3e"}, 119 | {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c95949560050d04d46b919301826525597f07b33beba6187d04fa64d47ac82e"}, 120 | {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff7687ca3d7028d8a5f0ebae95a6e4827c5616b31a4ee1192bdfde697db110d4"}, 121 | {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5fc1de20b2d4a061b3df27ab9b7c7111e9a710f10dc2b84d33a4ab25065994ec"}, 122 | {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c74880fc64d4958159fbd537a091d2a585448a8f8508bf248d72112723974cbd"}, 123 | {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:742a76a12aa45b44d236815d282b03cfb1de3b4323f3e4ec933acfae08e54ade"}, 124 | {file = "coverage-7.4.4-cp39-cp39-win32.whl", hash = "sha256:d89d7b2974cae412400e88f35d86af72208e1ede1a541954af5d944a8ba46c57"}, 125 | {file = "coverage-7.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:9ca28a302acb19b6af89e90f33ee3e1906961f94b54ea37de6737b7ca9d8827c"}, 126 | {file = "coverage-7.4.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:b2c5edc4ac10a7ef6605a966c58929ec6c1bd0917fb8c15cb3363f65aa40e677"}, 127 | {file = "coverage-7.4.4.tar.gz", hash = "sha256:c901df83d097649e257e803be22592aedfd5182f07b3cc87d640bbb9afd50f49"}, 128 | ] 129 | 130 | [package.dependencies] 131 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 132 | 133 | [package.extras] 134 | toml = ["tomli"] 135 | 136 | [[package]] 137 | name = "distlib" 138 | version = "0.3.8" 139 | description = "Distribution utilities" 140 | optional = false 141 | python-versions = "*" 142 | files = [ 143 | {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, 144 | {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, 145 | ] 146 | 147 | [[package]] 148 | name = "exceptiongroup" 149 | version = "1.2.0" 150 | description = "Backport of PEP 654 (exception groups)" 151 | optional = false 152 | python-versions = ">=3.7" 153 | files = [ 154 | {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, 155 | {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, 156 | ] 157 | 158 | [package.extras] 159 | test = ["pytest (>=6)"] 160 | 161 | [[package]] 162 | name = "filelock" 163 | version = "3.13.3" 164 | description = "A platform independent file lock." 165 | optional = false 166 | python-versions = ">=3.8" 167 | files = [ 168 | {file = "filelock-3.13.3-py3-none-any.whl", hash = "sha256:5ffa845303983e7a0b7ae17636509bc97997d58afeafa72fb141a17b152284cb"}, 169 | {file = "filelock-3.13.3.tar.gz", hash = "sha256:a79895a25bbefdf55d1a2a0a80968f7dbb28edcd6d4234a0afb3f37ecde4b546"}, 170 | ] 171 | 172 | [package.extras] 173 | docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] 174 | testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] 175 | typing = ["typing-extensions (>=4.8)"] 176 | 177 | [[package]] 178 | name = "h11" 179 | version = "0.14.0" 180 | description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" 181 | optional = false 182 | python-versions = ">=3.7" 183 | files = [ 184 | {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, 185 | {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, 186 | ] 187 | 188 | [[package]] 189 | name = "httpcore" 190 | version = "1.0.5" 191 | description = "A minimal low-level HTTP client." 192 | optional = false 193 | python-versions = ">=3.8" 194 | files = [ 195 | {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, 196 | {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, 197 | ] 198 | 199 | [package.dependencies] 200 | certifi = "*" 201 | h11 = ">=0.13,<0.15" 202 | 203 | [package.extras] 204 | asyncio = ["anyio (>=4.0,<5.0)"] 205 | http2 = ["h2 (>=3,<5)"] 206 | socks = ["socksio (==1.*)"] 207 | trio = ["trio (>=0.22.0,<0.26.0)"] 208 | 209 | [[package]] 210 | name = "httpx" 211 | version = "0.27.0" 212 | description = "The next generation HTTP client." 213 | optional = false 214 | python-versions = ">=3.8" 215 | files = [ 216 | {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, 217 | {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, 218 | ] 219 | 220 | [package.dependencies] 221 | anyio = "*" 222 | certifi = "*" 223 | httpcore = "==1.*" 224 | idna = "*" 225 | sniffio = "*" 226 | 227 | [package.extras] 228 | brotli = ["brotli", "brotlicffi"] 229 | cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] 230 | http2 = ["h2 (>=3,<5)"] 231 | socks = ["socksio (==1.*)"] 232 | 233 | [[package]] 234 | name = "identify" 235 | version = "2.5.35" 236 | description = "File identification library for Python" 237 | optional = false 238 | python-versions = ">=3.8" 239 | files = [ 240 | {file = "identify-2.5.35-py2.py3-none-any.whl", hash = "sha256:c4de0081837b211594f8e877a6b4fad7ca32bbfc1a9307fdd61c28bfe923f13e"}, 241 | {file = "identify-2.5.35.tar.gz", hash = "sha256:10a7ca245cfcd756a554a7288159f72ff105ad233c7c4b9c6f0f4d108f5f6791"}, 242 | ] 243 | 244 | [package.extras] 245 | license = ["ukkonen"] 246 | 247 | [[package]] 248 | name = "idna" 249 | version = "3.6" 250 | description = "Internationalized Domain Names in Applications (IDNA)" 251 | optional = false 252 | python-versions = ">=3.5" 253 | files = [ 254 | {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, 255 | {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, 256 | ] 257 | 258 | [[package]] 259 | name = "iniconfig" 260 | version = "2.0.0" 261 | description = "brain-dead simple config-ini parsing" 262 | optional = false 263 | python-versions = ">=3.7" 264 | files = [ 265 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 266 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 267 | ] 268 | 269 | [[package]] 270 | name = "instagram-private-api" 271 | version = "1.6.0.0" 272 | description = "A client interface for the private Instagram API." 273 | optional = false 274 | python-versions = "*" 275 | files = [ 276 | {file = "instagram_private_api-1.6.0.0-py3-none-any.whl", hash = "sha256:5f9403fbd359764b2d070fa8ac22bc4546ec5a53c45676bcddafa66dc74d2f07"}, 277 | {file = "instagram_private_api-1.6.0.0.tar.gz", hash = "sha256:dfe0c2cb5aa881b98b2e428c3b02fa664a52af3f2d2354c19c4a8967cedf5d8e"}, 278 | ] 279 | 280 | [[package]] 281 | name = "mypy" 282 | version = "1.9.0" 283 | description = "Optional static typing for Python" 284 | optional = false 285 | python-versions = ">=3.8" 286 | files = [ 287 | {file = "mypy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f"}, 288 | {file = "mypy-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed"}, 289 | {file = "mypy-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150"}, 290 | {file = "mypy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374"}, 291 | {file = "mypy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03"}, 292 | {file = "mypy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3"}, 293 | {file = "mypy-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc"}, 294 | {file = "mypy-1.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129"}, 295 | {file = "mypy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612"}, 296 | {file = "mypy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3"}, 297 | {file = "mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd"}, 298 | {file = "mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6"}, 299 | {file = "mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185"}, 300 | {file = "mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913"}, 301 | {file = "mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"}, 302 | {file = "mypy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b"}, 303 | {file = "mypy-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2"}, 304 | {file = "mypy-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e"}, 305 | {file = "mypy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04"}, 306 | {file = "mypy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89"}, 307 | {file = "mypy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02"}, 308 | {file = "mypy-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4"}, 309 | {file = "mypy-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d"}, 310 | {file = "mypy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf"}, 311 | {file = "mypy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9"}, 312 | {file = "mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e"}, 313 | {file = "mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974"}, 314 | ] 315 | 316 | [package.dependencies] 317 | mypy-extensions = ">=1.0.0" 318 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 319 | typing-extensions = ">=4.1.0" 320 | 321 | [package.extras] 322 | dmypy = ["psutil (>=4.0)"] 323 | install-types = ["pip"] 324 | mypyc = ["setuptools (>=50)"] 325 | reports = ["lxml"] 326 | 327 | [[package]] 328 | name = "mypy-extensions" 329 | version = "1.0.0" 330 | description = "Type system extensions for programs checked with the mypy type checker." 331 | optional = false 332 | python-versions = ">=3.5" 333 | files = [ 334 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 335 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 336 | ] 337 | 338 | [[package]] 339 | name = "nodeenv" 340 | version = "1.8.0" 341 | description = "Node.js virtual environment builder" 342 | optional = false 343 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" 344 | files = [ 345 | {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, 346 | {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, 347 | ] 348 | 349 | [package.dependencies] 350 | setuptools = "*" 351 | 352 | [[package]] 353 | name = "packaging" 354 | version = "24.0" 355 | description = "Core utilities for Python packages" 356 | optional = false 357 | python-versions = ">=3.7" 358 | files = [ 359 | {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, 360 | {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, 361 | ] 362 | 363 | [[package]] 364 | name = "pillow" 365 | version = "10.2.0" 366 | description = "Python Imaging Library (Fork)" 367 | optional = false 368 | python-versions = ">=3.8" 369 | files = [ 370 | {file = "pillow-10.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:7823bdd049099efa16e4246bdf15e5a13dbb18a51b68fa06d6c1d4d8b99a796e"}, 371 | {file = "pillow-10.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:83b2021f2ade7d1ed556bc50a399127d7fb245e725aa0113ebd05cfe88aaf588"}, 372 | {file = "pillow-10.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fad5ff2f13d69b7e74ce5b4ecd12cc0ec530fcee76356cac6742785ff71c452"}, 373 | {file = "pillow-10.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da2b52b37dad6d9ec64e653637a096905b258d2fc2b984c41ae7d08b938a67e4"}, 374 | {file = "pillow-10.2.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:47c0995fc4e7f79b5cfcab1fc437ff2890b770440f7696a3ba065ee0fd496563"}, 375 | {file = "pillow-10.2.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:322bdf3c9b556e9ffb18f93462e5f749d3444ce081290352c6070d014c93feb2"}, 376 | {file = "pillow-10.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:51f1a1bffc50e2e9492e87d8e09a17c5eea8409cda8d3f277eb6edc82813c17c"}, 377 | {file = "pillow-10.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69ffdd6120a4737710a9eee73e1d2e37db89b620f702754b8f6e62594471dee0"}, 378 | {file = "pillow-10.2.0-cp310-cp310-win32.whl", hash = "sha256:c6dafac9e0f2b3c78df97e79af707cdc5ef8e88208d686a4847bab8266870023"}, 379 | {file = "pillow-10.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:aebb6044806f2e16ecc07b2a2637ee1ef67a11840a66752751714a0d924adf72"}, 380 | {file = "pillow-10.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:7049e301399273a0136ff39b84c3678e314f2158f50f517bc50285fb5ec847ad"}, 381 | {file = "pillow-10.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35bb52c37f256f662abdfa49d2dfa6ce5d93281d323a9af377a120e89a9eafb5"}, 382 | {file = "pillow-10.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c23f307202661071d94b5e384e1e1dc7dfb972a28a2310e4ee16103e66ddb67"}, 383 | {file = "pillow-10.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:773efe0603db30c281521a7c0214cad7836c03b8ccff897beae9b47c0b657d61"}, 384 | {file = "pillow-10.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11fa2e5984b949b0dd6d7a94d967743d87c577ff0b83392f17cb3990d0d2fd6e"}, 385 | {file = "pillow-10.2.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:716d30ed977be8b37d3ef185fecb9e5a1d62d110dfbdcd1e2a122ab46fddb03f"}, 386 | {file = "pillow-10.2.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a086c2af425c5f62a65e12fbf385f7c9fcb8f107d0849dba5839461a129cf311"}, 387 | {file = "pillow-10.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c8de2789052ed501dd829e9cae8d3dcce7acb4777ea4a479c14521c942d395b1"}, 388 | {file = "pillow-10.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:609448742444d9290fd687940ac0b57fb35e6fd92bdb65386e08e99af60bf757"}, 389 | {file = "pillow-10.2.0-cp311-cp311-win32.whl", hash = "sha256:823ef7a27cf86df6597fa0671066c1b596f69eba53efa3d1e1cb8b30f3533068"}, 390 | {file = "pillow-10.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:1da3b2703afd040cf65ec97efea81cfba59cdbed9c11d8efc5ab09df9509fc56"}, 391 | {file = "pillow-10.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:edca80cbfb2b68d7b56930b84a0e45ae1694aeba0541f798e908a49d66b837f1"}, 392 | {file = "pillow-10.2.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:1b5e1b74d1bd1b78bc3477528919414874748dd363e6272efd5abf7654e68bef"}, 393 | {file = "pillow-10.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0eae2073305f451d8ecacb5474997c08569fb4eb4ac231ffa4ad7d342fdc25ac"}, 394 | {file = "pillow-10.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7c2286c23cd350b80d2fc9d424fc797575fb16f854b831d16fd47ceec078f2c"}, 395 | {file = "pillow-10.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e23412b5c41e58cec602f1135c57dfcf15482013ce6e5f093a86db69646a5aa"}, 396 | {file = "pillow-10.2.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:52a50aa3fb3acb9cf7213573ef55d31d6eca37f5709c69e6858fe3bc04a5c2a2"}, 397 | {file = "pillow-10.2.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:127cee571038f252a552760076407f9cff79761c3d436a12af6000cd182a9d04"}, 398 | {file = "pillow-10.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:8d12251f02d69d8310b046e82572ed486685c38f02176bd08baf216746eb947f"}, 399 | {file = "pillow-10.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:54f1852cd531aa981bc0965b7d609f5f6cc8ce8c41b1139f6ed6b3c54ab82bfb"}, 400 | {file = "pillow-10.2.0-cp312-cp312-win32.whl", hash = "sha256:257d8788df5ca62c980314053197f4d46eefedf4e6175bc9412f14412ec4ea2f"}, 401 | {file = "pillow-10.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:154e939c5f0053a383de4fd3d3da48d9427a7e985f58af8e94d0b3c9fcfcf4f9"}, 402 | {file = "pillow-10.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:f379abd2f1e3dddb2b61bc67977a6b5a0a3f7485538bcc6f39ec76163891ee48"}, 403 | {file = "pillow-10.2.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8373c6c251f7ef8bda6675dd6d2b3a0fcc31edf1201266b5cf608b62a37407f9"}, 404 | {file = "pillow-10.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:870ea1ada0899fd0b79643990809323b389d4d1d46c192f97342eeb6ee0b8483"}, 405 | {file = "pillow-10.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4b6b1e20608493548b1f32bce8cca185bf0480983890403d3b8753e44077129"}, 406 | {file = "pillow-10.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3031709084b6e7852d00479fd1d310b07d0ba82765f973b543c8af5061cf990e"}, 407 | {file = "pillow-10.2.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:3ff074fc97dd4e80543a3e91f69d58889baf2002b6be64347ea8cf5533188213"}, 408 | {file = "pillow-10.2.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:cb4c38abeef13c61d6916f264d4845fab99d7b711be96c326b84df9e3e0ff62d"}, 409 | {file = "pillow-10.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b1b3020d90c2d8e1dae29cf3ce54f8094f7938460fb5ce8bc5c01450b01fbaf6"}, 410 | {file = "pillow-10.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:170aeb00224ab3dc54230c797f8404507240dd868cf52066f66a41b33169bdbe"}, 411 | {file = "pillow-10.2.0-cp38-cp38-win32.whl", hash = "sha256:c4225f5220f46b2fde568c74fca27ae9771536c2e29d7c04f4fb62c83275ac4e"}, 412 | {file = "pillow-10.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:0689b5a8c5288bc0504d9fcee48f61a6a586b9b98514d7d29b840143d6734f39"}, 413 | {file = "pillow-10.2.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:b792a349405fbc0163190fde0dc7b3fef3c9268292586cf5645598b48e63dc67"}, 414 | {file = "pillow-10.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c570f24be1e468e3f0ce7ef56a89a60f0e05b30a3669a459e419c6eac2c35364"}, 415 | {file = "pillow-10.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8ecd059fdaf60c1963c58ceb8997b32e9dc1b911f5da5307aab614f1ce5c2fb"}, 416 | {file = "pillow-10.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c365fd1703040de1ec284b176d6af5abe21b427cb3a5ff68e0759e1e313a5e7e"}, 417 | {file = "pillow-10.2.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:70c61d4c475835a19b3a5aa42492409878bbca7438554a1f89d20d58a7c75c01"}, 418 | {file = "pillow-10.2.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b6f491cdf80ae540738859d9766783e3b3c8e5bd37f5dfa0b76abdecc5081f13"}, 419 | {file = "pillow-10.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d189550615b4948f45252d7f005e53c2040cea1af5b60d6f79491a6e147eef7"}, 420 | {file = "pillow-10.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:49d9ba1ed0ef3e061088cd1e7538a0759aab559e2e0a80a36f9fd9d8c0c21591"}, 421 | {file = "pillow-10.2.0-cp39-cp39-win32.whl", hash = "sha256:babf5acfede515f176833ed6028754cbcd0d206f7f614ea3447d67c33be12516"}, 422 | {file = "pillow-10.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:0304004f8067386b477d20a518b50f3fa658a28d44e4116970abfcd94fac34a8"}, 423 | {file = "pillow-10.2.0-cp39-cp39-win_arm64.whl", hash = "sha256:0fb3e7fc88a14eacd303e90481ad983fd5b69c761e9e6ef94c983f91025da869"}, 424 | {file = "pillow-10.2.0-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:322209c642aabdd6207517e9739c704dc9f9db943015535783239022002f054a"}, 425 | {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eedd52442c0a5ff4f887fab0c1c0bb164d8635b32c894bc1faf4c618dd89df2"}, 426 | {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb28c753fd5eb3dd859b4ee95de66cc62af91bcff5db5f2571d32a520baf1f04"}, 427 | {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:33870dc4653c5017bf4c8873e5488d8f8d5f8935e2f1fb9a2208c47cdd66efd2"}, 428 | {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3c31822339516fb3c82d03f30e22b1d038da87ef27b6a78c9549888f8ceda39a"}, 429 | {file = "pillow-10.2.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a2b56ba36e05f973d450582fb015594aaa78834fefe8dfb8fcd79b93e64ba4c6"}, 430 | {file = "pillow-10.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d8e6aeb9201e655354b3ad049cb77d19813ad4ece0df1249d3c793de3774f8c7"}, 431 | {file = "pillow-10.2.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:2247178effb34a77c11c0e8ac355c7a741ceca0a732b27bf11e747bbc950722f"}, 432 | {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15587643b9e5eb26c48e49a7b33659790d28f190fc514a322d55da2fb5c2950e"}, 433 | {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753cd8f2086b2b80180d9b3010dd4ed147efc167c90d3bf593fe2af21265e5a5"}, 434 | {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7c8f97e8e7a9009bcacbe3766a36175056c12f9a44e6e6f2d5caad06dcfbf03b"}, 435 | {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d1b35bcd6c5543b9cb547dee3150c93008f8dd0f1fef78fc0cd2b141c5baf58a"}, 436 | {file = "pillow-10.2.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fe4c15f6c9285dc54ce6553a3ce908ed37c8f3825b5a51a15c91442bb955b868"}, 437 | {file = "pillow-10.2.0.tar.gz", hash = "sha256:e87f0b2c78157e12d7686b27d63c070fd65d994e8ddae6f328e0dcf4a0cd007e"}, 438 | ] 439 | 440 | [package.extras] 441 | docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-removed-in", "sphinxext-opengraph"] 442 | fpx = ["olefile"] 443 | mic = ["olefile"] 444 | tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] 445 | typing = ["typing-extensions"] 446 | xmp = ["defusedxml"] 447 | 448 | [[package]] 449 | name = "platformdirs" 450 | version = "4.2.0" 451 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 452 | optional = false 453 | python-versions = ">=3.8" 454 | files = [ 455 | {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, 456 | {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, 457 | ] 458 | 459 | [package.extras] 460 | docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] 461 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] 462 | 463 | [[package]] 464 | name = "pluggy" 465 | version = "1.4.0" 466 | description = "plugin and hook calling mechanisms for python" 467 | optional = false 468 | python-versions = ">=3.8" 469 | files = [ 470 | {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, 471 | {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, 472 | ] 473 | 474 | [package.extras] 475 | dev = ["pre-commit", "tox"] 476 | testing = ["pytest", "pytest-benchmark"] 477 | 478 | [[package]] 479 | name = "pre-commit" 480 | version = "2.21.0" 481 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 482 | optional = false 483 | python-versions = ">=3.7" 484 | files = [ 485 | {file = "pre_commit-2.21.0-py2.py3-none-any.whl", hash = "sha256:e2f91727039fc39a92f58a588a25b87f936de6567eed4f0e673e0507edc75bad"}, 486 | {file = "pre_commit-2.21.0.tar.gz", hash = "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658"}, 487 | ] 488 | 489 | [package.dependencies] 490 | cfgv = ">=2.0.0" 491 | identify = ">=1.0.0" 492 | nodeenv = ">=0.11.1" 493 | pyyaml = ">=5.1" 494 | virtualenv = ">=20.10.0" 495 | 496 | [[package]] 497 | name = "pydantic" 498 | version = "2.6.4" 499 | description = "Data validation using Python type hints" 500 | optional = false 501 | python-versions = ">=3.8" 502 | files = [ 503 | {file = "pydantic-2.6.4-py3-none-any.whl", hash = "sha256:cc46fce86607580867bdc3361ad462bab9c222ef042d3da86f2fb333e1d916c5"}, 504 | {file = "pydantic-2.6.4.tar.gz", hash = "sha256:b1704e0847db01817624a6b86766967f552dd9dbf3afba4004409f908dcc84e6"}, 505 | ] 506 | 507 | [package.dependencies] 508 | annotated-types = ">=0.4.0" 509 | pydantic-core = "2.16.3" 510 | typing-extensions = ">=4.6.1" 511 | 512 | [package.extras] 513 | email = ["email-validator (>=2.0.0)"] 514 | 515 | [[package]] 516 | name = "pydantic-core" 517 | version = "2.16.3" 518 | description = "" 519 | optional = false 520 | python-versions = ">=3.8" 521 | files = [ 522 | {file = "pydantic_core-2.16.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:75b81e678d1c1ede0785c7f46690621e4c6e63ccd9192af1f0bd9d504bbb6bf4"}, 523 | {file = "pydantic_core-2.16.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9c865a7ee6f93783bd5d781af5a4c43dadc37053a5b42f7d18dc019f8c9d2bd1"}, 524 | {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:162e498303d2b1c036b957a1278fa0899d02b2842f1ff901b6395104c5554a45"}, 525 | {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2f583bd01bbfbff4eaee0868e6fc607efdfcc2b03c1c766b06a707abbc856187"}, 526 | {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b926dd38db1519ed3043a4de50214e0d600d404099c3392f098a7f9d75029ff8"}, 527 | {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:716b542728d4c742353448765aa7cdaa519a7b82f9564130e2b3f6766018c9ec"}, 528 | {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ad7f7ee1a13d9cb49d8198cd7d7e3aa93e425f371a68235f784e99741561f"}, 529 | {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bd87f48924f360e5d1c5f770d6155ce0e7d83f7b4e10c2f9ec001c73cf475c99"}, 530 | {file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0df446663464884297c793874573549229f9eca73b59360878f382a0fc085979"}, 531 | {file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4df8a199d9f6afc5ae9a65f8f95ee52cae389a8c6b20163762bde0426275b7db"}, 532 | {file = "pydantic_core-2.16.3-cp310-none-win32.whl", hash = "sha256:456855f57b413f077dff513a5a28ed838dbbb15082ba00f80750377eed23d132"}, 533 | {file = "pydantic_core-2.16.3-cp310-none-win_amd64.whl", hash = "sha256:732da3243e1b8d3eab8c6ae23ae6a58548849d2e4a4e03a1924c8ddf71a387cb"}, 534 | {file = "pydantic_core-2.16.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:519ae0312616026bf4cedc0fe459e982734f3ca82ee8c7246c19b650b60a5ee4"}, 535 | {file = "pydantic_core-2.16.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b3992a322a5617ded0a9f23fd06dbc1e4bd7cf39bc4ccf344b10f80af58beacd"}, 536 | {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d62da299c6ecb04df729e4b5c52dc0d53f4f8430b4492b93aa8de1f541c4aac"}, 537 | {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2acca2be4bb2f2147ada8cac612f8a98fc09f41c89f87add7256ad27332c2fda"}, 538 | {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b662180108c55dfbf1280d865b2d116633d436cfc0bba82323554873967b340"}, 539 | {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e7c6ed0dc9d8e65f24f5824291550139fe6f37fac03788d4580da0d33bc00c97"}, 540 | {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1bb0827f56654b4437955555dc3aeeebeddc47c2d7ed575477f082622c49e"}, 541 | {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e56f8186d6210ac7ece503193ec84104da7ceb98f68ce18c07282fcc2452e76f"}, 542 | {file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:936e5db01dd49476fa8f4383c259b8b1303d5dd5fb34c97de194560698cc2c5e"}, 543 | {file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:33809aebac276089b78db106ee692bdc9044710e26f24a9a2eaa35a0f9fa70ba"}, 544 | {file = "pydantic_core-2.16.3-cp311-none-win32.whl", hash = "sha256:ded1c35f15c9dea16ead9bffcde9bb5c7c031bff076355dc58dcb1cb436c4721"}, 545 | {file = "pydantic_core-2.16.3-cp311-none-win_amd64.whl", hash = "sha256:d89ca19cdd0dd5f31606a9329e309d4fcbb3df860960acec32630297d61820df"}, 546 | {file = "pydantic_core-2.16.3-cp311-none-win_arm64.whl", hash = "sha256:6162f8d2dc27ba21027f261e4fa26f8bcb3cf9784b7f9499466a311ac284b5b9"}, 547 | {file = "pydantic_core-2.16.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f56ae86b60ea987ae8bcd6654a887238fd53d1384f9b222ac457070b7ac4cff"}, 548 | {file = "pydantic_core-2.16.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9bd22a2a639e26171068f8ebb5400ce2c1bc7d17959f60a3b753ae13c632975"}, 549 | {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4204e773b4b408062960e65468d5346bdfe139247ee5f1ca2a378983e11388a2"}, 550 | {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f651dd19363c632f4abe3480a7c87a9773be27cfe1341aef06e8759599454120"}, 551 | {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aaf09e615a0bf98d406657e0008e4a8701b11481840be7d31755dc9f97c44053"}, 552 | {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e47755d8152c1ab5b55928ab422a76e2e7b22b5ed8e90a7d584268dd49e9c6b"}, 553 | {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:500960cb3a0543a724a81ba859da816e8cf01b0e6aaeedf2c3775d12ee49cade"}, 554 | {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf6204fe865da605285c34cf1172879d0314ff267b1c35ff59de7154f35fdc2e"}, 555 | {file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d33dd21f572545649f90c38c227cc8631268ba25c460b5569abebdd0ec5974ca"}, 556 | {file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:49d5d58abd4b83fb8ce763be7794d09b2f50f10aa65c0f0c1696c677edeb7cbf"}, 557 | {file = "pydantic_core-2.16.3-cp312-none-win32.whl", hash = "sha256:f53aace168a2a10582e570b7736cc5bef12cae9cf21775e3eafac597e8551fbe"}, 558 | {file = "pydantic_core-2.16.3-cp312-none-win_amd64.whl", hash = "sha256:0d32576b1de5a30d9a97f300cc6a3f4694c428d956adbc7e6e2f9cad279e45ed"}, 559 | {file = "pydantic_core-2.16.3-cp312-none-win_arm64.whl", hash = "sha256:ec08be75bb268473677edb83ba71e7e74b43c008e4a7b1907c6d57e940bf34b6"}, 560 | {file = "pydantic_core-2.16.3-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:b1f6f5938d63c6139860f044e2538baeee6f0b251a1816e7adb6cbce106a1f01"}, 561 | {file = "pydantic_core-2.16.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2a1ef6a36fdbf71538142ed604ad19b82f67b05749512e47f247a6ddd06afdc7"}, 562 | {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:704d35ecc7e9c31d48926150afada60401c55efa3b46cd1ded5a01bdffaf1d48"}, 563 | {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d937653a696465677ed583124b94a4b2d79f5e30b2c46115a68e482c6a591c8a"}, 564 | {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9803edf8e29bd825f43481f19c37f50d2b01899448273b3a7758441b512acf8"}, 565 | {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72282ad4892a9fb2da25defeac8c2e84352c108705c972db82ab121d15f14e6d"}, 566 | {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f752826b5b8361193df55afcdf8ca6a57d0232653494ba473630a83ba50d8c9"}, 567 | {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4384a8f68ddb31a0b0c3deae88765f5868a1b9148939c3f4121233314ad5532c"}, 568 | {file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a4b2bf78342c40b3dc830880106f54328928ff03e357935ad26c7128bbd66ce8"}, 569 | {file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:13dcc4802961b5f843a9385fc821a0b0135e8c07fc3d9949fd49627c1a5e6ae5"}, 570 | {file = "pydantic_core-2.16.3-cp38-none-win32.whl", hash = "sha256:e3e70c94a0c3841e6aa831edab1619ad5c511199be94d0c11ba75fe06efe107a"}, 571 | {file = "pydantic_core-2.16.3-cp38-none-win_amd64.whl", hash = "sha256:ecdf6bf5f578615f2e985a5e1f6572e23aa632c4bd1dc67f8f406d445ac115ed"}, 572 | {file = "pydantic_core-2.16.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:bda1ee3e08252b8d41fa5537413ffdddd58fa73107171a126d3b9ff001b9b820"}, 573 | {file = "pydantic_core-2.16.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:21b888c973e4f26b7a96491c0965a8a312e13be108022ee510248fe379a5fa23"}, 574 | {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be0ec334369316fa73448cc8c982c01e5d2a81c95969d58b8f6e272884df0074"}, 575 | {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5b6079cc452a7c53dd378c6f881ac528246b3ac9aae0f8eef98498a75657805"}, 576 | {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ee8d5f878dccb6d499ba4d30d757111847b6849ae07acdd1205fffa1fc1253c"}, 577 | {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7233d65d9d651242a68801159763d09e9ec96e8a158dbf118dc090cd77a104c9"}, 578 | {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6119dc90483a5cb50a1306adb8d52c66e447da88ea44f323e0ae1a5fcb14256"}, 579 | {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:578114bc803a4c1ff9946d977c221e4376620a46cf78da267d946397dc9514a8"}, 580 | {file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d8f99b147ff3fcf6b3cc60cb0c39ea443884d5559a30b1481e92495f2310ff2b"}, 581 | {file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4ac6b4ce1e7283d715c4b729d8f9dab9627586dafce81d9eaa009dd7f25dd972"}, 582 | {file = "pydantic_core-2.16.3-cp39-none-win32.whl", hash = "sha256:e7774b570e61cb998490c5235740d475413a1f6de823169b4cf94e2fe9e9f6b2"}, 583 | {file = "pydantic_core-2.16.3-cp39-none-win_amd64.whl", hash = "sha256:9091632a25b8b87b9a605ec0e61f241c456e9248bfdcf7abdf344fdb169c81cf"}, 584 | {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:36fa178aacbc277bc6b62a2c3da95226520da4f4e9e206fdf076484363895d2c"}, 585 | {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:dcca5d2bf65c6fb591fff92da03f94cd4f315972f97c21975398bd4bd046854a"}, 586 | {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a72fb9963cba4cd5793854fd12f4cfee731e86df140f59ff52a49b3552db241"}, 587 | {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60cc1a081f80a2105a59385b92d82278b15d80ebb3adb200542ae165cd7d183"}, 588 | {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cbcc558401de90a746d02ef330c528f2e668c83350f045833543cd57ecead1ad"}, 589 | {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:fee427241c2d9fb7192b658190f9f5fd6dfe41e02f3c1489d2ec1e6a5ab1e04a"}, 590 | {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f4cb85f693044e0f71f394ff76c98ddc1bc0953e48c061725e540396d5c8a2e1"}, 591 | {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b29eeb887aa931c2fcef5aa515d9d176d25006794610c264ddc114c053bf96fe"}, 592 | {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a425479ee40ff021f8216c9d07a6a3b54b31c8267c6e17aa88b70d7ebd0e5e5b"}, 593 | {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5c5cbc703168d1b7a838668998308018a2718c2130595e8e190220238addc96f"}, 594 | {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b6add4c0b39a513d323d3b93bc173dac663c27b99860dd5bf491b240d26137"}, 595 | {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f76ee558751746d6a38f89d60b6228fa174e5172d143886af0f85aa306fd89"}, 596 | {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00ee1c97b5364b84cb0bd82e9bbf645d5e2871fb8c58059d158412fee2d33d8a"}, 597 | {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:287073c66748f624be4cef893ef9174e3eb88fe0b8a78dc22e88eca4bc357ca6"}, 598 | {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ed25e1835c00a332cb10c683cd39da96a719ab1dfc08427d476bce41b92531fc"}, 599 | {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:86b3d0033580bd6bbe07590152007275bd7af95f98eaa5bd36f3da219dcd93da"}, 600 | {file = "pydantic_core-2.16.3.tar.gz", hash = "sha256:1cac689f80a3abab2d3c0048b29eea5751114054f032a941a32de4c852c59cad"}, 601 | ] 602 | 603 | [package.dependencies] 604 | typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" 605 | 606 | [[package]] 607 | name = "pytest" 608 | version = "7.4.4" 609 | description = "pytest: simple powerful testing with Python" 610 | optional = false 611 | python-versions = ">=3.7" 612 | files = [ 613 | {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, 614 | {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, 615 | ] 616 | 617 | [package.dependencies] 618 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 619 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 620 | iniconfig = "*" 621 | packaging = "*" 622 | pluggy = ">=0.12,<2.0" 623 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 624 | 625 | [package.extras] 626 | testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 627 | 628 | [[package]] 629 | name = "pytest-cov" 630 | version = "4.1.0" 631 | description = "Pytest plugin for measuring coverage." 632 | optional = false 633 | python-versions = ">=3.7" 634 | files = [ 635 | {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, 636 | {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, 637 | ] 638 | 639 | [package.dependencies] 640 | coverage = {version = ">=5.2.1", extras = ["toml"]} 641 | pytest = ">=4.6" 642 | 643 | [package.extras] 644 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] 645 | 646 | [[package]] 647 | name = "pytest-mock" 648 | version = "3.14.0" 649 | description = "Thin-wrapper around the mock package for easier use with pytest" 650 | optional = false 651 | python-versions = ">=3.8" 652 | files = [ 653 | {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, 654 | {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, 655 | ] 656 | 657 | [package.dependencies] 658 | pytest = ">=6.2.5" 659 | 660 | [package.extras] 661 | dev = ["pre-commit", "pytest-asyncio", "tox"] 662 | 663 | [[package]] 664 | name = "pyyaml" 665 | version = "6.0.1" 666 | description = "YAML parser and emitter for Python" 667 | optional = false 668 | python-versions = ">=3.6" 669 | files = [ 670 | {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, 671 | {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, 672 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, 673 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, 674 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, 675 | {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, 676 | {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, 677 | {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, 678 | {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, 679 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, 680 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, 681 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, 682 | {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, 683 | {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, 684 | {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, 685 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, 686 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, 687 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, 688 | {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, 689 | {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, 690 | {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, 691 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, 692 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, 693 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, 694 | {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, 695 | {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, 696 | {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, 697 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, 698 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, 699 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, 700 | {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, 701 | {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, 702 | {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, 703 | {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, 704 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, 705 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, 706 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, 707 | {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, 708 | {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, 709 | {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, 710 | ] 711 | 712 | [[package]] 713 | name = "ruff" 714 | version = "0.3.4" 715 | description = "An extremely fast Python linter and code formatter, written in Rust." 716 | optional = false 717 | python-versions = ">=3.7" 718 | files = [ 719 | {file = "ruff-0.3.4-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:60c870a7d46efcbc8385d27ec07fe534ac32f3b251e4fc44b3cbfd9e09609ef4"}, 720 | {file = "ruff-0.3.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6fc14fa742e1d8f24910e1fff0bd5e26d395b0e0e04cc1b15c7c5e5fe5b4af91"}, 721 | {file = "ruff-0.3.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3ee7880f653cc03749a3bfea720cf2a192e4f884925b0cf7eecce82f0ce5854"}, 722 | {file = "ruff-0.3.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf133dd744f2470b347f602452a88e70dadfbe0fcfb5fd46e093d55da65f82f7"}, 723 | {file = "ruff-0.3.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f3860057590e810c7ffea75669bdc6927bfd91e29b4baa9258fd48b540a4365"}, 724 | {file = "ruff-0.3.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:986f2377f7cf12efac1f515fc1a5b753c000ed1e0a6de96747cdf2da20a1b369"}, 725 | {file = "ruff-0.3.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fd98e85869603e65f554fdc5cddf0712e352fe6e61d29d5a6fe087ec82b76c"}, 726 | {file = "ruff-0.3.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64abeed785dad51801b423fa51840b1764b35d6c461ea8caef9cf9e5e5ab34d9"}, 727 | {file = "ruff-0.3.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df52972138318bc7546d92348a1ee58449bc3f9eaf0db278906eb511889c4b50"}, 728 | {file = "ruff-0.3.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:98e98300056445ba2cc27d0b325fd044dc17fcc38e4e4d2c7711585bd0a958ed"}, 729 | {file = "ruff-0.3.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:519cf6a0ebed244dce1dc8aecd3dc99add7a2ee15bb68cf19588bb5bf58e0488"}, 730 | {file = "ruff-0.3.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bb0acfb921030d00070539c038cd24bb1df73a2981e9f55942514af8b17be94e"}, 731 | {file = "ruff-0.3.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cf187a7e7098233d0d0c71175375c5162f880126c4c716fa28a8ac418dcf3378"}, 732 | {file = "ruff-0.3.4-py3-none-win32.whl", hash = "sha256:af27ac187c0a331e8ef91d84bf1c3c6a5dea97e912a7560ac0cef25c526a4102"}, 733 | {file = "ruff-0.3.4-py3-none-win_amd64.whl", hash = "sha256:de0d5069b165e5a32b3c6ffbb81c350b1e3d3483347196ffdf86dc0ef9e37dd6"}, 734 | {file = "ruff-0.3.4-py3-none-win_arm64.whl", hash = "sha256:6810563cc08ad0096b57c717bd78aeac888a1bfd38654d9113cb3dc4d3f74232"}, 735 | {file = "ruff-0.3.4.tar.gz", hash = "sha256:f0f4484c6541a99862b693e13a151435a279b271cff20e37101116a21e2a1ad1"}, 736 | ] 737 | 738 | [[package]] 739 | name = "setuptools" 740 | version = "69.2.0" 741 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 742 | optional = false 743 | python-versions = ">=3.8" 744 | files = [ 745 | {file = "setuptools-69.2.0-py3-none-any.whl", hash = "sha256:c21c49fb1042386df081cb5d86759792ab89efca84cf114889191cd09aacc80c"}, 746 | {file = "setuptools-69.2.0.tar.gz", hash = "sha256:0ff4183f8f42cd8fa3acea16c45205521a4ef28f73c6391d8a25e92893134f2e"}, 747 | ] 748 | 749 | [package.extras] 750 | docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] 751 | testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] 752 | testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] 753 | 754 | [[package]] 755 | name = "sniffio" 756 | version = "1.3.1" 757 | description = "Sniff out which async library your code is running under" 758 | optional = false 759 | python-versions = ">=3.7" 760 | files = [ 761 | {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, 762 | {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, 763 | ] 764 | 765 | [[package]] 766 | name = "tomli" 767 | version = "2.0.1" 768 | description = "A lil' TOML parser" 769 | optional = false 770 | python-versions = ">=3.7" 771 | files = [ 772 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 773 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 774 | ] 775 | 776 | [[package]] 777 | name = "types-requests" 778 | version = "2.31.0.20240311" 779 | description = "Typing stubs for requests" 780 | optional = false 781 | python-versions = ">=3.8" 782 | files = [ 783 | {file = "types-requests-2.31.0.20240311.tar.gz", hash = "sha256:b1c1b66abfb7fa79aae09097a811c4aa97130eb8831c60e47aee4ca344731ca5"}, 784 | {file = "types_requests-2.31.0.20240311-py3-none-any.whl", hash = "sha256:47872893d65a38e282ee9f277a4ee50d1b28bd592040df7d1fdaffdf3779937d"}, 785 | ] 786 | 787 | [package.dependencies] 788 | urllib3 = ">=2" 789 | 790 | [[package]] 791 | name = "typing-extensions" 792 | version = "4.10.0" 793 | description = "Backported and Experimental Type Hints for Python 3.8+" 794 | optional = false 795 | python-versions = ">=3.8" 796 | files = [ 797 | {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, 798 | {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, 799 | ] 800 | 801 | [[package]] 802 | name = "urllib3" 803 | version = "2.2.1" 804 | description = "HTTP library with thread-safe connection pooling, file post, and more." 805 | optional = false 806 | python-versions = ">=3.8" 807 | files = [ 808 | {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, 809 | {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, 810 | ] 811 | 812 | [package.extras] 813 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] 814 | h2 = ["h2 (>=4,<5)"] 815 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 816 | zstd = ["zstandard (>=0.18.0)"] 817 | 818 | [[package]] 819 | name = "virtualenv" 820 | version = "20.25.1" 821 | description = "Virtual Python Environment builder" 822 | optional = false 823 | python-versions = ">=3.7" 824 | files = [ 825 | {file = "virtualenv-20.25.1-py3-none-any.whl", hash = "sha256:961c026ac520bac5f69acb8ea063e8a4f071bcc9457b9c1f28f6b085c511583a"}, 826 | {file = "virtualenv-20.25.1.tar.gz", hash = "sha256:e08e13ecdca7a0bd53798f356d5831434afa5b07b93f0abdf0797b7a06ffe197"}, 827 | ] 828 | 829 | [package.dependencies] 830 | distlib = ">=0.3.7,<1" 831 | filelock = ">=3.12.2,<4" 832 | platformdirs = ">=3.9.1,<5" 833 | 834 | [package.extras] 835 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] 836 | test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] 837 | 838 | [extras] 839 | pillow = ["pillow"] 840 | 841 | [metadata] 842 | lock-version = "2.0" 843 | python-versions = "^3.10" 844 | content-hash = "ba8b18df29c1f776476eccb590e30397e4c374153f300c20233b909c773d47cc" 845 | --------------------------------------------------------------------------------