Repository: zerex290/sankaku (language: python)

Issue:
Why python3.11+?
I'm not entirely sure what operations are used in such a library that it requires a dependency on Python 3.11 or higher. If it's just to indicate that it has been tested only on Python 3.11, it may not be necessary. Running unit tests on various python versions and platforms through GitHub Actions should be sufficient to ensure compatibility. Restricting the Python version to such a high degree would greatly impact the usability of the library.


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 449cdf2..d6394b8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,4 +20,10 @@ dist/
 build/
 
 # mkdocs
-/site
\ No newline at end of file
+/site
+
+.python-version
+.env
+.coverage
+/test_*.py
+/venv*
\ No newline at end of file
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..d25c775
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,4 @@
+include README.md
+include MANIFEST.in
+include requirements.txt
+include requirements-*.txt
diff --git a/README.md b/README.md
index 17746f2..fd79d16 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ Source code: https://github.com/zerex290/sankaku
 
 ## Requirements
 
-- Python 3.11+
+- Python 3.7+
 - aiohttp
 - pydantic
 - loguru
diff --git a/requirements-socks.txt b/requirements-socks.txt
new file mode 100644
index 0000000..22c8e44
--- /dev/null
+++ b/requirements-socks.txt
@@ -0,0 +1,1 @@
+aiohttp-socks
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 7339b75..ff15f41 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,5 @@
 aiohttp
 pydantic
-loguru
\ No newline at end of file
+loguru
+aiohttp-retry
+typing_extensions; python_version < '3.9'
\ No newline at end of file
diff --git a/sankaku/clients/abc.py b/sankaku/clients/abc.py
index 5ce854c..8270c6f 100644
--- a/sankaku/clients/abc.py
+++ b/sankaku/clients/abc.py
@@ -1,19 +1,19 @@
 from abc import ABC, abstractmethod
-from typing import Optional, Self
+from typing import Optional
 
 from sankaku.models.http import ClientResponse
 
-
 __all__ = ["ABCHttpClient", "ABCClient"]
 
 
 class ABCHttpClient(ABC):
     """Abstract client for handling http requests."""
+
     @abstractmethod
     def __init__(self, *args, **kwargs) -> None:
         pass
 
-    async def __aenter__(self) -> Self:
+    async def __aenter__(self):
         return self
 
     async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
@@ -34,16 +34,17 @@ async def request(self, method: str, url: str, **kwargs) -> ClientResponse:
 
 class ABCClient(ABC):
     """Abstract Sankaku client."""
+
     @abstractmethod
     def __init__(self, *args, **kwargs) -> None:
         pass
 
     @abstractmethod
     async def login(
-        self,
-        *,
-        access_token: Optional[str] = None,
-        login: Optional[str] = None,
-        password: Optional[str] = None
+            self,
+            *,
+            access_token: Optional[str] = None,
+            login: Optional[str] = None,
+            password: Optional[str] = None
     ) -> None:
         """Login into sankakucomplex.com via access token or credentials."""
diff --git a/sankaku/clients/clients.py b/sankaku/clients/clients.py
index b7987af..02637f3 100644
--- a/sankaku/clients/clients.py
+++ b/sankaku/clients/clients.py
@@ -1,17 +1,21 @@
 import json
-from typing import Optional, Literal, Annotated
-from collections.abc import AsyncIterator
 from datetime import datetime
+from typing import Optional, Union, List, AsyncIterator
+
+from ..typedefs import ValueRange
+
+try:
+    from typing import Literal, Annotated
+except (ModuleNotFoundError, ImportError):
+    from typing_extensions import Literal, Annotated
 
 from loguru import logger
 
-from .abc import ABCClient
-from .http_client import HttpClient
-from sankaku.typedefs import ValueRange
-from sankaku.utils import from_locals
 from sankaku import models as mdl, constants as const, types, errors
 from sankaku.paginators import *
