Repository: uriyyo/instapi (language: python)

Issue:
🎉 Add cached decorator to prevent multiple calls to same endpoints



Discussion / hints:

Fix patch:
diff --git a/.coveragerc b/.coveragerc
deleted file mode 100644
index 5d20cac..0000000
--- a/.coveragerc
+++ /dev/null
@@ -1,3 +0,0 @@
-[report]
-exclude_lines =
-    pragma: no cover
\ No newline at end of file
diff --git a/.flake8 b/.flake8
deleted file mode 100644
index be7d543..0000000
--- a/.flake8
+++ /dev/null
@@ -1,7 +0,0 @@
-[flake8]
-ignore = F401, F811, D, I004, I001
-max-line-length = 120
-exclude = .tox
-show-source = true
-enable-extensions = G
-application-import-names = instapi
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 0000000..3e4ed48
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,26 @@
+name: Lint
+
+on: [push, pull_request]
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: [3.7]
+
+    steps:
+      - uses: actions/checkout@v2
+
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Install dependencies
+        run: |
+          pip install poetry
+          poetry install
+
+      - name: Lint
+        run: poetry run pre-commit run --all-files --show-diff-on-failure
\ No newline at end of file
diff --git a/.isort.cfg b/.isort.cfg
deleted file mode 100644
index 3683a04..0000000
--- a/.isort.cfg
+++ /dev/null
@@ -1,3 +0,0 @@
-[isort]
-force_single_line = True
-not_skip = __init__.py
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index df07517..2cc98a9 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,40 +1,29 @@
-default_stages: [push]
-
 repos:
-  - repo: local
+  - repo: https://github.com/asottile/seed-isort-config
+    rev: v2.2.0
     hooks:
-      - id: pytest
-        name: Check pytest unit tests pass
-        entry: pytest tests
-        language: system
-        types: [python]
-        always_run: true
-        pass_filenames: false
-        stages: [push, commit]
+      - id: seed-isort-config
 
-      - id: mypy
-        name: Check mypy static types match
-        entry: mypy instapi
-        language: system
-        types: [python]
-        always_run: true
-        pass_filenames: false
-        stages: [push, commit]
+  - repo: https://github.com/pre-commit/mirrors-isort
+    rev: v5.5.1
+    hooks:
+      - id: isort
 
+  - repo: https://gitlab.com/pycqa/flake8
+    rev: 3.8.3
+    hooks:
       - id: flake8
-        name: Check flake8 rules match
-        entry: flake8 instapi
-        language: system
-        types: [python]
-        always_run: true
-        pass_filenames: false
-        stages: [push, commit]
+        additional_dependencies:
+          - flake8-bugbear
 
-      - id: isort
-        name: Run isort to set correct order of imports
-        entry: flake8 instapi
-        language: system
-        types: [python]
-        always_run: true
-        pass_filenames: false
-        stages: [push, commit]
+  - repo: https://github.com/pre-commit/mirrors-mypy
+    rev: v0.782
+    hooks:
+      - id: mypy
+        exclude: tests
+
+  - repo: https://github.com/ambv/black
+    rev: stable
+    hooks:
+      - id: black
+        language_version: python3.7
\ No newline at end of file
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 064bc7f..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,39 +0,0 @@
-language: python
-
-install:
-  - pip install ".[dev]"
-
-env:
-  - TEST_CMD="pytest tests"
-
-script:
-  - $TEST_CMD
-
-matrix:
-  include:
-    - name: "flake8"
-      python: 3.6
-      env:
-        - TEST_CMD="flake8 instapi"
-
-    - name: "mypy"
-      python: "3.6"
-      env:
-        - TEST_CMD="mypy instapi"
-
-    - name: "codecov"
-      python: "3.6"
-      env:
-        - TEST_CMD="pytest --cov-report term --cov=instapi tests"
-      after_success:
-        - curl -s https://codecov.io/bash | bash
-
-    - name: "3.6"
-      python: "3.6"
-
-    - name: "3.7"
-      python: "3.7"
-
-    - name: "3.8"
-      python: "3.8-dev"
-
diff --git a/README.md b/README.md
index 2cfea0d..a265c7d 100644
--- a/README.md
+++ b/README.md
@@ -15,9 +15,9 @@ InstAPI - comfortable and easy to use Python's library for interaction with Inst
 
 Installation
 ------------
-1. Download InstAPI from this repository.
-2. Run `pip install -e [PATH to directory with InstAPI]` in console.
-3. Done!
+```bash
+pip install inst-api
+```
 
 Usage
 -----
