From c7e3812ee83a9bd41a1c5260886a9074e419d14b Mon Sep 17 00:00:00 2001 From: firedotguy Date: Fri, 30 Jan 2026 20:49:49 +0300 Subject: [PATCH 01/25] feat: add models and enum --- itd/client.py | 2 ++ itd/enums.py | 25 +++++++++++++++++++++++++ itd/models/_text.py | 17 +++++++++++++++++ itd/models/comment.py | 14 ++++++++++++++ itd/models/file.py | 10 ++++++++++ itd/models/hashtag.py | 8 ++++++++ itd/models/notification.py | 22 ++++++++++++++++++++++ itd/models/post.py | 29 +++++++++++++++++++++++++++++ itd/models/report.py | 16 ++++++++++++++++ itd/models/user.py | 37 +++++++++++++++++++++++++++++++++++++ itd/models/verification.py | 23 +++++++++++++++++++++++ itd/routes/comments.py | 2 +- itd/routes/hashtags.py | 2 +- itd/routes/posts.py | 2 +- pyproject.toml | 2 +- requirements.txt | 2 ++ setup.py | 2 +- 17 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 itd/enums.py create mode 100644 itd/models/_text.py create mode 100644 itd/models/comment.py create mode 100644 itd/models/file.py create mode 100644 itd/models/hashtag.py create mode 100644 itd/models/notification.py create mode 100644 itd/models/post.py create mode 100644 itd/models/report.py create mode 100644 itd/models/user.py create mode 100644 itd/models/verification.py diff --git a/itd/client.py b/itd/client.py index ba7a36b..326241b 100644 --- a/itd/client.py +++ b/itd/client.py @@ -14,6 +14,7 @@ from itd.routes.search import search from itd.routes.files import upload_file from itd.routes.auth import refresh_token, change_password, logout from itd.routes.verification import verificate, get_verification_status +from itd.request import set_cookies def refresh_on_error(func): @@ -35,6 +36,7 @@ class Client: if token: self.token = token.replace('Bearer ', '') elif self.cookies: + set_cookies(self.cookies) self.refresh_auth() else: raise ValueError('Provide token or cookie') diff --git a/itd/enums.py b/itd/enums.py new file mode 100644 index 0000000..3d88e59 --- /dev/null +++ b/itd/enums.py @@ -0,0 +1,25 @@ +from enum import Enum + +class NotificationType(Enum): + WALL_POST = 'wall_post' + REPLY = 'reply' + REPOST = 'repost' + COMMENT = 'comment' + FOLLOW = 'follow' + LIKE = 'like' + +class NotificationTargetType(Enum): + POST = 'post' + +class ReportTargetType(Enum): + POST = 'post' + USER = 'user' + COMMENT = 'comment' + +class ReportTargetReason(Enum): + SPAM = 'spam' # спам + VIOLENCE = 'violence' # насилие + HATE = 'hate' # ненависть + ADULT = 'adult' # 18+ + FRAUD = 'fraud' # обман\мошенничество + OTHER = 'other' # другое \ No newline at end of file diff --git a/itd/models/_text.py b/itd/models/_text.py new file mode 100644 index 0000000..aa6a372 --- /dev/null +++ b/itd/models/_text.py @@ -0,0 +1,17 @@ +from uuid import UUID +from datetime import datetime + +from pydantic import BaseModel, Field + +from itd.models.user import UserPost + + +class _TextObject(BaseModel): + id: UUID + content: str + author: UserPost + attachments: list[UUID] + + created_at: datetime = Field(alias='createdAt') + + model_config = {'populate_by_name': True} \ No newline at end of file diff --git a/itd/models/comment.py b/itd/models/comment.py new file mode 100644 index 0000000..ecf4324 --- /dev/null +++ b/itd/models/comment.py @@ -0,0 +1,14 @@ +from uuid import UUID +from datetime import datetime + +from pydantic import Field + +from itd.models._text import _TextObject + + +class CommentShort(_TextObject): + likes_count: int = Field(0, alias='likesCount') + replies_count: int = Field(0, alias='repliesCount') + is_liked: bool = Field(False, alias='isLiked') + + replies: list['CommentShort'] = [] \ No newline at end of file diff --git a/itd/models/file.py b/itd/models/file.py new file mode 100644 index 0000000..63fe4b4 --- /dev/null +++ b/itd/models/file.py @@ -0,0 +1,10 @@ +from uuid import UUID + +from pydantic import BaseModel, Field + +class File(BaseModel): + id: UUID + url: str + filename: str + mime_type: str = Field('image/png', alias='mimeType') + size: int \ No newline at end of file diff --git a/itd/models/hashtag.py b/itd/models/hashtag.py new file mode 100644 index 0000000..d1d970f --- /dev/null +++ b/itd/models/hashtag.py @@ -0,0 +1,8 @@ +from uuid import UUID + +from pydantic import BaseModel, Field + +class Hashtag(BaseModel): + id: UUID + name: str + posts_count: int = Field(0, alias='postsCount') \ No newline at end of file diff --git a/itd/models/notification.py b/itd/models/notification.py new file mode 100644 index 0000000..7ea3ea9 --- /dev/null +++ b/itd/models/notification.py @@ -0,0 +1,22 @@ +from uuid import UUID +from datetime import datetime + +from pydantic import BaseModel, Field + +from itd.enums import NotificationType, NotificationTargetType +from itd.models.user import UserNotification + +class Notification(BaseModel): + id: UUID + type: NotificationType + + target_type: NotificationTargetType | None = Field(None, alias='targetType') # none - follows, other - NotificationTragetType.POST + target_id: int | None = Field(None, alias='targetId') # none - follows + + preview: str | None = None # follow - none, comment/reply - content, repost - original post content, like - post content, wall_post - wall post content + + read: bool = False + read_at: datetime | None = Field(None, alias='readAt') + created_at: datetime = Field(alias='createdAt') + + actor: UserNotification \ No newline at end of file diff --git a/itd/models/post.py b/itd/models/post.py new file mode 100644 index 0000000..8969cad --- /dev/null +++ b/itd/models/post.py @@ -0,0 +1,29 @@ +from pydantic import Field + +from itd.models.user import UserPost +from itd.models._text import _TextObject + + +class PostShort(_TextObject): + likes_count: int = Field(0, alias='likesCount') + comments_count: int = Field(0, alias='commentsCount') + reposts_count: int = Field(0, alias='repostsCount') + views_count: int = Field(0, alias='viewsCount') + + +class OriginalPost(PostShort): + is_deleted: bool = Field(False, alias='isDeleted') + + +class Post(PostShort): + is_liked: bool = Field(False, alias='isLiked') + is_reposted: bool = Field(False, alias='isReposted') + is_viewed: bool = Field(False, alias='isViewed') + is_owner: bool = Field(False, alias='isOwner') + + comments: list = [] + + original_post: OriginalPost | None = None + + wall_recipient_id: int | None = None + wall_recipient: UserPost | None = None diff --git a/itd/models/report.py b/itd/models/report.py new file mode 100644 index 0000000..2a6108d --- /dev/null +++ b/itd/models/report.py @@ -0,0 +1,16 @@ +from uuid import UUID +from datetime import datetime + +from pydantic import BaseModel, Field + +from itd.enums import ReportTargetType, ReportTargetReason + +class Report(BaseModel): + id: UUID + reason: ReportTargetReason + description: str | None = None + + target_type: ReportTargetType = Field(alias='targetType') + target_id: UUID + + created_at: datetime = Field(alias='createdAt') \ No newline at end of file diff --git a/itd/models/user.py b/itd/models/user.py new file mode 100644 index 0000000..760128b --- /dev/null +++ b/itd/models/user.py @@ -0,0 +1,37 @@ +from uuid import UUID +from datetime import datetime + +from pydantic import BaseModel, Field + +class UserNotification(BaseModel): + id: UUID + username: str + display_name: str = Field(alias='displayName') + avatar: str + + model_config = {'populate_by_name': True} + + +class UserPost(UserNotification): + verified: bool = False + + +class UserSearch(UserPost): + followers_count: int = Field(0, alias='followersCount') + + +class User(UserSearch): + banner: str | None = None + bio: str | None = None + pinned_post_id: UUID | None + + private: bool | None = Field(None, alias='isPrivate') # none for not me + wall_closed: bool = Field(False, alias='wallClosed') + + following_count: int = Field(0, alias='followingCount') + posts_count: int = Field(0, alias='postsCount') + + is_following: bool | None = Field(None, alias='isFollowing') # none for me + is_followed: bool | None = Field(None, alias='isFollowedBy') # none for me + + created_at: datetime = Field(alias='createdAt') diff --git a/itd/models/verification.py b/itd/models/verification.py new file mode 100644 index 0000000..6b55b0d --- /dev/null +++ b/itd/models/verification.py @@ -0,0 +1,23 @@ +from uuid import UUID +from datetime import datetime + +from pydantic import BaseModel, Field + +class Verification(BaseModel): + id: UUID + user_id: UUID = Field(alias='userId') + video_url: str = Field(alias='videoUrl') + status: str # should be enum, but we dont know all statuses (what status for accepted?) + + reject_reason: str | None = Field(None, alias='rejectionReason') + reviewer: str | None = Field(None, alias='reviewedBy') + reviewed_at: datetime | None = Field(None, alias='reviewedAt') + + created_at: datetime = Field(alias='createdAt') + updated_at: datetime = Field(alias='updatedAt') + + +class VerificationStatus(BaseModel): + status: str # should be enum, but we dont know all statuses (what status for accepted?) + request_id: UUID = Field(alias='requestId') + submitted_at: datetime = Field(alias='submittedAt') \ No newline at end of file diff --git a/itd/routes/comments.py b/itd/routes/comments.py index e0e7395..0cdc702 100644 --- a/itd/routes/comments.py +++ b/itd/routes/comments.py @@ -7,7 +7,7 @@ def add_comment(token: str, post_id: str, content: str, reply_comment_id: str | return fetch(token, 'post', f'posts/{post_id}/comments', data) def get_comments(token: str, post_id: str, limit: int = 20, cursor: int = 0, sort: str = 'popular'): - return fetch(token, 'get', f'posts/{post_id}/comments', {'limit': limit, 'sort': sort, 'cursor': cursor}) + return fetch(token, 'get', f'posts/{post_id}/comments', {'limit': limit, 'sort': sort, 'cursor': cursor})['data'] def like_comment(token: str, comment_id: str): return fetch(token, 'post', f'comments/{comment_id}/like') diff --git a/itd/routes/hashtags.py b/itd/routes/hashtags.py index e6e9a63..b255096 100644 --- a/itd/routes/hashtags.py +++ b/itd/routes/hashtags.py @@ -1,7 +1,7 @@ from itd.request import fetch def get_hastags(token: str, limit: int = 10): - return fetch(token, 'get', 'hashtags/trending', {'limit': limit}) + return fetch(token, 'get', 'hashtags/trending', {'limit': limit})['data'] def get_posts_by_hastag(token: str, hashtag: str, limit: int = 20, cursor: int = 0): return fetch(token, 'get', f'hashtags/{hashtag}/posts', {'limit': limit, 'cursor': cursor}) diff --git a/itd/routes/posts.py b/itd/routes/posts.py index 55a129b..87223cc 100644 --- a/itd/routes/posts.py +++ b/itd/routes/posts.py @@ -18,7 +18,7 @@ def get_posts(token: str, username: str | None = None, limit: int = 20, cursor: if tab: data['tab'] = tab - return fetch(token, 'get', 'posts', data) + return fetch(token, 'get', 'posts', data)['data'] def get_post(token: str, id: str): return fetch(token, 'get', f'posts/{id}') diff --git a/pyproject.toml b/pyproject.toml index 050c285..0c8f326 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,6 @@ authors = [ ] license = "MIT" dependencies = [ - "requests" + "requests", "pydantic" ] requires-python = ">=3.9" diff --git a/requirements.txt b/requirements.txt index e69de29..541740e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,2 @@ +pydantic==2.11.9 +requests==2.32.3 \ No newline at end of file diff --git a/setup.py b/setup.py index 3d21301..03f39bf 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( version='0.2.0', packages=find_packages(), install_requires=[ - 'requests' + 'requests', 'pydantic' ], python_requires=">=3.9" ) From a388426d8d9dd3598643d45191a920697a9c2d40 Mon Sep 17 00:00:00 2001 From: firedotguy Date: Sat, 31 Jan 2026 12:10:20 +0300 Subject: [PATCH 02/25] feat: add models and partially custom error messages --- itd/client.py | 268 +++++++++++++++++++++++++++++++++------ itd/exceptions.py | 43 +++++++ itd/models/_text.py | 2 +- itd/models/clan.py | 6 + itd/models/comment.py | 7 +- itd/models/pagination.py | 7 + itd/models/post.py | 4 +- itd/models/user.py | 37 +++++- itd/request.py | 17 ++- itd/routes/auth.py | 10 +- itd/routes/comments.py | 2 +- itd/routes/files.py | 2 +- itd/routes/hashtags.py | 2 +- itd/routes/posts.py | 6 +- itd/routes/users.py | 10 +- 15 files changed, 352 insertions(+), 71 deletions(-) create mode 100644 itd/exceptions.py create mode 100644 itd/models/clan.py create mode 100644 itd/models/pagination.py diff --git a/itd/client.py b/itd/client.py index 326241b..d42861c 100644 --- a/itd/client.py +++ b/itd/client.py @@ -1,3 +1,4 @@ +from uuid import UUID from _io import BufferedReader from typing import cast @@ -14,7 +15,14 @@ from itd.routes.search import search from itd.routes.files import upload_file from itd.routes.auth import refresh_token, change_password, logout from itd.routes.verification import verificate, get_verification_status + +from itd.models.clan import Clan +from itd.models.user import User, UserProfileUpdate, UserPrivacy, UserFollower, UserWhoToFollow +from itd.models.pagination import Pagination +from itd.models.verification import Verification, VerificationStatus + from itd.request import set_cookies +from itd.exceptions import NoCookie, NoAuthData, SamePassword, InvalidOldPassword, UserNotFound, InvalidProfileData, UserBanned, PendingRequestExists def refresh_on_error(func): @@ -39,79 +47,261 @@ class Client: set_cookies(self.cookies) self.refresh_auth() else: - raise ValueError('Provide token or cookie') + raise NoAuthData() - def refresh_auth(self): - if self.cookies: - self.token = refresh_token(self.cookies) - return self.token - else: - print('no cookies') + def refresh_auth(self) -> str: + """Обновить access token - @refresh_on_error - def change_password(self, old: str, new: str): + Raises: + NoCookie: Нет cookie + + Returns: + str: Токен + """ + print('refresh token') if not self.cookies: - print('no cookies') - return - return change_password(self.cookies, self.token, old, new) + raise NoCookie() + + res = refresh_token(self.cookies) + res.raise_for_status() + + self.token = res.json()['accessToken'] + return self.token @refresh_on_error - def logout(self): + def change_password(self, old: str, new: str) -> dict: + """Смена пароля + + Args: + old (str): Страый пароль + new (str): Новый пароль + + Raises: + NoCookie: Нет cookie + SamePassword: Одинаковые пароли + InvalidOldPassword: Старый пароль неверный + + Returns: + dict: Ответ API `{'message': 'Password changed successfully'}` + """ if not self.cookies: - print('no cookies') - return - return logout(self.cookies) + raise NoCookie() + res = change_password(self.cookies, self.token, old, new) + if res.json().get('error', {}).get('code') == 'SAME_PASSWORD': + raise SamePassword() + if res.json().get('error', {}).get('code') == 'INVALID_OLD_PASSWORD': + raise InvalidOldPassword() + res.raise_for_status() + + return res.json() @refresh_on_error - def get_user(self, username: str) -> dict: - return get_user(self.token, username) + def logout(self) -> dict: + """Выход из аккаунта + + Raises: + NoCookie: Нет cookie + + Returns: + dict: Ответ API + """ + if not self.cookies: + raise NoCookie() + + res = logout(self.cookies) + res.raise_for_status() + + return res.json() @refresh_on_error - def get_me(self) -> dict: + def get_user(self, username: str) -> User: + """Получить пользователя + + Args: + username (str): username или "me" + + Raises: + UserNotFound: Пользователь не найден + UserBanned: Пользователь заблокирован + + Returns: + User: Пользователь + """ + res = get_user(self.token, username) + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise UserNotFound() + if res.json().get('error', {}).get('code') == 'USER_BLOCKED': + raise UserBanned() + res.raise_for_status() + + return User.model_validate(res.json()) + + @refresh_on_error + def get_me(self) -> User: + """Получить текущего пользователя (me) + + Returns: + User: Пользователь + """ return self.get_user('me') @refresh_on_error - def update_profile(self, username: str | None = None, display_name: str | None = None, bio: str | None = None, banner_id: str | None = None) -> dict: - return update_profile(self.token, bio, display_name, username, banner_id) + def update_profile(self, username: str | None = None, display_name: str | None = None, bio: str | None = None, banner_id: UUID | None = None) -> UserProfileUpdate: + """Обновить профиль + + Args: + username (str | None, optional): username. Defaults to None. + display_name (str | None, optional): Отображаемое имя. Defaults to None. + bio (str | None, optional): Биография (о себе). Defaults to None. + banner_id (UUID | None, optional): UUID баннера. Defaults to None. + + Raises: + InvalidProfileData: Неправильные данные (валидация не прошла) + + Returns: + UserProfileUpdate: Обновленный профиль + """ + res = update_profile(self.token, bio, display_name, username, banner_id) + if res.status_code == 422 and 'found' in res.json(): + raise InvalidProfileData(*list(res.json()['found'].items())[0]) + res.raise_for_status() + + return UserProfileUpdate.model_validate(res.json()) @refresh_on_error - def update_privacy(self, wall_closed: bool = False, private: bool = False): - return update_privacy(self.token, wall_closed, private) + def update_privacy(self, wall_closed: bool = False, private: bool = False) -> UserPrivacy: + """Обновить настройки приватности + + Args: + wall_closed (bool, optional): Закрыть стену. Defaults to False. + private (bool, optional): Приватность. На данный момент неизвестно, что делает этот параметр. Defaults to False. + + Returns: + UserPrivacy: Обновленные данные приватности + """ + res = update_privacy(self.token, wall_closed, private) + res.raise_for_status() + + return UserPrivacy.model_validate(res.json()) @refresh_on_error - def follow(self, username: str) -> dict: - return follow(self.token, username) + def follow(self, username: str) -> int: + """Подписаться на пользователя + + Args: + username (str): username + + Raises: + UserNotFound: Пользователь не найден + + Returns: + int: Число подписчиков после подписки + """ + res = follow(self.token, username) + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise UserNotFound() + res.raise_for_status() + + return res.json()['followersCount'] @refresh_on_error - def unfollow(self, username: str) -> dict: - return unfollow(self.token, username) + def unfollow(self, username: str) -> int: + """Отписаться от пользователя + + Args: + username (str): username + + Raises: + UserNotFound: Пользователь не найден + + Returns: + int: Число подписчиков после отписки + """ + res = unfollow(self.token, username) + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise UserNotFound() + res.raise_for_status() + + return res.json()['followersCount'] @refresh_on_error - def get_followers(self, username: str) -> dict: - return get_followers(self.token, username) + def get_followers(self, username: str, limit: int = 30, page: int = 1) -> tuple[list[UserFollower], Pagination]: + """Получить подписчиков пользователя + + Args: + username (str): username + limit (int, optional): Лимит. Defaults to 30. + page (int, optional): Страница (при дозагрузке, увеличивайте на 1). Defaults to 1. + + Raises: + UserNotFound: Пользователь не найден + + Returns: + list[UserFollower]: Список подписчиков + Pagination: Данные пагинации (лимит, страница, сколько всего, есть ли еще) + """ + res = get_followers(self.token, username, limit, page) + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise UserNotFound() + res.raise_for_status() + + return [UserFollower.model_validate(user) for user in res.json()['data']['users']], Pagination.model_validate(res.json()['data']['pagination']) @refresh_on_error - def get_following(self, username: str) -> dict: - return get_following(self.token, username) + def get_following(self, username: str, limit: int = 30, page: int = 1) -> tuple[list[UserFollower], Pagination]: + """Получить подписки пользователя + + Args: + username (str): username + limit (int, optional): Лимит. Defaults to 30. + page (int, optional): Страница (при дозагрузке, увеличивайте на 1). Defaults to 1. + + Raises: + UserNotFound: Пользователь не найден + + Returns: + list[UserFollower]: Список подписок + Pagination: Данные пагинации (лимит, страница, сколько всего, есть ли еще) + """ + res = get_following(self.token, username, limit, page) + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise UserNotFound() + res.raise_for_status() + + return [UserFollower.model_validate(user) for user in res.json()['data']['users']], Pagination.model_validate(res.json()['data']['pagination']) @refresh_on_error - def verificate(self, file_url: str): - return verificate(self.token, file_url) + def verificate(self, file_url: str) -> Verification: + res = verificate(self.token, file_url) + if res.json().get('error', {}).get('code') == 'PENDING_REQUEST_EXISTS': + raise PendingRequestExists() + res.raise_for_status() + + return Verification.model_validate(res.json()) @refresh_on_error - def get_verification_status(self): - return get_verification_status(self.token) + def get_verification_status(self) -> VerificationStatus: + res = get_verification_status(self.token) + res.raise_for_status() + + return VerificationStatus.model_validate(res.json()) @refresh_on_error - def get_who_to_follow(self) -> dict: - return get_who_to_follow(self.token) + def get_who_to_follow(self) -> list[UserWhoToFollow]: + res = get_who_to_follow(self.token) + res.raise_for_status() + + return [UserWhoToFollow.model_validate(user) for user in res.json()['users']] @refresh_on_error - def get_top_clans(self) -> dict: - return get_top_clans(self.token) + def get_top_clans(self) -> list[Clan]: + res = get_top_clans(self.token) + res.raise_for_status() + + return [Clan.model_validate(clan) for clan in res.json()['clans']] @refresh_on_error def get_platform_status(self) -> dict: diff --git a/itd/exceptions.py b/itd/exceptions.py new file mode 100644 index 0000000..a3e39ca --- /dev/null +++ b/itd/exceptions.py @@ -0,0 +1,43 @@ +class NoCookie(Exception): + def __str__(self): + return 'No cookie for refresh-token required action' + +class NoAuthData(Exception): + def __str__(self): + return 'No auth data. Provide token or cookies' + +class InvalidCookie(Exception): + def __str__(self): + return f'Invalid cookie data' + +class InvalidToken(Exception): + def __str__(self): + return f'Invalid access token' + + +class SamePassword(Exception): + def __str__(self): + return 'Old and new password must not equals' + +class InvalidOldPassword(Exception): + def __str__(self): + return 'Old password is incorrect' + +class UserNotFound(Exception): + def __str__(self): + return 'User not found' + +class UserBanned(Exception): + def __str__(self): + return 'User banned' + +class InvalidProfileData(Exception): + def __init__(self, name: str, value: str): + self.name = name + self.value = value + def __str__(self): + return f'Invalid update profile data {self.name}: "{self.value}"' + +class PendingRequestExists(Exception): + def __str__(self): + return 'Pending verifiaction request already exists' \ No newline at end of file diff --git a/itd/models/_text.py b/itd/models/_text.py index aa6a372..70944a0 100644 --- a/itd/models/_text.py +++ b/itd/models/_text.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field from itd.models.user import UserPost -class _TextObject(BaseModel): +class TextObject(BaseModel): id: UUID content: str author: UserPost diff --git a/itd/models/clan.py b/itd/models/clan.py new file mode 100644 index 0000000..10bd4b4 --- /dev/null +++ b/itd/models/clan.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel, Field + + +class Clan(BaseModel): + avatar: str + member_count: int = Field(0, alias='memberCount') \ No newline at end of file diff --git a/itd/models/comment.py b/itd/models/comment.py index ecf4324..2187242 100644 --- a/itd/models/comment.py +++ b/itd/models/comment.py @@ -1,12 +1,9 @@ -from uuid import UUID -from datetime import datetime - from pydantic import Field -from itd.models._text import _TextObject +from itd.models._text import TextObject -class CommentShort(_TextObject): +class CommentShort(TextObject): likes_count: int = Field(0, alias='likesCount') replies_count: int = Field(0, alias='repliesCount') is_liked: bool = Field(False, alias='isLiked') diff --git a/itd/models/pagination.py b/itd/models/pagination.py new file mode 100644 index 0000000..df81f56 --- /dev/null +++ b/itd/models/pagination.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel, Field + +class Pagination(BaseModel): + page: int = 1 + limit: int = 20 + total: int | None = None + has_more: bool = Field(True, alias='hasMore') \ No newline at end of file diff --git a/itd/models/post.py b/itd/models/post.py index 8969cad..dfe33dd 100644 --- a/itd/models/post.py +++ b/itd/models/post.py @@ -1,10 +1,10 @@ from pydantic import Field from itd.models.user import UserPost -from itd.models._text import _TextObject +from itd.models._text import TextObject -class PostShort(_TextObject): +class PostShort(TextObject): likes_count: int = Field(0, alias='likesCount') comments_count: int = Field(0, alias='commentsCount') reposts_count: int = Field(0, alias='repostsCount') diff --git a/itd/models/user.py b/itd/models/user.py index 760128b..a388647 100644 --- a/itd/models/user.py +++ b/itd/models/user.py @@ -3,6 +3,25 @@ from datetime import datetime from pydantic import BaseModel, Field + +class UserPrivacy(BaseModel): + private: bool | None = Field(None, alias='isPrivate') # none for not me + wall_closed: bool = Field(False, alias='wallClosed') + + model_config = {'populate_by_name': True} + + +class UserProfileUpdate(BaseModel): + id: UUID + username: str + display_name: str = Field(alias='displayName') + bio: str | None = None + + updated_at: datetime | None = Field(None, alias='updatedAt') + + model_config = {'populate_by_name': True} + + class UserNotification(BaseModel): id: UUID username: str @@ -16,22 +35,26 @@ class UserPost(UserNotification): verified: bool = False -class UserSearch(UserPost): +class UserWhoToFollow(UserPost): followers_count: int = Field(0, alias='followersCount') -class User(UserSearch): +class UserFollower(UserPost): + is_following: bool = Field(False, alias='isFollowing') # none for me + + +class UserSearch(UserFollower, UserWhoToFollow): + pass + + +class User(UserSearch, UserPrivacy): banner: str | None = None bio: str | None = None - pinned_post_id: UUID | None - - private: bool | None = Field(None, alias='isPrivate') # none for not me - wall_closed: bool = Field(False, alias='wallClosed') + pinned_post_id: UUID | None = Field(None, alias='pinnedPostId') following_count: int = Field(0, alias='followingCount') posts_count: int = Field(0, alias='postsCount') - is_following: bool | None = Field(None, alias='isFollowing') # none for me is_followed: bool | None = Field(None, alias='isFollowedBy') # none for me created_at: datetime = Field(alias='createdAt') diff --git a/itd/request.py b/itd/request.py index 10d73f2..ed02f31 100644 --- a/itd/request.py +++ b/itd/request.py @@ -2,6 +2,8 @@ from _io import BufferedReader from requests import Session +from itd.exceptions import InvalidToken, InvalidCookie + s = Session() @@ -29,8 +31,9 @@ def fetch(token: str, method: str, url: str, params: dict = {}, files: dict[str, else: res = s.request(method.upper(), base, timeout=20, json=params, headers=headers, files=files) - res.raise_for_status() - return res.json() + print(res.text) + return res + def set_cookies(cookies: str): for cookie in cookies.split('; '): @@ -65,5 +68,11 @@ def auth_fetch(cookies: str, method: str, url: str, params: dict = {}, token: st res = s.get(f'https://xn--d1ah4a.com/api/{url}', timeout=20, params=params, headers=headers) else: res = s.request(method, f'https://xn--d1ah4a.com/api/{url}', timeout=20, json=params, headers=headers) - res.raise_for_status() - return res.json() + + # print(res.text) + if res.text == 'UNAUTHORIZED': + raise InvalidToken() + if res.json().get('error', {}).get('code') in ('SESSION_NOT_FOUND', 'REFRESH_TOKEN_MISSING'): + raise InvalidCookie() + + return res diff --git a/itd/routes/auth.py b/itd/routes/auth.py index 0401467..73f3c5b 100644 --- a/itd/routes/auth.py +++ b/itd/routes/auth.py @@ -1,10 +1,12 @@ +from requests import Response + from itd.request import auth_fetch -def refresh_token(cookies: str): - return auth_fetch(cookies, 'post', 'v1/auth/refresh')['accessToken'] +def refresh_token(cookies: str) -> Response: + return auth_fetch(cookies, 'post', 'v1/auth/refresh') -def change_password(cookies: str, token: str, old: str, new: str): +def change_password(cookies: str, token: str, old: str, new: str) -> Response: return auth_fetch(cookies, 'post', 'v1/auth/change-password', {'newPassword': new, 'oldPassword': old}, token) -def logout(cookies: str): +def logout(cookies: str) -> Response: return auth_fetch(cookies, 'post', 'v1/auth/logout') diff --git a/itd/routes/comments.py b/itd/routes/comments.py index 0cdc702..e0e7395 100644 --- a/itd/routes/comments.py +++ b/itd/routes/comments.py @@ -7,7 +7,7 @@ def add_comment(token: str, post_id: str, content: str, reply_comment_id: str | return fetch(token, 'post', f'posts/{post_id}/comments', data) def get_comments(token: str, post_id: str, limit: int = 20, cursor: int = 0, sort: str = 'popular'): - return fetch(token, 'get', f'posts/{post_id}/comments', {'limit': limit, 'sort': sort, 'cursor': cursor})['data'] + return fetch(token, 'get', f'posts/{post_id}/comments', {'limit': limit, 'sort': sort, 'cursor': cursor}) def like_comment(token: str, comment_id: str): return fetch(token, 'post', f'comments/{comment_id}/like') diff --git a/itd/routes/files.py b/itd/routes/files.py index d13f48f..9303b82 100644 --- a/itd/routes/files.py +++ b/itd/routes/files.py @@ -4,4 +4,4 @@ from itd.request import fetch def upload_file(token: str, name: str, data: BufferedReader): - return fetch(token, 'post', 'files/upload', files={'file': (name, data)}) \ No newline at end of file + return fetch(token, 'post', 'files/upload', files={'file': (name, data)}) diff --git a/itd/routes/hashtags.py b/itd/routes/hashtags.py index b255096..e6e9a63 100644 --- a/itd/routes/hashtags.py +++ b/itd/routes/hashtags.py @@ -1,7 +1,7 @@ from itd.request import fetch def get_hastags(token: str, limit: int = 10): - return fetch(token, 'get', 'hashtags/trending', {'limit': limit})['data'] + return fetch(token, 'get', 'hashtags/trending', {'limit': limit}) def get_posts_by_hastag(token: str, hashtag: str, limit: int = 20, cursor: int = 0): return fetch(token, 'get', f'hashtags/{hashtag}/posts', {'limit': limit, 'cursor': cursor}) diff --git a/itd/routes/posts.py b/itd/routes/posts.py index 87223cc..b2a0705 100644 --- a/itd/routes/posts.py +++ b/itd/routes/posts.py @@ -18,7 +18,7 @@ def get_posts(token: str, username: str | None = None, limit: int = 20, cursor: if tab: data['tab'] = tab - return fetch(token, 'get', 'posts', data)['data'] + return fetch(token, 'get', 'posts', data) def get_post(token: str, id: str): return fetch(token, 'get', f'posts/{id}') @@ -42,4 +42,6 @@ def view_post(token: str, id: str): return fetch(token, 'post', f'posts/{id}/view') def get_liked_posts(token: str, username: str, limit: int = 20, cursor: int = 0): - return fetch(token, 'get', f'posts/user/{username}/liked', {'limit': limit, 'cursor': cursor}) \ No newline at end of file + return fetch(token, 'get', f'posts/user/{username}/liked', {'limit': limit, 'cursor': cursor}) + +# todo post restore \ No newline at end of file diff --git a/itd/routes/users.py b/itd/routes/users.py index 1fa7424..ddbbfa4 100644 --- a/itd/routes/users.py +++ b/itd/routes/users.py @@ -1,10 +1,12 @@ +from uuid import UUID + from itd.request import fetch def get_user(token: str, username: str): return fetch(token, 'get', f'users/{username}') -def update_profile(token: str, bio: str | None = None, display_name: str | None = None, username: str | None = None, banner_id: str | None = None): +def update_profile(token: str, bio: str | None = None, display_name: str | None = None, username: str | None = None, banner_id: UUID | None = None): data = {} if bio: data['bio'] = bio @@ -13,14 +15,14 @@ def update_profile(token: str, bio: str | None = None, display_name: str | None if username: data['username'] = username if banner_id: - data['bannerId'] = banner_id + data['bannerId'] = str(banner_id) return fetch(token, 'put', 'users/me', data) def update_privacy(token: str, wall_closed: bool = False, private: bool = False): data = {} - if wall_closed: + if wall_closed is not None: data['wallClosed'] = wall_closed - if private: + if private is not None: data['isPrivate'] = private return fetch(token, 'put', 'users/me/privacy', data) From 2a9f7da9a9467ec79fb3ff090be2e36d154a28b5 Mon Sep 17 00:00:00 2001 From: firedotguy Date: Sat, 31 Jan 2026 18:28:23 +0300 Subject: [PATCH 03/25] feat: add models part 2 --- itd/client.py | 137 +++++++++++++++++++++++++++++++++-------- itd/enums.py | 6 +- itd/exceptions.py | 18 ++++-- itd/models/_text.py | 15 ++++- itd/models/comment.py | 6 +- itd/models/file.py | 18 +++++- itd/models/user.py | 4 +- itd/request.py | 7 ++- itd/routes/comments.py | 20 +++--- 9 files changed, 183 insertions(+), 48 deletions(-) diff --git a/itd/client.py b/itd/client.py index d42861c..f2d4414 100644 --- a/itd/client.py +++ b/itd/client.py @@ -6,7 +6,7 @@ from requests.exceptions import HTTPError from itd.routes.users import get_user, update_profile, follow, unfollow, get_followers, get_following, update_privacy from itd.routes.etc import get_top_clans, get_who_to_follow, get_platform_status -from itd.routes.comments import get_comments, add_comment, delete_comment, like_comment, unlike_comment +from itd.routes.comments import get_comments, add_comment, delete_comment, like_comment, unlike_comment, add_reply_comment from itd.routes.hashtags import get_hastags, get_posts_by_hastag from itd.routes.notifications import get_notifications, mark_as_read, mark_all_as_read, get_unread_notifications_count from itd.routes.posts import create_post, get_posts, get_post, edit_post, delete_post, pin_post, repost, view_post, get_liked_posts @@ -16,13 +16,14 @@ from itd.routes.files import upload_file from itd.routes.auth import refresh_token, change_password, logout from itd.routes.verification import verificate, get_verification_status +from itd.models.comment import Comment from itd.models.clan import Clan from itd.models.user import User, UserProfileUpdate, UserPrivacy, UserFollower, UserWhoToFollow from itd.models.pagination import Pagination from itd.models.verification import Verification, VerificationStatus from itd.request import set_cookies -from itd.exceptions import NoCookie, NoAuthData, SamePassword, InvalidOldPassword, UserNotFound, InvalidProfileData, UserBanned, PendingRequestExists +from itd.exceptions import NoCookie, NoAuthData, SamePassword, InvalidOldPassword, NotFound, ValidationError, UserBanned, PendingRequestExists def refresh_on_error(func): @@ -122,7 +123,7 @@ class Client: username (str): username или "me" Raises: - UserNotFound: Пользователь не найден + NotFound: Пользователь не найден UserBanned: Пользователь заблокирован Returns: @@ -130,7 +131,7 @@ class Client: """ res = get_user(self.token, username) if res.json().get('error', {}).get('code') == 'NOT_FOUND': - raise UserNotFound() + raise NotFound('User') if res.json().get('error', {}).get('code') == 'USER_BLOCKED': raise UserBanned() res.raise_for_status() @@ -157,14 +158,14 @@ class Client: banner_id (UUID | None, optional): UUID баннера. Defaults to None. Raises: - InvalidProfileData: Неправильные данные (валидация не прошла) + ValidationError: Ошибка валидации Returns: UserProfileUpdate: Обновленный профиль """ res = update_profile(self.token, bio, display_name, username, banner_id) if res.status_code == 422 and 'found' in res.json(): - raise InvalidProfileData(*list(res.json()['found'].items())[0]) + raise ValidationError(*list(res.json()['found'].items())[0]) res.raise_for_status() return UserProfileUpdate.model_validate(res.json()) @@ -193,14 +194,14 @@ class Client: username (str): username Raises: - UserNotFound: Пользователь не найден + NotFound: Пользователь не найден Returns: int: Число подписчиков после подписки """ res = follow(self.token, username) if res.json().get('error', {}).get('code') == 'NOT_FOUND': - raise UserNotFound() + raise NotFound('User') res.raise_for_status() return res.json()['followersCount'] @@ -213,14 +214,14 @@ class Client: username (str): username Raises: - UserNotFound: Пользователь не найден + NotFound: Пользователь не найден Returns: int: Число подписчиков после отписки """ res = unfollow(self.token, username) if res.json().get('error', {}).get('code') == 'NOT_FOUND': - raise UserNotFound() + raise NotFound('User') res.raise_for_status() return res.json()['followersCount'] @@ -235,7 +236,7 @@ class Client: page (int, optional): Страница (при дозагрузке, увеличивайте на 1). Defaults to 1. Raises: - UserNotFound: Пользователь не найден + NotFound: Пользователь не найден Returns: list[UserFollower]: Список подписчиков @@ -243,7 +244,7 @@ class Client: """ res = get_followers(self.token, username, limit, page) if res.json().get('error', {}).get('code') == 'NOT_FOUND': - raise UserNotFound() + raise NotFound('User') res.raise_for_status() return [UserFollower.model_validate(user) for user in res.json()['data']['users']], Pagination.model_validate(res.json()['data']['pagination']) @@ -258,7 +259,7 @@ class Client: page (int, optional): Страница (при дозагрузке, увеличивайте на 1). Defaults to 1. Raises: - UserNotFound: Пользователь не найден + NotFound: Пользователь не найден Returns: list[UserFollower]: Список подписок @@ -266,7 +267,7 @@ class Client: """ res = get_following(self.token, username, limit, page) if res.json().get('error', {}).get('code') == 'NOT_FOUND': - raise UserNotFound() + raise NotFound('User') res.raise_for_status() return [UserFollower.model_validate(user) for user in res.json()['data']['users']], Pagination.model_validate(res.json()['data']['pagination']) @@ -274,6 +275,17 @@ class Client: @refresh_on_error def verificate(self, file_url: str) -> Verification: + """Отправить запрос на верификацию + + Args: + file_url (str): Ссылка на видео + + Raises: + PendingRequestExists: Запрос уже отправлен + + Returns: + Verification: Верификация + """ res = verificate(self.token, file_url) if res.json().get('error', {}).get('code') == 'PENDING_REQUEST_EXISTS': raise PendingRequestExists() @@ -283,6 +295,11 @@ class Client: @refresh_on_error def get_verification_status(self) -> VerificationStatus: + """Получить статус верификации + + Returns: + VerificationStatus: Верификация + """ res = get_verification_status(self.token) res.raise_for_status() @@ -291,6 +308,11 @@ class Client: @refresh_on_error def get_who_to_follow(self) -> list[UserWhoToFollow]: + """Получить список популярнык пользователей (кого читать) + + Returns: + list[UserWhoToFollow]: Список пользователей + """ res = get_who_to_follow(self.token) res.raise_for_status() @@ -298,34 +320,101 @@ class Client: @refresh_on_error def get_top_clans(self) -> list[Clan]: + """Получить топ кланов + + Returns: + list[Clan]: Топ кланов + """ res = get_top_clans(self.token) res.raise_for_status() return [Clan.model_validate(clan) for clan in res.json()['clans']] @refresh_on_error - def get_platform_status(self) -> dict: - return get_platform_status(self.token) + def get_platform_status(self) -> bool: + """Получить статус платформы + + Returns: + bool: read only + """ + res = get_platform_status(self.token) + res.raise_for_status() + + return res.json()['readOnly'] @refresh_on_error - def add_comment(self, post_id: str, content: str, reply_comment_id: str | None = None): - return add_comment(self.token, post_id, content, reply_comment_id) + def add_comment(self, post_id: UUID, content: str) -> Comment: + """Добавить комментарий + + Args: + post_id (str): UUID поста + content (str): Содержание + reply_comment_id (UUID | None, optional): ID коммента для ответа. Defaults to None. + + Raises: + ValidationError: Ошибка валидации + + Returns: + Comment: Комментарий + """ + res = add_comment(self.token, post_id, content) + if res.status_code == 422 and 'found' in res.json(): + raise ValidationError(*list(res.json()['found'].items())[0]) + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise NotFound('Post') + res.raise_for_status() + + return Comment.model_validate(res.json()) + @refresh_on_error - def get_comments(self, post_id: str, limit: int = 20, cursor: int = 0, sort: str = 'popular'): - return get_comments(self.token, post_id, limit, cursor, sort) + def add_reply_comment(self, comment_id: UUID, content: str, author_id: UUID) -> Comment: + """Добавить ответный комментарий + + Args: + comment_id (str): UUID комментария + content (str): Содержание + author_id (UUID | None, optional): ID пользователя, отправившего комментарий. Defaults to None. + + Raises: + ValidationError: Ошибка валидации + + Returns: + Comment: Комментарий + """ + res = add_reply_comment(self.token, comment_id, content, author_id) + if res.status_code == 500 and 'Failed query' in res.text: + raise NotFound('User') + if res.status_code == 422 and 'found' in res.json(): + raise ValidationError(*list(res.json()['found'].items())[0]) + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise NotFound('Comment') + res.raise_for_status() + + return Comment.model_validate(res.json()) + @refresh_on_error - def like_comment(self, id: str): + def get_comments(self, post_id: UUID, limit: int = 20, cursor: int = 0, sort: str = 'popular') -> tuple[list[Comment], Pagination]: + res = get_comments(self.token, post_id, limit, cursor, sort) + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise NotFound('Post') + res.raise_for_status() + data = res.json()['data'] + + return [Comment.model_validate(comment) for comment in data['comments']], Pagination(page=(cursor // limit) or 1, limit=limit, total=data['total'], hasMore=data['hasMore']) + + @refresh_on_error + def like_comment(self, id: UUID): return like_comment(self.token, id) @refresh_on_error - def unlike_comment(self, id: str): + def unlike_comment(self, id: UUID): return unlike_comment(self.token, id) @refresh_on_error - def delete_comment(self, id: str): + def delete_comment(self, id: UUID): return delete_comment(self.token, id) @@ -426,6 +515,6 @@ class Client: def upload_file(self, name: str, data: BufferedReader): return upload_file(self.token, name, data) - def update_banner(self, name: str): + def update_banner(self, name: str) -> UserProfileUpdate: id = self.upload_file(name, cast(BufferedReader, open(name, 'rb')))['id'] return self.update_profile(banner_id=id) \ No newline at end of file diff --git a/itd/enums.py b/itd/enums.py index 3d88e59..c37fdf5 100644 --- a/itd/enums.py +++ b/itd/enums.py @@ -22,4 +22,8 @@ class ReportTargetReason(Enum): HATE = 'hate' # ненависть ADULT = 'adult' # 18+ FRAUD = 'fraud' # обман\мошенничество - OTHER = 'other' # другое \ No newline at end of file + OTHER = 'other' # другое + +class AttachType(Enum): + AUDIO = 'audio' + IMAGE = 'image' diff --git a/itd/exceptions.py b/itd/exceptions.py index a3e39ca..68c4d8b 100644 --- a/itd/exceptions.py +++ b/itd/exceptions.py @@ -23,21 +23,29 @@ class InvalidOldPassword(Exception): def __str__(self): return 'Old password is incorrect' -class UserNotFound(Exception): +class NotFound(Exception): + def __init__(self, obj): + self.obj = obj def __str__(self): - return 'User not found' + return f'{self.obj} not found' class UserBanned(Exception): def __str__(self): return 'User banned' -class InvalidProfileData(Exception): +class ValidationError(Exception): def __init__(self, name: str, value: str): self.name = name self.value = value def __str__(self): - return f'Invalid update profile data {self.name}: "{self.value}"' + return f'Failed validation on {self.name}: "{self.value}"' class PendingRequestExists(Exception): def __str__(self): - return 'Pending verifiaction request already exists' \ No newline at end of file + return 'Pending verifiaction request already exists' + +class RateLimitExceeded(Exception): + def __init__(self, retry_after: int): + self.retry_after = retry_after + def __str__(self): + return f'Rate limit exceeded - too much requests. Retry after {self.retry_after} seconds' \ No newline at end of file diff --git a/itd/models/_text.py b/itd/models/_text.py index 70944a0..49238a7 100644 --- a/itd/models/_text.py +++ b/itd/models/_text.py @@ -1,17 +1,26 @@ from uuid import UUID from datetime import datetime -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from itd.models.user import UserPost +from itd.models.file import Attach class TextObject(BaseModel): id: UUID content: str author: UserPost - attachments: list[UUID] + attachments: list[Attach] = [] created_at: datetime = Field(alias='createdAt') - model_config = {'populate_by_name': True} \ No newline at end of file + model_config = {'populate_by_name': True} + + @field_validator('created_at', mode='plain') + @classmethod + def validate_created_at(cls, v: str): + try: + return datetime.strptime(v + '00', '%Y-%m-%d %H:%M:%S.%f%z') + except ValueError: + return datetime.strptime(v, '%Y-%m-%dT%H:%M:%S.%fZ') \ No newline at end of file diff --git a/itd/models/comment.py b/itd/models/comment.py index 2187242..efcd499 100644 --- a/itd/models/comment.py +++ b/itd/models/comment.py @@ -1,11 +1,13 @@ from pydantic import Field from itd.models._text import TextObject +from itd.models.user import UserPost -class CommentShort(TextObject): +class Comment(TextObject): likes_count: int = Field(0, alias='likesCount') replies_count: int = Field(0, alias='repliesCount') is_liked: bool = Field(False, alias='isLiked') - replies: list['CommentShort'] = [] \ No newline at end of file + replies: list['Comment'] = [] + reply_to: UserPost | None = None # author of replied comment, if this comment is reply \ No newline at end of file diff --git a/itd/models/file.py b/itd/models/file.py index 63fe4b4..b732b7e 100644 --- a/itd/models/file.py +++ b/itd/models/file.py @@ -2,9 +2,25 @@ from uuid import UUID from pydantic import BaseModel, Field +from itd.enums import AttachType + class File(BaseModel): id: UUID url: str filename: str mime_type: str = Field('image/png', alias='mimeType') - size: int \ No newline at end of file + size: int + + +class Attach(BaseModel): + id: UUID + type: AttachType = AttachType.IMAGE + url: str + thumbnail_url: str | None = Field(None, alias='thumbnailUrl') + filename: str + mime_type: str = Field(alias='mimeType') + size: int + width: int | None = None + height: int | None = None + duration: int | None = None + order: int = 0 \ No newline at end of file diff --git a/itd/models/user.py b/itd/models/user.py index a388647..0c717ae 100644 --- a/itd/models/user.py +++ b/itd/models/user.py @@ -13,7 +13,7 @@ class UserPrivacy(BaseModel): class UserProfileUpdate(BaseModel): id: UUID - username: str + username: str | None = None display_name: str = Field(alias='displayName') bio: str | None = None @@ -24,7 +24,7 @@ class UserProfileUpdate(BaseModel): class UserNotification(BaseModel): id: UUID - username: str + username: str | None = None display_name: str = Field(alias='displayName') avatar: str diff --git a/itd/request.py b/itd/request.py index ed02f31..a63cf10 100644 --- a/itd/request.py +++ b/itd/request.py @@ -2,7 +2,7 @@ from _io import BufferedReader from requests import Session -from itd.exceptions import InvalidToken, InvalidCookie +from itd.exceptions import InvalidToken, InvalidCookie, RateLimitExceeded s = Session() @@ -31,6 +31,9 @@ def fetch(token: str, method: str, url: str, params: dict = {}, files: dict[str, else: res = s.request(method.upper(), base, timeout=20, json=params, headers=headers, files=files) + if res.json().get('error', {}).get('code') == 'RATE_LIMIT_EXCEEDED': + raise RateLimitExceeded(res.json()['error'].get('retryAfter', 0)) + print(res.text) return res @@ -72,6 +75,8 @@ def auth_fetch(cookies: str, method: str, url: str, params: dict = {}, token: st # print(res.text) if res.text == 'UNAUTHORIZED': raise InvalidToken() + if res.json().get('error', {}).get('code') == 'RATE_LIMIT_EXCEEDED': + raise RateLimitExceeded(res.json()['error'].get('retryAfter', 0)) if res.json().get('error', {}).get('code') in ('SESSION_NOT_FOUND', 'REFRESH_TOKEN_MISSING'): raise InvalidCookie() diff --git a/itd/routes/comments.py b/itd/routes/comments.py index e0e7395..d98ddf2 100644 --- a/itd/routes/comments.py +++ b/itd/routes/comments.py @@ -1,19 +1,21 @@ +from uuid import UUID + from itd.request import fetch -def add_comment(token: str, post_id: str, content: str, reply_comment_id: str | None = None): - data = {'content': content} - if reply_comment_id: - data['replyTo'] = str(reply_comment_id) - return fetch(token, 'post', f'posts/{post_id}/comments', data) +def add_comment(token: str, post_id: UUID, content: str): + return fetch(token, 'post', f'posts/{post_id}/comments', {'content': content}) -def get_comments(token: str, post_id: str, limit: int = 20, cursor: int = 0, sort: str = 'popular'): +def add_reply_comment(token: str, comment_id: UUID, content: str, author_id: UUID): + return fetch(token, 'post', f'comments/{comment_id}/replies', {'content': content, 'replyToUserId': str(author_id)}) + +def get_comments(token: str, post_id: UUID, limit: int = 20, cursor: int = 0, sort: str = 'popular'): return fetch(token, 'get', f'posts/{post_id}/comments', {'limit': limit, 'sort': sort, 'cursor': cursor}) -def like_comment(token: str, comment_id: str): +def like_comment(token: str, comment_id: UUID): return fetch(token, 'post', f'comments/{comment_id}/like') -def unlike_comment(token: str, comment_id: str): +def unlike_comment(token: str, comment_id: UUID): return fetch(token, 'delete', f'comments/{comment_id}/like') -def delete_comment(token: str, comment_id: str): +def delete_comment(token: str, comment_id: UUID): return fetch(token, 'delete', f'comments/{comment_id}') From ba78457de5ea8507a0f2bfc3f217e967f8d2fdaa Mon Sep 17 00:00:00 2001 From: firedotguy Date: Sun, 1 Feb 2026 17:20:37 +0300 Subject: [PATCH 04/25] feat: add models part 3 --- itd/client.py | 197 +++++++++++++++++++++++++++++++----- itd/exceptions.py | 12 ++- itd/models/_text.py | 5 - itd/models/comment.py | 4 + itd/models/file.py | 9 +- itd/models/notification.py | 2 +- itd/models/pagination.py | 7 +- itd/models/post.py | 30 ++++-- itd/models/user.py | 17 ++-- itd/request.py | 21 ++-- itd/routes/hashtags.py | 3 +- itd/routes/notifications.py | 15 ++- itd/routes/posts.py | 23 +++-- 13 files changed, 267 insertions(+), 78 deletions(-) diff --git a/itd/client.py b/itd/client.py index f2d4414..714f5cd 100644 --- a/itd/client.py +++ b/itd/client.py @@ -2,7 +2,7 @@ from uuid import UUID from _io import BufferedReader from typing import cast -from requests.exceptions import HTTPError +from requests.exceptions import HTTPError, ConnectionError from itd.routes.users import get_user, update_profile, follow, unfollow, get_followers, get_following, update_privacy from itd.routes.etc import get_top_clans, get_who_to_follow, get_platform_status @@ -17,13 +17,16 @@ from itd.routes.auth import refresh_token, change_password, logout from itd.routes.verification import verificate, get_verification_status from itd.models.comment import Comment +from itd.models.notification import Notification +from itd.models.post import Post, NewPost from itd.models.clan import Clan +from itd.models.hashtag import Hashtag from itd.models.user import User, UserProfileUpdate, UserPrivacy, UserFollower, UserWhoToFollow from itd.models.pagination import Pagination from itd.models.verification import Verification, VerificationStatus from itd.request import set_cookies -from itd.exceptions import NoCookie, NoAuthData, SamePassword, InvalidOldPassword, NotFound, ValidationError, UserBanned, PendingRequestExists +from itd.exceptions import NoCookie, NoAuthData, SamePassword, InvalidOldPassword, NotFound, ValidationError, UserBanned, PendingRequestExists, Forbidden, UsernameTaken def refresh_on_error(func): @@ -35,6 +38,9 @@ def refresh_on_error(func): self.refresh_auth() return func(self, *args, **kwargs) raise e + except ConnectionError: + self.refresh_auth() + return func(self, *args, **kwargs) return wrapper @@ -166,6 +172,8 @@ class Client: res = update_profile(self.token, bio, display_name, username, banner_id) if res.status_code == 422 and 'found' in res.json(): raise ValidationError(*list(res.json()['found'].items())[0]) + if res.json().get('error', {}).get('code') == 'USERNAME_TAKEN': + raise UsernameTaken() res.raise_for_status() return UserProfileUpdate.model_validate(res.json()) @@ -354,6 +362,7 @@ class Client: Raises: ValidationError: Ошибка валидации + NotFound: Пост не найден Returns: Comment: Комментарий @@ -379,6 +388,7 @@ class Client: Raises: ValidationError: Ошибка валидации + NotFound: Пользователь или комментарий не найден Returns: Comment: Комментарий @@ -397,56 +407,191 @@ class Client: @refresh_on_error def get_comments(self, post_id: UUID, limit: int = 20, cursor: int = 0, sort: str = 'popular') -> tuple[list[Comment], Pagination]: + """Получить список комментариев + + Args: + post_id (UUID): UUID поста + limit (int, optional): Лимит. Defaults to 20. + cursor (int, optional): Курсор (сколько пропустить). Defaults to 0. + sort (str, optional): Сортировка. Defaults to 'popular'. + + Raises: + NotFound: Пост не найден + + Returns: + list[Comment]: Список комментариев + Pagination: Пагинация + """ res = get_comments(self.token, post_id, limit, cursor, sort) if res.json().get('error', {}).get('code') == 'NOT_FOUND': raise NotFound('Post') res.raise_for_status() data = res.json()['data'] - return [Comment.model_validate(comment) for comment in data['comments']], Pagination(page=(cursor // limit) or 1, limit=limit, total=data['total'], hasMore=data['hasMore']) + return [Comment.model_validate(comment) for comment in data['comments']], Pagination(page=(cursor // limit) or 1, limit=limit, total=data['total'], hasMore=data['hasMore'], nextCursor=None) @refresh_on_error - def like_comment(self, id: UUID): - return like_comment(self.token, id) + def like_comment(self, id: UUID) -> int: + """Лайкнуть комментарий + + Args: + id (UUID): UUID комментария + + Raises: + NotFound: Комментарий не найден + + Returns: + int: Количество лайков + """ + res = like_comment(self.token, id) + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise NotFound('Comment') + res.raise_for_status() + + return res.json()['likesCount'] @refresh_on_error - def unlike_comment(self, id: UUID): - return unlike_comment(self.token, id) + def unlike_comment(self, id: UUID) -> int: + """Убрать лайк с комментария + + Args: + id (UUID): UUID комментария + + Raises: + NotFound: Комментарий не найден + + Returns: + int: Количество лайков + """ + res = unlike_comment(self.token, id) + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise NotFound('Comment') + res.raise_for_status() + + return res.json()['likesCount'] @refresh_on_error - def delete_comment(self, id: UUID): - return delete_comment(self.token, id) + def delete_comment(self, id: UUID) -> None: + """Удалить комментарий + + Args: + id (UUID): UUID комментария + + Raises: + NotFound: Комментарий не найден + Forbidden: Нет прав на удаление + """ + res = delete_comment(self.token, id) + if res.status_code == 204: + return + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise NotFound('Comment') + if res.json().get('error', {}).get('code') == 'FORBIDDEN': + raise Forbidden('delete comment') + res.raise_for_status() @refresh_on_error - def get_hastags(self, limit: int = 10): - return get_hastags(self.token, limit) + def get_hastags(self, limit: int = 10) -> list[Hashtag]: + """Получить список популярных хэштэгов + + Args: + limit (int, optional): Лимит. Defaults to 10. + + Returns: + list[Hashtag]: Список хэштэгов + """ + res = get_hastags(self.token, limit) + res.raise_for_status() + + return [Hashtag.model_validate(hashtag) for hashtag in res.json()['data']['hashtags']] @refresh_on_error - def get_posts_by_hashtag(self, hashtag: str, limit: int = 20, cursor: int = 0): - return get_posts_by_hastag(self.token, hashtag, limit, cursor) + def get_posts_by_hashtag(self, hashtag: str, limit: int = 20, cursor: UUID | None = None) -> tuple[Hashtag | None, list[Post], Pagination]: + """Получить посты по хэштэгу + + Args: + hashtag (str): Хэштэг (без #) + limit (int, optional): Лимит. Defaults to 20. + cursor (UUID | None, optional): Курсор (UUID последнего поста, после которого брать данные). Defaults to None. + + Returns: + Hashtag | None: Хэштэг + list[Post]: Посты + Pagination: Пагинация + """ + res = get_posts_by_hastag(self.token, hashtag, limit, cursor) + res.raise_for_status() + data = res.json()['data'] + + return Hashtag.model_validate(data['hashtag']), [Post.model_validate(post) for post in data['posts']], Pagination.model_validate(data['pagination']) @refresh_on_error - def get_notifications(self, limit: int = 20, cursor: int = 0, type: str | None = None): - return get_notifications(self.token, limit, cursor, type) + def get_notifications(self, limit: int = 20, offset: int = 0) -> tuple[list[Notification], Pagination]: + """Получить уведомления + + Args: + limit (int, optional): Лимит. Defaults to 20. + offset (int, optional): Сдвиг. Defaults to 0. + + Returns: + list[Notification]: Уведомления + Pagination: Пагинация + """ + res = get_notifications(self.token, limit, offset) + res.raise_for_status() + + return ( + [Notification.model_validate(notification) for notification in res.json()['notifications']], + Pagination(page=(offset // limit) + 1, limit=limit, hasMore=res.json()['hasMore'], nextCursor=None) + ) @refresh_on_error - def mark_as_read(self, id: str): - return mark_as_read(self.token, id) + def mark_as_read(self, id: UUID) -> bool: + """Прочитать уведомление + + Args: + id (UUID): UUID уведомления + + Returns: + bool: Успешно (False - уже прочитано) + """ + res = mark_as_read(self.token, id) + res.raise_for_status() + + return res.json()['success'] @refresh_on_error - def mark_all_as_read(self): - return mark_all_as_read(self.token) - - @refresh_on_error - def get_unread_notifications_count(self): - return get_unread_notifications_count(self.token) + def mark_all_as_read(self) -> None: + """Прочитать все уведомления""" + res = mark_all_as_read(self.token) + res.raise_for_status() @refresh_on_error - def create_post(self, content: str, wall_recipient_id: int | None = None, attach_ids: list[str] = []): - return create_post(self.token, content, wall_recipient_id, attach_ids) + def get_unread_notifications_count(self) -> int: + """Получить количество непрочитанных уведомлений + + Returns: + int: Количество + """ + res = get_unread_notifications_count(self.token) + res.raise_for_status() + + return res.json()['count'] + + + @refresh_on_error + def create_post(self, content: str, wall_recipient_id: UUID | None = None, attach_ids: list[UUID] = []) -> NewPost: + res = create_post(self.token, content, wall_recipient_id, attach_ids) + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise NotFound('Wall recipient') + if res.status_code == 422 and 'found' in res.json(): + raise ValidationError(*list(res.json()['found'].items())[0]) + res.raise_for_status() + + return NewPost.model_validate(res.json()) @refresh_on_error def get_posts(self, username: str | None = None, limit: int = 20, cursor: int = 0, sort: str = '', tab: str = ''): @@ -513,7 +658,7 @@ class Client: @refresh_on_error def upload_file(self, name: str, data: BufferedReader): - return upload_file(self.token, name, data) + return upload_file(self.token, name, data).json() def update_banner(self, name: str) -> UserProfileUpdate: id = self.upload_file(name, cast(BufferedReader, open(name, 'rb')))['id'] diff --git a/itd/exceptions.py b/itd/exceptions.py index 68c4d8b..f1cc39f 100644 --- a/itd/exceptions.py +++ b/itd/exceptions.py @@ -48,4 +48,14 @@ class RateLimitExceeded(Exception): def __init__(self, retry_after: int): self.retry_after = retry_after def __str__(self): - return f'Rate limit exceeded - too much requests. Retry after {self.retry_after} seconds' \ No newline at end of file + return f'Rate limit exceeded - too much requests. Retry after {self.retry_after} seconds' + +class Forbidden(Exception): + def __init__(self, action: str): + self.action = action + def __str__(self): + return f'Forbidden to {self.action}' + +class UsernameTaken(Exception): + def __str__(self): + return 'Username is already taken' \ No newline at end of file diff --git a/itd/models/_text.py b/itd/models/_text.py index 49238a7..96aa99e 100644 --- a/itd/models/_text.py +++ b/itd/models/_text.py @@ -3,15 +3,10 @@ from datetime import datetime from pydantic import BaseModel, Field, field_validator -from itd.models.user import UserPost -from itd.models.file import Attach - class TextObject(BaseModel): id: UUID content: str - author: UserPost - attachments: list[Attach] = [] created_at: datetime = Field(alias='createdAt') diff --git a/itd/models/comment.py b/itd/models/comment.py index efcd499..1330225 100644 --- a/itd/models/comment.py +++ b/itd/models/comment.py @@ -2,12 +2,16 @@ from pydantic import Field from itd.models._text import TextObject from itd.models.user import UserPost +from itd.models.file import Attach class Comment(TextObject): + author: UserPost + likes_count: int = Field(0, alias='likesCount') replies_count: int = Field(0, alias='repliesCount') is_liked: bool = Field(False, alias='isLiked') + attachments: list[Attach] = [] replies: list['Comment'] = [] reply_to: UserPost | None = None # author of replied comment, if this comment is reply \ No newline at end of file diff --git a/itd/models/file.py b/itd/models/file.py index b732b7e..f7f34e7 100644 --- a/itd/models/file.py +++ b/itd/models/file.py @@ -12,15 +12,18 @@ class File(BaseModel): size: int -class Attach(BaseModel): +class PostAttach(BaseModel): id: UUID type: AttachType = AttachType.IMAGE url: str thumbnail_url: str | None = Field(None, alias='thumbnailUrl') + width: int | None = None + height: int | None = None + + +class Attach(PostAttach): filename: str mime_type: str = Field(alias='mimeType') size: int - width: int | None = None - height: int | None = None duration: int | None = None order: int = 0 \ No newline at end of file diff --git a/itd/models/notification.py b/itd/models/notification.py index 7ea3ea9..4dca9fe 100644 --- a/itd/models/notification.py +++ b/itd/models/notification.py @@ -11,7 +11,7 @@ class Notification(BaseModel): type: NotificationType target_type: NotificationTargetType | None = Field(None, alias='targetType') # none - follows, other - NotificationTragetType.POST - target_id: int | None = Field(None, alias='targetId') # none - follows + target_id: UUID | None = Field(None, alias='targetId') # none - follows preview: str | None = None # follow - none, comment/reply - content, repost - original post content, like - post content, wall_post - wall post content diff --git a/itd/models/pagination.py b/itd/models/pagination.py index df81f56..e140b8b 100644 --- a/itd/models/pagination.py +++ b/itd/models/pagination.py @@ -1,7 +1,10 @@ +from uuid import UUID + from pydantic import BaseModel, Field class Pagination(BaseModel): - page: int = 1 + page: int | None = 1 limit: int = 20 total: int | None = None - has_more: bool = Field(True, alias='hasMore') \ No newline at end of file + has_more: bool = Field(True, alias='hasMore') + next_cursor: UUID | None = Field(None, alias='nextCursor') \ No newline at end of file diff --git a/itd/models/post.py b/itd/models/post.py index dfe33dd..f07c36c 100644 --- a/itd/models/post.py +++ b/itd/models/post.py @@ -1,29 +1,47 @@ +from uuid import UUID + from pydantic import Field -from itd.models.user import UserPost +from itd.models.user import UserPost, UserNewPost from itd.models._text import TextObject +from itd.models.file import PostAttach +from itd.models.comment import Comment -class PostShort(TextObject): +class _PostShort(TextObject): likes_count: int = Field(0, alias='likesCount') comments_count: int = Field(0, alias='commentsCount') reposts_count: int = Field(0, alias='repostsCount') views_count: int = Field(0, alias='viewsCount') +class PostShort(_PostShort): + author: UserPost + + class OriginalPost(PostShort): is_deleted: bool = Field(False, alias='isDeleted') -class Post(PostShort): +class _Post(_PostShort): is_liked: bool = Field(False, alias='isLiked') is_reposted: bool = Field(False, alias='isReposted') is_viewed: bool = Field(False, alias='isViewed') is_owner: bool = Field(False, alias='isOwner') - comments: list = [] + attachments: list[PostAttach] = [] + comments: list[Comment] = [] original_post: OriginalPost | None = None - wall_recipient_id: int | None = None - wall_recipient: UserPost | None = None + wall_recipient_id: UUID | None = Field(None, alias='wallRecipientId') + wall_recipient: UserPost | None = Field(None, alias='wallRecipient') + + +class Post(_Post, PostShort): + pass + + +class NewPost(_Post): + author: UserNewPost + diff --git a/itd/models/user.py b/itd/models/user.py index 0c717ae..a35ff30 100644 --- a/itd/models/user.py +++ b/itd/models/user.py @@ -19,22 +19,23 @@ class UserProfileUpdate(BaseModel): updated_at: datetime | None = Field(None, alias='updatedAt') - model_config = {'populate_by_name': True} - -class UserNotification(BaseModel): - id: UUID +class UserNewPost(BaseModel): username: str | None = None display_name: str = Field(alias='displayName') avatar: str - model_config = {'populate_by_name': True} - - -class UserPost(UserNotification): verified: bool = False +class UserNotification(UserNewPost): + id: UUID + + +class UserPost(UserNotification, UserNewPost): + pass + + class UserWhoToFollow(UserPost): followers_count: int = Field(0, alias='followersCount') diff --git a/itd/request.py b/itd/request.py index a63cf10..909ed31 100644 --- a/itd/request.py +++ b/itd/request.py @@ -1,6 +1,7 @@ from _io import BufferedReader from requests import Session +from requests.exceptions import JSONDecodeError from itd.exceptions import InvalidToken, InvalidCookie, RateLimitExceeded @@ -29,10 +30,13 @@ def fetch(token: str, method: str, url: str, params: dict = {}, files: dict[str, if method == "get": res = s.get(base, timeout=120 if files else 20, params=params, headers=headers) else: - res = s.request(method.upper(), base, timeout=20, json=params, headers=headers, files=files) + res = s.request(method.upper(), base, timeout=120 if files else 20, json=params, headers=headers, files=files) - if res.json().get('error', {}).get('code') == 'RATE_LIMIT_EXCEEDED': - raise RateLimitExceeded(res.json()['error'].get('retryAfter', 0)) + try: + if res.json().get('error', {}).get('code') == 'RATE_LIMIT_EXCEEDED': + raise RateLimitExceeded(res.json()['error'].get('retryAfter', 0)) + except JSONDecodeError: + pass print(res.text) return res @@ -75,9 +79,12 @@ def auth_fetch(cookies: str, method: str, url: str, params: dict = {}, token: st # print(res.text) if res.text == 'UNAUTHORIZED': raise InvalidToken() - if res.json().get('error', {}).get('code') == 'RATE_LIMIT_EXCEEDED': - raise RateLimitExceeded(res.json()['error'].get('retryAfter', 0)) - if res.json().get('error', {}).get('code') in ('SESSION_NOT_FOUND', 'REFRESH_TOKEN_MISSING'): - raise InvalidCookie() + try: + if res.json().get('error', {}).get('code') == 'RATE_LIMIT_EXCEEDED': + raise RateLimitExceeded(res.json()['error'].get('retryAfter', 0)) + if res.json().get('error', {}).get('code') in ('SESSION_NOT_FOUND', 'REFRESH_TOKEN_MISSING'): + raise InvalidCookie() + except JSONDecodeError: + pass return res diff --git a/itd/routes/hashtags.py b/itd/routes/hashtags.py index e6e9a63..72404c4 100644 --- a/itd/routes/hashtags.py +++ b/itd/routes/hashtags.py @@ -1,7 +1,8 @@ +from uuid import UUID from itd.request import fetch def get_hastags(token: str, limit: int = 10): return fetch(token, 'get', 'hashtags/trending', {'limit': limit}) -def get_posts_by_hastag(token: str, hashtag: str, limit: int = 20, cursor: int = 0): +def get_posts_by_hastag(token: str, hashtag: str, limit: int = 20, cursor: UUID | None = None): return fetch(token, 'get', f'hashtags/{hashtag}/posts', {'limit': limit, 'cursor': cursor}) diff --git a/itd/routes/notifications.py b/itd/routes/notifications.py index 8ad6759..de0a741 100644 --- a/itd/routes/notifications.py +++ b/itd/routes/notifications.py @@ -1,16 +1,15 @@ +from uuid import UUID + from itd.request import fetch -def get_notifications(token: str, limit: int = 20, cursor: int = 0, type: str | None = None): - data = {'limit': str(limit), 'cursor': str(cursor)} - if type: - data['type'] = type - return fetch(token, 'get', 'notifications', data) +def get_notifications(token: str, limit: int = 20, offset: int = 0): + return fetch(token, 'get', 'notifications', {'limit': limit, 'offset': offset}) -def mark_as_read(token: str, id: str): - return fetch(token, 'post', f'notification/{id}/read') +def mark_as_read(token: str, id: UUID): + return fetch(token, 'post', f'notifications/{id}/read') def mark_all_as_read(token: str): - return fetch(token, 'post', f'notification/read-all') + return fetch(token, 'post', f'notifications/read-all') def get_unread_notifications_count(token: str): return fetch(token, 'get', 'notifications/count') \ No newline at end of file diff --git a/itd/routes/posts.py b/itd/routes/posts.py index b2a0705..97edcd2 100644 --- a/itd/routes/posts.py +++ b/itd/routes/posts.py @@ -1,11 +1,13 @@ +from uuid import UUID + from itd.request import fetch -def create_post(token: str, content: str, wall_recipient_id: int | None = None, attach_ids: list[str] = []): +def create_post(token: str, content: str, wall_recipient_id: UUID | None = None, attach_ids: list[UUID] = []): data: dict = {'content': content} if wall_recipient_id: - data['wallRecipientId'] = wall_recipient_id + data['wallRecipientId'] = str(wall_recipient_id) if attach_ids: - data['attachmentIds'] = attach_ids + data['attachmentIds'] = list(map(str, attach_ids)) return fetch(token, 'post', 'posts', data) @@ -20,28 +22,29 @@ def get_posts(token: str, username: str | None = None, limit: int = 20, cursor: return fetch(token, 'get', 'posts', data) -def get_post(token: str, id: str): +def get_post(token: str, id: UUID): return fetch(token, 'get', f'posts/{id}') -def edit_post(token: str, id: str, content: str): +def edit_post(token: str, id: UUID, content: str): return fetch(token, 'put', f'posts/{id}', {'content': content}) -def delete_post(token: str, id: str): +def delete_post(token: str, id: UUID): return fetch(token, 'delete', f'posts/{id}') -def pin_post(token: str, id: str): +def pin_post(token: str, id: UUID): return fetch(token, 'post', f'posts/{id}/pin') -def repost(token: str, id: str, content: str | None = None): +def repost(token: str, id: UUID, content: str | None = None): data = {} if content: data['content'] = content return fetch(token, 'post', f'posts/{id}/repost', data) -def view_post(token: str, id: str): +def view_post(token: str, id: UUID): return fetch(token, 'post', f'posts/{id}/view') def get_liked_posts(token: str, username: str, limit: int = 20, cursor: int = 0): return fetch(token, 'get', f'posts/user/{username}/liked', {'limit': limit, 'cursor': cursor}) -# todo post restore \ No newline at end of file +# todo post restore +# todo post like \ No newline at end of file From 1db59149f443c9ff951e952ceb8211cfbfdcb287 Mon Sep 17 00:00:00 2001 From: Rationess Date: Tue, 3 Feb 2026 20:32:43 +0300 Subject: [PATCH 05/25] =?UTF-8?q?=D0=BF=D0=BE=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=B8=D0=BB=20get=5Fhastags=20=D0=BD=D0=B0=20get=5Fhasht?= =?UTF-8?q?ags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- itd/client.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/itd/client.py b/itd/client.py index 714f5cd..54c4786 100644 --- a/itd/client.py +++ b/itd/client.py @@ -1,3 +1,4 @@ +from warnings import deprecated from uuid import UUID from _io import BufferedReader from typing import cast @@ -490,11 +491,23 @@ class Client: raise Forbidden('delete comment') res.raise_for_status() - + @deprecated("get_hastags устарел используйте get_hashtags") @refresh_on_error def get_hastags(self, limit: int = 10) -> list[Hashtag]: """Получить список популярных хэштэгов + Args: + limit (int, optional): Лимит. Defaults to 10. + + Returns: + list[Hashtag]: Список хэштэгов + """ + return self.get_hashtags(10) + + @refresh_on_error + def get_hashtags(self, limit: int = 10) -> list[Hashtag]: + """Получить список популярных хэштэгов + Args: limit (int, optional): Лимит. Defaults to 10. From c9a5dcad10733b0d15ed20ba0d660877baac8a0e Mon Sep 17 00:00:00 2001 From: Rationess Date: Tue, 3 Feb 2026 20:33:28 +0300 Subject: [PATCH 06/25] =?UTF-8?q?=D0=9E=D1=88=D0=B8=D0=B1=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B2=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B5=20=D0=A1=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D1=8B=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- itd/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/itd/client.py b/itd/client.py index 54c4786..0823fc3 100644 --- a/itd/client.py +++ b/itd/client.py @@ -81,7 +81,7 @@ class Client: """Смена пароля Args: - old (str): Страый пароль + old (str): Старый пароль new (str): Новый пароль Raises: From af0b2a1acc576048cee24528d7373e1686e2afae Mon Sep 17 00:00:00 2001 From: Rationess Date: Tue, 3 Feb 2026 20:41:34 +0300 Subject: [PATCH 07/25] =?UTF-8?q?=D0=9E=D1=88=D0=B8=D0=B1=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B2=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B5=20verificate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- itd/client.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/itd/client.py b/itd/client.py index 0823fc3..487cbd4 100644 --- a/itd/client.py +++ b/itd/client.py @@ -281,7 +281,7 @@ class Client: return [UserFollower.model_validate(user) for user in res.json()['data']['users']], Pagination.model_validate(res.json()['data']['pagination']) - + @deprecated("verificate устарел используйте verify") @refresh_on_error def verificate(self, file_url: str) -> Verification: """Отправить запрос на верификацию @@ -295,6 +295,21 @@ class Client: Returns: Verification: Верификация """ + return self.verify(file_url) + + @refresh_on_error + def verify(self, file_url: str) -> Verification: + """Отправить запрос на верификацию + + Args: + file_url (str): Ссылка на видео + + Raises: + PendingRequestExists: Запрос уже отправлен + + Returns: + Verification: Верификация + """ res = verificate(self.token, file_url) if res.json().get('error', {}).get('code') == 'PENDING_REQUEST_EXISTS': raise PendingRequestExists() @@ -314,7 +329,6 @@ class Client: return VerificationStatus.model_validate(res.json()) - @refresh_on_error def get_who_to_follow(self) -> list[UserWhoToFollow]: """Получить список популярнык пользователей (кого читать) From 2ccd26fc9bb3f5b99cf58ef6d51e81fcec11e738 Mon Sep 17 00:00:00 2001 From: Rationess Date: Tue, 3 Feb 2026 20:42:55 +0300 Subject: [PATCH 08/25] =?UTF-8?q?=D0=9E=D1=88=D0=B8=D0=B1=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B2=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B5=20=D0=BF=D0=BE=D0=BF?= =?UTF-8?q?=D1=83=D0=BB=D1=8F=D1=80=D0=BD=D1=8B=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- itd/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/itd/client.py b/itd/client.py index 487cbd4..2bfaf24 100644 --- a/itd/client.py +++ b/itd/client.py @@ -331,7 +331,7 @@ class Client: @refresh_on_error def get_who_to_follow(self) -> list[UserWhoToFollow]: - """Получить список популярнык пользователей (кого читать) + """Получить список популярных пользователей (кого читать) Returns: list[UserWhoToFollow]: Список пользователей From 3ad003a0b1d0a7c0eb6092516da007e4d2099891 Mon Sep 17 00:00:00 2001 From: Rationess Date: Tue, 3 Feb 2026 20:45:06 +0300 Subject: [PATCH 09/25] user limit --- itd/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/itd/client.py b/itd/client.py index 2bfaf24..217a237 100644 --- a/itd/client.py +++ b/itd/client.py @@ -516,7 +516,7 @@ class Client: Returns: list[Hashtag]: Список хэштэгов """ - return self.get_hashtags(10) + return self.get_hashtags(limit) @refresh_on_error def get_hashtags(self, limit: int = 10) -> list[Hashtag]: From 7ce699e42a1f1651ce6610f4fe4e2cadfd07aeb8 Mon Sep 17 00:00:00 2001 From: Rationess Date: Tue, 3 Feb 2026 20:48:08 +0300 Subject: [PATCH 10/25] =?UTF-8?q?=D0=BB=D0=B8=D1=88=D0=BD=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=B0=D1=80=D0=B3=D1=83=D0=BC=D0=B5=D0=BD=D1=82=20=D0=B2=20doc?= =?UTF-8?q?string?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- itd/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/itd/client.py b/itd/client.py index 217a237..c4f5602 100644 --- a/itd/client.py +++ b/itd/client.py @@ -373,7 +373,6 @@ class Client: Args: post_id (str): UUID поста content (str): Содержание - reply_comment_id (UUID | None, optional): ID коммента для ответа. Defaults to None. Raises: ValidationError: Ошибка валидации From 80a5a3344379136178917c57f446d0f0faf0abef Mon Sep 17 00:00:00 2001 From: Rationess Date: Tue, 3 Feb 2026 20:55:33 +0300 Subject: [PATCH 11/25] =?UTF-8?q?=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B2=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=D1=85?= =?UTF-8?q?=20get=5Fhastags=20=D0=B8=20get=5Fposts=5Fby=5Fhastag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- itd/client.py | 6 +++--- itd/routes/hashtags.py | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/itd/client.py b/itd/client.py index c4f5602..48c83cd 100644 --- a/itd/client.py +++ b/itd/client.py @@ -8,7 +8,7 @@ from requests.exceptions import HTTPError, ConnectionError from itd.routes.users import get_user, update_profile, follow, unfollow, get_followers, get_following, update_privacy from itd.routes.etc import get_top_clans, get_who_to_follow, get_platform_status from itd.routes.comments import get_comments, add_comment, delete_comment, like_comment, unlike_comment, add_reply_comment -from itd.routes.hashtags import get_hastags, get_posts_by_hastag +from itd.routes.hashtags import get_hashtags, get_posts_by_hashtag from itd.routes.notifications import get_notifications, mark_as_read, mark_all_as_read, get_unread_notifications_count from itd.routes.posts import create_post, get_posts, get_post, edit_post, delete_post, pin_post, repost, view_post, get_liked_posts from itd.routes.reports import report @@ -527,7 +527,7 @@ class Client: Returns: list[Hashtag]: Список хэштэгов """ - res = get_hastags(self.token, limit) + res = get_hashtags(self.token, limit) res.raise_for_status() return [Hashtag.model_validate(hashtag) for hashtag in res.json()['data']['hashtags']] @@ -546,7 +546,7 @@ class Client: list[Post]: Посты Pagination: Пагинация """ - res = get_posts_by_hastag(self.token, hashtag, limit, cursor) + res = get_posts_by_hashtag(self.token, hashtag, limit, cursor) res.raise_for_status() data = res.json()['data'] diff --git a/itd/routes/hashtags.py b/itd/routes/hashtags.py index 72404c4..cd4a836 100644 --- a/itd/routes/hashtags.py +++ b/itd/routes/hashtags.py @@ -1,8 +1,17 @@ +from warnings import deprecated from uuid import UUID from itd.request import fetch +@deprecated("get_hastags устарела используйте get_hashtags") def get_hastags(token: str, limit: int = 10): return fetch(token, 'get', 'hashtags/trending', {'limit': limit}) +def get_hashtags(token: str, limit: int = 10): + return fetch(token, 'get', 'hashtags/trending', {'limit': limit}) + +@deprecated("get_posts_by_hastag устерла используй get_posts_by_hashtag") def get_posts_by_hastag(token: str, hashtag: str, limit: int = 20, cursor: UUID | None = None): return fetch(token, 'get', f'hashtags/{hashtag}/posts', {'limit': limit, 'cursor': cursor}) + +def get_posts_by_hashtag(token: str, hashtag: str, limit: int = 20, cursor: UUID | None = None): + return fetch(token, 'get', f'hashtags/{hashtag}/posts', {'limit': limit, 'cursor': cursor}) From 0c212e41cb8b90a87add5152584c0a85031ef33d Mon Sep 17 00:00:00 2001 From: Rationess Date: Tue, 3 Feb 2026 20:58:22 +0300 Subject: [PATCH 12/25] =?UTF-8?q?=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B2=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D0=B8=20ver?= =?UTF-8?q?ificate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- itd/client.py | 4 ++-- itd/routes/verification.py | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/itd/client.py b/itd/client.py index 48c83cd..2ed3ac0 100644 --- a/itd/client.py +++ b/itd/client.py @@ -15,7 +15,7 @@ from itd.routes.reports import report from itd.routes.search import search from itd.routes.files import upload_file from itd.routes.auth import refresh_token, change_password, logout -from itd.routes.verification import verificate, get_verification_status +from itd.routes.verification import verify, get_verification_status from itd.models.comment import Comment from itd.models.notification import Notification @@ -310,7 +310,7 @@ class Client: Returns: Verification: Верификация """ - res = verificate(self.token, file_url) + res = verify(self.token, file_url) if res.json().get('error', {}).get('code') == 'PENDING_REQUEST_EXISTS': raise PendingRequestExists() res.raise_for_status() diff --git a/itd/routes/verification.py b/itd/routes/verification.py index 8c66426..ec643d5 100644 --- a/itd/routes/verification.py +++ b/itd/routes/verification.py @@ -1,8 +1,14 @@ +from warnings import deprecated from itd.request import fetch -def verificate(token: str, file_url: str): +def verify(token: str, file_url: str): # {"success":true,"request":{"id":"fc54e54f-8586-4d8c-809e-df93161f99da","userId":"9096a85b-c319-483e-8940-6921be427ad0","videoUrl":"https://943701f000610900cbe86b72234e451d.bckt.ru/videos/354f28a6-9ac7-48a6-879a-a454062b1d6b.mp4","status":"pending","rejectionReason":null,"reviewedBy":null,"reviewedAt":null,"createdAt":"2026-01-30T12:58:14.228Z","updatedAt":"2026-01-30T12:58:14.228Z"}} return fetch(token, 'post', 'verification/submit', {'videoUrl': file_url}) +@deprecated("verificate устарела используйте verify") +def verificate(token: str, file_url: str): + return verify(token, file_url) + + def get_verification_status(token: str): return fetch(token, 'get', 'verification/status') \ No newline at end of file From 6ac12c6f79b3a3e4d4882d5739aaf3957ee37c51 Mon Sep 17 00:00:00 2001 From: Rationess Date: Tue, 3 Feb 2026 22:20:33 +0300 Subject: [PATCH 13/25] =?UTF-8?q?attachment=5Fids=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8?= =?UTF-8?q?=D0=B5=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- itd/client.py | 10 ++++++---- itd/routes/comments.py | 8 ++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/itd/client.py b/itd/client.py index 714f5cd..9b8bd38 100644 --- a/itd/client.py +++ b/itd/client.py @@ -352,12 +352,13 @@ class Client: @refresh_on_error - def add_comment(self, post_id: UUID, content: str) -> Comment: + def add_comment(self, post_id: UUID, content: str, attachment_ids: list[UUID] = []) -> Comment: """Добавить комментарий Args: post_id (str): UUID поста content (str): Содержание + attachment_ids (list[UUID]): Список UUID прикреплённых файлов reply_comment_id (UUID | None, optional): ID коммента для ответа. Defaults to None. Raises: @@ -367,7 +368,7 @@ class Client: Returns: Comment: Комментарий """ - res = add_comment(self.token, post_id, content) + res = add_comment(self.token, post_id, content, attachment_ids) if res.status_code == 422 and 'found' in res.json(): raise ValidationError(*list(res.json()['found'].items())[0]) if res.json().get('error', {}).get('code') == 'NOT_FOUND': @@ -378,13 +379,14 @@ class Client: @refresh_on_error - def add_reply_comment(self, comment_id: UUID, content: str, author_id: UUID) -> Comment: + def add_reply_comment(self, comment_id: UUID, content: str, author_id: UUID, attachment_ids: list[UUID] = []) -> Comment: """Добавить ответный комментарий Args: comment_id (str): UUID комментария content (str): Содержание author_id (UUID | None, optional): ID пользователя, отправившего комментарий. Defaults to None. + attachment_ids (list[UUID]): Список UUID прикреплённых файлов Raises: ValidationError: Ошибка валидации @@ -393,7 +395,7 @@ class Client: Returns: Comment: Комментарий """ - res = add_reply_comment(self.token, comment_id, content, author_id) + res = add_reply_comment(self.token, comment_id, content, author_id, attachment_ids) if res.status_code == 500 and 'Failed query' in res.text: raise NotFound('User') if res.status_code == 422 and 'found' in res.json(): diff --git a/itd/routes/comments.py b/itd/routes/comments.py index d98ddf2..c211b17 100644 --- a/itd/routes/comments.py +++ b/itd/routes/comments.py @@ -2,11 +2,11 @@ from uuid import UUID from itd.request import fetch -def add_comment(token: str, post_id: UUID, content: str): - return fetch(token, 'post', f'posts/{post_id}/comments', {'content': content}) +def add_comment(token: str, post_id: UUID, content: str, attachment_ids: list[UUID] = []): + return fetch(token, 'post', f'posts/{post_id}/comments', {'content': content, "attachmentIds": list(map(str, attachment_ids))}) -def add_reply_comment(token: str, comment_id: UUID, content: str, author_id: UUID): - return fetch(token, 'post', f'comments/{comment_id}/replies', {'content': content, 'replyToUserId': str(author_id)}) +def add_reply_comment(token: str, comment_id: UUID, content: str, author_id: UUID, attachment_ids: list[UUID] = []): + return fetch(token, 'post', f'comments/{comment_id}/replies', {'content': content, 'replyToUserId': str(author_id), "attachmentIds": list(map(str, attachment_ids))}) def get_comments(token: str, post_id: UUID, limit: int = 20, cursor: int = 0, sort: str = 'popular'): return fetch(token, 'get', f'posts/{post_id}/comments', {'limit': limit, 'sort': sort, 'cursor': cursor}) From a547bbfdfb833866aa9182545d99426ddf540dbe Mon Sep 17 00:00:00 2001 From: Rationess Date: Tue, 3 Feb 2026 22:22:27 +0300 Subject: [PATCH 14/25] =?UTF-8?q?mime=5Ftype=20=D0=BD=D0=B5=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20png=20=D1=82=D0=B0=D0=BC=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B6=D0=B5=D1=82=20=D0=B1=D1=8B=D1=82=D1=8C=20=D0=B8=20?= =?UTF-8?q?jpg=20=D0=B8=20gid.=20=D0=95=D1=89=D1=91=20=D0=B8=20=D0=B0?= =?UTF-8?q?=D1=83=D0=B4=D0=B8=D0=BE=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- itd/models/file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/itd/models/file.py b/itd/models/file.py index f7f34e7..635df8c 100644 --- a/itd/models/file.py +++ b/itd/models/file.py @@ -8,7 +8,7 @@ class File(BaseModel): id: UUID url: str filename: str - mime_type: str = Field('image/png', alias='mimeType') + mime_type: str = Field(alias='mimeType') size: int From eb83c724ccdec7c67bfa911c11be66bc3d3c1e0f Mon Sep 17 00:00:00 2001 From: Rationess Date: Wed, 4 Feb 2026 11:15:19 +0300 Subject: [PATCH 15/25] =?UTF-8?q?restore=20=D0=B8=20like=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 - itd/client.py | 39 ++++++++++++++++++++++++++++++++++++--- itd/models/post.py | 6 +++++- itd/routes/posts.py | 10 ++++++++-- tests/__init__.py | 0 tests/settings.py | 1 + tests/test_like.py | 26 ++++++++++++++++++++++++++ 7 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/settings.py create mode 100644 tests/test_like.py diff --git a/.gitignore b/.gitignore index ddc7b0c..16b0b1f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -test.py venv/ __pycache__/ dist diff --git a/itd/client.py b/itd/client.py index 714f5cd..927230f 100644 --- a/itd/client.py +++ b/itd/client.py @@ -9,7 +9,7 @@ from itd.routes.etc import get_top_clans, get_who_to_follow, get_platform_status from itd.routes.comments import get_comments, add_comment, delete_comment, like_comment, unlike_comment, add_reply_comment from itd.routes.hashtags import get_hastags, get_posts_by_hastag from itd.routes.notifications import get_notifications, mark_as_read, mark_all_as_read, get_unread_notifications_count -from itd.routes.posts import create_post, get_posts, get_post, edit_post, delete_post, pin_post, repost, view_post, get_liked_posts +from itd.routes.posts import create_post, get_posts, get_post, edit_post, delete_post, pin_post, repost, view_post, get_liked_posts, restore_post, like_post, delete_like_post from itd.routes.reports import report from itd.routes.search import search from itd.routes.files import upload_file @@ -18,7 +18,7 @@ from itd.routes.verification import verificate, get_verification_status from itd.models.comment import Comment from itd.models.notification import Notification -from itd.models.post import Post, NewPost +from itd.models.post import Post, NewPost, LikePostResponse from itd.models.clan import Clan from itd.models.hashtag import Hashtag from itd.models.user import User, UserProfileUpdate, UserPrivacy, UserFollower, UserWhoToFollow @@ -662,4 +662,37 @@ class Client: def update_banner(self, name: str) -> UserProfileUpdate: id = self.upload_file(name, cast(BufferedReader, open(name, 'rb')))['id'] - return self.update_profile(banner_id=id) \ No newline at end of file + return self.update_profile(banner_id=id) + + @refresh_on_error + def restore_post(self, post_id: UUID) -> None: + """Восстановить удалённый пост + + Args: + post_id: UUID поста + """ + restore_post(self.token, post_id) + + @refresh_on_error + def like_post(self, post_id: UUID) -> LikePostResponse: + """Поставить лайк на пост + + Args: + post_id: UUID поста + """ + res = like_post(self.token, post_id) + if res.status_code == 404: + raise NotFound("Post not found") + return LikePostResponse.model_validate(res.json()) + + @refresh_on_error + def delete_like_post(self, post_id: UUID) -> LikePostResponse: + """Убрать лайк с поста + + Args: + post_id: UUID поста + """ + res = delete_like_post(self.token, post_id) + if res.status_code == 404: + raise NotFound("Post not found") + return LikePostResponse.model_validate(res.json()) diff --git a/itd/models/post.py b/itd/models/post.py index f07c36c..3343242 100644 --- a/itd/models/post.py +++ b/itd/models/post.py @@ -1,6 +1,6 @@ from uuid import UUID -from pydantic import Field +from pydantic import Field, BaseModel from itd.models.user import UserPost, UserNewPost from itd.models._text import TextObject @@ -45,3 +45,7 @@ class Post(_Post, PostShort): class NewPost(_Post): author: UserNewPost + +class LikePostResponse(BaseModel): + liked: bool + likes_count: int = Field(alias="likesCount") diff --git a/itd/routes/posts.py b/itd/routes/posts.py index 97edcd2..60e23bc 100644 --- a/itd/routes/posts.py +++ b/itd/routes/posts.py @@ -46,5 +46,11 @@ def view_post(token: str, id: UUID): def get_liked_posts(token: str, username: str, limit: int = 20, cursor: int = 0): return fetch(token, 'get', f'posts/user/{username}/liked', {'limit': limit, 'cursor': cursor}) -# todo post restore -# todo post like \ No newline at end of file +def restore_post(token: str, post_id: UUID): + return fetch(token, "post", f"posts/{post_id}/restore",) + +def like_post(token: str, post_id: UUID): + return fetch(token, "post", f"posts/{post_id}/like") + +def delete_like_post(token: str, post_id: UUID): + return fetch(token, "delete", f"posts/{post_id}/like") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..fad677d --- /dev/null +++ b/tests/settings.py @@ -0,0 +1 @@ +cookies = 'Сюда вставить куки' \ No newline at end of file diff --git a/tests/test_like.py b/tests/test_like.py new file mode 100644 index 0000000..602524e --- /dev/null +++ b/tests/test_like.py @@ -0,0 +1,26 @@ +from itd import ITDClient +from itd.models.post import LikePostResponse +from itd.exceptions import NotFound +import unittest +from . import settings + +class TestLike(unittest.TestCase): + def test_like(self): + c = ITDClient(None, settings.cookies) + + post = c.create_post("post_for_test_like") + + self.assertEqual(c.like_post(post.id), LikePostResponse(liked=True, likesCount=1)) # Лайк на пост без лайка + self.assertEqual(c.like_post(post.id), LikePostResponse(liked=True, likesCount=1)) # Лайк на пост с уже поставленным лайком + + self.assertEqual(c.delete_like_post(post.id), LikePostResponse(liked=False, likesCount=0)) # Убрать лайк с поста с уже поставленным лайком + self.assertEqual(c.delete_like_post(post.id), LikePostResponse(liked=False, likesCount=0)) # Убрать лайк с поста без лайков + + c.delete_post(str(post.id)) + + self.assertRaises(NotFound, c.like_post, post.id) # лайк на удалённый пост + self.assertRaises(NotFound, c.delete_like_post, post.id) # Убрать лайк с удалённого поста + + +if __name__ == "__main__": + unittest.main() From 2f026a32d7a9ee6909d50aea5891156880327da8 Mon Sep 17 00:00:00 2001 From: firedotguy Date: Wed, 4 Feb 2026 22:52:10 +0300 Subject: [PATCH 16/25] feat: add cant follow yourself error --- .gitignore | 1 + itd/client.py | 19 ++++++++++--------- itd/exceptions.py | 20 +++++++++++++++++--- itd/request.py | 13 +++++++++---- 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index ddc7b0c..0faebdf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ test.py +like.py venv/ __pycache__/ dist diff --git a/itd/client.py b/itd/client.py index 714f5cd..3a4b4e5 100644 --- a/itd/client.py +++ b/itd/client.py @@ -2,7 +2,7 @@ from uuid import UUID from _io import BufferedReader from typing import cast -from requests.exceptions import HTTPError, ConnectionError +from requests.exceptions import ConnectionError, HTTPError from itd.routes.users import get_user, update_profile, follow, unfollow, get_followers, get_following, update_privacy from itd.routes.etc import get_top_clans, get_who_to_follow, get_platform_status @@ -26,20 +26,18 @@ from itd.models.pagination import Pagination from itd.models.verification import Verification, VerificationStatus from itd.request import set_cookies -from itd.exceptions import NoCookie, NoAuthData, SamePassword, InvalidOldPassword, NotFound, ValidationError, UserBanned, PendingRequestExists, Forbidden, UsernameTaken +from itd.exceptions import NoCookie, NoAuthData, SamePassword, InvalidOldPassword, NotFound, ValidationError, UserBanned, PendingRequestExists, Forbidden, UsernameTaken, CantFollowYourself, Unauthorized def refresh_on_error(func): def wrapper(self, *args, **kwargs): - try: - return func(self, *args, **kwargs) - except HTTPError as e: - if '401' in str(e): + if self.cookies: + try: + return func(self, *args, **kwargs) + except (Unauthorized, ConnectionError, HTTPError): self.refresh_auth() return func(self, *args, **kwargs) - raise e - except ConnectionError: - self.refresh_auth() + else: return func(self, *args, **kwargs) return wrapper @@ -203,6 +201,7 @@ class Client: Raises: NotFound: Пользователь не найден + CantFollowYourself: Невозможно подписаться на самого себе Returns: int: Число подписчиков после подписки @@ -210,6 +209,8 @@ class Client: res = follow(self.token, username) if res.json().get('error', {}).get('code') == 'NOT_FOUND': raise NotFound('User') + if res.json().get('error', {}).get('code') == 'VALIDATION_ERROR' and res.status_code == 400: + raise CantFollowYourself() res.raise_for_status() return res.json()['followersCount'] diff --git a/itd/exceptions.py b/itd/exceptions.py index f1cc39f..1963ed9 100644 --- a/itd/exceptions.py +++ b/itd/exceptions.py @@ -7,14 +7,20 @@ class NoAuthData(Exception): return 'No auth data. Provide token or cookies' class InvalidCookie(Exception): + def __init__(self, code: str): + self.code = code def __str__(self): - return f'Invalid cookie data' + if self.code == 'SESSION_NOT_FOUND': + return f'Invalid cookie data: Session not found (incorrect refresh token)' + elif self.code == 'REFRESH_TOKEN_MISSING': + return f'Invalid cookie data: No refresh token' + # SESSION_REVOKED + return f'Invalid cookie data: Session revoked (logged out)' class InvalidToken(Exception): def __str__(self): return f'Invalid access token' - class SamePassword(Exception): def __str__(self): return 'Old and new password must not equals' @@ -58,4 +64,12 @@ class Forbidden(Exception): class UsernameTaken(Exception): def __str__(self): - return 'Username is already taken' \ No newline at end of file + return 'Username is already taken' + +class CantFollowYourself(Exception): + def __str__(self): + return 'Cannot follow yourself' + +class Unauthorized(Exception): + def __str__(self) -> str: + return 'Auth required - refresh token' \ No newline at end of file diff --git a/itd/request.py b/itd/request.py index 909ed31..ac4a2d7 100644 --- a/itd/request.py +++ b/itd/request.py @@ -3,7 +3,7 @@ from _io import BufferedReader from requests import Session from requests.exceptions import JSONDecodeError -from itd.exceptions import InvalidToken, InvalidCookie, RateLimitExceeded +from itd.exceptions import InvalidToken, InvalidCookie, RateLimitExceeded, Unauthorized s = Session() @@ -35,10 +35,13 @@ def fetch(token: str, method: str, url: str, params: dict = {}, files: dict[str, try: if res.json().get('error', {}).get('code') == 'RATE_LIMIT_EXCEEDED': raise RateLimitExceeded(res.json()['error'].get('retryAfter', 0)) + if res.json().get('error', {}).get('code') == 'UNAUTHORIZED': + raise Unauthorized() except JSONDecodeError: pass - print(res.text) + if not res.ok: + print(res.text) return res @@ -82,8 +85,10 @@ def auth_fetch(cookies: str, method: str, url: str, params: dict = {}, token: st try: if res.json().get('error', {}).get('code') == 'RATE_LIMIT_EXCEEDED': raise RateLimitExceeded(res.json()['error'].get('retryAfter', 0)) - if res.json().get('error', {}).get('code') in ('SESSION_NOT_FOUND', 'REFRESH_TOKEN_MISSING'): - raise InvalidCookie() + if res.json().get('error', {}).get('code') in ('SESSION_NOT_FOUND', 'REFRESH_TOKEN_MISSING', 'SESSION_REVOKED'): + raise InvalidCookie(res.json()['error']['code']) + if res.json().get('error', {}).get('code') == 'UNAUTHORIZED': + raise Unauthorized() except JSONDecodeError: pass From 55630bc23fbeabc745f838d4b31e0b8b6f6ffe68 Mon Sep 17 00:00:00 2001 From: firedotguy Date: Thu, 5 Feb 2026 00:20:26 +0300 Subject: [PATCH 17/25] feat: add models part 4 --- itd/client.py | 199 ++++++++++++++++++++++++++++++++++----- itd/enums.py | 4 + itd/exceptions.py | 12 ++- itd/models/pagination.py | 15 ++- itd/request.py | 2 +- itd/routes/posts.py | 17 +--- 6 files changed, 208 insertions(+), 41 deletions(-) diff --git a/itd/client.py b/itd/client.py index f740b31..45188bb 100644 --- a/itd/client.py +++ b/itd/client.py @@ -23,11 +23,16 @@ from itd.models.post import Post, NewPost from itd.models.clan import Clan from itd.models.hashtag import Hashtag from itd.models.user import User, UserProfileUpdate, UserPrivacy, UserFollower, UserWhoToFollow -from itd.models.pagination import Pagination +from itd.models.pagination import Pagination, PostsPagintaion, LikedPostsPagintaion from itd.models.verification import Verification, VerificationStatus +from itd.enums import PostsTab from itd.request import set_cookies -from itd.exceptions import NoCookie, NoAuthData, SamePassword, InvalidOldPassword, NotFound, ValidationError, UserBanned, PendingRequestExists, Forbidden, UsernameTaken, CantFollowYourself, Unauthorized +from itd.exceptions import ( + NoCookie, NoAuthData, SamePassword, InvalidOldPassword, NotFound, ValidationError, UserBanned, + PendingRequestExists, Forbidden, UsernameTaken, CantFollowYourself, Unauthorized, + CantRepostYourPost, AlreadyReposted +) def refresh_on_error(func): @@ -302,15 +307,15 @@ class Client: def verify(self, file_url: str) -> Verification: """Отправить запрос на верификацию - Args: - file_url (str): Ссылка на видео + Args: + file_url (str): Ссылка на видео - Raises: - PendingRequestExists: Запрос уже отправлен + Raises: + PendingRequestExists: Запрос уже отправлен - Returns: - Verification: Верификация - """ + Returns: + Verification: Верификация + """ res = verify(self.token, file_url) if res.json().get('error', {}).get('code') == 'PENDING_REQUEST_EXISTS': raise PendingRequestExists() @@ -614,6 +619,20 @@ class Client: @refresh_on_error def create_post(self, content: str, wall_recipient_id: UUID | None = None, attach_ids: list[UUID] = []) -> NewPost: + """Создать пост + + Args: + content (str): Содержимое + wall_recipient_id (UUID | None, optional): UUID пользователя (чтобы создать пост ему на стене). Defaults to None. + attach_ids (list[UUID], optional): UUID вложений. Defaults to []. + + Raises: + NotFound: Пользователь не найден + ValidationError: Ошибка валидации + + Returns: + NewPost: Новый пост + """ res = create_post(self.token, content, wall_recipient_id, attach_ids) if res.json().get('error', {}).get('code') == 'NOT_FOUND': raise NotFound('Wall recipient') @@ -624,36 +643,166 @@ class Client: return NewPost.model_validate(res.json()) @refresh_on_error - def get_posts(self, username: str | None = None, limit: int = 20, cursor: int = 0, sort: str = '', tab: str = ''): - return get_posts(self.token, username, limit, cursor, sort, tab) + def get_posts(self, cursor: int = 0, tab: PostsTab = PostsTab.POPULAR) -> tuple[list[Post], PostsPagintaion]: + """Получить список постов + + Args: + cursor (int, optional): Страница. Defaults to 0. + tab (PostsTab, optional): Вкладка (популярное или подписки). Defaults to PostsTab.POPULAR. + + Returns: + list[Post]: Список постов + Pagination: Пагинация + """ + res = get_posts(self.token, cursor, tab) + res.raise_for_status() + data = res.json()['data'] + + return [Post.model_validate(post) for post in data['posts']], PostsPagintaion.model_validate(data['pagination']) @refresh_on_error - def get_post(self, id: str): - return get_post(self.token, id) + def get_post(self, id: UUID) -> Post: + """Получить пост + + Args: + id (UUID): UUID поста + + Raises: + NotFound: Пост не найден + + Returns: + Post: Пост + """ + res = get_post(self.token, id) + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise NotFound('Post') + res.raise_for_status() + + return Post.model_validate(res.json()['data']) @refresh_on_error - def edit_post(self, id: str, content: str): - return edit_post(self.token, id, content) + def edit_post(self, id: UUID, content: str) -> str: + """Редактировать пост + + Args: + id (UUID): UUID поста + content (str): Содержимое + + Raises: + NotFound: Пост не найден + Forbidden: Нет доступа + ValidationError: Ошибка валидации + + Returns: + str: Новое содержимое + """ + res = edit_post(self.token, id, content) + + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise NotFound('Post') + if res.json().get('error', {}).get('code') == 'FORBIDDEN': + raise Forbidden('edit post') + if res.status_code == 422 and 'found' in res.json(): + raise ValidationError(*list(res.json()['found'].items())[0]) + res.raise_for_status() + + return res.json()['content'] @refresh_on_error - def delete_post(self, id: str): - return delete_post(self.token, id) + def delete_post(self, id: UUID) -> None: + """Удалить пост + + Args: + id (UUID): UUID поста + + Raises: + NotFound: Пост не найден + Forbidden: Нет доступа + """ + res = delete_post(self.token, id) + if res.status_code == 204: + return + + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise NotFound('Post') + if res.json().get('error', {}).get('code') == 'FORBIDDEN': + raise Forbidden('delete post') + res.raise_for_status() @refresh_on_error - def pin_post(self, id: str): - return pin_post(self.token, id) + def pin_post(self, id: UUID): + """Закрепить пост + + Args: + id (UUID): UUID поста + + Raises: + NotFound: Пост не найден + Forbidden: Нет доступа + """ + res = pin_post(self.token, id) + + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise NotFound('Post') + if res.json().get('error', {}).get('code') == 'FORBIDDEN': + raise Forbidden('pin post') + res.raise_for_status() @refresh_on_error - def repost(self, id: str, content: str | None = None): - return repost(self.token, id, content) + def repost(self, id: UUID, content: str | None = None) -> NewPost: + """Репостнуть пост + + Args: + id (UUID): UUID поста + content (str | None, optional): Содержимое (доп. комментарий). Defaults to None. + + Raises: + NotFound: Пост не найден + AlreadyReposted: Пост уже репостнут + CantRepostYourPost: Нельзя репостить самого себя + ValidationError: Ошибка валидации + + Returns: + NewPost: Новый пост + """ + res = repost(self.token, id, content) + + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise NotFound('Post') + if res.json().get('error', {}).get('code') == 'CONFLICT': + raise AlreadyReposted() + if res.status_code == 422 and res.json().get('message') == 'Cannot repost your own post': + raise CantRepostYourPost() + if res.status_code == 422 and 'found' in res.json(): + raise ValidationError(*list(res.json()['found'].items())[0]) + res.raise_for_status() + + return NewPost.model_validate(res.json()) @refresh_on_error - def view_post(self, id: str): - return view_post(self.token, id) + def view_post(self, id: UUID) -> None: + """Просмотреть пост + + Args: + id (UUID): UUID поста + + Raises: + NotFound: Пост не найден + """ + res = view_post(self.token, id) + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise NotFound('Post') + res.raise_for_status() @refresh_on_error - def get_liked_posts(self, username: str, limit: int = 20, cursor: int = 0): - return get_liked_posts(self.token, username, limit, cursor) + def get_liked_posts(self, username_or_id: str | UUID, limit: int = 20, cursor: int = 0): + res = get_liked_posts(self.token, username_or_id, limit, cursor) + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise NotFound('User') + res.raise_for_status() + data = res.json()['data'] + + return [Post.model_validate(post) for post in data['posts']], LikedPostsPagintaion.model_validate(data['pagination']) @refresh_on_error diff --git a/itd/enums.py b/itd/enums.py index c37fdf5..ff474b9 100644 --- a/itd/enums.py +++ b/itd/enums.py @@ -27,3 +27,7 @@ class ReportTargetReason(Enum): class AttachType(Enum): AUDIO = 'audio' IMAGE = 'image' + +class PostsTab(Enum): + FOLLOWING = 'following' + POPULAR = 'popular' diff --git a/itd/exceptions.py b/itd/exceptions.py index 1963ed9..abfa498 100644 --- a/itd/exceptions.py +++ b/itd/exceptions.py @@ -71,5 +71,13 @@ class CantFollowYourself(Exception): return 'Cannot follow yourself' class Unauthorized(Exception): - def __str__(self) -> str: - return 'Auth required - refresh token' \ No newline at end of file + def __str__(self): + return 'Auth required - refresh token' + +class CantRepostYourPost(Exception): + def __str__(self): + return 'Cannot repost your own post' + +class AlreadyReposted(Exception): + def __str__(self): + return 'Post already reposted' diff --git a/itd/models/pagination.py b/itd/models/pagination.py index e140b8b..6bfe1c7 100644 --- a/itd/models/pagination.py +++ b/itd/models/pagination.py @@ -1,4 +1,5 @@ from uuid import UUID +from datetime import datetime from pydantic import BaseModel, Field @@ -7,4 +8,16 @@ class Pagination(BaseModel): limit: int = 20 total: int | None = None has_more: bool = Field(True, alias='hasMore') - next_cursor: UUID | None = Field(None, alias='nextCursor') \ No newline at end of file + next_cursor: UUID | None = Field(None, alias='nextCursor') + + +class PostsPagintaion(BaseModel): + limit: int = 20 + next_cursor: int = Field(1, alias='nextCursor') + has_more: bool = Field(True, alias='hasMore') + + +class LikedPostsPagintaion(BaseModel): + limit: int = 20 + next_cursor: datetime | None = Field(None, alias='nextCursor') + has_more: bool = Field(True, alias='hasMore') diff --git a/itd/request.py b/itd/request.py index ac4a2d7..81bfad5 100644 --- a/itd/request.py +++ b/itd/request.py @@ -38,7 +38,7 @@ def fetch(token: str, method: str, url: str, params: dict = {}, files: dict[str, if res.json().get('error', {}).get('code') == 'UNAUTHORIZED': raise Unauthorized() except JSONDecodeError: - pass + pass # todo if not res.ok: print(res.text) diff --git a/itd/routes/posts.py b/itd/routes/posts.py index 97edcd2..9a5edd7 100644 --- a/itd/routes/posts.py +++ b/itd/routes/posts.py @@ -1,6 +1,7 @@ from uuid import UUID from itd.request import fetch +from itd.enums import PostsTab def create_post(token: str, content: str, wall_recipient_id: UUID | None = None, attach_ids: list[UUID] = []): data: dict = {'content': content} @@ -11,16 +12,8 @@ def create_post(token: str, content: str, wall_recipient_id: UUID | None = None, return fetch(token, 'post', 'posts', data) -def get_posts(token: str, username: str | None = None, limit: int = 20, cursor: int = 0, sort: str = '', tab: str = ''): - data: dict = {'limit': limit, 'cursor': cursor} - if username: - data['username'] = username - if sort: - data['sort'] = sort - if tab: - data['tab'] = tab - - return fetch(token, 'get', 'posts', data) +def get_posts(token: str, cursor: int = 0, tab: PostsTab = PostsTab.POPULAR): + return fetch(token, 'get', 'posts', {'cursor': cursor, 'tab': tab.value}) def get_post(token: str, id: UUID): return fetch(token, 'get', f'posts/{id}') @@ -43,8 +36,8 @@ def repost(token: str, id: UUID, content: str | None = None): def view_post(token: str, id: UUID): return fetch(token, 'post', f'posts/{id}/view') -def get_liked_posts(token: str, username: str, limit: int = 20, cursor: int = 0): - return fetch(token, 'get', f'posts/user/{username}/liked', {'limit': limit, 'cursor': cursor}) +def get_liked_posts(token: str, username_or_id: str | UUID, limit: int = 20, cursor: int = 0): + return fetch(token, 'get', f'posts/user/{username_or_id}/liked', {'limit': limit}) # todo post restore # todo post like \ No newline at end of file From 65cd617a1f0c905ba5dcc16768ffac4b221fc35a Mon Sep 17 00:00:00 2001 From: Rationess Date: Thu, 5 Feb 2026 09:26:16 +0300 Subject: [PATCH 18/25] =?UTF-8?q?=D0=9F=D1=80=D0=B5=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20str=20=D0=B2?= =?UTF-8?q?=20UUID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- itd/client.py | 142 ++++++++++++++++++++++++++++++------------ itd/routes/reports.py | 3 +- 2 files changed, 103 insertions(+), 42 deletions(-) diff --git a/itd/client.py b/itd/client.py index 45188bb..b52f8e8 100644 --- a/itd/client.py +++ b/itd/client.py @@ -158,14 +158,14 @@ class Client: return self.get_user('me') @refresh_on_error - def update_profile(self, username: str | None = None, display_name: str | None = None, bio: str | None = None, banner_id: UUID | None = None) -> UserProfileUpdate: + def update_profile(self, username: str | None = None, display_name: str | None = None, bio: str | None = None, banner_id: UUID | str | None = None) -> UserProfileUpdate: """Обновить профиль Args: username (str | None, optional): username. Defaults to None. display_name (str | None, optional): Отображаемое имя. Defaults to None. bio (str | None, optional): Биография (о себе). Defaults to None. - banner_id (UUID | None, optional): UUID баннера. Defaults to None. + banner_id (UUID | str | None, optional): UUID баннера. Defaults to None. Raises: ValidationError: Ошибка валидации @@ -173,6 +173,9 @@ class Client: Returns: UserProfileUpdate: Обновленный профиль """ + if isinstance(banner_id, str): + banner_id = UUID(banner_id) + res = update_profile(self.token, bio, display_name, username, banner_id) if res.status_code == 422 and 'found' in res.json(): raise ValidationError(*list(res.json()['found'].items())[0]) @@ -373,14 +376,13 @@ class Client: @refresh_on_error - def add_comment(self, post_id: UUID, content: str, attachment_ids: list[UUID] = []) -> Comment: + def add_comment(self, post_id: UUID | str, content: str, attachment_ids: list[UUID | str] = []) -> Comment: """Добавить комментарий Args: - post_id (str): UUID поста + post_id (str | UUID): UUID поста content (str): Содержание - attachment_ids (list[UUID]): Список UUID прикреплённых файлов - reply_comment_id (UUID | None, optional): ID коммента для ответа. Defaults to None. + attachment_ids (list[UUID | str]): Список UUID прикреплённых файлов Raises: ValidationError: Ошибка валидации @@ -389,6 +391,10 @@ class Client: Returns: Comment: Комментарий """ + if isinstance(post_id, str): + post_id = UUID(post_id) + attachment_ids = list(map(lambda id: UUID(id) if isinstance(id, str) else id, attachment_ids)) + res = add_comment(self.token, post_id, content, attachment_ids) if res.status_code == 422 and 'found' in res.json(): raise ValidationError(*list(res.json()['found'].items())[0]) @@ -400,14 +406,14 @@ class Client: @refresh_on_error - def add_reply_comment(self, comment_id: UUID, content: str, author_id: UUID, attachment_ids: list[UUID] = []) -> Comment: + def add_reply_comment(self, comment_id: UUID | str, content: str, author_id: UUID | str, attachment_ids: list[UUID | str] = []) -> Comment: """Добавить ответный комментарий Args: - comment_id (str): UUID комментария + comment_id (str | UUID): UUID комментария content (str): Содержание - author_id (UUID | None, optional): ID пользователя, отправившего комментарий. Defaults to None. - attachment_ids (list[UUID]): Список UUID прикреплённых файлов + author_id (UUID | str): ID пользователя, отправившего комментарий. + attachment_ids (list[UUID | str]): Список UUID прикреплённых файлов Raises: ValidationError: Ошибка валидации @@ -416,6 +422,12 @@ class Client: Returns: Comment: Комментарий """ + if isinstance(comment_id, str): + comment_id = UUID(comment_id) + if isinstance(author_id, str): + author_id = UUID(author_id) + attachment_ids = list(map(lambda id: UUID(id) if isinstance(id, str) else id, attachment_ids)) + res = add_reply_comment(self.token, comment_id, content, author_id, attachment_ids) if res.status_code == 500 and 'Failed query' in res.text: raise NotFound('User') @@ -429,11 +441,11 @@ class Client: @refresh_on_error - def get_comments(self, post_id: UUID, limit: int = 20, cursor: int = 0, sort: str = 'popular') -> tuple[list[Comment], Pagination]: + def get_comments(self, post_id: UUID | str, limit: int = 20, cursor: int = 0, sort: str = 'popular') -> tuple[list[Comment], Pagination]: """Получить список комментариев Args: - post_id (UUID): UUID поста + post_id (UUID | str): UUID поста limit (int, optional): Лимит. Defaults to 20. cursor (int, optional): Курсор (сколько пропустить). Defaults to 0. sort (str, optional): Сортировка. Defaults to 'popular'. @@ -445,6 +457,9 @@ class Client: list[Comment]: Список комментариев Pagination: Пагинация """ + if isinstance(post_id, str): + post_id = UUID(post_id) + res = get_comments(self.token, post_id, limit, cursor, sort) if res.json().get('error', {}).get('code') == 'NOT_FOUND': raise NotFound('Post') @@ -454,11 +469,11 @@ class Client: return [Comment.model_validate(comment) for comment in data['comments']], Pagination(page=(cursor // limit) or 1, limit=limit, total=data['total'], hasMore=data['hasMore'], nextCursor=None) @refresh_on_error - def like_comment(self, id: UUID) -> int: + def like_comment(self, id: UUID | str) -> int: """Лайкнуть комментарий Args: - id (UUID): UUID комментария + id (UUID | str): UUID комментария Raises: NotFound: Комментарий не найден @@ -466,6 +481,9 @@ class Client: Returns: int: Количество лайков """ + if isinstance(id, str): + id = UUID(id) + res = like_comment(self.token, id) if res.json().get('error', {}).get('code') == 'NOT_FOUND': raise NotFound('Comment') @@ -474,11 +492,11 @@ class Client: return res.json()['likesCount'] @refresh_on_error - def unlike_comment(self, id: UUID) -> int: + def unlike_comment(self, id: UUID | str) -> int: """Убрать лайк с комментария Args: - id (UUID): UUID комментария + id (UUID | str): UUID комментария Raises: NotFound: Комментарий не найден @@ -486,6 +504,9 @@ class Client: Returns: int: Количество лайков """ + if isinstance(id, str): + id = UUID(id) + res = unlike_comment(self.token, id) if res.json().get('error', {}).get('code') == 'NOT_FOUND': raise NotFound('Comment') @@ -494,16 +515,19 @@ class Client: return res.json()['likesCount'] @refresh_on_error - def delete_comment(self, id: UUID) -> None: + def delete_comment(self, id: UUID | str) -> None: """Удалить комментарий Args: - id (UUID): UUID комментария + id (UUID | str): UUID комментария Raises: NotFound: Комментарий не найден Forbidden: Нет прав на удаление """ + if isinstance(id, str): + id = UUID(id) + res = delete_comment(self.token, id) if res.status_code == 204: return @@ -542,19 +566,22 @@ class Client: return [Hashtag.model_validate(hashtag) for hashtag in res.json()['data']['hashtags']] @refresh_on_error - def get_posts_by_hashtag(self, hashtag: str, limit: int = 20, cursor: UUID | None = None) -> tuple[Hashtag | None, list[Post], Pagination]: + def get_posts_by_hashtag(self, hashtag: str, limit: int = 20, cursor: UUID | str | None = None) -> tuple[Hashtag | None, list[Post], Pagination]: """Получить посты по хэштэгу Args: hashtag (str): Хэштэг (без #) limit (int, optional): Лимит. Defaults to 20. - cursor (UUID | None, optional): Курсор (UUID последнего поста, после которого брать данные). Defaults to None. + cursor (UUID | str | None, optional): Курсор (UUID последнего поста, после которого брать данные). Defaults to None. Returns: Hashtag | None: Хэштэг list[Post]: Посты Pagination: Пагинация """ + if isinstance(cursor, str): + cursor = UUID(cursor) + res = get_posts_by_hashtag(self.token, hashtag, limit, cursor) res.raise_for_status() data = res.json()['data'] @@ -583,15 +610,18 @@ class Client: ) @refresh_on_error - def mark_as_read(self, id: UUID) -> bool: + def mark_as_read(self, id: UUID | str) -> bool: """Прочитать уведомление Args: - id (UUID): UUID уведомления + id (UUID | str): UUID уведомления Returns: bool: Успешно (False - уже прочитано) """ + if isinstance(id, str): + id = UUID(id) + res = mark_as_read(self.token, id) res.raise_for_status() @@ -618,13 +648,13 @@ class Client: @refresh_on_error - def create_post(self, content: str, wall_recipient_id: UUID | None = None, attach_ids: list[UUID] = []) -> NewPost: + def create_post(self, content: str, wall_recipient_id: UUID | str | None = None, attach_ids: list[UUID | str] = []) -> NewPost: """Создать пост Args: content (str): Содержимое - wall_recipient_id (UUID | None, optional): UUID пользователя (чтобы создать пост ему на стене). Defaults to None. - attach_ids (list[UUID], optional): UUID вложений. Defaults to []. + wall_recipient_id (UUID | str | None, optional): UUID пользователя (чтобы создать пост ему на стене). Defaults to None. + attach_ids (list[UUID | str], optional): UUID вложений. Defaults to []. Raises: NotFound: Пользователь не найден @@ -633,6 +663,10 @@ class Client: Returns: NewPost: Новый пост """ + if isinstance(wall_recipient_id, str): + wall_recipient_id = UUID(wall_recipient_id) + attach_ids = list(map(lambda id: UUID(id) if isinstance(id, str) else id, attach_ids)) + res = create_post(self.token, content, wall_recipient_id, attach_ids) if res.json().get('error', {}).get('code') == 'NOT_FOUND': raise NotFound('Wall recipient') @@ -661,11 +695,11 @@ class Client: return [Post.model_validate(post) for post in data['posts']], PostsPagintaion.model_validate(data['pagination']) @refresh_on_error - def get_post(self, id: UUID) -> Post: + def get_post(self, id: UUID | str) -> Post: """Получить пост Args: - id (UUID): UUID поста + id (UUID | str): UUID поста Raises: NotFound: Пост не найден @@ -673,6 +707,9 @@ class Client: Returns: Post: Пост """ + if isinstance(id, str): + id = UUID(id) + res = get_post(self.token, id) if res.json().get('error', {}).get('code') == 'NOT_FOUND': raise NotFound('Post') @@ -681,11 +718,11 @@ class Client: return Post.model_validate(res.json()['data']) @refresh_on_error - def edit_post(self, id: UUID, content: str) -> str: + def edit_post(self, id: UUID | str, content: str) -> str: """Редактировать пост Args: - id (UUID): UUID поста + id (UUID | str): UUID поста content (str): Содержимое Raises: @@ -696,6 +733,9 @@ class Client: Returns: str: Новое содержимое """ + if isinstance(id, str): + id = UUID(id) + res = edit_post(self.token, id, content) if res.json().get('error', {}).get('code') == 'NOT_FOUND': @@ -709,16 +749,19 @@ class Client: return res.json()['content'] @refresh_on_error - def delete_post(self, id: UUID) -> None: + def delete_post(self, id: UUID | str) -> None: """Удалить пост Args: - id (UUID): UUID поста + id (UUID | str): UUID поста Raises: NotFound: Пост не найден Forbidden: Нет доступа """ + if isinstance(id, str): + id = UUID(id) + res = delete_post(self.token, id) if res.status_code == 204: return @@ -730,16 +773,19 @@ class Client: res.raise_for_status() @refresh_on_error - def pin_post(self, id: UUID): + def pin_post(self, id: UUID | str): """Закрепить пост Args: - id (UUID): UUID поста + id (UUID | str): UUID поста Raises: NotFound: Пост не найден Forbidden: Нет доступа """ + if isinstance(id, str): + id = UUID(id) + res = pin_post(self.token, id) if res.json().get('error', {}).get('code') == 'NOT_FOUND': @@ -749,11 +795,11 @@ class Client: res.raise_for_status() @refresh_on_error - def repost(self, id: UUID, content: str | None = None) -> NewPost: + def repost(self, id: UUID | str, content: str | None = None) -> NewPost: """Репостнуть пост Args: - id (UUID): UUID поста + id (UUID | str): UUID поста content (str | None, optional): Содержимое (доп. комментарий). Defaults to None. Raises: @@ -765,6 +811,9 @@ class Client: Returns: NewPost: Новый пост """ + if isinstance(id, str): + id = UUID(id) + res = repost(self.token, id, content) if res.json().get('error', {}).get('code') == 'NOT_FOUND': @@ -780,15 +829,18 @@ class Client: return NewPost.model_validate(res.json()) @refresh_on_error - def view_post(self, id: UUID) -> None: + def view_post(self, id: UUID | str) -> None: """Просмотреть пост Args: - id (UUID): UUID поста + id (UUID | str): UUID поста Raises: NotFound: Пост не найден """ + if isinstance(id, str): + id = UUID(id) + res = view_post(self.token, id) if res.json().get('error', {}).get('code') == 'NOT_FOUND': raise NotFound('Post') @@ -806,19 +858,27 @@ class Client: @refresh_on_error - def report(self, id: str, type: str = 'post', reason: str = 'other', description: str = ''): + def report(self, id: str | UUID, type: str = 'post', reason: str = 'other', description: str = ''): + if isinstance(id, str): + id = UUID(id) return report(self.token, id, type, reason, description) @refresh_on_error - def report_user(self, id: str, reason: str = 'other', description: str = ''): + def report_user(self, id: str | UUID, reason: str = 'other', description: str = ''): + if isinstance(id, str): + id = UUID(id) return report(self.token, id, 'user', reason, description) @refresh_on_error - def report_post(self, id: str, reason: str = 'other', description: str = ''): + def report_post(self, id: str | UUID, reason: str = 'other', description: str = ''): + if isinstance(id, str): + id = UUID(id) return report(self.token, id, 'post', reason, description) @refresh_on_error - def report_comment(self, id: str, reason: str = 'other', description: str = ''): + def report_comment(self, id: str | UUID, reason: str = 'other', description: str = ''): + if isinstance(id, str): + id = UUID(id) return report(self.token, id, 'comment', reason, description) diff --git a/itd/routes/reports.py b/itd/routes/reports.py index 933f48d..a186c1e 100644 --- a/itd/routes/reports.py +++ b/itd/routes/reports.py @@ -1,4 +1,5 @@ from itd.request import fetch +from uuid import UUID -def report(token: str, id: str, type: str = 'post', reason: str = 'other', description: str = ''): +def report(token: str, id: UUID, type: str = 'post', reason: str = 'other', description: str = ''): return fetch(token, 'post', 'reports', {'targetId': id, 'targetType': type, 'reason': reason, 'description': description}) \ No newline at end of file From 38805156bff64bd25110ca73f58f92d26fdcb5e0 Mon Sep 17 00:00:00 2001 From: EpsilonRationes <148639079+EpsilonRationes@users.noreply.github.com> Date: Fri, 6 Feb 2026 21:40:10 +0300 Subject: [PATCH 19/25] Delete tests/__init__.py --- tests/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/__init__.py diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 From a2d019e1d6c82021e97943b440a18f0dbb84aa3f Mon Sep 17 00:00:00 2001 From: firedotguy Date: Fri, 6 Feb 2026 21:43:03 +0300 Subject: [PATCH 20/25] fix: JSONDecodeError when view post --- itd/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/itd/client.py b/itd/client.py index 45188bb..d4b56c5 100644 --- a/itd/client.py +++ b/itd/client.py @@ -790,6 +790,8 @@ class Client: NotFound: Пост не найден """ res = view_post(self.token, id) + if res.status_code == 204: + return if res.json().get('error', {}).get('code') == 'NOT_FOUND': raise NotFound('Post') res.raise_for_status() From b2bd982c1ba0be4483c6b9cf0f0bd06c9ac1d88f Mon Sep 17 00:00:00 2001 From: firedotguy Date: Fri, 6 Feb 2026 21:43:40 +0300 Subject: [PATCH 21/25] fix: remove tests --- tests/settings.py | 1 - tests/test_like.py | 26 -------------------------- 2 files changed, 27 deletions(-) delete mode 100644 tests/settings.py delete mode 100644 tests/test_like.py diff --git a/tests/settings.py b/tests/settings.py deleted file mode 100644 index fad677d..0000000 --- a/tests/settings.py +++ /dev/null @@ -1 +0,0 @@ -cookies = 'Сюда вставить куки' \ No newline at end of file diff --git a/tests/test_like.py b/tests/test_like.py deleted file mode 100644 index 602524e..0000000 --- a/tests/test_like.py +++ /dev/null @@ -1,26 +0,0 @@ -from itd import ITDClient -from itd.models.post import LikePostResponse -from itd.exceptions import NotFound -import unittest -from . import settings - -class TestLike(unittest.TestCase): - def test_like(self): - c = ITDClient(None, settings.cookies) - - post = c.create_post("post_for_test_like") - - self.assertEqual(c.like_post(post.id), LikePostResponse(liked=True, likesCount=1)) # Лайк на пост без лайка - self.assertEqual(c.like_post(post.id), LikePostResponse(liked=True, likesCount=1)) # Лайк на пост с уже поставленным лайком - - self.assertEqual(c.delete_like_post(post.id), LikePostResponse(liked=False, likesCount=0)) # Убрать лайк с поста с уже поставленным лайком - self.assertEqual(c.delete_like_post(post.id), LikePostResponse(liked=False, likesCount=0)) # Убрать лайк с поста без лайков - - c.delete_post(str(post.id)) - - self.assertRaises(NotFound, c.like_post, post.id) # лайк на удалённый пост - self.assertRaises(NotFound, c.delete_like_post, post.id) # Убрать лайк с удалённого поста - - -if __name__ == "__main__": - unittest.main() From 2d27507338772f758c5ea9c1dcf9f2fb7f29cde3 Mon Sep 17 00:00:00 2001 From: firedotguy Date: Fri, 6 Feb 2026 22:15:16 +0300 Subject: [PATCH 22/25] fix: fix type warnings --- itd/client.py | 15 ++++++++------- itd/routes/posts.py | 6 +++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/itd/client.py b/itd/client.py index 4e32033..6bb9d6c 100644 --- a/itd/client.py +++ b/itd/client.py @@ -395,7 +395,8 @@ class Client: post_id = UUID(post_id) attachment_ids = list(map(lambda id: UUID(id) if isinstance(id, str) else id, attachment_ids)) - res = add_comment(self.token, post_id, content, attachment_ids) + res = add_comment(self.token, post_id, content, cast(list[UUID], attachment_ids)) + if res.status_code == 422 and 'found' in res.json(): raise ValidationError(*list(res.json()['found'].items())[0]) if res.json().get('error', {}).get('code') == 'NOT_FOUND': @@ -428,7 +429,7 @@ class Client: author_id = UUID(author_id) attachment_ids = list(map(lambda id: UUID(id) if isinstance(id, str) else id, attachment_ids)) - res = add_reply_comment(self.token, comment_id, content, author_id, attachment_ids) + res = add_reply_comment(self.token, comment_id, content, author_id, cast(list[UUID], attachment_ids)) if res.status_code == 500 and 'Failed query' in res.text: raise NotFound('User') if res.status_code == 422 and 'found' in res.json(): @@ -648,13 +649,13 @@ class Client: @refresh_on_error - def create_post(self, content: str, wall_recipient_id: UUID | str | None = None, attach_ids: list[UUID | str] = []) -> NewPost: + def create_post(self, content: str, wall_recipient_id: UUID | str | None = None, attachment_ids: list[UUID | str] = []) -> NewPost: """Создать пост Args: content (str): Содержимое wall_recipient_id (UUID | str | None, optional): UUID пользователя (чтобы создать пост ему на стене). Defaults to None. - attach_ids (list[UUID | str], optional): UUID вложений. Defaults to []. + attachment_ids (list[UUID | str], optional): UUID вложений. Defaults to []. Raises: NotFound: Пользователь не найден @@ -665,9 +666,9 @@ class Client: """ if isinstance(wall_recipient_id, str): wall_recipient_id = UUID(wall_recipient_id) - attach_ids = list(map(lambda id: UUID(id) if isinstance(id, str) else id, attach_ids)) + attachment_ids = list(map(lambda id: UUID(id) if isinstance(id, str) else id, attachment_ids)) - res = create_post(self.token, content, wall_recipient_id, attach_ids) + res = create_post(self.token, content, wall_recipient_id, cast(list[UUID], attachment_ids)) if res.json().get('error', {}).get('code') == 'NOT_FOUND': raise NotFound('Wall recipient') if res.status_code == 422 and 'found' in res.json(): @@ -849,7 +850,7 @@ class Client: res.raise_for_status() @refresh_on_error - def get_liked_posts(self, username_or_id: str | UUID, limit: int = 20, cursor: int = 0): + def get_liked_posts(self, username_or_id: str | UUID, limit: int = 20, cursor: int = 0) -> tuple[list[Post], LikedPostsPagintaion]: res = get_liked_posts(self.token, username_or_id, limit, cursor) if res.json().get('error', {}).get('code') == 'NOT_FOUND': raise NotFound('User') diff --git a/itd/routes/posts.py b/itd/routes/posts.py index 34acae0..7476b88 100644 --- a/itd/routes/posts.py +++ b/itd/routes/posts.py @@ -3,12 +3,12 @@ from uuid import UUID from itd.request import fetch from itd.enums import PostsTab -def create_post(token: str, content: str, wall_recipient_id: UUID | None = None, attach_ids: list[UUID] = []): +def create_post(token: str, content: str, wall_recipient_id: UUID | None = None, attachment_ids: list[UUID] = []): data: dict = {'content': content} if wall_recipient_id: data['wallRecipientId'] = str(wall_recipient_id) - if attach_ids: - data['attachmentIds'] = list(map(str, attach_ids)) + if attachment_ids: + data['attachmentIds'] = list(map(str, attachment_ids)) return fetch(token, 'post', 'posts', data) From f33ed4f76aba81a8b3757e9bdcff58db974d89b5 Mon Sep 17 00:00:00 2001 From: firedotguy Date: Fri, 6 Feb 2026 22:19:56 +0300 Subject: [PATCH 23/25] fix: revert str UUID --- itd/client.py | 149 +++++++++++++----------------------------- itd/routes/reports.py | 3 +- 2 files changed, 45 insertions(+), 107 deletions(-) diff --git a/itd/client.py b/itd/client.py index 6bb9d6c..0a5d94e 100644 --- a/itd/client.py +++ b/itd/client.py @@ -158,14 +158,14 @@ class Client: return self.get_user('me') @refresh_on_error - def update_profile(self, username: str | None = None, display_name: str | None = None, bio: str | None = None, banner_id: UUID | str | None = None) -> UserProfileUpdate: + def update_profile(self, username: str | None = None, display_name: str | None = None, bio: str | None = None, banner_id: UUID | None = None) -> UserProfileUpdate: """Обновить профиль Args: username (str | None, optional): username. Defaults to None. display_name (str | None, optional): Отображаемое имя. Defaults to None. bio (str | None, optional): Биография (о себе). Defaults to None. - banner_id (UUID | str | None, optional): UUID баннера. Defaults to None. + banner_id (UUID | None, optional): UUID баннера. Defaults to None. Raises: ValidationError: Ошибка валидации @@ -173,9 +173,6 @@ class Client: Returns: UserProfileUpdate: Обновленный профиль """ - if isinstance(banner_id, str): - banner_id = UUID(banner_id) - res = update_profile(self.token, bio, display_name, username, banner_id) if res.status_code == 422 and 'found' in res.json(): raise ValidationError(*list(res.json()['found'].items())[0]) @@ -376,13 +373,14 @@ class Client: @refresh_on_error - def add_comment(self, post_id: UUID | str, content: str, attachment_ids: list[UUID | str] = []) -> Comment: + def add_comment(self, post_id: UUID, content: str, attachment_ids: list[UUID] = []) -> Comment: """Добавить комментарий Args: - post_id (str | UUID): UUID поста + post_id (str): UUID поста content (str): Содержание - attachment_ids (list[UUID | str]): Список UUID прикреплённых файлов + attachment_ids (list[UUID]): Список UUID прикреплённых файлов + reply_comment_id (UUID | None, optional): ID коммента для ответа. Defaults to None. Raises: ValidationError: Ошибка валидации @@ -391,12 +389,7 @@ class Client: Returns: Comment: Комментарий """ - if isinstance(post_id, str): - post_id = UUID(post_id) - attachment_ids = list(map(lambda id: UUID(id) if isinstance(id, str) else id, attachment_ids)) - - res = add_comment(self.token, post_id, content, cast(list[UUID], attachment_ids)) - + res = add_comment(self.token, post_id, content, attachment_ids) if res.status_code == 422 and 'found' in res.json(): raise ValidationError(*list(res.json()['found'].items())[0]) if res.json().get('error', {}).get('code') == 'NOT_FOUND': @@ -407,14 +400,14 @@ class Client: @refresh_on_error - def add_reply_comment(self, comment_id: UUID | str, content: str, author_id: UUID | str, attachment_ids: list[UUID | str] = []) -> Comment: + def add_reply_comment(self, comment_id: UUID, content: str, author_id: UUID, attachment_ids: list[UUID] = []) -> Comment: """Добавить ответный комментарий Args: - comment_id (str | UUID): UUID комментария + comment_id (str): UUID комментария content (str): Содержание - author_id (UUID | str): ID пользователя, отправившего комментарий. - attachment_ids (list[UUID | str]): Список UUID прикреплённых файлов + author_id (UUID | None, optional): ID пользователя, отправившего комментарий. Defaults to None. + attachment_ids (list[UUID]): Список UUID прикреплённых файлов Raises: ValidationError: Ошибка валидации @@ -423,13 +416,7 @@ class Client: Returns: Comment: Комментарий """ - if isinstance(comment_id, str): - comment_id = UUID(comment_id) - if isinstance(author_id, str): - author_id = UUID(author_id) - attachment_ids = list(map(lambda id: UUID(id) if isinstance(id, str) else id, attachment_ids)) - - res = add_reply_comment(self.token, comment_id, content, author_id, cast(list[UUID], attachment_ids)) + res = add_reply_comment(self.token, comment_id, content, author_id, attachment_ids) if res.status_code == 500 and 'Failed query' in res.text: raise NotFound('User') if res.status_code == 422 and 'found' in res.json(): @@ -442,11 +429,11 @@ class Client: @refresh_on_error - def get_comments(self, post_id: UUID | str, limit: int = 20, cursor: int = 0, sort: str = 'popular') -> tuple[list[Comment], Pagination]: + def get_comments(self, post_id: UUID, limit: int = 20, cursor: int = 0, sort: str = 'popular') -> tuple[list[Comment], Pagination]: """Получить список комментариев Args: - post_id (UUID | str): UUID поста + post_id (UUID): UUID поста limit (int, optional): Лимит. Defaults to 20. cursor (int, optional): Курсор (сколько пропустить). Defaults to 0. sort (str, optional): Сортировка. Defaults to 'popular'. @@ -458,9 +445,6 @@ class Client: list[Comment]: Список комментариев Pagination: Пагинация """ - if isinstance(post_id, str): - post_id = UUID(post_id) - res = get_comments(self.token, post_id, limit, cursor, sort) if res.json().get('error', {}).get('code') == 'NOT_FOUND': raise NotFound('Post') @@ -470,11 +454,11 @@ class Client: return [Comment.model_validate(comment) for comment in data['comments']], Pagination(page=(cursor // limit) or 1, limit=limit, total=data['total'], hasMore=data['hasMore'], nextCursor=None) @refresh_on_error - def like_comment(self, id: UUID | str) -> int: + def like_comment(self, id: UUID) -> int: """Лайкнуть комментарий Args: - id (UUID | str): UUID комментария + id (UUID): UUID комментария Raises: NotFound: Комментарий не найден @@ -482,9 +466,6 @@ class Client: Returns: int: Количество лайков """ - if isinstance(id, str): - id = UUID(id) - res = like_comment(self.token, id) if res.json().get('error', {}).get('code') == 'NOT_FOUND': raise NotFound('Comment') @@ -493,11 +474,11 @@ class Client: return res.json()['likesCount'] @refresh_on_error - def unlike_comment(self, id: UUID | str) -> int: + def unlike_comment(self, id: UUID) -> int: """Убрать лайк с комментария Args: - id (UUID | str): UUID комментария + id (UUID): UUID комментария Raises: NotFound: Комментарий не найден @@ -505,9 +486,6 @@ class Client: Returns: int: Количество лайков """ - if isinstance(id, str): - id = UUID(id) - res = unlike_comment(self.token, id) if res.json().get('error', {}).get('code') == 'NOT_FOUND': raise NotFound('Comment') @@ -516,19 +494,16 @@ class Client: return res.json()['likesCount'] @refresh_on_error - def delete_comment(self, id: UUID | str) -> None: + def delete_comment(self, id: UUID) -> None: """Удалить комментарий Args: - id (UUID | str): UUID комментария + id (UUID): UUID комментария Raises: NotFound: Комментарий не найден Forbidden: Нет прав на удаление """ - if isinstance(id, str): - id = UUID(id) - res = delete_comment(self.token, id) if res.status_code == 204: return @@ -567,22 +542,19 @@ class Client: return [Hashtag.model_validate(hashtag) for hashtag in res.json()['data']['hashtags']] @refresh_on_error - def get_posts_by_hashtag(self, hashtag: str, limit: int = 20, cursor: UUID | str | None = None) -> tuple[Hashtag | None, list[Post], Pagination]: + def get_posts_by_hashtag(self, hashtag: str, limit: int = 20, cursor: UUID | None = None) -> tuple[Hashtag | None, list[Post], Pagination]: """Получить посты по хэштэгу Args: hashtag (str): Хэштэг (без #) limit (int, optional): Лимит. Defaults to 20. - cursor (UUID | str | None, optional): Курсор (UUID последнего поста, после которого брать данные). Defaults to None. + cursor (UUID | None, optional): Курсор (UUID последнего поста, после которого брать данные). Defaults to None. Returns: Hashtag | None: Хэштэг list[Post]: Посты Pagination: Пагинация """ - if isinstance(cursor, str): - cursor = UUID(cursor) - res = get_posts_by_hashtag(self.token, hashtag, limit, cursor) res.raise_for_status() data = res.json()['data'] @@ -611,18 +583,15 @@ class Client: ) @refresh_on_error - def mark_as_read(self, id: UUID | str) -> bool: + def mark_as_read(self, id: UUID) -> bool: """Прочитать уведомление Args: - id (UUID | str): UUID уведомления + id (UUID): UUID уведомления Returns: bool: Успешно (False - уже прочитано) """ - if isinstance(id, str): - id = UUID(id) - res = mark_as_read(self.token, id) res.raise_for_status() @@ -649,13 +618,13 @@ class Client: @refresh_on_error - def create_post(self, content: str, wall_recipient_id: UUID | str | None = None, attachment_ids: list[UUID | str] = []) -> NewPost: + def create_post(self, content: str, wall_recipient_id: UUID | None = None, attach_ids: list[UUID] = []) -> NewPost: """Создать пост Args: content (str): Содержимое - wall_recipient_id (UUID | str | None, optional): UUID пользователя (чтобы создать пост ему на стене). Defaults to None. - attachment_ids (list[UUID | str], optional): UUID вложений. Defaults to []. + wall_recipient_id (UUID | None, optional): UUID пользователя (чтобы создать пост ему на стене). Defaults to None. + attach_ids (list[UUID], optional): UUID вложений. Defaults to []. Raises: NotFound: Пользователь не найден @@ -664,11 +633,7 @@ class Client: Returns: NewPost: Новый пост """ - if isinstance(wall_recipient_id, str): - wall_recipient_id = UUID(wall_recipient_id) - attachment_ids = list(map(lambda id: UUID(id) if isinstance(id, str) else id, attachment_ids)) - - res = create_post(self.token, content, wall_recipient_id, cast(list[UUID], attachment_ids)) + res = create_post(self.token, content, wall_recipient_id, attach_ids) if res.json().get('error', {}).get('code') == 'NOT_FOUND': raise NotFound('Wall recipient') if res.status_code == 422 and 'found' in res.json(): @@ -696,11 +661,11 @@ class Client: return [Post.model_validate(post) for post in data['posts']], PostsPagintaion.model_validate(data['pagination']) @refresh_on_error - def get_post(self, id: UUID | str) -> Post: + def get_post(self, id: UUID) -> Post: """Получить пост Args: - id (UUID | str): UUID поста + id (UUID): UUID поста Raises: NotFound: Пост не найден @@ -708,9 +673,6 @@ class Client: Returns: Post: Пост """ - if isinstance(id, str): - id = UUID(id) - res = get_post(self.token, id) if res.json().get('error', {}).get('code') == 'NOT_FOUND': raise NotFound('Post') @@ -719,11 +681,11 @@ class Client: return Post.model_validate(res.json()['data']) @refresh_on_error - def edit_post(self, id: UUID | str, content: str) -> str: + def edit_post(self, id: UUID, content: str) -> str: """Редактировать пост Args: - id (UUID | str): UUID поста + id (UUID): UUID поста content (str): Содержимое Raises: @@ -734,9 +696,6 @@ class Client: Returns: str: Новое содержимое """ - if isinstance(id, str): - id = UUID(id) - res = edit_post(self.token, id, content) if res.json().get('error', {}).get('code') == 'NOT_FOUND': @@ -750,19 +709,16 @@ class Client: return res.json()['content'] @refresh_on_error - def delete_post(self, id: UUID | str) -> None: + def delete_post(self, id: UUID) -> None: """Удалить пост Args: - id (UUID | str): UUID поста + id (UUID): UUID поста Raises: NotFound: Пост не найден Forbidden: Нет доступа """ - if isinstance(id, str): - id = UUID(id) - res = delete_post(self.token, id) if res.status_code == 204: return @@ -774,19 +730,16 @@ class Client: res.raise_for_status() @refresh_on_error - def pin_post(self, id: UUID | str): + def pin_post(self, id: UUID): """Закрепить пост Args: - id (UUID | str): UUID поста + id (UUID): UUID поста Raises: NotFound: Пост не найден Forbidden: Нет доступа """ - if isinstance(id, str): - id = UUID(id) - res = pin_post(self.token, id) if res.json().get('error', {}).get('code') == 'NOT_FOUND': @@ -796,11 +749,11 @@ class Client: res.raise_for_status() @refresh_on_error - def repost(self, id: UUID | str, content: str | None = None) -> NewPost: + def repost(self, id: UUID, content: str | None = None) -> NewPost: """Репостнуть пост Args: - id (UUID | str): UUID поста + id (UUID): UUID поста content (str | None, optional): Содержимое (доп. комментарий). Defaults to None. Raises: @@ -812,9 +765,6 @@ class Client: Returns: NewPost: Новый пост """ - if isinstance(id, str): - id = UUID(id) - res = repost(self.token, id, content) if res.json().get('error', {}).get('code') == 'NOT_FOUND': @@ -830,18 +780,15 @@ class Client: return NewPost.model_validate(res.json()) @refresh_on_error - def view_post(self, id: UUID | str) -> None: + def view_post(self, id: UUID) -> None: """Просмотреть пост Args: - id (UUID | str): UUID поста + id (UUID): UUID поста Raises: NotFound: Пост не найден """ - if isinstance(id, str): - id = UUID(id) - res = view_post(self.token, id) if res.status_code == 204: return @@ -861,27 +808,19 @@ class Client: @refresh_on_error - def report(self, id: str | UUID, type: str = 'post', reason: str = 'other', description: str = ''): - if isinstance(id, str): - id = UUID(id) + def report(self, id: str, type: str = 'post', reason: str = 'other', description: str = ''): return report(self.token, id, type, reason, description) @refresh_on_error - def report_user(self, id: str | UUID, reason: str = 'other', description: str = ''): - if isinstance(id, str): - id = UUID(id) + def report_user(self, id: str, reason: str = 'other', description: str = ''): return report(self.token, id, 'user', reason, description) @refresh_on_error - def report_post(self, id: str | UUID, reason: str = 'other', description: str = ''): - if isinstance(id, str): - id = UUID(id) + def report_post(self, id: str, reason: str = 'other', description: str = ''): return report(self.token, id, 'post', reason, description) @refresh_on_error - def report_comment(self, id: str | UUID, reason: str = 'other', description: str = ''): - if isinstance(id, str): - id = UUID(id) + def report_comment(self, id: str, reason: str = 'other', description: str = ''): return report(self.token, id, 'comment', reason, description) diff --git a/itd/routes/reports.py b/itd/routes/reports.py index a186c1e..933f48d 100644 --- a/itd/routes/reports.py +++ b/itd/routes/reports.py @@ -1,5 +1,4 @@ from itd.request import fetch -from uuid import UUID -def report(token: str, id: UUID, type: str = 'post', reason: str = 'other', description: str = ''): +def report(token: str, id: str, type: str = 'post', reason: str = 'other', description: str = ''): return fetch(token, 'post', 'reports', {'targetId': id, 'targetType': type, 'reason': reason, 'description': description}) \ No newline at end of file From 506e6a5d091e8a396ea450164d2992a358ee6070 Mon Sep 17 00:00:00 2001 From: firedotguy Date: Sat, 7 Feb 2026 17:23:42 +0300 Subject: [PATCH 24/25] feat: add models final part 5 --- itd/client.py | 177 +++++++++++++++++++++++++++++++-------- itd/exceptions.py | 22 +++-- itd/models/pagination.py | 2 +- itd/models/pin.py | 6 ++ itd/models/post.py | 5 -- itd/models/report.py | 9 +- itd/models/user.py | 3 + itd/request.py | 4 +- itd/routes/posts.py | 7 +- itd/routes/reports.py | 11 ++- 10 files changed, 189 insertions(+), 57 deletions(-) create mode 100644 itd/models/pin.py diff --git a/itd/client.py b/itd/client.py index 0a5d94e..0192714 100644 --- a/itd/client.py +++ b/itd/client.py @@ -2,6 +2,7 @@ from warnings import deprecated from uuid import UUID from _io import BufferedReader from typing import cast +from datetime import datetime from requests.exceptions import ConnectionError, HTTPError @@ -10,7 +11,7 @@ from itd.routes.etc import get_top_clans, get_who_to_follow, get_platform_status from itd.routes.comments import get_comments, add_comment, delete_comment, like_comment, unlike_comment, add_reply_comment from itd.routes.hashtags import get_hashtags, get_posts_by_hashtag from itd.routes.notifications import get_notifications, mark_as_read, mark_all_as_read, get_unread_notifications_count -from itd.routes.posts import create_post, get_posts, get_post, edit_post, delete_post, pin_post, repost, view_post, get_liked_posts, restore_post, like_post, delete_like_post +from itd.routes.posts import create_post, get_posts, get_post, edit_post, delete_post, pin_post, repost, view_post, get_liked_posts, restore_post, like_post, unlike_post from itd.routes.reports import report from itd.routes.search import search from itd.routes.files import upload_file @@ -19,19 +20,21 @@ from itd.routes.verification import verify, get_verification_status from itd.models.comment import Comment from itd.models.notification import Notification -from itd.models.post import Post, NewPost, LikePostResponse +from itd.models.post import Post, NewPost from itd.models.clan import Clan from itd.models.hashtag import Hashtag from itd.models.user import User, UserProfileUpdate, UserPrivacy, UserFollower, UserWhoToFollow from itd.models.pagination import Pagination, PostsPagintaion, LikedPostsPagintaion from itd.models.verification import Verification, VerificationStatus +from itd.models.report import NewReport +from itd.models.file import File -from itd.enums import PostsTab +from itd.enums import PostsTab, ReportTargetType, ReportTargetReason from itd.request import set_cookies from itd.exceptions import ( NoCookie, NoAuthData, SamePassword, InvalidOldPassword, NotFound, ValidationError, UserBanned, PendingRequestExists, Forbidden, UsernameTaken, CantFollowYourself, Unauthorized, - CantRepostYourPost, AlreadyReposted + CantRepostYourPost, AlreadyReposted, AlreadyReported, TooLarge ) @@ -797,7 +800,21 @@ class Client: res.raise_for_status() @refresh_on_error - def get_liked_posts(self, username_or_id: str | UUID, limit: int = 20, cursor: int = 0) -> tuple[list[Post], LikedPostsPagintaion]: + def get_liked_posts(self, username_or_id: str | UUID, limit: int = 20, cursor: datetime | None = None) -> tuple[list[Post], LikedPostsPagintaion]: + """Получить список лайкнутых постов пользователя + + Args: + username_or_id (str | UUID): UUID или username пользователя + limit (int, optional): Лимит. Defaults to 20. + cursor (datetime | None, optional): Сдвиг (next_cursor). Defaults to None. + + Raises: + NotFound: Пользователь не найден + + Returns: + list[Post]: Список постов + LikedPostsPagintaion: Пагинация + """ res = get_liked_posts(self.token, username_or_id, limit, cursor) if res.json().get('error', {}).get('code') == 'NOT_FOUND': raise NotFound('User') @@ -808,41 +825,114 @@ class Client: @refresh_on_error - def report(self, id: str, type: str = 'post', reason: str = 'other', description: str = ''): - return report(self.token, id, type, reason, description) + def report(self, id: UUID, type: ReportTargetType = ReportTargetType.POST, reason: ReportTargetReason = ReportTargetReason.OTHER, description: str | None = None) -> NewReport: + """Отправить жалобу - @refresh_on_error - def report_user(self, id: str, reason: str = 'other', description: str = ''): - return report(self.token, id, 'user', reason, description) + Args: + id (UUID): UUID цели + type (ReportTargetType, optional): Тип цели (пост/пользователь/комментарий). Defaults to ReportTargetType.POST. + reason (ReportTargetReason, optional): Причина. Defaults to ReportTargetReason.OTHER. + description (str | None, optional): Описание. Defaults to None. - @refresh_on_error - def report_post(self, id: str, reason: str = 'other', description: str = ''): - return report(self.token, id, 'post', reason, description) + Raises: + NotFound: Цель не найдена + AlreadyReported: Жалоба уже отправлена + ValidationError: Ошибка валидации - @refresh_on_error - def report_comment(self, id: str, reason: str = 'other', description: str = ''): - return report(self.token, id, 'comment', reason, description) + Returns: + NewReport: Новая жалоба + """ + res = report(self.token, id, type, reason, description) + + if res.json().get('error', {}).get('code') == 'VALIDATION_ERROR' and 'не найден' in res.json()['error'].get('message', ''): + raise NotFound(type.value.title()) + if res.json().get('error', {}).get('code') == 'VALIDATION_ERROR' and 'Вы уже отправляли жалобу' in res.json()['error'].get('message', ''): + raise AlreadyReported(type.value.title()) + if res.status_code == 422 and 'found' in res.json(): + raise ValidationError(*list(res.json()['found'].items())[-1]) + res.raise_for_status() + + return NewReport.model_validate(res.json()['data']) @refresh_on_error - def search(self, query: str, user_limit: int = 5, hashtag_limit: int = 5): - return search(self.token, query, user_limit, hashtag_limit) + def search(self, query: str, user_limit: int = 5, hashtag_limit: int = 5) -> tuple[list[UserWhoToFollow], list[Hashtag]]: + """Поиск по пользователям и хэштэгам + + Args: + query (str): Запрос + user_limit (int, optional): Лимит пользователей. Defaults to 5. + hashtag_limit (int, optional): Лимит хэштэгов. Defaults to 5. + + Raises: + TooLarge: Слишком длинный запрос + + Returns: + list[UserWhoToFollow]: Список пользователей + list[Hashtag]: Список хэштэгов + """ + res = search(self.token, query, user_limit, hashtag_limit) + + if res.status_code == 414: + raise TooLarge() + res.raise_for_status() + data = res.json()['data'] + + return [UserWhoToFollow.model_validate(user) for user in data['users']], [Hashtag.model_validate(hashtag) for hashtag in data['hashtags']] @refresh_on_error - def search_user(self, query: str, limit: int = 5): - return search(self.token, query, limit, 0) + def search_user(self, query: str, limit: int = 5) -> list[UserWhoToFollow]: + """Поиск пользователей + + Args: + query (str): Запрос + limit (int, optional): Лимит. Defaults to 5. + + Returns: + list[UserWhoToFollow]: Список пользователей + """ + return self.search(query, limit, 0)[0] @refresh_on_error - def search_hashtag(self, query: str, limit: int = 5): - return search(self.token, query, 0, limit) + def search_hashtag(self, query: str, limit: int = 5) -> list[Hashtag]: + """Поиск хэштэгов + + Args: + query (str): Запрос + limit (int, optional): Лимит. Defaults to 5. + + Returns: + list[Hashtag]: Список хэштэгов + """ + return self.search(query, 0, limit)[1] @refresh_on_error - def upload_file(self, name: str, data: BufferedReader): - return upload_file(self.token, name, data).json() + def upload_file(self, name: str, data: BufferedReader) -> File: + """Загрузить файл + + Args: + name (str): Имя файла + data (BufferedReader): Содержимое (open('имя', 'rb')) + + Returns: + File: Файл + """ + res = upload_file(self.token, name, data) + res.raise_for_status() + + return File.model_validate(res.json()) def update_banner(self, name: str) -> UserProfileUpdate: - id = self.upload_file(name, cast(BufferedReader, open(name, 'rb')))['id'] + """Обновить банер (шорткат из upload_file + update_profile) + + Args: + name (str): Имя файла + + Returns: + UserProfileUpdate: Обновленный профиль + """ + id = self.upload_file(name, cast(BufferedReader, open(name, 'rb'))).id return self.update_profile(banner_id=id) @refresh_on_error @@ -852,28 +942,45 @@ class Client: Args: post_id: UUID поста """ - restore_post(self.token, post_id) + res = restore_post(self.token, post_id) + res.raise_for_status() @refresh_on_error - def like_post(self, post_id: UUID) -> LikePostResponse: - """Поставить лайк на пост + def like_post(self, post_id: UUID) -> int: + """Лайкнуть пост Args: - post_id: UUID поста + post_id (UUID): UUID поста + + Raises: + NotFound: Пост не найден + + Returns: + int: Количество лайков """ res = like_post(self.token, post_id) + if res.status_code == 404: - raise NotFound("Post not found") - return LikePostResponse.model_validate(res.json()) + raise NotFound("Post") + + return res.json()['likesCount'] @refresh_on_error - def delete_like_post(self, post_id: UUID) -> LikePostResponse: + def unlike_post(self, post_id: UUID) -> int: """Убрать лайк с поста Args: - post_id: UUID поста + post_id (UUID): UUID поста + + Raises: + NotFound: Пост не найден + + Returns: + int: Количество лайков """ - res = delete_like_post(self.token, post_id) + res = unlike_post(self.token, post_id) + if res.status_code == 404: raise NotFound("Post not found") - return LikePostResponse.model_validate(res.json()) + + return res.json()['likesCount'] diff --git a/itd/exceptions.py b/itd/exceptions.py index abfa498..63b5925 100644 --- a/itd/exceptions.py +++ b/itd/exceptions.py @@ -11,15 +11,17 @@ class InvalidCookie(Exception): self.code = code def __str__(self): if self.code == 'SESSION_NOT_FOUND': - return f'Invalid cookie data: Session not found (incorrect refresh token)' + return 'Invalid cookie data: Session not found (incorrect refresh token)' elif self.code == 'REFRESH_TOKEN_MISSING': - return f'Invalid cookie data: No refresh token' + return 'Invalid cookie data: No refresh token' + elif self.code == 'SESSION_EXPIRED': + return 'Invalid cookie data: Session expired' # SESSION_REVOKED - return f'Invalid cookie data: Session revoked (logged out)' + return 'Invalid cookie data: Session revoked (logged out)' class InvalidToken(Exception): def __str__(self): - return f'Invalid access token' + return 'Invalid access token' class SamePassword(Exception): def __str__(self): @@ -30,7 +32,7 @@ class InvalidOldPassword(Exception): return 'Old password is incorrect' class NotFound(Exception): - def __init__(self, obj): + def __init__(self, obj: str): self.obj = obj def __str__(self): return f'{self.obj} not found' @@ -81,3 +83,13 @@ class CantRepostYourPost(Exception): class AlreadyReposted(Exception): def __str__(self): return 'Post already reposted' + +class AlreadyReported(Exception): + def __init__(self, obj: str) -> None: + self.obj = obj + def __str__(self): + return f'{self.obj} already reported' + +class TooLarge(Exception): + def __str__(self): + return 'Search query too large' diff --git a/itd/models/pagination.py b/itd/models/pagination.py index 6bfe1c7..a970999 100644 --- a/itd/models/pagination.py +++ b/itd/models/pagination.py @@ -13,7 +13,7 @@ class Pagination(BaseModel): class PostsPagintaion(BaseModel): limit: int = 20 - next_cursor: int = Field(1, alias='nextCursor') + next_cursor: int | None = Field(1, alias='nextCursor') has_more: bool = Field(True, alias='hasMore') diff --git a/itd/models/pin.py b/itd/models/pin.py new file mode 100644 index 0000000..dd74d97 --- /dev/null +++ b/itd/models/pin.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + +class Pin(BaseModel): + slug: str + name: str + description: str \ No newline at end of file diff --git a/itd/models/post.py b/itd/models/post.py index 3343242..429de17 100644 --- a/itd/models/post.py +++ b/itd/models/post.py @@ -44,8 +44,3 @@ class Post(_Post, PostShort): class NewPost(_Post): author: UserNewPost - - -class LikePostResponse(BaseModel): - liked: bool - likes_count: int = Field(alias="likesCount") diff --git a/itd/models/report.py b/itd/models/report.py index 2a6108d..425bec1 100644 --- a/itd/models/report.py +++ b/itd/models/report.py @@ -5,12 +5,15 @@ from pydantic import BaseModel, Field from itd.enums import ReportTargetType, ReportTargetReason -class Report(BaseModel): + +class NewReport(BaseModel): id: UUID + created_at: datetime = Field(alias='createdAt') + + +class Report(NewReport): reason: ReportTargetReason description: str | None = None target_type: ReportTargetType = Field(alias='targetType') target_id: UUID - - created_at: datetime = Field(alias='createdAt') \ No newline at end of file diff --git a/itd/models/user.py b/itd/models/user.py index a35ff30..64913eb 100644 --- a/itd/models/user.py +++ b/itd/models/user.py @@ -3,6 +3,8 @@ from datetime import datetime from pydantic import BaseModel, Field +from itd.models.pin import Pin + class UserPrivacy(BaseModel): private: bool | None = Field(None, alias='isPrivate') # none for not me @@ -24,6 +26,7 @@ class UserNewPost(BaseModel): username: str | None = None display_name: str = Field(alias='displayName') avatar: str + pin: Pin | None = None verified: bool = False diff --git a/itd/request.py b/itd/request.py index 81bfad5..b943969 100644 --- a/itd/request.py +++ b/itd/request.py @@ -85,11 +85,11 @@ def auth_fetch(cookies: str, method: str, url: str, params: dict = {}, token: st try: if res.json().get('error', {}).get('code') == 'RATE_LIMIT_EXCEEDED': raise RateLimitExceeded(res.json()['error'].get('retryAfter', 0)) - if res.json().get('error', {}).get('code') in ('SESSION_NOT_FOUND', 'REFRESH_TOKEN_MISSING', 'SESSION_REVOKED'): + if res.json().get('error', {}).get('code') in ('SESSION_NOT_FOUND', 'REFRESH_TOKEN_MISSING', 'SESSION_REVOKED', 'SESSION_EXPIRED'): raise InvalidCookie(res.json()['error']['code']) if res.json().get('error', {}).get('code') == 'UNAUTHORIZED': raise Unauthorized() except JSONDecodeError: - pass + print('fail to parse json') return res diff --git a/itd/routes/posts.py b/itd/routes/posts.py index 7476b88..0cd1290 100644 --- a/itd/routes/posts.py +++ b/itd/routes/posts.py @@ -1,3 +1,4 @@ +from datetime import datetime from uuid import UUID from itd.request import fetch @@ -36,8 +37,8 @@ def repost(token: str, id: UUID, content: str | None = None): def view_post(token: str, id: UUID): return fetch(token, 'post', f'posts/{id}/view') -def get_liked_posts(token: str, username_or_id: str | UUID, limit: int = 20, cursor: int = 0): - return fetch(token, 'get', f'posts/user/{username_or_id}/liked', {'limit': limit}) +def get_liked_posts(token: str, username_or_id: str | UUID, limit: int = 20, cursor: datetime | None = None): + return fetch(token, 'get', f'posts/user/{username_or_id}/liked', {'limit': limit, 'cursor': cursor}) def restore_post(token: str, post_id: UUID): return fetch(token, "post", f"posts/{post_id}/restore",) @@ -45,5 +46,5 @@ def restore_post(token: str, post_id: UUID): def like_post(token: str, post_id: UUID): return fetch(token, "post", f"posts/{post_id}/like") -def delete_like_post(token: str, post_id: UUID): +def unlike_post(token: str, post_id: UUID): return fetch(token, "delete", f"posts/{post_id}/like") diff --git a/itd/routes/reports.py b/itd/routes/reports.py index 933f48d..7306bac 100644 --- a/itd/routes/reports.py +++ b/itd/routes/reports.py @@ -1,4 +1,9 @@ -from itd.request import fetch +from uuid import UUID -def report(token: str, id: str, type: str = 'post', reason: str = 'other', description: str = ''): - return fetch(token, 'post', 'reports', {'targetId': id, 'targetType': type, 'reason': reason, 'description': description}) \ No newline at end of file +from itd.request import fetch +from itd.enums import ReportTargetReason, ReportTargetType + +def report(token: str, id: UUID, type: ReportTargetType = ReportTargetType.POST, reason: ReportTargetReason = ReportTargetReason.OTHER, description: str | None = None): + if description is None: + description = '' + return fetch(token, 'post', 'reports', {'targetId': str(id), 'targetType': type.value, 'reason': reason.value, 'description': description}) \ No newline at end of file From 8aef43e11d8de2b4e09ce6d98e5f681c688b0b27 Mon Sep 17 00:00:00 2001 From: firedotguy Date: Sat, 7 Feb 2026 17:47:09 +0300 Subject: [PATCH 25/25] feat: add pins --- itd/client.py | 38 ++++++++++++++++++++++++++++++++++++-- itd/exceptions.py | 6 ++++++ itd/models/pin.py | 12 +++++++++--- itd/models/user.py | 4 ++-- itd/routes/pins.py | 10 ++++++++++ 5 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 itd/routes/pins.py diff --git a/itd/client.py b/itd/client.py index 0192714..2b5deaa 100644 --- a/itd/client.py +++ b/itd/client.py @@ -17,6 +17,7 @@ from itd.routes.search import search from itd.routes.files import upload_file from itd.routes.auth import refresh_token, change_password, logout from itd.routes.verification import verify, get_verification_status +from itd.routes.pins import get_pins, remove_pin, set_pin from itd.models.comment import Comment from itd.models.notification import Notification @@ -28,13 +29,14 @@ from itd.models.pagination import Pagination, PostsPagintaion, LikedPostsPaginta from itd.models.verification import Verification, VerificationStatus from itd.models.report import NewReport from itd.models.file import File +from itd.models.pin import Pin from itd.enums import PostsTab, ReportTargetType, ReportTargetReason from itd.request import set_cookies from itd.exceptions import ( NoCookie, NoAuthData, SamePassword, InvalidOldPassword, NotFound, ValidationError, UserBanned, PendingRequestExists, Forbidden, UsernameTaken, CantFollowYourself, Unauthorized, - CantRepostYourPost, AlreadyReposted, AlreadyReported, TooLarge + CantRepostYourPost, AlreadyReposted, AlreadyReported, TooLarge, PinNotOwned ) @@ -52,7 +54,7 @@ def refresh_on_error(func): class Client: - def __init__(self, token: str | None, cookies: str | None = None): + def __init__(self, token: str | None = None, cookies: str | None = None): self.cookies = cookies if token: @@ -984,3 +986,35 @@ class Client: raise NotFound("Post not found") return res.json()['likesCount'] + + + @refresh_on_error + def get_pins(self) -> tuple[list[Pin], str]: + """Список пинов + + Returns: + list[Pin]: Список пинов + str: Активный пин + """ + res = get_pins(self.token) + res.raise_for_status() + data = res.json()['data'] + + return [Pin.model_validate(pin) for pin in data['pins']], data['activePin'] + + @refresh_on_error + def remove_pin(self): + """Снять пин""" + res = remove_pin(self.token) + res.raise_for_status() + + @refresh_on_error + def set_pin(self, slug: str): + res = set_pin(self.token, slug) + if res.status_code == 422 and 'found' in res.json(): + raise ValidationError(*list(res.json()['found'].items())[0]) + if res.json().get('error', {}).get('code') == 'PIN_NOT_OWNED': + raise PinNotOwned(slug) + res.raise_for_status() + + return res.json()['pin'] \ No newline at end of file diff --git a/itd/exceptions.py b/itd/exceptions.py index 63b5925..121929a 100644 --- a/itd/exceptions.py +++ b/itd/exceptions.py @@ -93,3 +93,9 @@ class AlreadyReported(Exception): class TooLarge(Exception): def __str__(self): return 'Search query too large' + +class PinNotOwned(Exception): + def __init__(self, pin: str) -> None: + self.pin = pin + def __str__(self): + return f'You do not own "{self.pin}" pin' \ No newline at end of file diff --git a/itd/models/pin.py b/itd/models/pin.py index dd74d97..b0a07b1 100644 --- a/itd/models/pin.py +++ b/itd/models/pin.py @@ -1,6 +1,12 @@ -from pydantic import BaseModel +from datetime import datetime -class Pin(BaseModel): +from pydantic import BaseModel, Field + +class ShortPin(BaseModel): slug: str name: str - description: str \ No newline at end of file + description: str + + +class Pin(ShortPin): + granted_at: datetime = Field(alias='grantedAt') \ No newline at end of file diff --git a/itd/models/user.py b/itd/models/user.py index 64913eb..5a90f02 100644 --- a/itd/models/user.py +++ b/itd/models/user.py @@ -3,7 +3,7 @@ from datetime import datetime from pydantic import BaseModel, Field -from itd.models.pin import Pin +from itd.models.pin import ShortPin class UserPrivacy(BaseModel): @@ -26,7 +26,7 @@ class UserNewPost(BaseModel): username: str | None = None display_name: str = Field(alias='displayName') avatar: str - pin: Pin | None = None + pin: ShortPin | None = None verified: bool = False diff --git a/itd/routes/pins.py b/itd/routes/pins.py new file mode 100644 index 0000000..e53e257 --- /dev/null +++ b/itd/routes/pins.py @@ -0,0 +1,10 @@ +from itd.request import fetch + +def get_pins(token: str): + return fetch(token, 'get', 'users/me/pins') + +def remove_pin(token: str): + return fetch(token, 'delete', 'users/me/pin') + +def set_pin(token: str, slug: str): + return fetch(token, 'put', 'users/me/pin', {'slug': slug}) \ No newline at end of file