├── 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 | [](https://www.python.org/dev/peps/pep-0008/)
5 | 
6 | [](/LICENSE)
7 | [](https://travis-ci.com/uriyyo/instapi)
8 | [](https://codecov.io/gh/uriyyo/instapi)
9 |
10 |
11 |
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 |
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 |
--------------------------------------------------------------------------------