@@ -36,18 +36,6 @@ for feed in instagram_profile.iter_feeds():
   feed.like()
 ```
 
-Install requires
-----------------
-* dataclasses (version 0.6.0)
-* instagram-private-api (version 1.6.0.0)
-* Pillow (version 6.1.0)
-* requests (version 2.22.0)
-
-Release History
----------------
-* 0.0.1
-  * Work in progress
-
 Contribute
 ----------
 Contributions are always welcome!
diff --git a/codecov.yml b/codecov.yml
deleted file mode 100644
index e69de29..0000000
diff --git a/instapi/__init__.py b/instapi/__init__.py
index 2c2e675..a5d35e3 100644
--- a/instapi/__init__.py
+++ b/instapi/__init__.py
@@ -1,26 +1,28 @@
 from instapi.client import bind
 from instapi.exceptions import ClientNotInitedException
-from instapi.models import Candidate
-from instapi.models import Comment
-from instapi.models import Direct
-from instapi.models import Feed
-from instapi.models import Image
-from instapi.models import Message
-from instapi.models import Resource
-from instapi.models import Resources
-from instapi.models import User
-from instapi.models import Video
+from instapi.models import (
+    Candidate,
+    Comment,
+    Direct,
+    Feed,
+    Image,
+    Message,
+    Resource,
+    Resources,
+    User,
+    Video,
+)
 
 __all__ = [
-    'bind',
-    'ClientNotInitedException',
-    'Comment',
-    'Candidate',
-    'Direct',
-    'Feed',
-    'Image',
-    'Resource',
-    'Resources',
-    'User',
-    'Video',
+    "bind",
+    "ClientNotInitedException",
+    "Comment",
+    "Candidate",
+    "Direct",
+    "Feed",
+    "Image",
+    "Resource",
+    "Resources",
+    "User",
+    "Video",
 ]
diff --git a/instapi/cache.py b/instapi/cache.py
index 4d5a77d..a72e383 100644
--- a/instapi/cache.py
+++ b/instapi/cache.py
@@ -1,7 +1,12 @@
+from collections import deque
+from contextvars import ContextVar
+from dataclasses import dataclass
+from functools import partial, wraps
 from hashlib import md5
 from itertools import chain
 from pathlib import Path
-from typing import Optional
+from time import time
+from typing import Any, Callable, Deque, Dict, Optional, Tuple, TypeVar
 
 from instagram_private_api.http import ClientCookieJar
 
@@ -12,12 +17,12 @@ def _get_cache_root() -> Path:  # pragma: no cover
     cwd = Path.cwd()
 
     for p in chain([cwd], cwd.parents):
-        cache = p / '.instapi_cache'
+        cache = p / ".instapi_cache"
 
         if cache.exists():
             return cache
 
-    return cwd / '.instapi_cache'
+    return cwd / ".instapi_cache"
 
 
 _CACHE_ROOT = _get_cache_root()
@@ -25,11 +30,10 @@ def _get_cache_root() -> Path:  # pragma: no cover
 
 
 def _get_hash(credentials: Credentials) -> str:  # pragma: no cover
-    return md5(':'.join(credentials).encode()).hexdigest()
+    return md5(":".join(credentials).encode()).hexdigest()
 
 
 # TODO: add tests for cache logic
-
 def get_from_cache(credentials: Credentials) -> Optional[bytes]:  # pragma: no cover
     cache = _CACHE_ROOT / _get_hash(credentials)
     return cache.read_bytes() if cache.exists() else None
@@ -40,4 +44,55 @@ def write_to_cache(credentials: Credentials, cookie: ClientCookieJar) -> None:
     cache.write_bytes(cookie.dump())
 
 
-__all__ = ['get_from_cache', 'write_to_cache']
+CACHED_TIME = ContextVar("CACHED_TIME", default=60)
+CacheKey = Tuple[Tuple, Tuple]
+
+T = TypeVar("T")
+
+
+@dataclass
+class _CacheInfo:
+    cache: Dict[CacheKey, Any]
+    keys: Deque[Tuple[CacheKey, float]]
+
+
+def cached(func: Callable[..., T]) -> Callable[..., T]:
+    cache: Dict[CacheKey, Any] = {}
+    keys: Deque[Tuple[CacheKey, float]] = deque()
+
+    def _delete_expired_keys() -> None:  # pragma: no cover
+        while keys:
+            key, expired = keys[0]
+
+            if expired > time():
+                break
+
+            keys.popleft()
+            del cache[key]
+
+    def _add_key(key: CacheKey) -> None:
+        keys.append((key, time() + CACHED_TIME.get()))
+
+    @wraps(func)
+    def wrapper(*args: Any, **kwargs: Any) -> Any:
+        _delete_expired_keys()
+
+        key: CacheKey = (args, tuple(kwargs.items()))
+
+        if key not in cache:
+            cache[key] = func(*args, **kwargs)
+            _add_key(key)
+
+        return cache[key]
+
+    wrapper.info: Callable[..., _CacheInfo] = partial(_CacheInfo, cache, keys)  # type: ignore
+
+    return wrapper
+
+
+__all__ = [
+    "CACHED_TIME",
+    "cached",
+    "get_from_cache",
+    "write_to_cache",
+]
diff --git a/instapi/client.py b/instapi/client.py
index a807cba..4c42c72 100644
--- a/instapi/client.py
+++ b/instapi/client.py
@@ -1,26 +1,22 @@
 import os
 import ssl
-from typing import Any
-from typing import ClassVar
-from typing import Optional
-from typing import cast
-
 from dataclasses import dataclass
+from typing import Any, ClassVar, Optional, cast
+
 from instagram_private_api import ClientError
 
-from instapi import cache
-from instapi.client_api import Client
-from instapi.exceptions import ClientNotInitedException
-from instapi.utils import LoggingMeta
+from . import cache
+from .client_api import Client
+from .exceptions import ClientNotInitedException
 
 ssl._create_default_https_context = ssl._create_unverified_context
 
-ENV_USERNAME = os.environ.get('INSTAPI_USERNAME')
-ENV_PASSWORD = os.environ.get('INSTAPI_PASSWORD')
+ENV_USERNAME = os.environ.get("INSTAPI_USERNAME")
+ENV_PASSWORD = os.environ.get("INSTAPI_PASSWORD")
 
 
 @dataclass
-class ClientProxy(metaclass=LoggingMeta):
+class ClientProxy:
     obj: Optional[Client] = None
 
     # Used to return dummy implementation of methods
@@ -40,8 +36,8 @@ def __getattr__(self, item: str) -> Any:
 
 
 def bind(
-        username: Optional[str] = ENV_USERNAME,
-        password: Optional[str] = ENV_PASSWORD,
+    username: Optional[str] = ENV_USERNAME,
+    password: Optional[str] = ENV_PASSWORD,
 ) -> None:
     if username is None or password is None:
         raise ValueError("Both username and password should be passed")
@@ -55,7 +51,7 @@ def bind(
 
 
 __all__ = [
-    'bind',
-    'client',
-    'ClientProxy',
+    "bind",
+    "client",
+    "ClientProxy",
 ]
diff --git a/instapi/client_api/__init__.py b/instapi/client_api/__init__.py
index c48c9ab..87ec15f 100644
--- a/instapi/client_api/__init__.py
+++ b/instapi/client_api/__init__.py
@@ -1,5 +1,5 @@
 from instapi.client_api.client import Client
 
 __all__ = [
-    'Client',
+    "Client",
 ]
diff --git a/instapi/client_api/base.py b/instapi/client_api/base.py
index 5d8f653..b3c38e5 100644
--- a/instapi/client_api/base.py
+++ b/instapi/client_api/base.py
@@ -1,21 +1,19 @@
-from typing import Any
-from typing import Dict
-from typing import cast
+from typing import Any, Dict, cast
 
 from instagram_private_api import Client
 
-from instapi.types import StrDict
+from ..types import StrDict
 
 
 class BaseClient(Client):
     def _call_api(
-            self,
-            endpoint: str,
-            params: Dict[str, Any] = None,
-            query: Dict[str, Any] = None,
-            return_response: bool = False,
-            unsigned: bool = False,
-            version: str = 'v1',
+        self,
+        endpoint: str,
+        params: Dict[str, Any] = None,
+        query: Dict[str, Any] = None,
+        return_response: bool = False,
+        unsigned: bool = False,
+        version: str = "v1",
     ) -> StrDict:
         value = super()._call_api(
             endpoint=endpoint,
@@ -30,5 +28,5 @@ def _call_api(
 
 
 __all__ = [
-    'BaseClient',
+    "BaseClient",
 ]
diff --git a/instapi/client_api/client.py b/instapi/client_api/client.py
index 9e02aab..74142d9 100644
--- a/instapi/client_api/client.py
+++ b/instapi/client_api/client.py
@@ -1,4 +1,4 @@
-from instapi.client_api.direct import DirectEndpoint
+from .direct import DirectEndpoint
 
 
 class Client(
@@ -8,5 +8,5 @@ class Client(
 
 
 __all__ = [
-    'Client',
+    "Client",
 ]
diff --git a/instapi/client_api/direct.py b/instapi/client_api/direct.py
index 42fbf3e..7815d01 100644
--- a/instapi/client_api/direct.py
+++ b/instapi/client_api/direct.py
@@ -1,14 +1,11 @@
-from typing import Iterable
-from typing import Union
-from typing import cast
+from typing import Iterable, Union, cast
 
 import requests
 from instagram_private_api.errors import ClientError
 
-from instapi.client_api.base import BaseClient
-from instapi.types import StrDict
-from instapi.types import SupportsInt_co
-from instapi.utils import join
+from ..types import StrDict, SupportsInt_co
+from ..utils import join
+from .base import BaseClient
 
 RecipientUsers = Union[SupportsInt_co, Iterable[SupportsInt_co]]
 
@@ -16,8 +13,8 @@
 class DirectEndpoint(BaseClient):
     @staticmethod
     def _convert_recipient_users(
-            recipient_users: RecipientUsers,
-            braces_count: int = 2,
+        recipient_users: RecipientUsers,
+        braces_count: int = 2,
     ) -> str:
         try:
             value = join(map(int, cast(Iterable, recipient_users)))
@@ -27,24 +24,24 @@ def _convert_recipient_users(
         return f'{"[" * braces_count}{value}{"]" * braces_count}'
 
     def direct_v2_send_item(
-            self,
-            recipient_users: RecipientUsers,
-            thread_id: int = None,
-            *,
-            item_type: str,
-            item_data: StrDict,
-            version: str = 'v1',
+        self,
+        recipient_users: RecipientUsers,
+        thread_id: int = None,
+        *,
+        item_type: str,
+        item_data: StrDict,
+        version: str = "v1",
     ) -> StrDict:
-        url = f'{self.api_url.format(version=version)}direct_v2/threads/broadcast/{item_type}/'
+        url = f"{self.api_url.format(version=version)}direct_v2/threads/broadcast/{item_type}/"
 
         data = {
-            'action': 'send_item',
-            'recipient_users': self._convert_recipient_users(recipient_users),
+            "action": "send_item",
+            "recipient_users": self._convert_recipient_users(recipient_users),
             **item_data,
         }
 
         if thread_id:
-            data['thread_ids'] = f'[{thread_id}]'
+            data["thread_ids"] = f"[{thread_id}]"
 
         response = requests.post(
             url,
@@ -64,108 +61,108 @@ def direct_v2_send_item(
         return cast(StrDict, response.json())
 
     def direct_v2_send_text(
-            self,
-            recipient_users: RecipientUsers = (),
-            thread_id: int = None,
-            *,
-            text: str,
+        self,
+        recipient_users: RecipientUsers = (),
+        thread_id: int = None,
+        *,
+        text: str,
     ) -> StrDict:
         return self.direct_v2_send_item(
             recipient_users=recipient_users,
             thread_id=thread_id,
-            item_type='text',
-            item_data={'text': text},
+            item_type="text",
+            item_data={"text": text},
         )
 
     def direct_v2_send_link(
-            self,
-            recipient_users: RecipientUsers = (),
-            thread_id: int = None,
-            *,
-            text: str = '',
-            link: str
+        self,
+        recipient_users: RecipientUsers = (),
+        thread_id: int = None,
+        *,
+        text: str = "",
+        link: str,
     ) -> StrDict:
         return self.direct_v2_send_item(
             recipient_users=recipient_users,
             thread_id=thread_id,
-            item_type='link',
+            item_type="link",
             item_data={
-                'link_text': text or link,
-                'link_urls': f'["{link}"]',
+                "link_text": text or link,
+                "link_urls": f'["{link}"]',
             },
         )
 
     def direct_v2_send_media_share(
-            self,
-            recipient_users: RecipientUsers = (),
-            thread_id: int = None,
-            *,
-            text: str = '',
-            media_type: str = 'photo',
-            media_id: int,
+        self,
+        recipient_users: RecipientUsers = (),
+        thread_id: int = None,
+        *,
+        text: str = "",
+        media_type: str = "photo",
+        media_id: int,
     ) -> StrDict:
         return self.direct_v2_send_item(
             recipient_users=recipient_users,
             thread_id=thread_id,
-            item_type='media_share',
+            item_type="media_share",
             item_data={
-                'text': text,
-                'media_type': media_type,
-                'media_id': media_id,
+                "text": text,
+                "media_type": media_type,
+                "media_id": media_id,
             },
         )
 
     def direct_v2_send_hashtag(
-            self,
-            recipient_users: RecipientUsers = (),
-            thread_id: int = None,
-            *,
-            text: str = '',
-            hashtag: str,
+        self,
+        recipient_users: RecipientUsers = (),
+        thread_id: int = None,
+        *,
+        text: str = "",
+        hashtag: str,
     ) -> StrDict:
         return self.direct_v2_send_item(
             recipient_users=recipient_users,
             thread_id=thread_id,
-            item_type='hashtag',
+            item_type="hashtag",
             item_data={
-                'text': text,
-                'hashtag': hashtag,
+                "text": text,
+                "hashtag": hashtag,
             },
         )
 
     def direct_v2_send_profile(
-            self,
-            recipient_users: RecipientUsers = (),
-            thread_id: int = None,
-            *,
-            text: str = '',
-            profile_id: int,
+        self,
+        recipient_users: RecipientUsers = (),
+        thread_id: int = None,
+        *,
+        text: str = "",
+        profile_id: int,
     ) -> StrDict:
         return self.direct_v2_send_item(
             recipient_users=recipient_users,
             thread_id=thread_id,
-            item_type='profile',
+            item_type="profile",
             item_data={
-                'text': text,
-                'profile_user_id': profile_id,
+                "text": text,
+                "profile_user_id": profile_id,
             },
         )
 
     def direct_v2_inbox(self, **kwargs: str) -> StrDict:
-        return self._call_api('direct_v2/inbox', query=kwargs)
+        return self._call_api("direct_v2/inbox", query=kwargs)
 
     def direct_v2_get_by_participants(self, recipient_users: RecipientUsers) -> StrDict:
         return self._call_api(
-            'direct_v2/threads/get_by_participants',
+            "direct_v2/threads/get_by_participants",
             query={
-                'recipient_users': self._convert_recipient_users(recipient_users, braces_count=1),
-            }
+                "recipient_users": self._convert_recipient_users(recipient_users, braces_count=1),
+            },
         )
 
     def direct_v2_thread(self, thread_id: int, **kwargs: str) -> StrDict:
-        return self._call_api(f'direct_v2/threads/{thread_id}', query=kwargs)
+        return self._call_api(f"direct_v2/threads/{thread_id}", query=kwargs)
 
 
 __all__ = [
-    'DirectEndpoint',
+    "DirectEndpoint",
 ]
diff --git a/instapi/exceptions.py b/instapi/exceptions.py
index 3caa977..097eba4 100644
--- a/instapi/exceptions.py
+++ b/instapi/exceptions.py
@@ -3,5 +3,5 @@ class ClientNotInitedException(Exception):
 
 
 __all__ = [
-    'ClientNotInitedException',
+    "ClientNotInitedException",
 ]
diff --git a/instapi/models/__init__.py b/instapi/models/__init__.py
index 016a1a8..d40ae33 100644
--- a/instapi/models/__init__.py
+++ b/instapi/models/__init__.py
@@ -1,31 +1,31 @@
-from instapi.models.base import BaseModel
-from instapi.models.base import Entity
+from instapi.models.base import BaseModel, Entity
 from instapi.models.comment import Comment
-from instapi.models.direct import Direct
-from instapi.models.direct import Message
+from instapi.models.direct import Direct, Message
 from instapi.models.feed import Feed
 from instapi.models.media import Media
-from instapi.models.resource import Candidate
-from instapi.models.resource import Image
-from instapi.models.resource import Resource
-from instapi.models.resource import Resources
-from instapi.models.resource import Video
+from instapi.models.resource import (
+    Candidate,
+    Image,
+    Resource,
+    Resources,
+    Video,
+)
 from instapi.models.story import Story
 from instapi.models.user import User
 
 __all__ = [
-    'BaseModel',
-    'Direct',
-    'Entity',
-    'Feed',
-    'Media',
-    'Message',
-    'Comment',
-    'Candidate',
-    'Resource',
-    'Resources',
-    'Image',
-    'Video',
-    'Story',
-    'User',
+    "BaseModel",
+    "Direct",
+    "Entity",
+    "Feed",
+    "Media",
+    "Message",
+    "Comment",
+    "Candidate",
+    "Resource",
+    "Resources",
+    "Image",
+    "Video",
+    "Story",
+    "User",
 ]
diff --git a/instapi/models/base.py b/instapi/models/base.py
index c8a36bf..70f5f28 100644
--- a/instapi/models/base.py
+++ b/instapi/models/base.py
@@ -1,33 +1,21 @@
-from typing import AbstractSet
-from typing import Any
-from typing import ClassVar
-from typing import Dict
-from typing import Type
-from typing import TypeVar
+from dataclasses import asdict, dataclass, field, fields
+from typing import AbstractSet, Any, Type, TypeVar, cast
 
-from dataclasses import Field
-from dataclasses import asdict
-from dataclasses import dataclass
-from dataclasses import field
+from ..types import StrDict
 
-from instapi.types import StrDict
-from instapi.utils import LoggingMeta
-
-ModelT_co = TypeVar('ModelT_co', bound='BaseModel', covariant=True)
+ModelT_co = TypeVar("ModelT_co", bound="BaseModel", covariant=True)
 
 
 @dataclass(frozen=True)
-class BaseModel(metaclass=LoggingMeta):
-    __dataclass_fields__: ClassVar[Dict[str, Field]]
-
+class BaseModel:
     @classmethod
     def fields(cls) -> AbstractSet[str]:
-        return cls.__dataclass_fields__.keys() - {'__dataclass_fields__'}
+        return {f.name for f in fields(cls)} - {"__dataclass_fields__"}
 
     @classmethod
     def create(cls: Type[ModelT_co], data: Any) -> ModelT_co:
         # noinspection PyArgumentList
-        return cls(**{k: data[k] for k in cls.fields() if k in data})  # type: ignore
+        return cast(ModelT_co, cls(**{k: data[k] for k in cls.fields() if k in data}))  # type: ignore
 
     def as_dict(self) -> StrDict:
         """
@@ -55,11 +43,11 @@ def __int__(self) -> int:
 
     @classmethod
     def create(cls: Type[ModelT_co], data: StrDict) -> ModelT_co:
-        return super().create(data)
+        return super().create(data)  # type: ignore
 
 
 __all__ = [
-    'BaseModel',
-    'Entity',
-    'ModelT_co',
+    "BaseModel",
+    "Entity",
+    "ModelT_co",
 ]
diff --git a/instapi/models/comment.py b/instapi/models/comment.py
index 11b709e..d8798ab 100644
--- a/instapi/models/comment.py
+++ b/instapi/models/comment.py
@@ -1,9 +1,8 @@
-from typing import TYPE_CHECKING
-
 from dataclasses import dataclass
+from typing import TYPE_CHECKING
 
-from instapi.client import client
-from instapi.models.media import Media
+from ..client import client
+from .media import Media
 
 if TYPE_CHECKING:
     from instapi.models import User  # pragma: no cover
@@ -14,8 +13,9 @@ class Comment(Media):
     """
     This class represents a comment in Instagram
     """
+
     text: str
-    user: 'User'
+    user: "User"
 
     def like(self) -> None:
         """
@@ -35,5 +35,5 @@ def unlike(self) -> None:
 
 
 __all__ = [
-    'Comment',
+    "Comment",
 ]
diff --git a/instapi/models/direct.py b/instapi/models/direct.py
index 2f76e0a..7858026 100644
--- a/instapi/models/direct.py
+++ b/instapi/models/direct.py
@@ -1,22 +1,13 @@
-from typing import Any
-from typing import Dict
-from typing import Iterable
-from typing import List
-from typing import Optional
-from typing import Tuple
-from typing import Type
-
-from dataclasses import dataclass
-from dataclasses import field
-
-from instapi.client import client
-from instapi.models.base import BaseModel
-from instapi.models.base import ModelT_co
-from instapi.models.media import Media
-from instapi.models.user import User
-from instapi.types import StrDict
-from instapi.utils import process_many
-from instapi.utils import to_list
+from dataclasses import dataclass, field
+from typing import Any, Iterable, List, Optional, Tuple, Type
+
+from ..cache import cached
+from ..client import client
+from ..types import StrDict
+from ..utils import process_many, to_list
+from .base import BaseModel, ModelT_co
+from .media import Media
+from .user import User
 
 
 @dataclass(frozen=True)
@@ -29,22 +20,13 @@ class Message(BaseModel):
     story_share: StrDict = field(default_factory=dict)
 
     @classmethod
-    def create(cls: Type[ModelT_co], data: StrDict, cache: Dict[int, User] = None) -> ModelT_co:
-        user_id = data.pop('user_id')
-
-        if cache is not None and user_id in cache:
-            user = cache[user_id]
-        else:
-            user = User.get(user_id)
-
-            if cache is not None:
-                cache[user_id] = user
-
-        return super().create({'user': user, **data})
+    def create(cls: Type[ModelT_co], data: StrDict) -> ModelT_co:
+        user_id = data.pop("user_id")
+        return super().create({"user": User.get(user_id), **data})  # type: ignore
 
     def as_dict(self) -> StrDict:
         data = super().as_dict()
-        data['user_id'] = data.pop('user')['pk']
+        data["user_id"] = data.pop("user")["pk"]
 
         return data
 
@@ -59,48 +41,51 @@ class Direct(BaseModel):
 
     @classmethod
     def create(cls: Type[ModelT_co], data: Any) -> ModelT_co:
-        return super().create({
-            **data,
-            'users': tuple(map(User.create, data['users'])),
-        })
+        return super().create(  # type: ignore
+            {
+                **data,
+                "users": tuple(map(User.create, data["users"])),
+            }
+        )
 
     @classmethod
-    def iter_directs(cls) -> Iterable['Direct']:
-        for response in process_many(client.direct_v2_inbox, key='cursor', key_path='inbox.oldest_cursor'):
-            yield from map(Direct.create, response['inbox']['threads'])
+    def iter_directs(cls) -> Iterable["Direct"]:
+        for response in process_many(
+            client.direct_v2_inbox, key="cursor", key_path="inbox.oldest_cursor"
+        ):
+            yield from map(Direct.create, response["inbox"]["threads"])
 
     @classmethod
-    def directs(cls, limit: Optional[int] = None) -> List['Direct']:
+    def directs(cls, limit: Optional[int] = None) -> List["Direct"]:
         return to_list(cls.iter_directs(), limit)
 
     @classmethod
+    @cached
     def with_user(cls: Type[ModelT_co], user: User) -> ModelT_co:
         result = client.direct_v2_get_by_participants(user)
 
         try:
-            return cls.create(result['thread'])
+            return cls.create(result["thread"])
         except KeyError:
-            return cls(user.username, 'private', False, (user,))  # type: ignore
-
-    def iter_message(self) -> Iterable['Message']:
-        cache: Dict[int, User] = {}
+            return cls(user.username, "private", False, (user,))  # type: ignore
 
+    def iter_message(self) -> Iterable["Message"]:
         for response in process_many(
-                client.direct_v2_thread,
-                self.thread_id,
-                key='cursor',
-                key_path='thread.oldest_cursor',
+            client.direct_v2_thread,
+            self.thread_id,
+            key="cursor",
+            key_path="thread.oldest_cursor",
         ):
-            yield from (Message.create(item, cache) for item in response['thread']['items'])
+            yield from map(Message.create, response["thread"]["items"])
 
-    def messages(self, limit: Optional[int] = None) -> List['Message']:
+    def messages(self, limit: Optional[int] = None) -> List["Message"]:
         return to_list(self.iter_message(), limit)
 
     @property
     def _send_args(self) -> StrDict:
         return {
-            'recipient_users': self.users,
-            'thread_id': self.thread_id,
+            "recipient_users": self.users,
+            "thread_id": self.thread_id,
         }
 
     def send_text(self, text: str) -> None:
@@ -109,28 +94,28 @@ def send_text(self, text: str) -> None:
             **self._send_args,
         )
 
-    def send_link(self, link: str, text: str = '') -> None:
+    def send_link(self, link: str, text: str = "") -> None:
         client.direct_v2_send_link(
             link=link,
             text=text,
             **self._send_args,
         )
 
-    def send_profile(self, user: User, text: str = '') -> None:
+    def send_profile(self, user: User, text: str = "") -> None:
         client.direct_v2_send_profile(
             text=text,
             profile_id=user.pk,
             **self._send_args,
         )
 
-    def send_hashtag(self, hashtag: str, text: str = '') -> None:
+    def send_hashtag(self, hashtag: str, text: str = "") -> None:
         client.direct_v2_send_hashtag(
             hashtag=hashtag,
             text=text,
             **self._send_args,
         )
 
-    def send_media(self, media: Media, text: str = '') -> None:
+    def send_media(self, media: Media, text: str = "") -> None:
         client.direct_v2_send_media_share(
             text=text,
             media_id=media.pk,
@@ -139,6 +124,6 @@ def send_media(self, media: Media, text: str = '') -> None:
 
 
 __all__ = [
-    'Direct',
-    'Message',
+    "Direct",
+    "Message",
 ]
diff --git a/instapi/models/feed.py b/instapi/models/feed.py
index a1f782e..300bb30 100644
--- a/instapi/models/feed.py
+++ b/instapi/models/feed.py
@@ -1,16 +1,13 @@
-from typing import Iterable
-from typing import List
-from typing import Optional
-
 from dataclasses import dataclass
+from typing import Iterable, List, Optional
 
-from instapi.client import client
-from instapi.models.comment import Comment
-from instapi.models.resource import ResourceContainer
-from instapi.models.user import User
-from instapi.types import StrDict
-from instapi.utils import process_many
-from instapi.utils import to_list
+from ..cache import cached
+from ..client import client
+from ..types import StrDict
+from ..utils import process_many, to_list
+from .comment import Comment
+from .resource import ResourceContainer
+from .user import User
 
 
 @dataclass(frozen=True)
@@ -22,11 +19,12 @@ class Feed(ResourceContainer):
     -   Like/Unlike posts
     -   Get media (videos and images) from posts
     """
+
     like_count: int
     comment_count: int = 0
 
     @classmethod
-    def iter_timeline(cls) -> Iterable['Feed']:
+    def iter_timeline(cls) -> Iterable["Feed"]:
         """
         Create generator for iteration over posts from feed
 
@@ -34,12 +32,13 @@ def iter_timeline(cls) -> Iterable['Feed']:
         """
         for result in process_many(client.feed_timeline):
             yield from (
-                Feed.create(data['media_or_ad']) for data in result['feed_items']
-                if 'media_or_ad' in data
+                Feed.create(data["media_or_ad"])
+                for data in result["feed_items"]
+                if "media_or_ad" in data
             )
 
     @classmethod
-    def timeline(cls, limit: Optional[int] = None) -> List['Feed']:
+    def timeline(cls, limit: Optional[int] = None) -> List["Feed"]:
         """
         Generate list of posts from feed
 
@@ -48,16 +47,17 @@ def timeline(cls, limit: Optional[int] = None) -> List['Feed']:
         """
         return to_list(cls.iter_timeline(), limit=limit)
 
-    def _resources(self) -> Iterable['StrDict']:
+    def _resources(self) -> Iterable["StrDict"]:
         """
         Feed can contain multiple images and videos that located in carousel_media
 
         :return: source of videos or images
         """
         media_info = self._media_info()
-        return media_info.get('carousel_media', [media_info])
+        return media_info.get("carousel_media", [media_info])
 
-    def user_tags(self) -> List['User']:
+    @cached
+    def user_tags(self) -> List["User"]:
         """
         Generate list of Users from Feed usertags
 
@@ -65,21 +65,21 @@ def user_tags(self) -> List['User']:
         """
         info = self._media_info()
 
-        if 'usertags' not in info:
+        if "usertags" not in info:
             return []
 
-        return [User.create(u['user']) for u in info['usertags']['in']]
+        return [User.create(u["user"]) for u in info["usertags"]["in"]]
 
-    def iter_likes(self) -> Iterable['User']:
+    def iter_likes(self) -> Iterable["User"]:
         """
         Create generator for iteration over posts from feed
 
         :return: generator with users, which has liked a post
         """
         for result in process_many(client.media_likers, self.pk):
-            yield from map(User.create, result['users'])
+            yield from map(User.create, result["users"])
 
-    def likes(self, limit: Optional[int] = None) -> List['User']:
+    def likes(self, limit: Optional[int] = None) -> List["User"]:
         """
         Generate list of users, which has liked a post
 
@@ -88,7 +88,7 @@ def likes(self, limit: Optional[int] = None) -> List['User']:
         """
         return to_list(self.iter_likes(), limit=limit)
 
-    def liked_by(self, user: 'User') -> bool:
+    def liked_by(self, user: "User") -> bool:
         """
         Check if post was liked by user
 
@@ -96,7 +96,7 @@ def liked_by(self, user: 'User') -> bool:
         :return: boolean value
         """
         return any(
-            any(user.pk == u['pk'] for u in result['users'])
+            any(user.pk == u["pk"] for u in result["users"])
             for result in process_many(client.media_likers, self.pk)
         )
 
@@ -116,19 +116,19 @@ def unlike(self) -> None:
         """
         client.delete_like(self.pk)
 
-    def iter_comments(self) -> Iterable['Comment']:
+    def iter_comments(self) -> Iterable["Comment"]:
         """
         Create generator for iteration over comments, which was attached to the post
 
         :return: generator with comments
         """
         for result in process_many(client.media_comments, self.pk):
-            for c in result['comments']:
-                c['user'] = User.create(c['user'])
+            for c in result["comments"]:
+                c["user"] = User.create(c["user"])
 
-            yield from map(Comment.create, result['comments'])
+            yield from map(Comment.create, result["comments"])
 
-    def comments(self, limit: Optional[int] = None) -> List['Comment']:
+    def comments(self, limit: Optional[int] = None) -> List["Comment"]:
         """
         Generate list of comments, which was attached to the post
 
@@ -139,5 +139,5 @@ def comments(self, limit: Optional[int] = None) -> List['Comment']:
 
 
 __all__ = [
-    'Feed',
+    "Feed",
 ]
diff --git a/instapi/models/media.py b/instapi/models/media.py
index fbb088a..2ef1352 100644
--- a/instapi/models/media.py
+++ b/instapi/models/media.py
@@ -1,17 +1,15 @@
-from typing import cast
-
 from dataclasses import dataclass
+from typing import cast
 
-from instapi.client import client
-from instapi.models.base import Entity
-from instapi.types import StrDict
+from ..client import client
+from ..types import StrDict
+from .base import Entity
 
 
 @dataclass(frozen=True)
 class Media(Entity):
-
     def _media_info(self) -> StrDict:
-        items, *_ = client.media_info(self.pk)['items']
+        items, *_ = client.media_info(self.pk)["items"]
         return cast(StrDict, items)
 
     def comment(self, text: str) -> None:
@@ -19,5 +17,5 @@ def comment(self, text: str) -> None:
 
 
 __all__ = [
-    'Media',
+    "Media",
 ]
diff --git a/instapi/models/resource.py b/instapi/models/resource.py
index c2ab121..97d3ff3 100644
--- a/instapi/models/resource.py
+++ b/instapi/models/resource.py
@@ -1,25 +1,16 @@
 import shutil
+from dataclasses import dataclass, field
 from pathlib import Path
-from typing import IO
-from typing import Iterable
-from typing import List
-from typing import Optional
-from typing import Tuple
-from typing import Type
-from typing import Union
-from typing import cast
+from typing import IO, Iterable, List, Optional, Tuple, Type, Union, cast
 from urllib.parse import urlparse
 
 import requests
-from dataclasses import dataclass
-from dataclasses import field
 from PIL import Image as PILImage
 
-from instapi.models.base import BaseModel
-from instapi.models.base import ModelT_co
-from instapi.models.media import Media
-from instapi.types import StrDict
-from instapi.utils import to_list
+from ..types import StrDict
+from ..utils import to_list
+from .base import BaseModel, ModelT_co
+from .media import Media
 
 
 @dataclass(frozen=True, order=True)
@@ -27,6 +18,7 @@ class Candidate(BaseModel):
     """
     Represent a candidate for Resource
     """
+
     width: int
     height: int
     url: str = field(compare=False)
@@ -38,7 +30,7 @@ def filename(self) -> Path:
 
         :return: path to file
         """
-        *_, filename = urlparse(self.url).path.split('/')
+        *_, filename = urlparse(self.url).path.split("/")
         return Path(filename)
 
     def content(self) -> IO[bytes]:
@@ -65,8 +57,8 @@ def download(self, directory: Path = None, filename: Union[Path, str] = None) ->
         else:
             into = Path(filename)
 
-        with into.open(mode='wb') as f:
-            shutil.copyfileobj(self.content(), f)
+        with into.open(mode="wb") as f:
+            shutil.copyfileobj(self.content(), f)  # type: ignore
 
 
 @dataclass(frozen=True)
@@ -74,6 +66,7 @@ class Resource(BaseModel):
     """
     This class represents image or video, which contains in the post
     """
+
     candidates: Tuple[Candidate]
 
     def __post_init__(self) -> None:
@@ -101,10 +94,10 @@ def best_candidate(self) -> Candidate:
         return max(self.candidates)
 
     def download(
-            self,
-            directory: Path = None,
-            filename: Union[Path, str] = None,
-            candidate: Candidate = None,
+        self,
+        directory: Path = None,
+        filename: Union[Path, str] = None,
+        candidate: Candidate = None,
     ) -> None:
         """
         Download image/video
@@ -119,11 +112,11 @@ def download(
 
     @classmethod
     def create_resources(
-            cls,
-            resources_data: Iterable[StrDict],
-            video: bool = True,
-            image: bool = True,
-    ) -> Iterable['Resources']:
+        cls,
+        resources_data: Iterable[StrDict],
+        video: bool = True,
+        image: bool = True,
+    ) -> Iterable["Resources"]:
         """
         Create a generator for iteration over images/videos, which contains in the resources_data
 
@@ -140,7 +133,7 @@ def create_resources(
                     yield resource
 
     @classmethod
-    def from_data(cls, data: StrDict) -> Optional['Resources']:
+    def from_data(cls, data: StrDict) -> Optional["Resources"]:
         """
         Create resource based on data fetched from api
 
@@ -148,9 +141,9 @@ def from_data(cls, data: StrDict) -> Optional['Resources']:
         :return: resource instance or None
         """
         if cls.is_video_data(data):
-            return Video.create(data['video_versions'])
+            return Video.create(data["video_versions"])
         elif cls.is_image_data(data):
-            return Image.create(data['image_versions2']['candidates'])
+            return Image.create(data["image_versions2"]["candidates"])
         else:
             return None
 
@@ -162,7 +155,7 @@ def is_video_data(data: StrDict) -> bool:
         :param data: resource data
         :return: is given data contains information about video resource
         """
-        return 'video_versions' in data
+        return "video_versions" in data
 
     @staticmethod
     def is_image_data(data: StrDict) -> bool:
@@ -172,7 +165,7 @@ def is_image_data(data: StrDict) -> bool:
         :param data: resource data
         :return: is given data contains information about image resource
         """
-        return 'video_versions' not in data and 'image_versions2' in data
+        return "video_versions" not in data and "image_versions2" in data
 
 
 @dataclass(frozen=True)
@@ -182,9 +175,7 @@ class Video(Resource):
     """
 
     def as_dict(self) -> StrDict:
-        return {
-            'video_versions': [c.as_dict() for c in self.candidates]
-        }
+        return {"video_versions": [c.as_dict() for c in self.candidates]}
 
 
 @dataclass(frozen=True)
@@ -194,11 +185,7 @@ class Image(Resource):
     """
 
     def as_dict(self) -> StrDict:
-        return {
-            'image_versions2': {
-                'candidates': [c.as_dict() for c in self.candidates]
-            }
-        }
+        return {"image_versions2": {"candidates": [c.as_dict() for c in self.candidates]}}
 
     def preview(self, candidate: Candidate = None) -> None:
         """
@@ -218,7 +205,7 @@ class ResourceContainer(Media):
     The class represents media with resources
     """
 
-    def _resources(self) -> Iterable['StrDict']:
+    def _resources(self) -> Iterable["StrDict"]:
         """
         Return source of videos or images
 
@@ -226,7 +213,7 @@ def _resources(self) -> Iterable['StrDict']:
         """
         return [self._media_info()]
 
-    def iter_resources(self, *, video: bool = True, image: bool = True) -> Iterable['Resources']:
+    def iter_resources(self, *, video: bool = True, image: bool = True) -> Iterable["Resources"]:
         """
         Create generator for iteration over images/videos, which contains in the media
 
@@ -236,7 +223,9 @@ def iter_resources(self, *, video: bool = True, image: bool = True) -> Iterable[
         """
         return Resource.create_resources(self._resources(), video=video, image=image)
 
-    def resources(self, video: bool = True, image: bool = True, limit: Optional[int] = None) -> List['Resources']:
+    def resources(
+        self, video: bool = True, image: bool = True, limit: Optional[int] = None
+    ) -> List["Resources"]:
         """
         Generate list of images/videos, which contains in the media
 
@@ -247,15 +236,15 @@ def resources(self, video: bool = True, image: bool = True, limit: Optional[int]
         """
         return to_list(self.iter_resources(video=video, image=image), limit=limit)
 
-    def iter_videos(self) -> Iterable['Video']:
+    def iter_videos(self) -> Iterable["Video"]:
         """
         Create generator for iteration over videos, which contains in the media
 
         :return: generator with videos, which contains in the post
         """
-        return cast(Iterable['Video'], self.iter_resources(video=True, image=False))
+        return cast(Iterable["Video"], self.iter_resources(video=True, image=False))
 
-    def videos(self, limit: Optional[int] = None) -> List['Video']:
+    def videos(self, limit: Optional[int] = None) -> List["Video"]:
         """
         Generate list of videos, which contains in the media
 
@@ -264,15 +253,15 @@ def videos(self, limit: Optional[int] = None) -> List['Video']:
         """
         return to_list(self.iter_videos(), limit=limit)
 
-    def iter_images(self) -> Iterable['Image']:
+    def iter_images(self) -> Iterable["Image"]:
         """
         Create generator for iteration over images, which contains in the media
 
         :return: generator with images, which contains in the meia
         """
-        return cast(Iterable['Image'], self.iter_resources(video=False, image=True))
+        return cast(Iterable["Image"], self.iter_resources(video=False, image=True))
 
-    def images(self, limit: Optional[int] = None) -> List['Image']:
+    def images(self, limit: Optional[int] = None) -> List["Image"]:
         """
         Generate list of images, which contains in the media
 
@@ -281,7 +270,7 @@ def images(self, limit: Optional[int] = None) -> List['Image']:
         """
         return to_list(self.iter_images(), limit=limit)
 
-    def image(self) -> Optional['Image']:
+    def image(self) -> Optional["Image"]:
         """
         Return the first image from media if it exists.
 
@@ -289,7 +278,7 @@ def image(self) -> Optional['Image']:
         """
         return next(iter(self.iter_images()), None)
 
-    def video(self) -> Optional['Video']:
+    def video(self) -> Optional["Video"]:
         """
         Return the first video from media if it exists
 
@@ -301,10 +290,10 @@ def video(self) -> Optional['Video']:
 Resources = Union[Image, Video]
 
 __all__ = [
-    'Candidate',
-    'Resource',
-    'Resources',
-    'ResourceContainer',
-    'Video',
-    'Image',
+    "Candidate",
+    "Resource",
+    "Resources",
+    "ResourceContainer",
+    "Video",
+    "Image",
 ]
diff --git a/instapi/models/story.py b/instapi/models/story.py
index daafffe..d55d73a 100644
--- a/instapi/models/story.py
+++ b/instapi/models/story.py
@@ -1,13 +1,11 @@
-from typing import List
-from typing import Type
-
 from dataclasses import dataclass
+from typing import List, Type
 
-from instapi.client import client
-from instapi.models.base import ModelT_co
-from instapi.models.resource import ResourceContainer
-from instapi.models.user import User
-from instapi.types import StrDict
+from ..client import client
+from ..types import StrDict
+from .base import ModelT_co
+from .resource import ResourceContainer
+from .user import User
 
 
 @dataclass(frozen=True)
@@ -15,9 +13,10 @@ class Story(ResourceContainer):
     """
     Class that represent user story
     """
+
     # TODO: Add ability to send reactions on the story
 
-    mentions: List['User']
+    mentions: List["User"]
 
     @classmethod
     def create(cls: Type[ModelT_co], data: StrDict) -> ModelT_co:
@@ -28,10 +27,12 @@ def create(cls: Type[ModelT_co], data: StrDict) -> ModelT_co:
         :param data: information about a story
         :return: Story instance
         """
-        return super().create({
-            **data,
-            'mentions': [User.create(d['user']) for d in data.get('reel_mentions', ())],
-        })
+        return super().create(  # type: ignore
+            {
+                **data,
+                "mentions": [User.create(d["user"]) for d in data.get("reel_mentions", ())],
+            }
+        )
 
     def as_dict(self) -> StrDict:
         """
@@ -40,7 +41,7 @@ def as_dict(self) -> StrDict:
         :return: dict with information about story
         """
         data = super().as_dict()
-        data['reel_mentions'] = [{'user': user} for user in data.pop('mentions')]
+        data["reel_mentions"] = [{"user": user} for user in data.pop("mentions")]
 
         return data
 
@@ -56,5 +57,5 @@ def mark_seen(self) -> None:
 
 
 __all__ = [
-    'Story',
+    "Story",
 ]
diff --git a/instapi/models/user.py b/instapi/models/user.py
index 68b2442..8a41fc7 100644
--- a/instapi/models/user.py
+++ b/instapi/models/user.py
@@ -1,24 +1,18 @@
 from collections import Counter as RealCounter
-from itertools import chain
-from typing import TYPE_CHECKING
-from typing import Counter
-from typing import Iterable
-from typing import List
-from typing import Optional
-from typing import cast
-
 from dataclasses import dataclass
+from itertools import chain
+from typing import TYPE_CHECKING, Counter, Iterable, List, Optional, cast
 
-from instapi.client import client
-from instapi.models.base import Entity
-from instapi.models.resource import Resource
-from instapi.types import StrDict
-from instapi.utils import process_many
-from instapi.utils import to_list
+from ..cache import cached
+from ..client import client
+from ..types import StrDict
+from ..utils import process_many, to_list
+from .base import Entity
+from .resource import Resource
 
-if TYPE_CHECKING:
-    from instapi.models.feed import Feed  # pragma: no cover
-    from instapi.models.story import Story  # pragma: no cover
+if TYPE_CHECKING:  # pragma: no cover
+    from .feed import Feed
+    from .story import Story
 
 
 @dataclass(frozen=True)
@@ -29,27 +23,29 @@ class User(Entity):
     is_verified: bool
 
     @classmethod
-    def get(cls, pk: int) -> 'User':
+    @cached
+    def get(cls, pk: int) -> "User":
         """
         Create User object from unique user's identifier
 
         :param pk: unique user's identifier
         :return: User object
         """
-        return cls.create(client.user_info(pk)['user'])
+        return cls.create(client.user_info(pk)["user"])
 
     @classmethod
-    def from_username(cls, username: str) -> 'User':
+    @cached
+    def from_username(cls, username: str) -> "User":
         """
         Create User object from username
 
         :param username: name of user
         :return: User object
         """
-        return cls.create(client.username_info(username)['user'])
+        return cls.create(client.username_info(username)["user"])
 
     @classmethod
-    def match_username(cls, username: str, limit: Optional[int] = None) -> List['User']:
+    def match_username(cls, username: str, limit: Optional[int] = None) -> List["User"]:
         """
         Search users by username
 
@@ -59,19 +55,20 @@ def match_username(cls, username: str, limit: Optional[int] = None) -> List['Use
         """
         response = client.search_users(
             query=username,
-            **({'count': limit} if limit is not None else {}),
+            **({"count": limit} if limit is not None else {}),
         )
 
-        return [cls.create(user) for user in response['users']]
+        return [cls.create(user) for user in response["users"]]
 
     @classmethod
-    def self(cls) -> 'User':
+    @cached
+    def self(cls) -> "User":
         """
         Create User object from current user
 
         :return: User object
         """
-        return cls.get(client.current_user()['user']['pk'])
+        return cls.get(client.current_user()["user"]["pk"])
 
     @property
     def biography(self) -> str:
@@ -80,7 +77,7 @@ def biography(self) -> str:
 
         :return: string
         """
-        return cast(str, self.user_detail()['biography'])
+        return cast(str, self.user_detail()["biography"])
 
     @property
     def media_count(self) -> int:
@@ -89,7 +86,7 @@ def media_count(self) -> int:
 
         :return: number
         """
-        return cast(int, self.user_detail()['media_count'])
+        return cast(int, self.user_detail()["media_count"])
 
     @property
     def follower_count(self) -> int:
@@ -98,7 +95,7 @@ def follower_count(self) -> int:
 
         :return: number
         """
-        return cast(int, self.user_detail()['follower_count'])
+        return cast(int, self.user_detail()["follower_count"])
 
     @property
     def following_count(self) -> int:
@@ -107,15 +104,16 @@ def following_count(self) -> int:
 
         :return: number
         """
-        return cast(int, self.user_detail()['following_count'])
+        return cast(int, self.user_detail()["following_count"])
 
     def user_detail(self) -> StrDict:
-        return cast(StrDict, self.full_info()['user_detail']['user'])
+        return cast(StrDict, self.full_info()["user_detail"]["user"])
 
+    @cached
     def full_info(self) -> StrDict:
         return cast(StrDict, client.user_detail_info(self.pk))
 
-    def follow(self, user: 'User') -> None:
+    def follow(self, user: "User") -> None:
         """
         Follow on user
 
@@ -127,7 +125,7 @@ def follow(self, user: 'User') -> None:
 
         client.friendships_create(user.pk)
 
-    def unfollow(self, user: 'User') -> None:
+    def unfollow(self, user: "User") -> None:
         """
         Unfollow from user
 
@@ -139,44 +137,44 @@ def unfollow(self, user: 'User') -> None:
 
         client.friendships_destroy(user.pk)
 
-    def iter_images(self) -> Iterable['Resource']:
+    def iter_images(self) -> Iterable["Resource"]:
         for feed in self.iter_feeds():
             yield from feed.images()
 
-    def images(self, limit: Optional[int] = None) -> List['Resource']:
+    def images(self, limit: Optional[int] = None) -> List["Resource"]:
         return to_list(self.iter_images(), limit=limit)
 
-    def iter_videos(self) -> Iterable['Resource']:
+    def iter_videos(self) -> Iterable["Resource"]:
         for feed in self.feeds():
             yield from feed.videos()
 
-    def videos(self, limit: Optional[int] = None) -> List['Resource']:
+    def videos(self, limit: Optional[int] = None) -> List["Resource"]:
         return to_list(self.iter_videos(), limit=limit)
 
-    def iter_resources(self) -> Iterable['Resource']:
+    def iter_resources(self) -> Iterable["Resource"]:
         for feed in self.iter_feeds():
             yield from feed.iter_resources()
 
-    def resources(self, limit: Optional[int] = None) -> List['Resource']:
+    def resources(self, limit: Optional[int] = None) -> List["Resource"]:
         return to_list(self.iter_resources(), limit=limit)
 
-    def iter_followers(self) -> Iterable['User']:
+    def iter_followers(self) -> Iterable["User"]:
         for result in process_many(client.user_followers, self.pk, with_rank_token=True):
-            yield from map(User.create, result['users'])
+            yield from map(User.create, result["users"])
 
-    def followers(self, limit: Optional[int] = None) -> List['User']:
+    def followers(self, limit: Optional[int] = None) -> List["User"]:
         return to_list(self.iter_followers(), limit=limit)
 
-    def iter_followings(self) -> Iterable['User']:
+    def iter_followings(self) -> Iterable["User"]:
         """
         Create generator for followers
 
         :return: generator with User objects
         """
         for result in process_many(client.user_following, self.pk, with_rank_token=True):
-            yield from map(User.create, result['users'])
+            yield from map(User.create, result["users"])
 
-    def followings(self, limit: Optional[int] = None) -> List['User']:
+    def followings(self, limit: Optional[int] = None) -> List["User"]:
         """
         Generate list of followers
 
@@ -185,12 +183,13 @@ def followings(self, limit: Optional[int] = None) -> List['User']:
         """
         return to_list(self.iter_followings(), limit=limit)
 
-    def iter_feeds(self) -> Iterable['Feed']:
+    def iter_feeds(self) -> Iterable["Feed"]:
         from instapi.models.feed import Feed
+
         for result in process_many(client.user_feed, self.pk):
-            yield from map(Feed.create, result['items'])
+            yield from map(Feed.create, result["items"])
 
-    def feeds(self, limit: Optional[int] = None) -> List['Feed']:
+    def feeds(self, limit: Optional[int] = None) -> List["Feed"]:
         return to_list(self.iter_feeds(), limit=limit)
 
     def total_comments(self) -> int:
@@ -199,27 +198,28 @@ def total_comments(self) -> int:
     def total_likes(self) -> int:
         return sum(feed.like_count for feed in self.iter_feeds())
 
-    def likes_chain(self) -> Iterable['User']:
+    def likes_chain(self) -> Iterable["User"]:
         return chain.from_iterable(feed.iter_likes() for feed in self.iter_feeds())
 
-    def likes_statistic(self) -> Counter['User']:
+    def likes_statistic(self) -> Counter["User"]:
         return RealCounter(self.likes_chain())
 
-    def iter_liked_by_user(self, user: 'User') -> Iterable['Feed']:
+    def iter_liked_by_user(self, user: "User") -> Iterable["Feed"]:
         return (f for f in self.iter_feeds() if f.liked_by(user))
 
-    def liked_by_user(self, user: 'User', limit: Optional[int] = None) -> List['Feed']:
+    def liked_by_user(self, user: "User", limit: Optional[int] = None) -> List["Feed"]:
         return to_list(self.iter_liked_by_user(user), limit=limit)
 
-    def iter_stories(self) -> Iterable['Story']:
+    def iter_stories(self) -> Iterable["Story"]:
         from instapi.models.story import Story
-        items = (client.user_story_feed(self.pk)['reel'] or {}).get('items', ())
+
+        items = (client.user_story_feed(self.pk)["reel"] or {}).get("items", ())
         yield from map(Story.create, items)
 
-    def stories(self, limit: Optional[int] = None) -> List['Story']:
+    def stories(self, limit: Optional[int] = None) -> List["Story"]:
         return to_list(self.iter_stories(), limit=limit)
 
 
 __all__ = [
-    'User',
+    "User",
 ]
diff --git a/instapi/types.py b/instapi/types.py
index 9ce3cc9..6fc1148 100644
--- a/instapi/types.py
+++ b/instapi/types.py
@@ -1,15 +1,11 @@
-from typing import Any
-from typing import Dict
-from typing import SupportsInt
-from typing import TypeVar
-from typing import Tuple
+from typing import Any, Dict, SupportsInt, Tuple, TypeVar
 
 Credentials = Tuple[str, str]
 StrDict = Dict[str, Any]
-SupportsInt_co = TypeVar('SupportsInt_co', bound=SupportsInt, covariant=True)
+SupportsInt_co = TypeVar("SupportsInt_co", bound=SupportsInt, covariant=True)
 
 __all__ = [
-    'StrDict',
-    'SupportsInt_co',
-    'Credentials',
+    "StrDict",
+    "SupportsInt_co",
+    "Credentials",
 ]
diff --git a/instapi/utils/utils.py b/instapi/utils.py
similarity index 69%
rename from instapi/utils/utils.py
rename to instapi/utils.py
index 737bd61..43950fd 100644
--- a/instapi/utils/utils.py
+++ b/instapi/utils.py
@@ -1,19 +1,13 @@
 from functools import partial
 from itertools import chain
-from typing import Any
-from typing import Callable
-from typing import Dict
-from typing import Iterable
-from typing import List
-from typing import Optional
-from typing import TypeVar
+from typing import Any, Callable, Dict, Iterable, List, Optional, TypeVar
 from uuid import uuid1
 
-T = TypeVar('T')
+T = TypeVar("T")
 
 
 def fetch_key(source: Dict, key_path: str) -> Any:
-    for key in key_path.split('.'):
+    for key in key_path.split("."):
         if key in source:
             source = source[key]
         else:
@@ -23,11 +17,11 @@ def fetch_key(source: Dict, key_path: str) -> Any:
 
 
 def process_many(
-        fetcher: Callable,
-        pk: Optional[int] = None,
-        with_rank_token: bool = False,
-        key: str = 'max_id',
-        key_path: str = 'next_max_id',
+    fetcher: Callable,
+    pk: Optional[int] = None,
+    with_rank_token: bool = False,
+    key: str = "max_id",
+    key_path: str = "next_max_id",
 ) -> Iterable:
     next_value = None
 
@@ -56,7 +50,7 @@ def limited(iterable: Iterable[T], limit: Optional[int] = None) -> Iterable[T]:
         yield from iterable
     else:
         if limit < 0:
-            raise ValueError('Limited can\'t handle negative numbers')
+            raise ValueError("Limited can't handle negative numbers")
 
         yield from (i for _, i in zip(range(limit), iterable))
 
@@ -75,14 +69,14 @@ def flat(source: List[Iterable[T]]) -> List[T]:
     return [*chain.from_iterable(source)]
 
 
-def join(iterable: Iterable, separator: str = ',') -> str:
+def join(iterable: Iterable, separator: str = ",") -> str:
     return separator.join(str(s) for s in iterable)
 
 
 __all__ = [
-    'process_many',
-    'limited',
-    'to_list',
-    'flat',
-    'join',
+    "process_many",
+    "limited",
+    "to_list",
+    "flat",
+    "join",
 ]
diff --git a/instapi/utils/__init__.py b/instapi/utils/__init__.py
deleted file mode 100644
index 5d6fa1e..0000000
--- a/instapi/utils/__init__.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from instapi.utils.loggingmeta import LoggingMeta
-from instapi.utils.utils import flat
-from instapi.utils.utils import join
-from instapi.utils.utils import limited
-from instapi.utils.utils import process_many
-from instapi.utils.utils import to_list
-
-__all__ = [
-    'LoggingMeta',
-    'flat',
-    'join',
-    'limited',
-    'process_many',
-    'to_list',
-]
diff --git a/instapi/utils/loggingmeta.py b/instapi/utils/loggingmeta.py
deleted file mode 100644
index 1028729..0000000
--- a/instapi/utils/loggingmeta.py
+++ /dev/null
@@ -1,41 +0,0 @@
-import inspect
-import logging
-import types
-from typing import Any
-from typing import Optional
-from typing import Tuple
-from typing import Type
-from typing import cast
-
-from autologging import logged
-from autologging import traced
-
-from instapi.types import StrDict
-
-
-class LoggingMeta(type):
-    def __new__(
-            mcs,
-            name: str,
-            bases: Tuple[Type],
-            namespace: StrDict,
-            *,
-            logger: Optional[logging.Logger] = None,
-    ) -> Any:
-        if logger is None:
-            current_frame = cast(types.FrameType, inspect.currentframe())
-            frame = current_frame.f_back
-
-            try:
-                logger = logging.getLogger(frame.f_globals['__name__'])
-            finally:
-                del frame
-                del current_frame
-
-        cls = super().__new__(mcs, name, bases, namespace)
-        return logged(logger)(traced(logger))(cls)
-
-
-__all__ = [
-    'LoggingMeta',
-]
diff --git a/mypy.ini b/mypy.ini
deleted file mode 100644
index 5b8b8ce..0000000
--- a/mypy.ini
+++ /dev/null
@@ -1,17 +0,0 @@
-[mypy]
-python_version = 3.6
-show_column_numbers = True
-
-follow_imports = normal
-
-ignore_missing_imports = True
-
-disallow_untyped_calls = True
-warn_return_any = True
-strict_optional = True
-warn_no_return = True
-warn_redundant_casts = True
-warn_unused_ignores = True
-
-disallow_untyped_defs = True
-check_untyped_defs = True
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..323e37e
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,718 @@
+[[package]]
+category = "dev"
+description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+name = "appdirs"
+optional = false
+python-versions = "*"
+version = "1.4.4"
+
+[[package]]
+category = "dev"
+description = "Atomic file writes."
+marker = "sys_platform == \"win32\""
+name = "atomicwrites"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+version = "1.4.0"
+
+[[package]]
+category = "dev"
+description = "Classes Without Boilerplate"
+name = "attrs"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+version = "20.2.0"
+
+[package.extras]
+dev = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "sphinx-rtd-theme", "pre-commit"]
+docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
+tests = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
+tests_no_zope = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"]
+
+[[package]]
+category = "dev"
+description = "The uncompromising code formatter."
+name = "black"
+optional = false
+python-versions = ">=3.6"
+version = "20.8b1"
+
+[package.dependencies]
+appdirs = "*"
+click = ">=7.1.2"
+mypy-extensions = ">=0.4.3"
+pathspec = ">=0.6,<1"
+regex = ">=2020.1.8"
+toml = ">=0.10.1"
+typed-ast = ">=1.4.0"
+typing-extensions = ">=3.7.4"
+
+[package.extras]
+colorama = ["colorama (>=0.4.3)"]
+d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
+
+[[package]]
+category = "main"
+description = "Python package for providing Mozilla's CA Bundle."
+name = "certifi"
+optional = false
+python-versions = "*"
+version = "2020.6.20"
+
+[[package]]
+category = "dev"
+description = "Validate configuration and produce human readable error messages."
+name = "cfgv"
+optional = false
+python-versions = ">=3.6.1"
+version = "3.2.0"
+
+[[package]]
+category = "main"
+description = "Universal encoding detector for Python 2 and 3"
+name = "chardet"
+optional = false
+python-versions = "*"
+version = "3.0.4"
+
+[[package]]
+category = "dev"
+description = "Composable command line interface toolkit"
+name = "click"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+version = "7.1.2"
+
+[[package]]
+category = "dev"
+description = "Cross-platform colored terminal text."
+marker = "sys_platform == \"win32\""
+name = "colorama"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+version = "0.4.3"
+
+[[package]]
+category = "dev"
+description = "Code coverage measurement for Python"
+name = "coverage"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
+version = "5.2.1"
+
+[package.extras]
+toml = ["toml"]
+
+[[package]]
+category = "dev"
+description = "Distribution utilities"
+name = "distlib"
+optional = false
+python-versions = "*"
+version = "0.3.1"
+
+[[package]]
+category = "dev"
+description = "A platform independent file lock."
+name = "filelock"
+optional = false
+python-versions = "*"
+version = "3.0.12"
+
+[[package]]
+category = "dev"
+description = "File identification library for Python"
+name = "identify"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
+version = "1.5.0"
+
+[package.extras]
+license = ["editdistance"]
+
+[[package]]
+category = "main"
+description = "Internationalized Domain Names in Applications (IDNA)"
+name = "idna"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+version = "2.10"
+
+[[package]]
+category = "dev"
+description = "Read metadata from Python packages"
+marker = "python_version < \"3.8\""
+name = "importlib-metadata"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+version = "1.7.0"
+
+[package.dependencies]
+zipp = ">=0.5"
+
+[package.extras]
+docs = ["sphinx", "rst.linker"]
+testing = ["packaging", "pep517", "importlib-resources (>=1.3)"]
+
+[[package]]
+category = "dev"
+description = "iniconfig: brain-dead simple config-ini parsing"
+name = "iniconfig"
+optional = false
+python-versions = "*"
+version = "1.0.1"
+
+[[package]]
+category = "main"
+description = "A client interface for the private Instagram API."
+name = "instagram-private-api"
+optional = false
+python-versions = "*"
+version = "1.6.0.0"
+
+[[package]]
+category = "dev"
+description = "More routines for operating on iterables, beyond itertools"
+name = "more-itertools"
+optional = false
+python-versions = ">=3.5"
+version = "8.5.0"
+
+[[package]]
+category = "dev"
+description = "Experimental type system extensions for programs checked with the mypy typechecker."
+name = "mypy-extensions"
+optional = false
+python-versions = "*"
+version = "0.4.3"
+
+[[package]]
+category = "dev"
+description = "Node.js virtual environment builder"
+name = "nodeenv"
+optional = false
+python-versions = "*"
+version = "1.5.0"
+
+[[package]]
+category = "dev"
+description = "Core utilities for Python packages"
+name = "packaging"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+version = "20.4"
+
+[package.dependencies]
+pyparsing = ">=2.0.2"
+six = "*"
+
+[[package]]
+category = "dev"
+description = "Utility library for gitignore style pattern matching of file paths."
+name = "pathspec"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+version = "0.8.0"
+
+[[package]]
+category = "main"
+description = "Python Imaging Library (Fork)"
+name = "pillow"
+optional = false
+python-versions = ">=3.5"
+version = "7.2.0"
+
+[[package]]
+category = "dev"
+description = "plugin and hook calling mechanisms for python"
+name = "pluggy"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+version = "0.13.1"
+
+[package.dependencies]
+[package.dependencies.importlib-metadata]
+python = "<3.8"
+version = ">=0.12"
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+
+[[package]]
+category = "dev"
+description = "A framework for managing and maintaining multi-language pre-commit hooks."
+name = "pre-commit"
+optional = false
+python-versions = ">=3.6.1"
+version = "2.7.1"
+
+[package.dependencies]
+cfgv = ">=2.0.0"
+identify = ">=1.0.0"
+nodeenv = ">=0.11.1"
+pyyaml = ">=5.1"
+toml = "*"
+virtualenv = ">=20.0.8"
+
+[package.dependencies.importlib-metadata]
+python = "<3.8"
+version = "*"
+
+[[package]]
+category = "dev"
+description = "library with cross-python path, ini-parsing, io, code, log facilities"
+name = "py"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+version = "1.9.0"
+
+[[package]]
+category = "dev"
+description = "Python parsing module"
+name = "pyparsing"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+version = "2.4.7"
+
+[[package]]
+category = "dev"
+description = "pytest: simple powerful testing with Python"
+name = "pytest"
+optional = false
+python-versions = ">=3.5"
+version = "6.0.1"
+
+[package.dependencies]
+atomicwrites = ">=1.0"
+attrs = ">=17.4.0"
+colorama = "*"
+iniconfig = "*"
+more-itertools = ">=4.0.0"
+packaging = "*"
+pluggy = ">=0.12,<1.0"
+py = ">=1.8.2"
+toml = "*"
+
+[package.dependencies.importlib-metadata]
+python = "<3.8"
+version = ">=0.12"
+
+[package.extras]
+checkqa_mypy = ["mypy (0.780)"]
+testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
+
+[[package]]
+category = "dev"
+description = "Pytest plugin for measuring coverage."
+name = "pytest-cov"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+version = "2.10.1"
+
+[package.dependencies]
+coverage = ">=4.4"
+pytest = ">=4.6"
+
+[package.extras]
+testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "pytest-xdist", "virtualenv"]
+
+[[package]]
+category = "dev"
+description = "Thin-wrapper around the mock package for easier use with pytest"
+name = "pytest-mock"
+optional = false
+python-versions = ">=3.5"
+version = "3.3.1"
+
+[package.dependencies]
+pytest = ">=5.0"
+
+[package.extras]
+dev = ["pre-commit", "tox", "pytest-asyncio"]
+
+[[package]]
+category = "dev"
+description = "YAML parser and emitter for Python"
+name = "pyyaml"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+version = "5.3.1"
+
+[[package]]
+category = "dev"
+description = "Alternative regular expression module, to replace re."
+name = "regex"
+optional = false
+python-versions = "*"
+version = "2020.7.14"
+
+[[package]]
+category = "main"
+description = "Python HTTP for Humans."
+name = "requests"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+version = "2.24.0"
+
+[package.dependencies]
+certifi = ">=2017.4.17"
+chardet = ">=3.0.2,<4"
+idna = ">=2.5,<3"
+urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26"
+
+[package.extras]
+security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"]
+socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"]
+
+[[package]]
+category = "dev"
+description = "Python 2 and 3 compatibility utilities"
+name = "six"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+version = "1.15.0"
+
+[[package]]
+category = "dev"
+description = "Python Library for Tom's Obvious, Minimal Language"
+name = "toml"
+optional = false
+python-versions = "*"
+version = "0.10.1"
+
+[[package]]
+category = "dev"
+description = "a fork of Python 2 and 3 ast modules with type comment support"
+name = "typed-ast"
+optional = false
+python-versions = "*"
+version = "1.4.1"
+
+[[package]]
+category = "dev"
+description = "Backported and Experimental Type Hints for Python 3.5+"
+name = "typing-extensions"
+optional = false
+python-versions = "*"
+version = "3.7.4.3"
+
+[[package]]
+category = "main"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+name = "urllib3"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
+version = "1.25.10"
+
+[package.extras]
+brotli = ["brotlipy (>=0.6.0)"]
+secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)", "ipaddress"]
+socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"]
+
+[[package]]
+category = "dev"
+description = "Virtual Python Environment builder"
+name = "virtualenv"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
+version = "20.0.31"
+
+[package.dependencies]
+appdirs = ">=1.4.3,<2"
+distlib = ">=0.3.1,<1"
+filelock = ">=3.0.0,<4"
+six = ">=1.9.0,<2"
+
+[package.dependencies.importlib-metadata]
+python = "<3.8"
+version = ">=0.12,<2"
+
+[package.extras]
+docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"]
+testing = ["coverage (>=5)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "pytest-xdist (>=1.31.0)", "packaging (>=20.0)", "xonsh (>=0.9.16)"]
+
+[[package]]
+category = "dev"
+description = "Backport of pathlib-compatible object wrapper for zip files"
+marker = "python_version < \"3.8\""
+name = "zipp"
+optional = false
+python-versions = ">=3.6"
+version = "3.1.0"
+
+[package.extras]
+docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
+testing = ["jaraco.itertools", "func-timeout"]
+
+[metadata]
+content-hash = "d7914f6a7d99cc2ac83337c5ba36291e5c8b4fc5c3ca888b77a34c42ec9c3b94"
+lock-version = "1.0"
+python-versions = "^3.7"
+
+[metadata.files]
+appdirs = [
+    {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
+    {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
+]
+atomicwrites = [
+    {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
+    {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
+]
+attrs = [
+    {file = "attrs-20.2.0-py2.py3-none-any.whl", hash = "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"},
+    {file = "attrs-20.2.0.tar.gz", hash = "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594"},
+]
+black = [
+    {file = "black-20.8b1-py3-none-any.whl", hash = "sha256:70b62ef1527c950db59062cda342ea224d772abdf6adc58b86a45421bab20a6b"},
+    {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"},
+]
+certifi = [
+    {file = "certifi-2020.6.20-py2.py3-none-any.whl", hash = "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"},
+    {file = "certifi-2020.6.20.tar.gz", hash = "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3"},
+]
+cfgv = [
+    {file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"},
+    {file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"},
+]
+chardet = [
+    {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"},
+    {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"},
+]
+click = [
+    {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
+    {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
+]
+colorama = [
+    {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"},
+    {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"},
+]
+coverage = [
+    {file = "coverage-5.2.1-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:40f70f81be4d34f8d491e55936904db5c527b0711b2a46513641a5729783c2e4"},
+    {file = "coverage-5.2.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:675192fca634f0df69af3493a48224f211f8db4e84452b08d5fcebb9167adb01"},
+    {file = "coverage-5.2.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:2fcc8b58953d74d199a1a4d633df8146f0ac36c4e720b4a1997e9b6327af43a8"},
+    {file = "coverage-5.2.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:64c4f340338c68c463f1b56e3f2f0423f7b17ba6c3febae80b81f0e093077f59"},
+    {file = "coverage-5.2.1-cp27-cp27m-win32.whl", hash = "sha256:52f185ffd3291196dc1aae506b42e178a592b0b60a8610b108e6ad892cfc1bb3"},
+    {file = "coverage-5.2.1-cp27-cp27m-win_amd64.whl", hash = "sha256:30bc103587e0d3df9e52cd9da1dd915265a22fad0b72afe54daf840c984b564f"},
+    {file = "coverage-5.2.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:9ea749fd447ce7fb1ac71f7616371f04054d969d412d37611716721931e36efd"},
+    {file = "coverage-5.2.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ce7866f29d3025b5b34c2e944e66ebef0d92e4a4f2463f7266daa03a1332a651"},
+    {file = "coverage-5.2.1-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:4869ab1c1ed33953bb2433ce7b894a28d724b7aa76c19b11e2878034a4e4680b"},
+    {file = "coverage-5.2.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a3ee9c793ffefe2944d3a2bd928a0e436cd0ac2d9e3723152d6fd5398838ce7d"},
+    {file = "coverage-5.2.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:28f42dc5172ebdc32622a2c3f7ead1b836cdbf253569ae5673f499e35db0bac3"},
+    {file = "coverage-5.2.1-cp35-cp35m-win32.whl", hash = "sha256:e26c993bd4b220429d4ec8c1468eca445a4064a61c74ca08da7429af9bc53bb0"},
+    {file = "coverage-5.2.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4186fc95c9febeab5681bc3248553d5ec8c2999b8424d4fc3a39c9cba5796962"},
+    {file = "coverage-5.2.1-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:b360d8fd88d2bad01cb953d81fd2edd4be539df7bfec41e8753fe9f4456a5082"},
+    {file = "coverage-5.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:1adb6be0dcef0cf9434619d3b892772fdb48e793300f9d762e480e043bd8e716"},
+    {file = "coverage-5.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:098a703d913be6fbd146a8c50cc76513d726b022d170e5e98dc56d958fd592fb"},
+    {file = "coverage-5.2.1-cp36-cp36m-win32.whl", hash = "sha256:962c44070c281d86398aeb8f64e1bf37816a4dfc6f4c0f114756b14fc575621d"},
+    {file = "coverage-5.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1ed2bdb27b4c9fc87058a1cb751c4df8752002143ed393899edb82b131e0546"},
+    {file = "coverage-5.2.1-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:c890728a93fffd0407d7d37c1e6083ff3f9f211c83b4316fae3778417eab9811"},
+    {file = "coverage-5.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:538f2fd5eb64366f37c97fdb3077d665fa946d2b6d95447622292f38407f9258"},
+    {file = "coverage-5.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:27ca5a2bc04d68f0776f2cdcb8bbd508bbe430a7bf9c02315cd05fb1d86d0034"},
+    {file = "coverage-5.2.1-cp37-cp37m-win32.whl", hash = "sha256:aab75d99f3f2874733946a7648ce87a50019eb90baef931698f96b76b6769a46"},
+    {file = "coverage-5.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:c2ff24df02a125b7b346c4c9078c8936da06964cc2d276292c357d64378158f8"},
+    {file = "coverage-5.2.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:304fbe451698373dc6653772c72c5d5e883a4aadaf20343592a7abb2e643dae0"},
+    {file = "coverage-5.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:c96472b8ca5dc135fb0aa62f79b033f02aa434fb03a8b190600a5ae4102df1fd"},
+    {file = "coverage-5.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8505e614c983834239f865da2dd336dcf9d72776b951d5dfa5ac36b987726e1b"},
+    {file = "coverage-5.2.1-cp38-cp38-win32.whl", hash = "sha256:700997b77cfab016533b3e7dbc03b71d33ee4df1d79f2463a318ca0263fc29dd"},
+    {file = "coverage-5.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:46794c815e56f1431c66d81943fa90721bb858375fb36e5903697d5eef88627d"},
+    {file = "coverage-5.2.1-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:16042dc7f8e632e0dcd5206a5095ebd18cb1d005f4c89694f7f8aafd96dd43a3"},
+    {file = "coverage-5.2.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:c1bbb628ed5192124889b51204de27c575b3ffc05a5a91307e7640eff1d48da4"},
+    {file = "coverage-5.2.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:4f6428b55d2916a69f8d6453e48a505c07b2245653b0aa9f0dee38785939f5e4"},
+    {file = "coverage-5.2.1-cp39-cp39-win32.whl", hash = "sha256:9e536783a5acee79a9b308be97d3952b662748c4037b6a24cbb339dc7ed8eb89"},
+    {file = "coverage-5.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:b8f58c7db64d8f27078cbf2a4391af6aa4e4767cc08b37555c4ae064b8558d9b"},
+    {file = "coverage-5.2.1.tar.gz", hash = "sha256:a34cb28e0747ea15e82d13e14de606747e9e484fb28d63c999483f5d5188e89b"},
+]
+distlib = [
+    {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"},
+    {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"},
+]
+filelock = [
+    {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"},
+    {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"},
+]
+identify = [
+    {file = "identify-1.5.0-py2.py3-none-any.whl", hash = "sha256:0868312cb7402b48cf44fe3f568259f804ef4e983c143d11bf7a51ca311ebc34"},
+    {file = "identify-1.5.0.tar.gz", hash = "sha256:009f92ba753c467a99f6fd3eb395412cbc34077dd5a64313b62ba04297f2ab8e"},
+]
+idna = [
+    {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
+    {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
+]
+importlib-metadata = [
+    {file = "importlib_metadata-1.7.0-py2.py3-none-any.whl", hash = "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"},
+    {file = "importlib_metadata-1.7.0.tar.gz", hash = "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83"},
+]
+iniconfig = [
+    {file = "iniconfig-1.0.1-py3-none-any.whl", hash = "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437"},
+    {file = "iniconfig-1.0.1.tar.gz", hash = "sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69"},
+]
+instagram-private-api = [
+    {file = "instagram_private_api-1.6.0.0-py3-none-any.whl", hash = "sha256:5f9403fbd359764b2d070fa8ac22bc4546ec5a53c45676bcddafa66dc74d2f07"},
+    {file = "instagram_private_api-1.6.0.0.tar.gz", hash = "sha256:dfe0c2cb5aa881b98b2e428c3b02fa664a52af3f2d2354c19c4a8967cedf5d8e"},
+]
+more-itertools = [
+    {file = "more-itertools-8.5.0.tar.gz", hash = "sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20"},
+    {file = "more_itertools-8.5.0-py3-none-any.whl", hash = "sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c"},
+]
+mypy-extensions = [
+    {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
+    {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
+]
+nodeenv = [
+    {file = "nodeenv-1.5.0-py2.py3-none-any.whl", hash = "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9"},
+    {file = "nodeenv-1.5.0.tar.gz", hash = "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"},
+]
+packaging = [
+    {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"},
+    {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"},
+]
+pathspec = [
+    {file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"},
+    {file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"},
+]
+pillow = [
+    {file = "Pillow-7.2.0-cp35-cp35m-macosx_10_10_intel.whl", hash = "sha256:1ca594126d3c4def54babee699c055a913efb01e106c309fa6b04405d474d5ae"},
+    {file = "Pillow-7.2.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c92302a33138409e8f1ad16731568c55c9053eee71bb05b6b744067e1b62380f"},
+    {file = "Pillow-7.2.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:8dad18b69f710bf3a001d2bf3afab7c432785d94fcf819c16b5207b1cfd17d38"},
+    {file = "Pillow-7.2.0-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:431b15cffbf949e89df2f7b48528be18b78bfa5177cb3036284a5508159492b5"},
+    {file = "Pillow-7.2.0-cp35-cp35m-win32.whl", hash = "sha256:09d7f9e64289cb40c2c8d7ad674b2ed6105f55dc3b09aa8e4918e20a0311e7ad"},
+    {file = "Pillow-7.2.0-cp35-cp35m-win_amd64.whl", hash = "sha256:0295442429645fa16d05bd567ef5cff178482439c9aad0411d3f0ce9b88b3a6f"},
+    {file = "Pillow-7.2.0-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:ec29604081f10f16a7aea809ad42e27764188fc258b02259a03a8ff7ded3808d"},
+    {file = "Pillow-7.2.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:612cfda94e9c8346f239bf1a4b082fdd5c8143cf82d685ba2dba76e7adeeb233"},
+    {file = "Pillow-7.2.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0a80dd307a5d8440b0a08bd7b81617e04d870e40a3e46a32d9c246e54705e86f"},
+    {file = "Pillow-7.2.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:06aba4169e78c439d528fdeb34762c3b61a70813527a2c57f0540541e9f433a8"},
+    {file = "Pillow-7.2.0-cp36-cp36m-win32.whl", hash = "sha256:f7e30c27477dffc3e85c2463b3e649f751789e0f6c8456099eea7ddd53be4a8a"},
+    {file = "Pillow-7.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:ffe538682dc19cc542ae7c3e504fdf54ca7f86fb8a135e59dd6bc8627eae6cce"},
+    {file = "Pillow-7.2.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:94cf49723928eb6070a892cb39d6c156f7b5a2db4e8971cb958f7b6b104fb4c4"},
+    {file = "Pillow-7.2.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6edb5446f44d901e8683ffb25ebdfc26988ee813da3bf91e12252b57ac163727"},
+    {file = "Pillow-7.2.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:52125833b070791fcb5710fabc640fc1df07d087fc0c0f02d3661f76c23c5b8b"},
+    {file = "Pillow-7.2.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:9ad7f865eebde135d526bb3163d0b23ffff365cf87e767c649550964ad72785d"},
+    {file = "Pillow-7.2.0-cp37-cp37m-win32.whl", hash = "sha256:c79f9c5fb846285f943aafeafda3358992d64f0ef58566e23484132ecd8d7d63"},
+    {file = "Pillow-7.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d350f0f2c2421e65fbc62690f26b59b0bcda1b614beb318c81e38647e0f673a1"},
+    {file = "Pillow-7.2.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:6d7741e65835716ceea0fd13a7d0192961212fd59e741a46bbed7a473c634ed6"},
+    {file = "Pillow-7.2.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:edf31f1150778abd4322444c393ab9c7bd2af271dd4dafb4208fb613b1f3cdc9"},
+    {file = "Pillow-7.2.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:d08b23fdb388c0715990cbc06866db554e1822c4bdcf6d4166cf30ac82df8c41"},
+    {file = "Pillow-7.2.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:5e51ee2b8114def244384eda1c82b10e307ad9778dac5c83fb0943775a653cd8"},
+    {file = "Pillow-7.2.0-cp38-cp38-win32.whl", hash = "sha256:725aa6cfc66ce2857d585f06e9519a1cc0ef6d13f186ff3447ab6dff0a09bc7f"},
+    {file = "Pillow-7.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:a060cf8aa332052df2158e5a119303965be92c3da6f2d93b6878f0ebca80b2f6"},
+    {file = "Pillow-7.2.0-pp36-pypy36_pp73-macosx_10_10_x86_64.whl", hash = "sha256:9c87ef410a58dd54b92424ffd7e28fd2ec65d2f7fc02b76f5e9b2067e355ebf6"},
+    {file = "Pillow-7.2.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:e901964262a56d9ea3c2693df68bc9860b8bdda2b04768821e4c44ae797de117"},
+    {file = "Pillow-7.2.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:25930fadde8019f374400f7986e8404c8b781ce519da27792cbe46eabec00c4d"},
+    {file = "Pillow-7.2.0.tar.gz", hash = "sha256:97f9e7953a77d5a70f49b9a48da7776dc51e9b738151b22dacf101641594a626"},
+]
+pluggy = [
+    {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
+    {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
+]
+pre-commit = [
+    {file = "pre_commit-2.7.1-py2.py3-none-any.whl", hash = "sha256:810aef2a2ba4f31eed1941fc270e72696a1ad5590b9751839c90807d0fff6b9a"},
+    {file = "pre_commit-2.7.1.tar.gz", hash = "sha256:c54fd3e574565fe128ecc5e7d2f91279772ddb03f8729645fa812fe809084a70"},
+]
+py = [
+    {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"},
+    {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"},
+]
+pyparsing = [
+    {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
+    {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
+]
+pytest = [
+    {file = "pytest-6.0.1-py3-none-any.whl", hash = "sha256:8b6007800c53fdacd5a5c192203f4e531eb2a1540ad9c752e052ec0f7143dbad"},
+    {file = "pytest-6.0.1.tar.gz", hash = "sha256:85228d75db9f45e06e57ef9bf4429267f81ac7c0d742cc9ed63d09886a9fe6f4"},
+]
+pytest-cov = [
+    {file = "pytest-cov-2.10.1.tar.gz", hash = "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"},
+    {file = "pytest_cov-2.10.1-py2.py3-none-any.whl", hash = "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191"},
+]
+pytest-mock = [
+    {file = "pytest-mock-3.3.1.tar.gz", hash = "sha256:a4d6d37329e4a893e77d9ffa89e838dd2b45d5dc099984cf03c703ac8411bb82"},
+    {file = "pytest_mock-3.3.1-py3-none-any.whl", hash = "sha256:024e405ad382646318c4281948aadf6fe1135632bea9cc67366ea0c4098ef5f2"},
+]
+pyyaml = [
+    {file = "PyYAML-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f"},
+    {file = "PyYAML-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76"},
+    {file = "PyYAML-5.3.1-cp35-cp35m-win32.whl", hash = "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2"},
+    {file = "PyYAML-5.3.1-cp35-cp35m-win_amd64.whl", hash = "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c"},
+    {file = "PyYAML-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2"},
+    {file = "PyYAML-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648"},
+    {file = "PyYAML-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"},
+    {file = "PyYAML-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf"},
+    {file = "PyYAML-5.3.1-cp38-cp38-win32.whl", hash = "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97"},
+    {file = "PyYAML-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee"},
+    {file = "PyYAML-5.3.1.tar.gz", hash = "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d"},
+]
+regex = [
+    {file = "regex-2020.7.14-cp27-cp27m-win32.whl", hash = "sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7"},
+    {file = "regex-2020.7.14-cp27-cp27m-win_amd64.whl", hash = "sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644"},
+    {file = "regex-2020.7.14-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc"},
+    {file = "regex-2020.7.14-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067"},
+    {file = "regex-2020.7.14-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd"},
+    {file = "regex-2020.7.14-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88"},
+    {file = "regex-2020.7.14-cp36-cp36m-win32.whl", hash = "sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4"},
+    {file = "regex-2020.7.14-cp36-cp36m-win_amd64.whl", hash = "sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f"},
+    {file = "regex-2020.7.14-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162"},
+    {file = "regex-2020.7.14-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf"},
+    {file = "regex-2020.7.14-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7"},
+    {file = "regex-2020.7.14-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89"},
+    {file = "regex-2020.7.14-cp37-cp37m-win32.whl", hash = "sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6"},
+    {file = "regex-2020.7.14-cp37-cp37m-win_amd64.whl", hash = "sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204"},
+    {file = "regex-2020.7.14-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99"},
+    {file = "regex-2020.7.14-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e"},
+    {file = "regex-2020.7.14-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e"},
+    {file = "regex-2020.7.14-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:75aaa27aa521a182824d89e5ab0a1d16ca207318a6b65042b046053cfc8ed07a"},
+    {file = "regex-2020.7.14-cp38-cp38-win32.whl", hash = "sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341"},
+    {file = "regex-2020.7.14-cp38-cp38-win_amd64.whl", hash = "sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840"},
+    {file = "regex-2020.7.14.tar.gz", hash = "sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb"},
+]
+requests = [
+    {file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"},
+    {file = "requests-2.24.0.tar.gz", hash = "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b"},
+]
+six = [
+    {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
+    {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
+]
+toml = [
+    {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"},
+    {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"},
+]
+typed-ast = [
+    {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"},
+    {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"},
+    {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"},
+    {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"},
+    {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"},
+    {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"},
+    {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"},
+    {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"},
+    {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"},
+    {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"},
+    {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"},
+    {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"},
+    {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"},
+    {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"},
+    {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"},
+    {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"},
+    {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"},
+    {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"},
+    {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"},
+    {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"},
+    {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"},
+]
+typing-extensions = [
+    {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"},
+    {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"},
+    {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"},
+]
+urllib3 = [
+    {file = "urllib3-1.25.10-py2.py3-none-any.whl", hash = "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"},
+    {file = "urllib3-1.25.10.tar.gz", hash = "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a"},
+]
+virtualenv = [
+    {file = "virtualenv-20.0.31-py2.py3-none-any.whl", hash = "sha256:e0305af10299a7fb0d69393d8f04cb2965dda9351140d11ac8db4e5e3970451b"},
+    {file = "virtualenv-20.0.31.tar.gz", hash = "sha256:43add625c53c596d38f971a465553f6318decc39d98512bc100fa1b1e839c8dc"},
+]
+zipp = [
+    {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"},
+    {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"},
+]
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..7e6c6b5
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,57 @@
+[tool.poetry]
+name = "inst-api"
+version = "0.2.0"
+description = "InstAPI - comfortable and easy to use Python's library for interaction with Instagram"
+authors = ["Yurii Karabas <1998uriyyo@gmail.com>"]
+
+license = "MIT"
+readme = "README.md"
+repository = "https://github.com/uriyyo/instapi"
+
+classifiers = [
+    'Operating System :: OS Independent',
+    'Programming Language :: Python',
+    'Programming Language :: Python :: 3',
+    'Programming Language :: Python :: 3.7',
+    'Programming Language :: Python :: 3.8',
+    'Programming Language :: Python :: 3.9',
+    'Programming Language :: Python :: 3 :: Only',
+]
+
+[tool.poetry.dependencies]
+python = "^3.7"
+instagram-private-api = "^1.6.0"
+pillow = "^7.2.0"
+requests = "^2.24.0" # TODO: replace with httpx
+
+[tool.poetry.dev-dependencies]
+pytest = "^6.0.1"
+black = "^20.8b1"
+pre-commit = "^2.7.1"
+pytest-mock = "^3.3.1"
+pytest-cov = "^2.10.1"
+
+[tool.black]
+line-length = 100
+target-version = ['py37']
+include = '\.pyi?$'
+exclude = '''
+(
+  /(
+      \.eggs         # exclude a few common directories in the
+    | \.git          # root of the project
+    | \.hg
+    | \.mypy_cache
+    | \.tox
+    | \.venv
+    | _build
+    | buck-out
+    | build
+    | dist
+  )/
+)
+'''
+
+[build-system]
+requires = ["poetry>=0.12"]
+build-backend = "poetry.masonry.api"
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..2c56530
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,25 @@
+[flake8]
+ignore = D,WPS,C,F,E,B
+exclude = .git,__pycache__,build,dist
+max-line-length = 120
+
+[isort]
+multi_line_output = 3
+include_trailing_comma = true
+force_grid_wrap = 0
+use_parentheses = true
+known_third_party = PIL,instagram_private_api,pytest,requests
+
+[mypy]
+python_version = 3.7
+show_column_numbers = True
+follow_imports = normal
+ignore_missing_imports = True
+disallow_untyped_calls = True
+warn_return_any = True
+strict_optional = True
+warn_no_return = True
+warn_redundant_casts = True
+warn_unused_ignores = True
+disallow_untyped_defs = True
+check_untyped_defs = True
\ No newline at end of file
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 5b27841..0000000
--- a/setup.py
+++ /dev/null
@@ -1,65 +0,0 @@
-from pathlib import Path
-
-from setuptools import (
-    find_packages,
-    setup,
-)
-
-__version__ = '0.0.1'
-
-README: Path = Path(__file__).parent / 'README.md'
-
-test_requirements = [
-    'pytest==5.1.2',
-    'pytest-mock==1.10.4',
-    'pytest-cov==2.7.1',
-    'pre-commit==1.18.3 ',
-]
-
-lint_requirements = [
-    'mypy==0.720',
-    'isort==4.3',
-    'flake8==3.7.8',
-    'flake8-isort==2.7.0',
-    'flake8-docstrings==1.4',
-    'flake8-bugbear==19.8',
-]
-
-setup(
-    name='instapi',
-    version=__version__,
-    python_requires='>=3.6',
-    author='Yurii Karabas',
-    author_email='1998uriyyo@gmail.com',
-    url='https://github.com/uriyyo/instapi',
-    include_package_data=True,
-    description="InstAPI - comfortable and easy to use Python's library for interaction with Instagram",
-    long_description=README.read_text(),
-    license='MIT',
-    packages=find_packages(exclude=('tests*',)),
-    install_requires=[
-        'dataclasses==0.6.0',
-        'instagram-private-api==1.6.0.0',
-        'pillow>=6.2.0',
-        'requests>=2.22.0',
-        'autologging==1.3.2',
-    ],
-    extras_require={
-        'test': test_requirements,
-        'lint': lint_requirements,
-        'dev': [
-            *test_requirements,
-            *lint_requirements,
-            'pre-commit==1.18.3 ',
-        ]
-    },
-    classifiers=[
-        'Operating System :: OS Independent',
-        'Programming Language :: Python',
-        'Programming Language :: Python :: 3',
-        'Programming Language :: Python :: 3.6',
-        'Programming Language :: Python :: 3.7',
-        'Programming Language :: Python :: 3.8',
-        'Programming Language :: Python :: 3 :: Only',
-    ],
-)


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..135d246
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,33 @@
+name: Test
+
+on: [push, pull_request]
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version: [3.7, 3.8]
+
+    steps:
+      - uses: actions/checkout@v2
+
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Install dependencies
+        run: |
+          pip install poetry
+          poetry install
+
+      - name: Unit tests
+        run: poetry run pytest tests --cov=instapi --cov-report=term-missing --cov-report=xml
+
+      - name: Upload coverage to Codecov
+        uses: codecov/codecov-action@v1
+        with:
+          fail_ci_if_error: true
+          file: ./coverage.xml
\ No newline at end of file
diff --git a/pytest.ini b/pytest.ini
index ac3a11c..2be53ad 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,4 +1,5 @@
 [pytest]
 
 addopts =
-
+    --cov=instapi
+    --cov-report=term-missing
diff --git a/tests/unit_tests/client_api/test_base.py b/tests/unit_tests/client_api/test_base.py
index 664ba0b..40e2f68 100644
--- a/tests/unit_tests/client_api/test_base.py
+++ b/tests/unit_tests/client_api/test_base.py
@@ -1,10 +1,11 @@
 from instapi.client_api.base import BaseClient
+
 from ..conftest import random_string
 
 
 def test_redirect_to_base(mocker):
-    mocker.patch('instagram_private_api.client.Client.__init__', return_value=None)
-    mock = mocker.patch('instagram_private_api.client.Client._call_api', return_value=None)
+    mocker.patch("instagram_private_api.client.Client.__init__", return_value=None)
+    mock = mocker.patch("instagram_private_api.client.Client._call_api", return_value=None)
 
     client = BaseClient(random_string(), random_string())
     client._call_api(random_string())
diff --git a/tests/unit_tests/client_api/test_direct.py b/tests/unit_tests/client_api/test_direct.py
index d0e4079..536534b 100644
--- a/tests/unit_tests/client_api/test_direct.py
+++ b/tests/unit_tests/client_api/test_direct.py
@@ -1,27 +1,24 @@
 from instagram_private_api import ClientError
-from pytest import (
-    fixture,
-    mark,
-    raises,
-)
+from pytest import fixture, mark, raises
 from requests import Response
 
 from instapi.client_api.direct import DirectEndpoint
+
 from ..conftest import random_string
 
 
 @fixture
 def direct_endpoint(mocker):
-    mocker.patch('instagram_private_api.client.Client.login', return_value=None)
+    mocker.patch("instagram_private_api.client.Client.login", return_value=None)
     return DirectEndpoint(random_string(), random_string())
 
 
 @mark.parametrize(
-    'data,expected',
+    "data,expected",
     [
-        [1, '[[1]]'],
-        [[1, 2, 3], '[[1,2,3]]'],
-    ]
+        [1, "[[1]]"],
+        [[1, 2, 3], "[[1,2,3]]"],
+    ],
 )
 def test_convert_recipient(data, expected):
     assert DirectEndpoint._convert_recipient_users(data) == expected
@@ -36,21 +33,21 @@ def response(self):
 
     @fixture
     def mock_post(self, mocker, response):
-        return mocker.patch('requests.post', return_value=response)
+        return mocker.patch("requests.post", return_value=response)
 
     def test_send_item(self, mock_post, direct_endpoint, response):
         response.status_code = 200
 
         data = direct_endpoint.direct_v2_send_item(
             recipient_users=1,
-            item_type='text',
+            item_type="text",
             item_data={},
         )
 
-        assert data == {'key': 'value'}
+        assert data == {"key": "value"}
 
         _, kwargs = mock_post.call_args
-        assert 'thread_ids' not in kwargs['data']
+        assert "thread_ids" not in kwargs["data"]
 
     def test_send_item_with_thread_id(self, mock_post, direct_endpoint, response):
         response.status_code = 200
@@ -58,14 +55,14 @@ def test_send_item_with_thread_id(self, mock_post, direct_endpoint, response):
         data = direct_endpoint.direct_v2_send_item(
             recipient_users=1,
             thread_id=1,
-            item_type='text',
+            item_type="text",
             item_data={},
         )
 
-        assert data == {'key': 'value'}
+        assert data == {"key": "value"}
 
         _, kwargs = mock_post.call_args
-        assert 'thread_ids' in kwargs['data']
+        assert "thread_ids" in kwargs["data"]
 
     def test_send_item_raise_exception(self, mock_post, direct_endpoint, response):
         response.status_code = 400
@@ -74,38 +71,38 @@ def test_send_item_raise_exception(self, mock_post, direct_endpoint, response):
             direct_endpoint.direct_v2_send_item(
                 recipient_users=1,
                 thread_id=1,
-                item_type='text',
+                item_type="text",
                 item_data={},
             )
 
 
 @mark.parametrize(
-    'method,kwargs',
+    "method,kwargs",
     [
-        [DirectEndpoint.direct_v2_send_media_share, {'media_id': 1}],
-        [DirectEndpoint.direct_v2_send_hashtag, {'hashtag': ''}],
-        [DirectEndpoint.direct_v2_send_profile, {'profile_id': 1}],
-        [DirectEndpoint.direct_v2_send_link, {'link': ''}],
-        [DirectEndpoint.direct_v2_send_text, {'text': ''}],
-    ]
+        [DirectEndpoint.direct_v2_send_media_share, {"media_id": 1}],
+        [DirectEndpoint.direct_v2_send_hashtag, {"hashtag": ""}],
+        [DirectEndpoint.direct_v2_send_profile, {"profile_id": 1}],
+        [DirectEndpoint.direct_v2_send_link, {"link": ""}],
+        [DirectEndpoint.direct_v2_send_text, {"text": ""}],
+    ],
 )
 def test_custom_send(mocker, method, direct_endpoint, kwargs):
-    mock = mocker.patch('instapi.client_api.direct.DirectEndpoint.direct_v2_send_item')
+    mock = mocker.patch("instapi.client_api.direct.DirectEndpoint.direct_v2_send_item")
     method(direct_endpoint, **kwargs)
 
     mock.assert_called_once()
 
 
 @mark.parametrize(
-    'method,kwargs',
+    "method,kwargs",
     [
         [DirectEndpoint.direct_v2_inbox, {}],
-        [DirectEndpoint.direct_v2_get_by_participants, {'recipient_users': 1}],
-        [DirectEndpoint.direct_v2_thread, {'thread_id': 1}]
-    ]
+        [DirectEndpoint.direct_v2_get_by_participants, {"recipient_users": 1}],
+        [DirectEndpoint.direct_v2_thread, {"thread_id": 1}],
+    ],
 )
 def test_direct_call_api(mocker, method, direct_endpoint, kwargs):
-    mock = mocker.patch('instapi.client_api.direct.DirectEndpoint._call_api')
+    mock = mocker.patch("instapi.client_api.direct.DirectEndpoint._call_api")
     method(direct_endpoint, **kwargs)
 
     mock.assert_called_once()
diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py
index 4eaa61b..7790759 100644
--- a/tests/unit_tests/conftest.py
+++ b/tests/unit_tests/conftest.py
@@ -1,26 +1,15 @@
+from dataclasses import is_dataclass
 from functools import partial
-from random import (
-    choice,
-    randint,
-)
+from random import choice, randint
 from string import ascii_letters
-from typing import (
-    Any,
-    Callable,
-    Dict,
-    List,
-    Type,
-    TypeVar,
-    Union,
-)
+from typing import Any, Callable, Dict, List, Type, TypeVar, Union
 
-from dataclasses import is_dataclass
 from pytest import fixture
 
 from instapi import models
 from instapi.client import ClientProxy
 
-T = TypeVar('T')
+T = TypeVar("T")
 
 
 def pytest_configure():
@@ -35,7 +24,7 @@ def random_bytes(count: int = 10) -> bytes:
     :param count: how many bytes will be generated
     :return: random bytes
     """
-    return b''.join(bytes(choice(ascii_letters), 'ascii') for _ in range(count))
+    return b"".join(bytes(choice(ascii_letters), "ascii") for _ in range(count))
 
 
 def random_string(length: int = 10, source: str = ascii_letters) -> str:
@@ -46,7 +35,7 @@ def random_string(length: int = 10, source: str = ascii_letters) -> str:
     :param source: source of characters to use
     :return: random string
     """
-    return ''.join(choice(source) for _ in range(length))
+    return "".join(choice(source) for _ in range(length))
 
 
 def random_int(start: int = 1, end: int = 100) -> int:
@@ -60,14 +49,14 @@ def random_int(start: int = 1, end: int = 100) -> int:
     return randint(start, end)
 
 
-def random_url(extension: str = '.jpg'):
+def random_url(extension: str = ".jpg"):
     """
     Generate random url
 
     :param extension: extension to add to url
     :return: random url
     """
-    return f'http://{random_string()}.com/{random_string()}{extension}'
+    return f"http://{random_string()}.com/{random_string()}{extension}"
 
 
 # Define default actions to do for different types
@@ -109,10 +98,12 @@ def rand(cls: Type[T], **kwargs) -> T:
 
     fields_info = {f: cls.__dataclass_fields__[f] for f in cls.fields() if f not in kwargs}
 
-    return cls(**{
-        **{name: _get_rand_type(field.type) for name, field in fields_info.items()},
-        **kwargs,
-    })
+    return cls(
+        **{
+            **{name: _get_rand_type(field.type) for name, field in fields_info.items()},
+            **kwargs,
+        }
+    )
 
 
 def rands(cls: Type[T], length: int = 10, **kwargs: Callable[[], Any]) -> List[T]:
diff --git a/tests/unit_tests/models/conftest.py b/tests/unit_tests/models/conftest.py
index 743d36e..913fa01 100644
--- a/tests/unit_tests/models/conftest.py
+++ b/tests/unit_tests/models/conftest.py
@@ -1,40 +1,24 @@
 from functools import partial
 from random import shuffle
-from typing import (
-    Iterable,
-    List,
-    Tuple,
-    Type,
-    TypeVar,
-)
+from typing import Iterable, List, Tuple, Type, TypeVar
 
 from pytest import fixture
 
 from instapi import Direct
-from instapi.models import (
-    Comment,
-    Entity,
-    Feed,
-    Media,
-    User,
-)
+from instapi.models import Comment, Entity, Feed, Media, User
 from instapi.models.direct import Message
 from instapi.models.resource import (
     Candidate,
     Image,
     Resource,
-    Video,
     ResourceContainer,
+    Video,
 )
 from instapi.models.story import Story
-from ..conftest import (
-    rand,
-    random_int,
-    random_url,
-    rands,
-)
 
-T = TypeVar('T')
+from ..conftest import rand, random_int, random_url, rands
+
+T = TypeVar("T")
 
 
 def as_dicts(models: Iterable[T]) -> List[T]:
@@ -67,7 +51,7 @@ def create_feeds(length: int = 10) -> List[Feed]:
     return rands(Feed, length)
 
 
-def create_candidates(length: int = 10, extension: str = '.jpg') -> Tuple[Candidate]:
+def create_candidates(length: int = 10, extension: str = ".jpg") -> Tuple[Candidate]:
     """
     Generate list of dummy resource candidates
 
@@ -79,9 +63,9 @@ def create_candidates(length: int = 10, extension: str = '.jpg') -> Tuple[Candid
 
 
 def create_resource(
-        length: int = 10,
-        extension: str = '.jpg',
-        resource_cls: Type[T] = Resource,
+    length: int = 10,
+    extension: str = ".jpg",
+    resource_cls: Type[T] = Resource,
 ) -> List[T]:
     """
     Generate list of dummy resources
@@ -125,7 +109,7 @@ def create_videos(length: int = 10) -> List[Video]:
     :param length: length of list
     :return: list of dummy videos
     """
-    return create_resource(resource_cls=Video, extension='.mp4', length=length)
+    return create_resource(resource_cls=Video, extension=".mp4", length=length)
 
 
 def create_directs(length: int = 10) -> List[Direct]:
@@ -179,28 +163,28 @@ def media():
 @fixture()
 def image():
     """Fixture that return dummy image"""
-    im, = create_images(length=1)
+    (im,) = create_images(length=1)
     return im
 
 
 @fixture()
 def resource():
     """Fixture that return dummy resource"""
-    resp, = create_resource(length=1)
+    (resp,) = create_resource(length=1)
     return resp
 
 
 @fixture()
 def feed() -> Feed:
     """Fixture that return dummy feed"""
-    f, = create_feeds(length=1)
+    (f,) = create_feeds(length=1)
     return f
 
 
 @fixture()
 def candidate() -> Candidate:
     """Fixture that return dummy candidate"""
-    c, = create_candidates(length=1)
+    (c,) = create_candidates(length=1)
     return c
 
 
diff --git a/tests/unit_tests/models/test_base.py b/tests/unit_tests/models/test_base.py
index cc25b83..635a506 100644
--- a/tests/unit_tests/models/test_base.py
+++ b/tests/unit_tests/models/test_base.py
@@ -1,14 +1,9 @@
 from dataclasses import dataclass
-from pytest import (
-    fixture,
-    raises,
-)
+
+from pytest import fixture, raises
 
 from instapi.models.base import BaseModel
-from tests.unit_tests.conftest import (
-    random_int,
-    random_string,
-)
+from tests.unit_tests.conftest import random_int, random_string
 
 
 class TestBaseModel:
@@ -26,8 +21,8 @@ class TempModel(BaseModel):
 
     @fixture()
     def mocked_fields(self, mocker):
-        fields = {'a', 'b', 'c'}
-        mocker.patch('instapi.models.base.BaseModel.fields', return_value=fields)
+        fields = {"a", "b", "c"}
+        mocker.patch("instapi.models.base.BaseModel.fields", return_value=fields)
         data = {key: random_int() for key in fields}
 
         return data
@@ -35,7 +30,7 @@ def mocked_fields(self, mocker):
     def test_fields(self, model_cls):
         """Test for BaseModel.fields classmethod"""
 
-        assert model_cls.fields() == {'a', 'b', 'c'}
+        assert model_cls.fields() == {"a", "b", "c"}
 
     def test_fields_with_inheritance(self):
         """Test for BaseModel.fields classmethod"""
@@ -44,19 +39,19 @@ def test_fields_with_inheritance(self):
         class First(BaseModel):
             a: int
 
-        assert First.fields() == {'a'}
+        assert First.fields() == {"a"}
 
         @dataclass(frozen=True)
         class Two(First):
             b: int
 
-        assert Two.fields() == {'a', 'b'}
+        assert Two.fields() == {"a", "b"}
 
         @dataclass(frozen=True)
         class Three(Two):
             c: int
 
-        assert Three.fields() == {'a', 'b', 'c'}
+        assert Three.fields() == {"a", "b", "c"}
 
     def test_create(self, model_cls, mocked_fields):
         assert model_cls.create(mocked_fields) == model_cls(**mocked_fields)
diff --git a/tests/unit_tests/models/test_comments.py b/tests/unit_tests/models/test_comments.py
index 941af1a..5276c17 100644
--- a/tests/unit_tests/models/test_comments.py
+++ b/tests/unit_tests/models/test_comments.py
@@ -1,6 +1,6 @@
 def test_like_comment(comment, mocker):
     """Test for Comment.like method"""
-    like_mock = mocker.patch('instapi.client.client.comment_like')
+    like_mock = mocker.patch("instapi.client.client.comment_like")
 
     comment.like()
 
@@ -9,7 +9,7 @@ def test_like_comment(comment, mocker):
 
 def test_unlike_comment(comment, mocker):
     """Test for Comment.unlike method"""
-    unlike_mock = mocker.patch('instapi.client.client.comment_unlike')
+    unlike_mock = mocker.patch("instapi.client.client.comment_unlike")
 
     comment.unlike()
 
diff --git a/tests/unit_tests/models/test_direct.py b/tests/unit_tests/models/test_direct.py
index 236cbbc..3f9660e 100644
--- a/tests/unit_tests/models/test_direct.py
+++ b/tests/unit_tests/models/test_direct.py
@@ -2,40 +2,34 @@
 
 from pytest import fixture
 
-from instapi import (
-    Direct,
-    Message,
-    User,
-)
+from instapi import Direct, Message, User
 from tests.unit_tests.models.conftest import as_dicts
-from ..conftest import (
-    random_int,
-    random_string,
-)
+
+from ..conftest import random_int, random_string
 
 
 def test_direct_create(user):
     data = {
-        'thread_title': random_string(),
-        'thread_type': random_string(),
-        'is_group': False,
-        'users': [user.as_dict()],
-        'thread_id': random_int(),
+        "thread_title": random_string(),
+        "thread_type": random_string(),
+        "is_group": False,
+        "users": [user.as_dict()],
+        "thread_id": random_int(),
     }
 
     direct = Direct.create(data)
 
-    assert {**data, 'users': tuple(User.create(d) for d in data['users'])} == vars(direct)
+    assert {**data, "users": tuple(User.create(d) for d in data["users"])} == vars(direct)
 
 
 class TestDirects:
     @fixture
     def inbox_data(self, directs):
-        return {'inbox': {'threads': as_dicts(directs)}}
+        return {"inbox": {"threads": as_dicts(directs)}}
 
     @fixture
     def mock_inbox(self, mocker, inbox_data):
-        return mocker.patch('instapi.client.client.direct_v2_inbox', return_value=inbox_data)
+        return mocker.patch("instapi.client.client.direct_v2_inbox", return_value=inbox_data)
 
     def test_iter_direct_return_type(self):
         assert isinstance(Direct.iter_directs(), Generator)
@@ -54,7 +48,7 @@ def test_direct_with_limit(self, mock_inbox, directs):
 class TestMessages:
     @fixture
     def message_data(self, direct, messages):
-        return {'thread': {'items': as_dicts(messages)}}
+        return {"thread": {"items": as_dicts(messages)}}
 
     @fixture
     def mock_user_get(self, messages, mocker):
@@ -63,11 +57,11 @@ def mock_user_get(self, messages, mocker):
         def get(key):
             return users[key]
 
-        mocker.patch('instapi.models.user.User.get', side_effect=get)
+        mocker.patch("instapi.models.user.User.get", side_effect=get)
 
     @fixture
     def mock_thread(self, mocker, message_data, mock_user_get):
-        return mocker.patch('instapi.client.client.direct_v2_thread', return_value=message_data)
+        return mocker.patch("instapi.client.client.direct_v2_thread", return_value=message_data)
 
     def test_iter_direct_return_type(self, direct):
         assert isinstance(direct.iter_message(), Generator)
@@ -83,33 +77,17 @@ def test_direct_with_limit(self, mock_thread, direct, messages):
         assert direct.messages(limit) == messages[:limit]
 
 
-def test_messages_cache(mocker, message):
-    mocker.patch('instapi.models.user.User.get', return_value=message.user)
-
-    data = message.as_dict()
-    cache = {}
-
-    m1 = Message.create({**data}, cache)
-
-    assert data['user_id'] in cache
-
-    m2 = Message.create({**data}, cache)
-
-    # Second call should use user from a cache
-    assert m1.user is m2.user
-
-
 class TestWithUser:
     def test_with_user_thread_exists(self, mocker, direct, user):
         mocker.patch(
-            'instapi.client.client.direct_v2_get_by_participants',
-            return_value={'thread': direct.as_dict()},
+            "instapi.client.client.direct_v2_get_by_participants",
+            return_value={"thread": direct.as_dict()},
         )
         assert Direct.with_user(user) == direct
 
     def test_with_user_thread_doesnt_exists(self, mocker, user):
         mocker.patch(
-            'instapi.client.client.direct_v2_get_by_participants',
+            "instapi.client.client.direct_v2_get_by_participants",
             return_value={},
         )
 
@@ -120,7 +98,7 @@ def test_with_user_thread_doesnt_exists(self, mocker, user):
 
 
 def test_send_text(mocker, direct):
-    mock = mocker.patch('instapi.client.client.direct_v2_send_text')
+    mock = mocker.patch("instapi.client.client.direct_v2_send_text")
 
     text = random_string()
     direct.send_text(text)
@@ -132,7 +110,7 @@ def test_send_text(mocker, direct):
 
 
 def test_send_link(mocker, direct):
-    mock = mocker.patch('instapi.client.client.direct_v2_send_link')
+    mock = mocker.patch("instapi.client.client.direct_v2_send_link")
 
     link = random_string()
     text = random_string()
@@ -146,7 +124,7 @@ def test_send_link(mocker, direct):
 
 
 def test_send_profile(mocker, direct, user):
-    mock = mocker.patch('instapi.client.client.direct_v2_send_profile')
+    mock = mocker.patch("instapi.client.client.direct_v2_send_profile")
 
     text = random_string()
     direct.send_profile(user, text)
@@ -159,7 +137,7 @@ def test_send_profile(mocker, direct, user):
 
 
 def test_send_hashtag(mocker, direct, user):
-    mock = mocker.patch('instapi.client.client.direct_v2_send_hashtag')
+    mock = mocker.patch("instapi.client.client.direct_v2_send_hashtag")
 
     text = random_string()
     hashtag = random_string()
@@ -173,7 +151,7 @@ def test_send_hashtag(mocker, direct, user):
 
 
 def test_send_media(mocker, direct, feed):
-    mock = mocker.patch('instapi.client.client.direct_v2_send_media_share')
+    mock = mocker.patch("instapi.client.client.direct_v2_send_media_share")
 
     text = random_string()
     direct.send_media(feed, text)
diff --git a/tests/unit_tests/models/test_feed.py b/tests/unit_tests/models/test_feed.py
index 570c118..5aca118 100644
--- a/tests/unit_tests/models/test_feed.py
+++ b/tests/unit_tests/models/test_feed.py
@@ -1,62 +1,61 @@
 from pytest import fixture
 
-from .conftest import as_dicts
 from ..conftest import random_int
+from .conftest import as_dicts
 
 
 class TestFeed:
-
     @fixture
     def mock_likers(self, mocker, users):
         return mocker.patch(
-            'instapi.client.client.media_likers',
-            return_value={'users': as_dicts(users)},
+            "instapi.client.client.media_likers",
+            return_value={"users": as_dicts(users)},
         )
 
     @fixture
     def mock_comments(self, mocker, comments):
         return mocker.patch(
-            'instapi.client.client.media_comments',
-            return_value={'comments': as_dicts(comments)},
+            "instapi.client.client.media_comments",
+            return_value={"comments": as_dicts(comments)},
         )
 
     @fixture
     def mock_timeline(self, mocker, feeds):
         return mocker.patch(
-            'instapi.client.client.feed_timeline',
-            return_value={'feed_items': [{'media_or_ad': f.as_dict()} for f in feeds]},
+            "instapi.client.client.feed_timeline",
+            return_value={"feed_items": [{"media_or_ad": f.as_dict()} for f in feeds]},
         )
 
     def test_usertags_one_user(self, mocker, feed, user):
         mocker.patch(
-            'instapi.models.feed.Feed._media_info',
-            return_value={'usertags': {'in': [{'user': user.as_dict()}]}},
+            "instapi.models.feed.Feed._media_info",
+            return_value={"usertags": {"in": [{"user": user.as_dict()}]}},
         )
 
         assert feed.user_tags() == [user]
 
     def test_usertags_many_users(self, mocker, feed, users):
         mocker.patch(
-            'instapi.models.feed.Feed._media_info',
-            return_value={'usertags': {'in': [{'user': u} for u in as_dicts(users)]}},
+            "instapi.models.feed.Feed._media_info",
+            return_value={"usertags": {"in": [{"user": u} for u in as_dicts(users)]}},
         )
 
         assert feed.user_tags() == users
 
     def test_usertags_no_users(self, mocker, feed):
-        mocker.patch('instapi.models.feed.Feed._media_info', return_value={})
+        mocker.patch("instapi.models.feed.Feed._media_info", return_value={})
 
         assert not feed.user_tags()
 
     def test_like(self, mocker, feed):
-        like_mock = mocker.patch('instapi.client.client.post_like')
+        like_mock = mocker.patch("instapi.client.client.post_like")
 
         feed.like()
 
         like_mock.assert_called_once_with(feed.pk)
 
     def test_unlike(self, mocker, feed):
-        unlike_mock = mocker.patch('instapi.client.client.delete_like')
+        unlike_mock = mocker.patch("instapi.client.client.delete_like")
 
         feed.unlike()
 
@@ -110,4 +109,3 @@ def test_timeline_with_limit(self, mock_timeline, feed, feeds):
         feeds = feed.timeline(limit=limit)
 
         assert feeds == feeds[:limit]
-
diff --git a/tests/unit_tests/models/test_media.py b/tests/unit_tests/models/test_media.py
index b746bd7..be5cf8d 100644
--- a/tests/unit_tests/models/test_media.py
+++ b/tests/unit_tests/models/test_media.py
@@ -6,15 +6,15 @@ class TestMedia:
 
     def test_media_info(self, media, mocker):
         items = [[*range(100)]]
-        data = {'items': items}
+        data = {"items": items}
 
-        media_info_mock = mocker.patch('instapi.client.client.media_info', return_value=data)
+        media_info_mock = mocker.patch("instapi.client.client.media_info", return_value=data)
 
         assert media._media_info() == items[0]
         media_info_mock.assert_called_once_with(media.pk)
 
     def test_comment(self, mocker, media):
-        comment_mock = mocker.patch('instapi.client.client.post_comment')
+        comment_mock = mocker.patch("instapi.client.client.post_comment")
         text = random_string()
 
         media.comment(text)
diff --git a/tests/unit_tests/models/test_resource.py b/tests/unit_tests/models/test_resource.py
index b18e087..d0ae313 100644
--- a/tests/unit_tests/models/test_resource.py
+++ b/tests/unit_tests/models/test_resource.py
@@ -1,24 +1,18 @@
 import io
 from pathlib import Path
 
-from pytest import (
-    fixture,
-    mark,
-    raises,
-)
+from pytest import fixture, mark, raises
 
 from instapi import Resource
 from instapi.models.resource import Candidate
-from ..conftest import (
-    random_bytes,
-    random_string,
-)
+
+from ..conftest import random_bytes, random_string
 
 
 @fixture
 def mock_content(mocker):
     content = random_bytes()
-    mock = mocker.patch('instapi.Candidate.content', side_effect=lambda: io.BytesIO(content))
+    mock = mocker.patch("instapi.Candidate.content", side_effect=lambda: io.BytesIO(content))
 
     return mock, content
 
@@ -28,9 +22,9 @@ class TestImage:
     Test for Image class
     """
 
-    @mark.usefixtures('mock_content')
+    @mark.usefixtures("mock_content")
     def test_image(self, mocker, image):
-        open_mock = mocker.patch('PIL.Image.open')
+        open_mock = mocker.patch("PIL.Image.open")
 
         image.preview()
 
@@ -46,18 +40,21 @@ class TestCandidate:
     def mock_requests_get(self, mocker):
         resource_content = random_bytes()
         get_mock = mocker.patch(
-            'requests.get',
+            "requests.get",
             return_value=mocker.Mock(raw=io.BytesIO(resource_content)),
         )
         return get_mock, resource_content
 
     @mark.parametrize(
-        'url,filename',
+        "url,filename",
         [
-            ['http://instapi.com/sasha.jpg', 'sasha.jpg'],
-            ['https://instapi/images/thumb/5/not-sasha.jpg/this-is-sasha.jpg', 'this-is-sasha.jpg'],
-            ['https://instapi/images/sasha.jpg?age=too_old&for=school', 'sasha.jpg'],
-        ]
+            ["http://instapi.com/sasha.jpg", "sasha.jpg"],
+            [
+                "https://instapi/images/thumb/5/not-sasha.jpg/this-is-sasha.jpg",
+                "this-is-sasha.jpg",
+            ],
+            ["https://instapi/images/sasha.jpg?age=too_old&for=school", "sasha.jpg"],
+        ],
     )
     def test_filename(self, resource, url, filename):
         r = Candidate(0, 0, url)
@@ -71,7 +68,7 @@ def test_candidate_content(self, mock_requests_get, candidate):
 
         get_mock.assert_called_with(candidate.url, stream=True)
 
-    @mark.usefixtures('mock_content')
+    @mark.usefixtures("mock_content")
     def test_download_without_param(self, tmp_path, candidate):
         candidate.download()
 
@@ -79,16 +76,16 @@ def test_download_without_param(self, tmp_path, candidate):
 
         candidate.filename.unlink()
 
-    @mark.usefixtures('mock_content')
+    @mark.usefixtures("mock_content")
     def test_download_with_path(self, tmp_path, candidate):
         candidate.download(tmp_path)
         result_path: Path = tmp_path / candidate.filename
 
         assert result_path.exists()
 
-    @mark.usefixtures('mock_content')
+    @mark.usefixtures("mock_content")
     def test_download_with_path_and_filename(self, tmp_path, resource):
-        rand_filename = random_string() + '.jpg'
+        rand_filename = random_string() + ".jpg"
         resource.download(tmp_path, rand_filename)
         result_path: Path = tmp_path / rand_filename
 
@@ -101,11 +98,11 @@ class TestResource:
     """
 
     @mark.parametrize(
-        'data',
+        "data",
         [
             [{}],
-            [{'invalid_key': []}],
-        ]
+            [{"invalid_key": []}],
+        ],
     )
     def test_from_data_invalid_data(self, data):
         assert Resource.from_data(data) is None
@@ -116,23 +113,22 @@ def test_resource_with_no_candidates(self):
 
 
 class TestResourceContainer:
-
     @fixture
     def mock_images(self, mocker, images):
         return mocker.patch(
-            'instapi.models.media.Media._media_info',
+            "instapi.models.media.Media._media_info",
             return_value=images[0].as_dict(),
         )
 
     @fixture
     def mock_videos(self, mocker, videos):
         return mocker.patch(
-            'instapi.models.media.Media._media_info',
+            "instapi.models.media.Media._media_info",
             return_value=videos[0].as_dict(),
         )
 
     def test_resources(self, mocker, resource_container):
-        mock = mocker.patch('instapi.models.media.Media._media_info')
+        mock = mocker.patch("instapi.models.media.Media._media_info")
 
         resource_container._resources()
 
diff --git a/tests/unit_tests/models/test_story.py b/tests/unit_tests/models/test_story.py
index 2ccc245..2460bdc 100644
--- a/tests/unit_tests/models/test_story.py
+++ b/tests/unit_tests/models/test_story.py
@@ -1,8 +1,8 @@
 class TestStory:
     def test_mark_seen(self, mocker, story):
         media_info = {}
-        mocker.patch('instapi.models.Media._media_info', return_value=media_info)
-        mock = mocker.patch('instapi.client.client.media_seen')
+        mocker.patch("instapi.models.Media._media_info", return_value=media_info)
+        mock = mocker.patch("instapi.client.client.media_seen")
 
         story.mark_seen()
 
@@ -11,5 +11,5 @@ def test_mark_seen(self, mocker, story):
     def test_as_dict(self, story):
         data = story.as_dict()
 
-        assert 'reel_mentions' in data
-        assert data['reel_mentions'] == story.mentions
+        assert "reel_mentions" in data
+        assert data["reel_mentions"] == story.mentions
diff --git a/tests/unit_tests/models/test_user.py b/tests/unit_tests/models/test_user.py
index 2b6dca4..4a5efef 100644
--- a/tests/unit_tests/models/test_user.py
+++ b/tests/unit_tests/models/test_user.py
@@ -1,30 +1,14 @@
 from collections import Counter
-from typing import (
-    Iterable,
-    List,
-)
-
-from pytest import (
-    fixture,
-    raises,
-)
-
-from instapi.models import (
-    Feed,
-    User,
-)
-from instapi.models.resource import (
-    Resources,
-)
+from typing import Iterable, List
+
+from pytest import fixture, raises
+
+from instapi.models import Feed, User
+from instapi.models.resource import Resources
 from instapi.utils import flat
-from .conftest import (
-    as_dicts,
-    create_users,
-)
-from ..conftest import (
-    random_int,
-    random_string,
-)
+
+from ..conftest import random_int, random_string
+from .conftest import as_dicts, create_users
 
 
 @fixture()
@@ -32,16 +16,18 @@ def mock_feeds_with_resources(mocker, feeds, images, videos):
     """
     Fixture that mocks user feeds and feeds resources
     """
-    mocker.patch('instapi.models.User.iter_feeds', return_value=feeds)
-    mocker.patch('instapi.models.Media._media_info', return_value={})
+    mocker.patch("instapi.models.User.iter_feeds", return_value=feeds)
+    mocker.patch("instapi.models.Media._media_info", return_value={})
 
-    def mocked_resources(resource_data, video: bool = True, image: bool = True) -> Iterable[Resources]:
+    def mocked_resources(
+        resource_data, video: bool = True, image: bool = True
+    ) -> Iterable[Resources]:
         if video:
             yield from videos
         if image:
             yield from images
 
-    mocker.patch('instapi.Resource.create_resources', side_effect=mocked_resources)
+    mocker.patch("instapi.Resource.create_resources", side_effect=mocked_resources)
 
 
 @fixture()
@@ -50,14 +36,16 @@ def mock_feeds(mocker, feeds) -> List[Feed]:
     Fixture that mocks user feeds
     """
     return mocker.patch(
-        'instapi.client.client.user_feed',
-        return_value={'items': as_dicts(feeds)},
+        "instapi.client.client.user_feed",
+        return_value={"items": as_dicts(feeds)},
     )
 
 
 def test_user_get(user, mocker):
     """Test for User.get classmethod"""
-    user_info_mock = mocker.patch('instapi.client.client.user_info', return_value={'user': user.as_dict()})
+    user_info_mock = mocker.patch(
+        "instapi.client.client.user_info", return_value={"user": user.as_dict()}
+    )
 
     assert User.get(user.pk) == user
 
@@ -66,7 +54,9 @@ def test_user_get(user, mocker):
 
 def test_user_from_username(user, mocker):
     """Test for User.from_username classmethod"""
-    username_info_mock = mocker.patch('instapi.client.client.username_info', return_value={'user': user.as_dict()})
+    username_info_mock = mocker.patch(
+        "instapi.client.client.username_info", return_value={"user": user.as_dict()}
+    )
 
     assert User.from_username(user.username) == user
 
@@ -76,7 +66,10 @@ def test_user_from_username(user, mocker):
 def test_user_match_username(user, mocker):
     """Test for User.match_username classmethod"""
     list_of_users = create_users(length=50)
-    search_mock = mocker.patch('instapi.client.client.search_users', return_value={'users': as_dicts(list_of_users)})
+    search_mock = mocker.patch(
+        "instapi.client.client.search_users",
+        return_value={"users": as_dicts(list_of_users)},
+    )
 
     assert User.match_username(user.username) == list_of_users
 
@@ -84,7 +77,7 @@ def test_user_match_username(user, mocker):
     search_mock.reset_mock()
 
     limit = 10
-    search_mock.return_value = {'users': as_dicts(list_of_users[:limit])}
+    search_mock.return_value = {"users": as_dicts(list_of_users[:limit])}
 
     assert User.match_username(user.username, limit=limit) == list_of_users[:limit]
 
@@ -93,8 +86,12 @@ def test_user_match_username(user, mocker):
 
 def test_user_self(user, mocker):
     """Test for User.self classmethod"""
-    user_info_mock = mocker.patch('instapi.client.client.user_info', return_value={'user': user.as_dict()})
-    current_user_mock = mocker.patch('instapi.client.client.current_user', return_value={'user': user.as_dict()})
+    user_info_mock = mocker.patch(
+        "instapi.client.client.user_info", return_value={"user": user.as_dict()}
+    )
+    current_user_mock = mocker.patch(
+        "instapi.client.client.current_user", return_value={"user": user.as_dict()}
+    )
 
     assert User.self() == user
 
@@ -113,33 +110,32 @@ def test_user_details(user, mocker):
     User.full_info method
     """
     user_details = {
-        'biography': random_string(),
-        'media_count': random_int(),
-        'follower_count': random_int(),
-        'following_count': random_int(),
+        "biography": random_string(),
+        "media_count": random_int(),
+        "follower_count": random_int(),
+        "following_count": random_int(),
         **user.as_dict(),
     }
-    full_info = {'user_detail': {'user': user_details}}
+    full_info = {"user_detail": {"user": user_details}}
 
-    details_mock = mocker.patch('instapi.client.client.user_detail_info', return_value=full_info)
+    details_mock = mocker.patch("instapi.client.client.user_detail_info", return_value=full_info)
 
-    assert user.biography == user_details['biography']
-    assert user.media_count == user_details['media_count']
-    assert user.follower_count == user_details['follower_count']
-    assert user.following_count == user_details['following_count']
+    assert user.biography == user_details["biography"]
+    assert user.media_count == user_details["media_count"]
+    assert user.follower_count == user_details["follower_count"]
+    assert user.following_count == user_details["following_count"]
     assert user.user_detail() == user_details
     assert user.full_info() == full_info
 
-    details_mock.assert_called_with(user.pk)
-    assert details_mock.call_count == 6
+    details_mock.assert_called_once_with(user.pk)
 
 
 def test_follow(user, mocker):
     """Test for User.follow method"""
-    self, = create_users(length=1)
+    (self,) = create_users(length=1)
 
-    friendships_mock = mocker.patch('instapi.client.client.friendships_create')
-    mocker.patch('instapi.models.User.self', return_value=self)
+    friendships_mock = mocker.patch("instapi.client.client.friendships_create")
+    mocker.patch("instapi.models.User.self", return_value=self)
 
     with raises(ValueError):
         user.follow(self)
@@ -153,10 +149,10 @@ def test_follow(user, mocker):
 
 def test_unfollow(user, mocker):
     """Test for User.unfollow method"""
-    self, = create_users(length=1)
+    (self,) = create_users(length=1)
 
-    friendships_mock = mocker.patch('instapi.client.client.friendships_destroy')
-    mocker.patch('instapi.models.User.self', return_value=self)
+    friendships_mock = mocker.patch("instapi.client.client.friendships_destroy")
+    mocker.patch("instapi.models.User.self", return_value=self)
 
     with raises(ValueError):
         user.unfollow(self)
@@ -220,7 +216,9 @@ def test_followers(mocker, user, users):
     User.iter_followers method
     User.followers method
     """
-    follow_mock = mocker.patch('instapi.client.client.user_followers', return_value={'users': as_dicts(users)})
+    follow_mock = mocker.patch(
+        "instapi.client.client.user_followers", return_value={"users": as_dicts(users)}
+    )
 
     assert [*user.iter_followers()] == users
     follow_mock.assert_called_once()
@@ -244,7 +242,9 @@ def test_followings(mocker, user, users):
     User.iter_followings method
     User.followings method
     """
-    follow_mock = mocker.patch('instapi.client.client.user_following', return_value={'users': as_dicts(users)})
+    follow_mock = mocker.patch(
+        "instapi.client.client.user_following", return_value={"users": as_dicts(users)}
+    )
 
     assert [*user.iter_followings()] == users
     follow_mock.assert_called_once()
@@ -295,7 +295,7 @@ def test_likes(mocker, mock_feeds, user, users, feeds):
     User.likes_chain
     User.likes_statistic
     """
-    mocker.patch('instapi.models.Feed.iter_likes', return_value=users)
+    mocker.patch("instapi.models.Feed.iter_likes", return_value=users)
     expected = flat([users] * len(feeds))
 
     assert [*user.likes_chain()] == expected
@@ -308,7 +308,7 @@ def test_liked_by(mocker, mock_feeds, feeds, user, users):
     User.iter_liked_by_user
     User.liked_by_user
     """
-    liked_by_mock = mocker.patch('instapi.models.Feed.liked_by')
+    liked_by_mock = mocker.patch("instapi.models.Feed.liked_by")
 
     def assert_method(like_user: User, expected: List[Feed]):
         liked_by_mock.reset_mock()
@@ -349,9 +349,9 @@ def test_stories(user, mocker, stories):
     User.stories
     """
 
-    return_value = {'reel': {'items': as_dicts(stories)}}
+    return_value = {"reel": {"items": as_dicts(stories)}}
 
-    story_mock = mocker.patch('instapi.client.client.user_story_feed', return_value=return_value)
+    story_mock = mocker.patch("instapi.client.client.user_story_feed", return_value=return_value)
 
     assert [*user.iter_stories()] == stories
     story_mock.assert_called_once_with(user.pk)
diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py
index c4b88c7..41750a0 100644
--- a/tests/unit_tests/test_client.py
+++ b/tests/unit_tests/test_client.py
@@ -1,32 +1,27 @@
 from instagram_private_api import Client
-from pytest import (
-    mark,
-    raises,
-)
-
-from instapi.client import (
-    bind,
-    client,
-)
+from pytest import mark, raises
+
+from instapi.client import bind, client
 from instapi.exceptions import ClientNotInitedException
 from instapi.models import User
+
 from .conftest import random_string
 
 
-@mark.usefixtures('regular_client_mode')
+@mark.usefixtures("regular_client_mode")
 def test_client_not_initialized():
     """Test for: bind function wasn't call"""
     with raises(ClientNotInitedException):
         User.self()
 
 
-@mark.usefixtures('regular_client_mode')
+@mark.usefixtures("regular_client_mode")
 def test_client_inited_after_bind(mocker):
     """Test for: bind function was called"""
-    mocker.patch('instagram_private_api.client.Client.__init__', return_value=None)
-    cookie_jar_mock = mocker.patch('instagram_private_api.client.Client.cookie_jar')
-    get_from_cache_mock = mocker.patch('instapi.cache.get_from_cache', return_value=None)
-    write_to_cache_mock = mocker.patch('instapi.cache.write_to_cache', return_value=None)
+    mocker.patch("instagram_private_api.client.Client.__init__", return_value=None)
+    cookie_jar_mock = mocker.patch("instagram_private_api.client.Client.cookie_jar")
+    get_from_cache_mock = mocker.patch("instapi.cache.get_from_cache", return_value=None)
+    write_to_cache_mock = mocker.patch("instapi.cache.write_to_cache", return_value=None)
 
     username, password = random_string(), random_string()
 
@@ -40,7 +35,7 @@ def test_client_inited_after_bind(mocker):
     write_to_cache_mock.assert_called_once_with((username, password), cookie_jar_mock)
 
 
-@mark.usefixtures('regular_client_mode')
+@mark.usefixtures("regular_client_mode")
 def test_bind_with_no_arguments():
     with raises(ValueError):
         bind()
diff --git a/tests/unit_tests/test_utils.py b/tests/unit_tests/test_utils.py
index e644e86..5085d0b 100644
--- a/tests/unit_tests/test_utils.py
+++ b/tests/unit_tests/test_utils.py
@@ -1,20 +1,9 @@
-from typing import (
-    Generator,
-    List,
-)
+from typing import Generator, List
+
+from pytest import fixture, mark, raises
+
+from instapi.utils import flat, join, limited, process_many, to_list
 
-from pytest import (
-    fixture,
-    mark,
-    raises)
-
-from instapi.utils import (
-    flat,
-    limited,
-    process_many,
-    to_list,
-    join,
-)
 from .conftest import random_int
 
 
@@ -34,7 +23,7 @@ def test_flat_dont_unpack_inner(self):
 
 
 @mark.parametrize(
-    'func,ret_type',
+    "func,ret_type",
     [
         [limited, Generator],
         [to_list, List],
@@ -55,20 +44,20 @@ def test_return_type(self, arr, func, ret_type):
         assert isinstance(func(arr), ret_type)
         assert isinstance(func(arr, limit=10), ret_type)
 
-    @mark.usefixtures('ret_type')
+    @mark.usefixtures("ret_type")
     def test_without_limit(self, arr, func):
         assert [*func(arr)] == arr
 
-    @mark.usefixtures('ret_type')
+    @mark.usefixtures("ret_type")
     def test_limit_bigger(self, arr, func):
         assert [*func(arr, limit=len(arr) * 5)] == arr
 
-    @mark.usefixtures('ret_type')
+    @mark.usefixtures("ret_type")
     def test_limit_less(self, arr, func):
         limit = len(arr) // 2
         assert [*func(arr, limit=limit)] == arr[:limit]
 
-    @mark.usefixtures('ret_type')
+    @mark.usefixtures("ret_type")
     def test_limit_negative(self, arr, func):
         with raises(ValueError):
             _ = [*func(arr, limit=-10)]
@@ -98,34 +87,35 @@ def test_process_many_with_rank_token(self, mocker):
         mock.assert_called_once()
 
         _, kwargs = mock.call_args
-        assert 'rank_token' in kwargs
-        assert isinstance(kwargs['rank_token'], str)
+        assert "rank_token" in kwargs
+        assert isinstance(kwargs["rank_token"], str)
 
     def test_process_many_called_multiple_times(self, mocker):
-        max_ids = [*[{'next_max_id': random_int()} for _ in range(3)], {}]
+        max_ids = [*[{"next_max_id": random_int()} for _ in range(3)], {}]
         mock = mocker.Mock(side_effect=max_ids)
 
         _ = [*process_many(mock)]
 
-        mock.assert_has_calls([
-            mocker.call(),
-            *[mocker.call(max_id=m['next_max_id']) for m in max_ids[:-1]]
-        ])
+        mock.assert_has_calls(
+            [
+                mocker.call(),
+                *[mocker.call(max_id=m["next_max_id"]) for m in max_ids[:-1]],
+            ]
+        )
 
     def test_process_many_return_value(self, mocker):
-        max_ids = [*[{'next_max_id': random_int()} for _ in range(3)], {}]
+        max_ids = [*[{"next_max_id": random_int()} for _ in range(3)], {}]
         mock = mocker.Mock(side_effect=max_ids)
 
         assert [*process_many(mock)] == max_ids
 
 
 class TestJoin:
-
     def test_join_not_str(self):
-        assert join(range(4)) == '0,1,2,3'
+        assert join(range(4)) == "0,1,2,3"
 
     def test_join_str(self):
-        assert join(['hello', 'world'], 'hello,world')
+        assert join(["hello", "world"], "hello,world")
 
     def test_custom_separator(self):
-        assert join(['hello', 'world'], ' ') == 'hello world'
+        assert join(["hello", "world"], " ") == "hello world"