-
+from sankaku.utils import from_locals
+from .abc import ABCClient
+from .http_client import HttpClient
 
 __all__ = [
     "PostClient",
@@ -24,6 +28,7 @@
 
 class BaseClient(ABCClient):
     """Base client used for login."""
+
     def __init__(self) -> None:
         self._profile: Optional[mdl.ExtendedUser] = None
         self._http_client: HttpClient = HttpClient()
@@ -69,11 +74,11 @@ async def _get_profile(self, access_token: str) -> mdl.ExtendedUser:
         return mdl.ExtendedUser(**response.json["user"])
 
     async def login(
-        self,
-        *,
-        access_token: Optional[str] = None,
-        login: Optional[str] = None,
-        password: Optional[str] = None
+            self,
+            *,
+            access_token: Optional[str] = None,
+            login: Optional[str] = None,
+            password: Optional[str] = None
     ) -> None:
         """Login into sankakucomplex.com via access token or credentials.
         In case when all arguments are specified, preference will be given
@@ -84,16 +89,15 @@ async def login(
             login: User email or nickname
             password: User password
         """
-        match (access_token, login, password):
-            case [str(), str(), str()] | [_, str(), str()]:
-                await self._login_via_credentials(login, password)  # type: ignore[arg-type]
-            case [str(), _, _]:
-                await self._login_via_access_token(access_token)  # type: ignore[arg-type]
-            case _:
-                raise errors.SankakuError(
-                    "The given data is not enough "
-                    "or invalid (perhaps of the wrong type)."
-                )
+        if login and password:
+            await self._login_via_credentials(login, password)  # type: ignore[arg-type]
+        elif access_token and not login and not password:
+            await self._login_via_access_token(access_token)  # type: ignore[arg-type]
+        else:
+            raise errors.SankakuError(
+                "The given data is not enough "
+                "or invalid (perhaps of the wrong type)."
+            )
 
         self._http_client.headers.update(
             authorization=f"{self._token_type} {self._access_token}"
@@ -107,24 +111,25 @@ def profile(self) -> Optional[mdl.ExtendedUser]:
 
 class PostClient(BaseClient):
     """Client for post browsing."""
+
     async def browse_posts(
-        self,
-        order: Optional[types.PostOrder] = None,
-        date: Optional[list[datetime]] = None,
-        rating: Optional[types.Rating] = None,
-        threshold: Optional[Annotated[int, ValueRange(1, 100)]] = None,
-        hide_posts_in_books: Optional[Literal["in-larger-tags", "always"]] = None,
-        file_size: Optional[types.FileSize] = None,
-        file_type: Optional[types.FileType] = None,
-        video_duration: Optional[list[int]] = None,
-        recommended_for: Optional[str] = None,
-        favorited_by: Optional[str] = None,
-        tags: Optional[list[str]] = None,
-        added_by: Optional[list[str]] = None,
-        voted: Optional[str] = None,
-        *,
-        page_number: Optional[int] = None,
-        limit: Optional[Annotated[int, ValueRange(1, 100)]] = None
+            self,
+            order: Optional[types.PostOrder] = None,
+            date: Optional[List[datetime]] = None,
+            rating: Optional[types.Rating] = None,
+            threshold: Optional[Annotated[int, ValueRange(1, 100)]] = None,
+            hide_posts_in_books: Optional[Literal["in-larger-tags", "always"]] = None,
+            file_size: Optional[types.FileSize] = None,
+            file_type: Optional[types.FileType] = None,
+            video_duration: Optional[List[int]] = None,
+            recommended_for: Optional[str] = None,
+            favorited_by: Optional[str] = None,
+            tags: Optional[List[str]] = None,
+            added_by: Optional[List[str]] = None,
+            voted: Optional[str] = None,
+            *,
+            page_number: Optional[int] = None,
+            limit: Optional[Annotated[int, ValueRange(1, 100)]] = None
 
     ) -> AsyncIterator[mdl.Post]:
         """Get posts from post pages.
@@ -147,7 +152,7 @@ async def browse_posts(
             limit: Maximum amount of posts per page
         """
         async for page in PostPaginator(
-            self._http_client, const.POST_URL, **from_locals(locals())
+                self._http_client, const.POST_URL, **from_locals(locals())
         ):
             for post in page.items:
                 yield post
@@ -187,9 +192,9 @@ async def get_similar_posts(self, post_id: int) -> AsyncIterator[mdl.Post]:
     async def get_post_comments(self, post_id: int) -> AsyncIterator[mdl.Comment]:
         """Get comments of the specific post by its ID."""
         async for page in Paginator(
-            self._http_client,
-            const.COMMENT_URL.format(post_id=post_id),
-            mdl.Comment
+                self._http_client,
+                const.COMMENT_URL.format(post_id=post_id),
+                mdl.Comment
         ):
             for comment in page.items:
                 yield comment
@@ -209,11 +214,12 @@ async def create_post(self):  # TODO: TBA
 
 class AIClient(BaseClient):
     """Client for working with Sankaku built-in AI."""
+
     async def browse_ai_posts(
-        self,
-        *,
-        page_number: Optional[int] = None,
-        limit: Optional[Annotated[int, ValueRange(1, 100)]] = None
+            self,
+            *,
+            page_number: Optional[int] = None,
+            limit: Optional[Annotated[int, ValueRange(1, 100)]] = None
     ) -> AsyncIterator[mdl.AIPost]:
         """Get AI created posts from AI dedicated post pages.
 
@@ -222,8 +228,8 @@ async def browse_ai_posts(
             limit: Maximum amount of posts per page
         """
         async for page in Paginator(
-            self._http_client, const.AI_POST_URL,
-            mdl.AIPost, **from_locals(locals())
+                self._http_client, const.AI_POST_URL,
+                mdl.AIPost, **from_locals(locals())
         ):
             for post in page.items:
                 yield post
@@ -243,17 +249,18 @@ async def create_ai_post(self):  # TODO: TBA
 
 class TagClient(BaseClient):
     """Client for tag browsing."""
+
     async def browse_tags(
-        self,
-        tag_type: Optional[types.TagType] = None,  # TODO: ability to specify multiple tags
-        order: Optional[types.TagOrder] = None,
-        rating: Optional[types.Rating] = None,
-        max_post_count: Optional[int] = None,
-        sort_parameter: Optional[types.SortParameter] = None,
-        sort_direction: Optional[types.SortDirection] = None,
-        *,
-        page_number: Optional[int] = None,
-        limit: Optional[Annotated[int, ValueRange(1, 100)]] = None
+            self,
+            tag_type: Optional[types.TagType] = None,  # TODO: ability to specify multiple tags
+            order: Optional[types.TagOrder] = None,
+            rating: Optional[types.Rating] = None,
+            max_post_count: Optional[int] = None,
+            sort_parameter: Optional[types.SortParameter] = None,
+            sort_direction: Optional[types.SortDirection] = None,
+            *,
+            page_number: Optional[int] = None,
+            limit: Optional[Annotated[int, ValueRange(1, 100)]] = None
 
     ) -> AsyncIterator[mdl.PageTag]:
         """Get tags from tag pages.
@@ -269,12 +276,12 @@ async def browse_tags(
             limit: Maximum amount of tags per page
         """
         async for page in TagPaginator(
-            self._http_client, const.TAG_URL, **from_locals(locals())
+                self._http_client, const.TAG_URL, **from_locals(locals())
         ):
             for tag in page.items:
                 yield tag
 
-    async def get_tag(self, name_or_id: str | int) -> mdl.WikiTag:
+    async def get_tag(self, name_or_id: Union[str, int]) -> mdl.WikiTag:
         """Get specific tag by its name or ID."""
         ref = "/name" if isinstance(name_or_id, str) else "/id"
         url = f"{const.TAG_WIKI_URL}{ref}/{name_or_id}"
@@ -289,18 +296,19 @@ async def get_tag(self, name_or_id: str | int) -> mdl.WikiTag:
 
 class BookClient(BaseClient):
     """Client for book (pool) browsing."""
+
     async def browse_books(
-        self,
-        order: Optional[types.BookOrder] = None,
-        rating: Optional[types.Rating] = None,
-        recommended_for: Optional[str] = None,
-        favorited_by: Optional[str] = None,
-        tags: Optional[list[str]] = None,
-        added_by: Optional[list[str]] = None,
-        voted: Optional[str] = None,
-        *,
-        page_number: Optional[int] = None,
-        limit: Optional[Annotated[int, ValueRange(1, 100)]] = None
+            self,
+            order: Optional[types.BookOrder] = None,
+            rating: Optional[types.Rating] = None,
+            recommended_for: Optional[str] = None,
+            favorited_by: Optional[str] = None,
+            tags: Optional[List[str]] = None,
+            added_by: Optional[List[str]] = None,
+            voted: Optional[str] = None,
+            *,
+            page_number: Optional[int] = None,
+            limit: Optional[Annotated[int, ValueRange(1, 100)]] = None
     ) -> AsyncIterator[mdl.PageBook]:
         """Get books from book (pool) pages.
 
@@ -316,7 +324,7 @@ async def browse_books(
             limit: Maximum amount of books per page
         """
         async for page in BookPaginator(
-            self._http_client, const.BOOK_URL, **from_locals(locals())
+                self._http_client, const.BOOK_URL, **from_locals(locals())
         ):
             for book in page.items:
                 yield book
@@ -348,7 +356,7 @@ async def get_recently_read_books(self) -> AsyncIterator[mdl.PageBook]:
     async def get_related_books(self, post_id: int) -> AsyncIterator[mdl.PageBook]:
         """Get books related to specific post by its ID."""
         async for page in BookPaginator(
-            self._http_client, const.RELATED_BOOK_URL.format(post_id=post_id)
+                self._http_client, const.RELATED_BOOK_URL.format(post_id=post_id)
         ):
             for book in page.items:
                 yield book
@@ -365,13 +373,14 @@ async def get_book(self, book_id: int) -> mdl.Book:
 
 class UserClient(BaseClient):
     """Client for browsing users."""
+
     async def browse_users(
-        self,
-        order: Optional[types.UserOrder] = None,
-        level: Optional[types.UserLevel] = None,
-        *,
-        page_number: Optional[int] = None,
-        limit: Optional[Annotated[int, ValueRange(1, 100)]] = None
+            self,
+            order: Optional[types.UserOrder] = None,
+            level: Optional[types.UserLevel] = None,
+            *,
+            page_number: Optional[int] = None,
+            limit: Optional[Annotated[int, ValueRange(1, 100)]] = None
     ) -> AsyncIterator[mdl.User]:
         """Get user profiles from user pages.
 
@@ -382,12 +391,12 @@ async def browse_users(
             limit: Maximum amount of users per page
         """
         async for page in UserPaginator(
-            self._http_client, const.USER_URL, **from_locals(locals())
+                self._http_client, const.USER_URL, **from_locals(locals())
         ):
             for user in page.items:
                 yield user
 
-    async def get_user(self, name_or_id: str | int) -> mdl.User:
+    async def get_user(self, name_or_id: Union[str, int]) -> mdl.User:
         """Get specific user by its name or ID."""
         ref = "/name" if isinstance(name_or_id, str) else ""
         url = f"{const.USER_URL}{ref}/{name_or_id}"
diff --git a/sankaku/clients/http_client.py b/sankaku/clients/http_client.py
index f364931..af679a9 100644
--- a/sankaku/clients/http_client.py
+++ b/sankaku/clients/http_client.py
@@ -1,23 +1,59 @@
+import os
+from typing import Dict, Optional
+
 from aiohttp import ClientSession
+from aiohttp_retry import ExponentialRetry, RetryClient
 from loguru import logger
 
-from .abc import ABCHttpClient
 from sankaku import errors, constants as const
+from sankaku.constants import BASE_RETRIES
 from sankaku.models.http import ClientResponse
-
+from .abc import ABCHttpClient
 
 __all__ = ["HttpClient"]
 
+try:
+    from aiohttp_socks import ProxyConnector as SocksProxyConnector
+except (ImportError, ModuleNotFoundError):
+    SocksProxyConnector = None
+
+
+def _get_socks_connector() -> Optional[SocksProxyConnector]:
+    if SocksProxyConnector is None:
+        return None
+
+    proxy = os.getenv("ALL_PROXY") or os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY")
+    if proxy is None or not proxy.startswith("socks"):
+        return None
+
+    return SocksProxyConnector.from_url(proxy)
+
 
 class HttpClient(ABCHttpClient):
     """HTTP client for API requests that instances use a single session."""
+
     def __init__(self) -> None:
-        self.headers: dict[str, str] = const.HEADERS.copy()
-        self.session: ClientSession = ClientSession()
+        self.headers: Dict[str, str] = const.HEADERS.copy()
+
+        socks_connector = _get_socks_connector()
+        if socks_connector is not None:
+            # use socks connector
+            kwargs = {'connector': socks_connector}
+        else:
+            # use trust env option, aiohttp will read HTTP_PROXY and HTTPS_PROXY from env
+            kwargs = {'trust_env': True}
+        self._client_session: ClientSession = ClientSession(**kwargs)
+
+        retry_options = ExponentialRetry(attempts=BASE_RETRIES)
+        self.session: RetryClient = RetryClient(
+            raise_for_status=False,
+            retry_options=retry_options,
+            client_session=self._client_session
+        )
 
     def __del__(self) -> None:
-        if not self.session.closed and self.session.connector is not None:
-            self.session.connector.close()
+        if not self._client_session.closed and self._client_session.connector is not None:
+            self._client_session.connector.close()
 
     async def close(self) -> None:
         """There is no need to close client with single session."""
diff --git a/sankaku/constants.py b/sankaku/constants.py
index ca3fdee..aac0188 100644
--- a/sankaku/constants.py
+++ b/sankaku/constants.py
@@ -1,8 +1,9 @@
 """Necessary constants such as hardcoded headers, API urls and endpoints,
 default values of parameters etc.
 """
+from typing import Dict
 
-HEADERS: dict[str, str] = {
+HEADERS: Dict[str, str] = {
     "user-agent": (
         "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
         "(KHTML, like Gecko) Chrome/94.0.4606.85 YaBrowser/21.11.0.1996 "
@@ -14,7 +15,6 @@
     "host": "capi-v2.sankakucomplex.com"
 }
 
-
 BASE_URL = "https://login.sankakucomplex.com"
 API_URL = "https://capi-v2.sankakucomplex.com"
 
@@ -31,12 +31,13 @@
 COMMENT_URL = f"{POST_URL}/{{post_id}}/comments"
 RELATED_BOOK_URL = f"{API_URL}/post/{{post_id}}/pools"
 
-
 BASE_RPS = 3
 BASE_RPM = 180
 BASE_PAGE_NUMBER = 1
 BASE_PAGE_LIMIT = 40
 
+BASE_RETRIES = 3
+
 PAGE_ALLOWED_ERRORS = [
     "snackbar__anonymous-recommendations-limit-reached",
     "snackbar__account_offset-forbidden"
diff --git a/sankaku/errors.py b/sankaku/errors.py
index 7629a90..931c08b 100644
--- a/sankaku/errors.py
+++ b/sankaku/errors.py
@@ -1,6 +1,5 @@
 from typing import Optional
 
-
 __all__ = [
     "SankakuError",
     "RateLimitError",
@@ -41,6 +40,7 @@ class VideoDurationError(SankakuError):
 
 class SankakuServerError(SankakuError):
     """Error class for parametrized exceptions."""
+
     def __init__(
             self,
             status: Optional[int],
diff --git a/sankaku/models/books.py b/sankaku/models/books.py
index ba0f669..834988b 100644
--- a/sankaku/models/books.py
+++ b/sankaku/models/books.py
@@ -1,15 +1,14 @@
 from __future__ import annotations
 
-from typing import Optional
 from datetime import datetime
+from typing import Optional, List
 
 from pydantic import BaseModel
 
 from sankaku import types
-from .users import Author
-from .tags import PostTag
 from .posts import Post
-
+from .tags import PostTag
+from .users import Author
 
 __all__ = ["PageBook", "Book"]
 
@@ -50,13 +49,13 @@ class PageBook(BaseModel):
     vote_count: int
     total_score: int
     comment_count: Optional[int]
-    tags: list[PostTag]
-    post_tags: list[PostTag]
-    artist_tags: list[PostTag]
-    genre_tags: list[PostTag]
+    tags: List[PostTag]
+    post_tags: List[PostTag]
+    artist_tags: List[PostTag]
+    genre_tags: List[PostTag]
     is_favorited: bool
     user_vote: Optional[int]
-    posts: list[Post]
+    posts: List[Post]
     file_url: Optional[str]
     sample_url: Optional[str]
     preview_url: Optional[str]
@@ -76,6 +75,6 @@ class PageBook(BaseModel):
 
 class Book(PageBook):
     """Model that describes specific book."""
-    child_pools: Optional[list[PageBook]]
+    child_pools: Optional[List[PageBook]]
     flagged_by_user: bool
     prem_post_count: int
diff --git a/sankaku/models/http.py b/sankaku/models/http.py
index 6501845..2898962 100644
--- a/sankaku/models/http.py
+++ b/sankaku/models/http.py
@@ -1,7 +1,6 @@
 from dataclasses import dataclass
 from typing import Any
 
-
 __all__ = ["ClientResponse"]
 
 
diff --git a/sankaku/models/pages.py b/sankaku/models/pages.py
index ac78ab6..492c186 100644
--- a/sankaku/models/pages.py
+++ b/sankaku/models/pages.py
@@ -1,10 +1,8 @@
-from typing import Generic, TypeVar
 from dataclasses import dataclass
-
+from typing import Generic, TypeVar, List
 
 __all__ = ["Page"]
 
-
 _T = TypeVar("_T")
 
 
@@ -12,4 +10,4 @@
 class Page(Generic[_T]):
     """Model that describes page containing content with specific type."""
     number: int
-    items: list[_T]
+    items: List[_T]
diff --git a/sankaku/models/posts.py b/sankaku/models/posts.py
index b0745a2..bb212f6 100644
--- a/sankaku/models/posts.py
+++ b/sankaku/models/posts.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
-from typing import Optional
 from datetime import datetime
+from typing import Optional, List
 
 from pydantic import BaseModel, Field, validator
 
@@ -10,7 +10,6 @@
 from .tags import PostTag
 from .users import Author
 
-
 __all__ = ["Comment", "Post", "AIPost"]
 
 
@@ -40,20 +39,19 @@ class BasePost(BaseModel):
     extension: Optional[str] = Field(alias="file_type")
     generation_directives: Optional[GenerationDirectives]
     md5: str
-    tags: list[PostTag]
+    tags: List[PostTag]
 
     @property
     def file_type(self) -> Optional[types.FileType]:
         """Get type of the file."""
-        match self.extension:
-            case "png" | "jpeg" | "webp":
-                return types.FileType.IMAGE
-            case "webm" | "mp4":
-                return types.FileType.VIDEO
-            case "gif":
-                return types.FileType.GIF
-            case _:
-                return None
+        if self.extension in ('png', 'jpeg', 'webp'):
+            return types.FileType.IMAGE
+        elif self.extension in ('webm', 'mp4'):
+            return types.FileType.VIDEO
+        elif self.extension in ('gif',):
+            return types.FileType.GIF
+        else:
+            return None
 
     # Validators
     _normalize_datetime = (
@@ -75,7 +73,7 @@ class Comment(BaseModel):
     body: str
     score: int
     parent_id: Optional[int]
-    children: list[Comment]
+    children: List[Comment]
     deleted: bool
     deleted_by: dict  # Seen only empty dictionaries so IDK real type
     updated_at: Optional[datetime]
diff --git a/sankaku/models/tags.py b/sankaku/models/tags.py
index e2160b0..45bd9a6 100644
--- a/sankaku/models/tags.py
+++ b/sankaku/models/tags.py
@@ -1,5 +1,5 @@
-from typing import Optional
 from datetime import datetime
+from typing import Optional, List
 
 from pydantic import BaseModel, Field, validator
 
@@ -7,7 +7,6 @@
 from sankaku.utils import convert_ts_to_datetime
 from .users import Author
 
-
 __all__ = ["PostTag", "PageTag", "Wiki", "WikiTag"]
 
 
@@ -41,7 +40,7 @@ class PostTag(BaseTag, TagMixin):
 class NestedTag(BaseTag):
     """Model that describes tags with specific relation to certain tag on tag page."""
     post_count: int = Field(alias="postCount")
-    cached_related: Optional[list[int]] = Field(alias="cachedRelated")
+    cached_related: Optional[List[int]] = Field(alias="cachedRelated")
     cached_related_expires_on: datetime = Field(alias="cachedRelatedExpiresOn")
     type: types.TagType = Field(alias="tagType")
     name_en: str = Field(alias="nameEn")
@@ -52,8 +51,8 @@ class NestedTag(BaseTag):
     popularity_safe: Optional[float] = Field(alias="scTagPopularitySafe")
     quality_ero: Optional[float] = Field(alias="scTagQualityEro")
     quality_safe: Optional[float] = Field(alias="scTagQualitySafe")
-    parent_tags: Optional[list[int]] = Field(alias="parentTags")
-    child_tags: Optional[list[int]] = Field(alias="childTags")
+    parent_tags: Optional[List[int]] = Field(alias="parentTags")
+    child_tags: Optional[List[int]] = Field(alias="childTags")
     pool_count: int = Field(alias="poolCount")
     premium_post_count: int = Field(alias="premPostCount")
     non_premium_post_count: int = Field(alias="nonPremPostCount")
@@ -68,7 +67,7 @@ class NestedTag(BaseTag):
     version: Optional[int]
 
     @validator("cached_related", "parent_tags", "child_tags", pre=True)
-    def flatten(cls, v) -> Optional[list[int]]:  # noqa
+    def flatten(cls, v) -> Optional[List[int]]:  # noqa
         """Flatten nested lists into one."""
         if not v:
             return None
@@ -88,10 +87,10 @@ class Translations(BaseModel):
 
 class PageTag(PostTag):
     """Model that describes tags on tag page."""
-    translations: list[Translations]
-    related_tags: list[NestedTag]
-    child_tags: list[NestedTag]
-    parent_tags: list[NestedTag]
+    translations: List[Translations]
+    related_tags: List[NestedTag]
+    child_tags: List[NestedTag]
+    parent_tags: List[NestedTag]
 
 
 class Wiki(BaseModel):
@@ -114,10 +113,10 @@ class Wiki(BaseModel):
 
 class WikiTag(BaseTag, TagMixin):
     """Model that describes tag on wiki page."""
-    related_tags: list[PostTag]
-    child_tags: list[PostTag]
-    parent_tags: list[PostTag]
-    alias_tags: list[PostTag]
-    implied_tags: list[PostTag]
-    translations: list[Translations]
+    related_tags: List[PostTag]
+    child_tags: List[PostTag]
+    parent_tags: List[PostTag]
+    alias_tags: List[PostTag]
+    implied_tags: List[PostTag]
+    translations: List[Translations]
     wiki: Wiki
diff --git a/sankaku/models/users.py b/sankaku/models/users.py
index 6d722c9..d75882b 100644
--- a/sankaku/models/users.py
+++ b/sankaku/models/users.py
@@ -1,11 +1,10 @@
 from datetime import datetime
-from typing import Optional
+from typing import Optional, List
 
 from pydantic import BaseModel, Field, validator
 
 from sankaku import types
 
-
 __all__ = ["Author", "User", "ExtendedUser"]
 
 
@@ -55,7 +54,7 @@ class User(BaseUser):
     post_vote_count: Optional[int] = None
     pool_vote_count: Optional[int] = None
     recommended_posts_for_user: Optional[int] = None
-    subscriptions: list[str] = []
+    subscriptions: List[str] = []
 
 
 class ExtendedUser(User):
@@ -69,11 +68,11 @@ class ExtendedUser(User):
     is_verified: bool
     verifications_count: int
     blacklist_is_hidden: bool
-    blacklisted_tags: list[str]
-    blacklisted: list[str]
+    blacklisted_tags: List[str]
+    blacklisted: List[str]
     mfa_method: int
 
     @validator("blacklisted_tags", pre=True)
-    def flatten_blacklisted_tags(cls, v) -> list[str]:  # noqa
+    def flatten_blacklisted_tags(cls, v) -> List[str]:  # noqa
         """Flatten nested lists into one."""
         return [tag[0] for tag in v]
diff --git a/sankaku/paginators/abc.py b/sankaku/paginators/abc.py
index f8f0385..e875bcf 100644
--- a/sankaku/paginators/abc.py
+++ b/sankaku/paginators/abc.py
@@ -1,18 +1,16 @@
 from abc import ABC, abstractmethod
-from collections.abc import AsyncIterator
-from typing import Generic, TypeVar
+from typing import Generic, TypeVar, AsyncIterator
 
 from sankaku import errors, models as mdl
 
-
 __all__ = ["ABCPaginator"]
 
-
 _T = TypeVar("_T")
 
 
 class ABCPaginator(ABC, Generic[_T]):
     """Abstract paginator class."""
+
     @abstractmethod
     def __init__(self, *args, **kwargs) -> None:
         pass
diff --git a/sankaku/paginators/paginators.py b/sankaku/paginators/paginators.py
index 5fccf4c..0c83561 100644
--- a/sankaku/paginators/paginators.py
+++ b/sankaku/paginators/paginators.py
@@ -1,12 +1,17 @@
-from typing import Optional, Literal, Annotated, TypeVar
 from datetime import datetime
+from typing import Optional, TypeVar, List, Dict, Type
+
+from ..typedefs import ValueRange
+
+try:
+    from typing import Literal, Annotated
+except (ModuleNotFoundError, ImportError):
+    from typing_extensions import Literal, Annotated
 
-from .abc import ABCPaginator
-from sankaku.typedefs import ValueRange
-from sankaku.utils import ratelimit
 from sankaku import models as mdl, constants as const, types, errors
 from sankaku.clients import HttpClient
-
+from sankaku.utils import ratelimit
+from .abc import ABCPaginator
 
 __all__ = [
     "Paginator",
@@ -16,27 +21,27 @@
     "UserPaginator"
 ]
 
-
 _T = TypeVar("_T")
 
 
 class Paginator(ABCPaginator[_T]):
     """Basic paginator for iteration without any special parameters."""
+
     def __init__(
-        self,
-        http_client: HttpClient,
-        url: str,
-        model: type[_T],
-        page_number: Optional[int] = None,
-        limit: Optional[Annotated[int, ValueRange(1, 100)]] = None,
-        params: Optional[dict[str, str]] = None
+            self,
+            http_client: HttpClient,
+            url: str,
+            model: Type[_T],
+            page_number: Optional[int] = None,
+            limit: Optional[Annotated[int, ValueRange(1, 100)]] = None,
+            params: Optional[Dict[str, str]] = None
     ) -> None:
         self.http_client = http_client
         self.url = url
         self.model = model
         self.page_number = page_number or const.BASE_PAGE_NUMBER
         self.limit = limit or const.BASE_PAGE_LIMIT
-        self.params: dict[str, str] = params or {}
+        self.params: Dict[str, str] = params or {}
 
         self.complete_params()
 
@@ -44,15 +49,15 @@ def __init__(
     async def next_page(self) -> mdl.Page[_T]:  # type: ignore[override]
         """Get paginator next page."""
         response = await self.http_client.get(self.url, params=self.params)
-        match response.json:
-            case [] | {"data": []}:
-                raise errors.PaginatorLastPage(response.status, page=self.page_number)
-            case {"code": code} if code in const.PAGE_ALLOWED_ERRORS:
-                raise errors.PaginatorLastPage(response.status, page=self.page_number)
-            case {"code": _, "errorId": _}:
-                raise errors.SankakuServerError(response.status, **response.json)
-            case {"data": list() as data} if data:
-                response.json = data
+        json_ = response.json
+        if json_ == [] or (isinstance(json_, dict) and not json_.get("data")):
+            raise errors.PaginatorLastPage(response.status, page=self.page_number)
+        elif 'code' in json_ and json_['code'] in const.PAGE_ALLOWED_ERRORS:
+            raise errors.PaginatorLastPage(response.status, page=self.page_number)
+        elif 'code' in json_ and 'errorId' in json_:
+            raise errors.SankakuServerError(response.status, **response.json)
+        elif 'data' in json_:
+            response.json = json_['data']
 
         self.page_number += 1
         self.params["page"] = str(self.page_number)
@@ -66,7 +71,7 @@ def complete_params(self) -> None:
         if self.limit is not None:
             self.params["limit"] = str(self.limit)
 
-    def _construct_page(self, data: list[dict]) -> mdl.Page[_T]:
+    def _construct_page(self, data: List[dict]) -> mdl.Page[_T]:
         """Construct and return page model."""
         items = [self.model(**d) for d in data]
         return mdl.Page[_T](number=self.page_number - 1, items=items)
@@ -74,27 +79,28 @@ def _construct_page(self, data: list[dict]) -> mdl.Page[_T]:
 
 class PostPaginator(Paginator[mdl.Post]):
     """Paginator used for iteration through post pages."""
+
     def __init__(
-        self,
-        http_client: HttpClient,
-        url: str,
-        model: type[mdl.Post] = mdl.Post,
-        page_number: Optional[int] = None,
-        limit: Optional[Annotated[int, ValueRange(1, 100)]] = None,
-        params: Optional[dict[str, str]] = None,
-        order: Optional[types.PostOrder] = None,
-        date: Optional[list[datetime]] = None,
-        rating: Optional[types.Rating] = None,
-        threshold: Optional[Annotated[int, ValueRange(1, 100)]] = None,
-        hide_posts_in_books: Optional[Literal["in-larger-tags", "always"]] = None,
-        file_size: Optional[types.FileSize] = None,
-        file_type: Optional[types.FileType] = None,
-        video_duration: Optional[list[int]] = None,
-        recommended_for: Optional[str] = None,
-        favorited_by: Optional[str] = None,
-        tags: Optional[list[str]] = None,
-        added_by: Optional[list[str]] = None,
-        voted: Optional[str] = None
+            self,
+            http_client: HttpClient,
+            url: str,
+            model: Type[mdl.Post] = mdl.Post,
+            page_number: Optional[int] = None,
+            limit: Optional[Annotated[int, ValueRange(1, 100)]] = None,
+            params: Optional[Dict[str, str]] = None,
+            order: Optional[types.PostOrder] = None,
+            date: Optional[List[datetime]] = None,
+            rating: Optional[types.Rating] = None,
+            threshold: Optional[Annotated[int, ValueRange(1, 100)]] = None,
+            hide_posts_in_books: Optional[Literal["in-larger-tags", "always"]] = None,
+            file_size: Optional[types.FileSize] = None,
+            file_type: Optional[types.FileType] = None,
+            video_duration: Optional[List[int]] = None,
+            recommended_for: Optional[str] = None,
+            favorited_by: Optional[str] = None,
+            tags: Optional[List[str]] = None,
+            added_by: Optional[List[str]] = None,
+            voted: Optional[str] = None
     ) -> None:
         self.order = order
         self.date = date
@@ -116,29 +122,28 @@ def complete_params(self) -> None:
         if self.tags is None:
             self.tags = []
 
-        for items in self.__dict__.items():
-            match items:
-                case [_, None]:
-                    continue
-                case ["order" | "rating" | "file_type" as k, v] if v is not types.FileType.IMAGE:  # noqa
-                    self.tags.append(f"{k}:{v.value}")
-                case ["threshold" | "recommended_for" | "voted" as k, v]:
-                    self.tags.append(f"{k}:{v}")
-                case ["file_size", _]:
-                    self.tags.append(self.file_size.value)  # type: ignore[union-attr]
-                case ["date", _]:
-                    date = "..".join(d.strftime("%Y-%m-%dT%H:%M") for d in self.date)  # type: ignore[union-attr]
-                    self.tags.append(f"date:{date}")
-                case ["video_duration", _] if self.file_type is not types.FileType.VIDEO:  # noqa
-                    raise errors.VideoDurationError
-                case ["video_duration", _]:
-                    duration = "..".join(str(sec) for sec in self.video_duration)  # type: ignore[union-attr]
-                    self.tags.append(f"duration:{duration}")
-                case ["favorited_by", _]:
-                    self.tags.append(f"fav:{self.favorited_by}")
-                case ["added_by", _]:
-                    for user in self.added_by:  # type: ignore[union-attr]
-                        self.tags.append(f"user:{user}")
+        for k, v in self.__dict__.items():
+            if v is None:
+                continue
+            elif k in {"order", "rating", "file_type"} and v is not types.FileType.IMAGE:
+                self.tags.append(f"{k}:{v.value}")
+            elif k in {"threshold", "recommended_for", "voted"}:
+                self.tags.append(f"{k}:{v}")
+            elif k == 'file_size':
+                self.tags.append(self.file_size.value)  # type: ignore[union-attr]
+            elif k == 'date':
+                date = "..".join(d.strftime("%Y-%m-%dT%H:%M") for d in self.date)  # type: ignore[union-attr]
+                self.tags.append(f"date:{date}")
+            elif k == 'video_duration' and self.file_type is not types.FileType.VIDEO:
+                raise errors.VideoDurationError
+            elif k == 'video_duration':
+                duration = "..".join(str(sec) for sec in self.video_duration)  # type: ignore[union-attr]
+                self.tags.append(f"duration:{duration}")
+            elif k == 'favorited_by':
+                self.tags.append(f"fav:{self.favorited_by}")
+            elif k == 'added_by':
+                for user in self.added_by:  # type: ignore[union-attr]
+                    self.tags.append(f"user:{user}")
 
         if self.hide_posts_in_books is not None:
             self.params["hide_posts_in_books"] = self.hide_posts_in_books
@@ -148,20 +153,21 @@ def complete_params(self) -> None:
 
 class TagPaginator(Paginator[mdl.PageTag]):
     """Paginator used for iteration through tag pages."""
+
     def __init__(
-        self,
-        http_client: HttpClient,
-        url: str,
-        model: type[mdl.PageTag] = mdl.PageTag,
-        page_number: Optional[int] = None,
-        limit: Optional[Annotated[int, ValueRange(1, 100)]] = None,
-        params: Optional[dict[str, str]] = None,
-        tag_type: Optional[types.TagType] = None,
-        order: Optional[types.TagOrder] = None,
-        rating: Optional[types.Rating] = None,
-        max_post_count: Optional[int] = None,
-        sort_parameter: Optional[types.SortParameter] = None,
-        sort_direction: Optional[types.SortDirection] = None
+            self,
+            http_client: HttpClient,
+            url: str,
+            model: Type[mdl.PageTag] = mdl.PageTag,
+            page_number: Optional[int] = None,
+            limit: Optional[Annotated[int, ValueRange(1, 100)]] = None,
+            params: Optional[Dict[str, str]] = None,
+            tag_type: Optional[types.TagType] = None,
+            order: Optional[types.TagOrder] = None,
+            rating: Optional[types.Rating] = None,
+            max_post_count: Optional[int] = None,
+            sort_parameter: Optional[types.SortParameter] = None,
+            sort_direction: Optional[types.SortDirection] = None
     ) -> None:
         self.tag_type = tag_type
         self.order = order
@@ -190,21 +196,22 @@ def complete_params(self) -> None:
 
 class BookPaginator(Paginator[mdl.PageBook]):
     """Paginator used for iteration through book pages."""
+
     def __init__(
-        self,
-        http_client: HttpClient,
-        url: str,
-        model: type[mdl.PageBook] = mdl.PageBook,
-        page_number: Optional[int] = None,
-        limit: Optional[Annotated[int, ValueRange(1, 100)]] = None,
-        params: Optional[dict[str, str]] = None,
-        order: Optional[types.BookOrder] = None,
-        rating: Optional[types.Rating] = None,
-        recommended_for: Optional[str] = None,
-        favorited_by: Optional[str] = None,
-        tags: Optional[list[str]] = None,
-        added_by: Optional[list[str]] = None,
-        voted: Optional[str] = None
+            self,
+            http_client: HttpClient,
+            url: str,
+            model: Type[mdl.PageBook] = mdl.PageBook,
+            page_number: Optional[int] = None,
+            limit: Optional[Annotated[int, ValueRange(1, 100)]] = None,
+            params: Optional[Dict[str, str]] = None,
+            order: Optional[types.BookOrder] = None,
+            rating: Optional[types.Rating] = None,
+            recommended_for: Optional[str] = None,
+            favorited_by: Optional[str] = None,
+            tags: Optional[List[str]] = None,
+            added_by: Optional[List[str]] = None,
+            voted: Optional[str] = None
     ) -> None:
         self.order = order
         self.rating = rating
@@ -220,19 +227,18 @@ def complete_params(self) -> None:
         if self.tags is None:
             self.tags = []
 
-        for items in self.__dict__.items():
-            match items:
-                case [_, None]:
-                    continue
-                case ["order" | "rating" as k, v]:  # noqa
-                    self.tags.append(f"{k}:{v.value}")
-                case ["recommended_for" | "voted" as k, v]:
-                    self.tags.append(f"{k}:{v}")
-                case ["favorited_by", _]:
-                    self.tags.append(f"fav:{self.favorited_by}")
-                case ["added_by", _]:
-                    for user in self.added_by:  # type: ignore[union-attr]
-                        self.tags.append(f"user:{user}")
+        for k, v in self.__dict__.items():
+            if v is None:
+                continue
+            elif k in {"order", "rating"}:
+                self.tags.append(f"{k}:{v.value}")
+            elif k in {"recommended_for", "voted"}:
+                self.tags.append(f"{k}:{v}")
+            elif k == 'favorited_by':
+                self.tags.append(f"fav:{self.favorited_by}")
+            elif k == 'added_by':
+                for user in self.added_by:  # type: ignore[union-attr]
+                    self.tags.append(f"user:{user}")
 
         if self.tags:
             self.params["tags"] = " ".join(self.tags)
@@ -240,16 +246,17 @@ def complete_params(self) -> None:
 
 class UserPaginator(Paginator[mdl.User]):
     """Paginator used for iteration through user pages."""
+
     def __init__(
-        self,
-        http_client: HttpClient,
-        url: str,
-        model: type[mdl.User] = mdl.User,
-        page_number: Optional[int] = None,
-        limit: Optional[Annotated[int, ValueRange(1, 100)]] = None,
-        params: Optional[dict[str, str]] = None,
-        order: Optional[types.UserOrder] = None,
-        level: Optional[types.UserLevel] = None
+            self,
+            http_client: HttpClient,
+            url: str,
+            model: Type[mdl.User] = mdl.User,
+            page_number: Optional[int] = None,
+            limit: Optional[Annotated[int, ValueRange(1, 100)]] = None,
+            params: Optional[Dict[str, str]] = None,
+            order: Optional[types.UserOrder] = None,
+            level: Optional[types.UserLevel] = None
     ) -> None:
         self.order = order
         self.level = level
diff --git a/sankaku/typedefs.py b/sankaku/typedefs.py
index 25cc8f1..8ded32c 100644
--- a/sankaku/typedefs.py
+++ b/sankaku/typedefs.py
@@ -1,6 +1,10 @@
 from dataclasses import dataclass
-from typing import TypedDict, Optional
+from typing import Optional
 
+try:
+    from typing import TypedDict
+except (ImportError, ModuleNotFoundError):
+    from typing_extensions import TypedDict
 
 __all__ = ["ValueRange", "Timestamp"]
 
diff --git a/sankaku/types.py b/sankaku/types.py
index 3f49cc9..89b6b2c 100644
--- a/sankaku/types.py
+++ b/sankaku/types.py
@@ -1,6 +1,5 @@
 from enum import Enum
 
-
 __all__ = [
     "Rating",
     "PostOrder",
diff --git a/sankaku/utils.py b/sankaku/utils.py
index 8a188de..aeeb29a 100644
--- a/sankaku/utils.py
+++ b/sankaku/utils.py
@@ -3,26 +3,21 @@
 import asyncio
 from datetime import datetime
 from functools import wraps
-from typing import TypeVar, ParamSpec, Optional, Any
-from collections.abc import Callable, Awaitable
-
+from typing import TypeVar, Optional, Any, Dict, Tuple, Callable, Awaitable
 
 from sankaku.errors import RateLimitError
 from sankaku.typedefs import Timestamp
 
-
 __all__ = ["ratelimit", "convert_ts_to_datetime", "from_locals"]
 
-
 _T = TypeVar("_T")
-_P = ParamSpec("_P")
 
 
 def ratelimit(
-    *,
-    rps: Optional[int] = None,
-    rpm: Optional[int] = None
-) -> Callable[[Callable[_P, Awaitable[_T]]], Callable[_P, Awaitable[_T]]]:
+        *,
+        rps: Optional[int] = None,
+        rpm: Optional[int] = None
+) -> Callable[[Callable[..., Awaitable[_T]]], Callable[..., Awaitable[_T]]]:
     """Limit the number of requests.
 
     Args:
@@ -36,11 +31,12 @@ def ratelimit(
 
     sleep_time: float = (1 / rps) if rps else (60 / rpm)  # type: ignore[operator]
 
-    def wrapper(func: Callable[_P, Awaitable[_T]]) -> Callable[_P, Awaitable[_T]]:
+    def wrapper(func: Callable[..., Awaitable[_T]]) -> Callable[..., Awaitable[_T]]:
         @wraps(func)
-        async def inner(*args: _P.args, **kwargs: _P.kwargs) -> _T:
+        async def inner(*args, **kwargs) -> _T:
             await asyncio.sleep(sleep_time)
             return await func(*args, **kwargs)  # noqa
+
         return inner
 
     return wrapper
@@ -54,8 +50,8 @@ def convert_ts_to_datetime(ts: Timestamp) -> Optional[datetime]:
 
 
 def from_locals(
-    loc: dict[str, Any], exclude: tuple[str, ...] = ("self",)
-) -> dict[str, Any]:
+        loc: Dict[str, Any], exclude: Tuple[str, ...] = ("self",)
+) -> Dict[str, Any]:
     """Get arguments of calling function from its locals to pass them to paginator.
 
     Args:
diff --git a/setup.py b/setup.py
index 234e30d..6245001 100644
--- a/setup.py
+++ b/setup.py
@@ -1,5 +1,22 @@
+import os
+import re
+
 import setuptools
 
+
+def _load_req(file: str):
+    with open(file, 'r', encoding='utf-8') as f:
+        return [line.strip() for line in f.readlines() if line.strip()]
+
+
+requirements = _load_req('requirements.txt')
+
+_REQ_PATTERN = re.compile('^requirements-([a-zA-Z0-9_]+)\\.txt$')
+group_requirements = {
+    item.group(1): _load_req(item.group(0))
+    for item in [_REQ_PATTERN.fullmatch(reqpath) for reqpath in os.listdir()] if item
+}
+
 setuptools.setup(
     name="sankaku",
     version="1.0.1",
@@ -14,10 +31,17 @@
     packages=setuptools.find_packages(exclude=["tests", "tests.*"]),
     license="MIT",
     classifiers=[
+        "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
+        "Programming Language :: Python :: 3.9",
+        "Programming Language :: Python :: 3.10",
         "Programming Language :: Python :: 3.11",
         "License :: OSI Approved :: MIT License",
         "Operating System :: OS Independent",
     ],
-    python_requires=">=3.11",
-    install_requires=["aiohttp", "pydantic", "loguru"]
+    python_requires=">=3.7",
+    install_requires=requirements,  # this will change after you change requirements
+
+    # you can install other requirements like `pip install sankaku[socks]`
+    extras_require=group_requirements,
 )


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..25dff0d
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,104 @@
+name: Code Test
+
+on:
+  - push
+
+jobs:
+  unittest:
+    name: Code test
+    runs-on: ${{ matrix.os }}
+    if: "!contains(github.event.head_commit.message, 'ci skip')"
+    strategy:
+      fail-fast: false
+      matrix:
+        os:
+          - 'ubuntu-latest'
+          - 'windows-latest'
+          - 'macos-latest'
+        python-version:
+          - '3.7'
+          - '3.8'
+          - '3.9'
+          - '3.10'
+          - '3.11'
+
+    steps:
+      - name: Get system version for Linux
+        if: ${{ contains(matrix.os, 'ubuntu') }}
+        shell: bash
+        run: |
+          echo "OS_NAME=Linux" >> $GITHUB_ENV
+          echo "IS_WIN=" >> $GITHUB_ENV
+          echo "IS_MAC=" >> $GITHUB_ENV
+      - name: Get system version for Windows
+        if: ${{ contains(matrix.os, 'windows') }}
+        shell: bash
+        run: |
+          echo "OS_NAME=Windows" >> $GITHUB_ENV
+          echo "IS_WIN=1" >> $GITHUB_ENV
+          echo "IS_MAC=" >> $GITHUB_ENV
+      - name: Get system version for MacOS
+        if: ${{ contains(matrix.os, 'macos') }}
+        shell: bash
+        run: |
+          echo "OS_NAME=MacOS" >> $GITHUB_ENV
+          echo "IS_WIN=" >> $GITHUB_ENV
+          echo "IS_MAC=1" >> $GITHUB_ENV
+      - name: Set environment for Cpython
+        if: ${{ !contains(matrix.python-version, 'pypy') }}
+        shell: bash
+        run: |
+          echo "IS_PYPY=" >> $GITHUB_ENV
+      - name: Set environment for PyPy
+        if: ${{ contains(matrix.python-version, 'pypy') }}
+        shell: bash
+        run: |
+          echo "IS_PYPY=1" >> $GITHUB_ENV
+      - name: Checkout code
+        uses: actions/checkout@v2
+        with:
+          fetch-depth: 20
+          submodules: 'recursive'
+      - name: Set up system dependences on Linux
+        if: ${{ env.OS_NAME == 'Linux' }}
+        shell: bash
+        run: |
+          sudo apt-get update
+          sudo apt-get install -y tree cloc wget curl make zip
+          sudo apt-get install -y git-lfs
+      - name: Set up system dependences on Windows
+        if: ${{ env.OS_NAME == 'Windows' }}
+        shell: bash
+        run: |
+          choco install tree cloc wget curl make zip
+      - name: Set up system dependences on MacOS
+        if: ${{ env.OS_NAME == 'MacOS' }}
+        run: |
+          brew install tree cloc wget curl make zip
+      - name: Set up python ${{ matrix.python-version }}
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        shell: bash
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r requirements.txt
+          pip install -r requirements-test.txt
+      - name: Test the basic environment
+        shell: bash
+        run: |
+          python -V
+          pip --version
+          pip list
+          tree .
+          cloc sankaku
+          cloc tests
+      - name: Run unittest
+        env:
+          CI: 'true'
+          LOGIN: ${{ secrets.UNITTEST_USERNAME }}
+          PASSWORD: ${{ secrets.UNITTEST_PASSWORD }}
+        shell: bash
+        run: |
+          pytest tests -sv --cov=sankaku --cov-report term-missing
diff --git a/requirements-test.txt b/requirements-test.txt
new file mode 100644
index 0000000..14bd34c
--- /dev/null
+++ b/requirements-test.txt
@@ -0,0 +1,7 @@
+pytest~=7.3.1
+pytest-asyncio~=0.21.0
+pytest-cov~=4.0.0
+pytest-trio~=0.8.0
+pytest-twisted~=1.14.0
+anyio~=3.6.2
+twisted~=22.10.0
\ No newline at end of file