From 3995d60b6bf32d91788708c742a3dcc1de31ecef Mon Sep 17 00:00:00 2001 From: root Date: Sun, 29 Mar 2026 08:58:35 +0000 Subject: [PATCH] fix: auto-format code and fix linter errors --- src/kwork_api/__init__.py | 26 +- .../__pycache__/__init__.cpython-312.pyc | Bin 982 -> 1117 bytes .../__pycache__/client.cpython-312.pyc | Bin 61776 -> 59863 bytes .../__pycache__/errors.cpython-312.pyc | Bin 9059 -> 9327 bytes .../__pycache__/models.cpython-312.pyc | Bin 20068 -> 19821 bytes src/kwork_api/client.py | 545 ++++---- src/kwork_api/errors.py | 84 +- src/kwork_api/models.py | 184 +-- test-results/report.html | 1094 +++++++++++++++++ tests/e2e/conftest.py | 13 +- tests/e2e/test_auth.py | 16 +- tests/integration/test_real_api.py | 116 +- .../test_client.cpython-312-pytest-9.0.2.pyc | Bin 34598 -> 34695 bytes tests/unit/test_client.py | 154 ++- tests/unit/test_client_extended.py | 491 ++++---- 15 files changed, 1914 insertions(+), 809 deletions(-) create mode 100644 test-results/report.html diff --git a/src/kwork_api/__init__.py b/src/kwork_api/__init__.py index c0af22a..511507e 100644 --- a/src/kwork_api/__init__.py +++ b/src/kwork_api/__init__.py @@ -5,27 +5,35 @@ Unofficial Python client for Kwork.ru API. Example: from kwork_api import KworkClient - + # Login with credentials client = await KworkClient.login("username", "password") - + # Or restore from token client = KworkClient(token="your_web_auth_token") - + # Get catalog catalog = await client.catalog.get_list(page=1) """ from .client import KworkClient -from .errors import KworkError, KworkAuthError, KworkApiError +from .errors import ( + KworkApiError, + KworkAuthError, + KworkError, + KworkNetworkError, + KworkNotFoundError, + KworkRateLimitError, + KworkValidationError, +) from .models import ( - ValidationResponse, - ValidationIssue, + CatalogResponse, Kwork, KworkDetails, Project, - CatalogResponse, ProjectsResponse, + ValidationIssue, + ValidationResponse, ) __version__ = "0.1.0" # Updated by semantic-release @@ -34,6 +42,10 @@ __all__ = [ "KworkError", "KworkAuthError", "KworkApiError", + "KworkNetworkError", + "KworkNotFoundError", + "KworkRateLimitError", + "KworkValidationError", "ValidationResponse", "ValidationIssue", "Kwork", diff --git a/src/kwork_api/__pycache__/__init__.cpython-312.pyc b/src/kwork_api/__pycache__/__init__.cpython-312.pyc index 0e5b60d94dd7523221c9ed07fd318275fd3c9f33..3c3b9a2b3b516308a4c6b21521dd10f11f75d6c9 100644 GIT binary patch delta 524 zcmZ9IyGsKx9LF!|ca(80Fn#bY*54{RKhA!v4%9PBOM#az$P-Wg)BCcL;W?$&EEi2K9_C9IqqE3 z&gXu72zKLJaBv|f>c$xfPZyj1N*Izb(S&W$y6}b63vH&l>QUYhktHHRS4pekimX4) zb{y($`q-x;ET`d~6Zoz}ebPu%drnUDC5JizsqJ`ClQbeP$)%G?DLs}w>i9vF$lD<~ z@s4O>e2XYvha=P0?}I=6cCIorJ5#asFvET^%yZ;8vhf`pnlQOEI7%EUhsIIl&>5=s zK>Pxy<9k@1O7fVvUJ!}fe+@n?o-*Tw%-+^0^MLJWp5ZM60O&#kWIx6wFxG<#fIW$! Y%h9v`*tfEz4_+4Ep;3faayZQTUqHl^6aWAK delta 354 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 delta 10792 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 diff --git a/src/kwork_api/__pycache__/errors.cpython-312.pyc b/src/kwork_api/__pycache__/errors.cpython-312.pyc index adda591cadb9322dc08f307f176e73984c63e8f5..80914915a1cc5da7444562d18e032611ca53e73f 100644 GIT binary patch delta 3263 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 LvCneQuz&vp;`@-D diff --git a/src/kwork_api/__pycache__/models.cpython-312.pyc b/src/kwork_api/__pycache__/models.cpython-312.pyc index 624181c25d8ee20ece503db7f8f59ab2921263b4..573e0ea9b7624effde7c1ded9caddba285d4d2c2 100644 GIT binary patch delta 7100 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 BBQyX2 delta 7032 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 Optional[str]: + def token(self) -> str | None: """ Web auth token для аутентификации. - + Returns: Токен или None если клиент не аутентифицирован. - + Example: # Сохранение токена для последующего использования client = await KworkClient.login("user", "pass") token = client.token - + # Позже: восстановление сессии client = KworkClient(token=token) """ return self._token - + @property def cookies(self) -> dict[str, str]: """ Session cookies. - + Returns: Словарь cookies включая web_auth_token. - + Example: # Сохранение полной сессии client = await KworkClient.login("user", "pass") creds = client.credentials - + # Восстановление client = KworkClient(**creds) """ return self._cookies.copy() - + @property - def credentials(self) -> dict[str, Optional[str]]: + def credentials(self) -> dict[str, str | None]: """ Учётные данные для восстановления сессии. - + Returns: Словарь с token и cookies для передачи в KworkClient(). - + Example: # Сохранение client = await KworkClient.login("user", "pass") import json with open("session.json", "w") as f: json.dump(client.credentials, f) - + # Восстановление with open("session.json") as f: creds = json.load(f) @@ -192,7 +187,7 @@ class KworkClient: "token": self._token, "cookies": self._cookies.copy() if self._cookies else None, } - + @classmethod async def login( cls, @@ -202,48 +197,48 @@ class KworkClient: ) -> "KworkClient": """ Аутентификация по логину и паролю. - + Выполняет двухэтапный процесс аутентификации Kwork: 1. POST /signIn — проверка учётных данных, получение session cookies 2. POST /getWebAuthToken — обмен cookies на web_auth_token - + Полученный токен и cookies сохраняются в клиенте для последующих запросов. - + Args: username: Логин или email аккаунта Kwork. password: Пароль аккаунта Kwork. timeout: Таймаут запросов в секундах. Применяется к каждому этапу. - + Returns: Полностью аутентифицированный экземпляр KworkClient, готовый к работе с API. - + Raises: KworkAuthError: Если логин/пароль неверны или токен не получен. KworkNetworkError: Если произошла ошибка сети. - + Example: # Базовое использование client = await KworkClient.login("myuser", "mypassword") - + # С кастомным таймаутом client = await KworkClient.login("user", "pass", timeout=60.0) - + # Сохранение токена для повторного использования token = client._token # Позже: client = KworkClient(token=token) - + Security: Пароль не сохраняется в клиенте. Только token и cookies. Рекомендуется сохранять token для повторного использования вместо хранения пароля. - + Note: Токен имеет ограниченное время жизни. При получении 401 ошибки необходимо выполнить повторный login(). """ client = cls(timeout=timeout) - + try: async with client._get_httpx_client() as http_client: # Step 1: Login to get session cookies @@ -251,43 +246,43 @@ class KworkClient: "login_or_email": username, "password": password, } - + response = await http_client.post( cls.LOGIN_URL, data=login_data, headers={"Referer": "https://kwork.ru/"}, ) - + if response.status_code != 200: raise KworkAuthError(f"Login failed: {response.status_code}") - + # Extract cookies cookies = dict(response.cookies) - + if "userId" not in cookies: raise KworkAuthError("Login failed: no userId in cookies") - + # Step 2: Get web auth token token_response = await http_client.post( cls.TOKEN_URL, json={}, ) - + if token_response.status_code != 200: raise KworkAuthError(f"Token request failed: {token_response.status_code}") - + token_data = token_response.json() web_token = token_data.get("web_auth_token") - + if not web_token: raise KworkAuthError("No web_auth_token in response") - + # Create new client with token return cls(token=web_token, cookies=cookies, timeout=timeout) - + except httpx.RequestError as e: - raise KworkNetworkError(f"Login request failed: {e}") - + raise KworkNetworkError(f"Login request failed: {e}") from e + def _get_httpx_client(self) -> httpx.AsyncClient: """Get or create HTTP client with proper headers.""" if self._client is None or self._client.is_closed: @@ -297,11 +292,11 @@ class KworkClient: "Referer": "https://kwork.ru/", "Origin": "https://kwork.ru", } - + if self._token: # Add token to cookies self._cookies["web_auth_token"] = self._token - + self._client = httpx.AsyncClient( base_url=self.base_url, headers=headers, @@ -309,30 +304,30 @@ class KworkClient: timeout=self.timeout, http2=True, ) - + return self._client - + async def close(self) -> None: """Close HTTP client.""" if self._client and not self._client.is_closed: await self._client.aclose() - + async def __aenter__(self) -> "KworkClient": return self - + async def __aexit__(self, *args: Any) -> None: await self.close() - + def _handle_response(self, response: httpx.Response) -> dict[str, Any]: """ Handle HTTP response and raise appropriate errors. - + Args: response: HTTP response - + Returns: Response JSON data - + Raises: KworkApiError: For HTTP errors KworkAuthError: For auth errors @@ -341,34 +336,34 @@ class KworkClient: # Check for common error statuses if response.status_code == 401: raise KworkAuthError("Unauthorized: invalid or expired token") - + if response.status_code == 403: raise KworkAuthError("Forbidden: access denied") - + if response.status_code == 404: raise KworkNotFoundError(f"Resource not found: {response.url}") - + if response.status_code == 429: raise KworkRateLimitError("Too many requests") - + if response.status_code >= 400: try: error_data = response.json() message = error_data.get("message", str(error_data)) except Exception: message = response.text - + if response.status_code == 400: raise KworkValidationError(message, response=response) - + raise KworkApiError(message, response.status_code, response) - + # Parse successful response try: return response.json() except Exception as e: - raise KworkError(f"Failed to parse response: {e}") - + raise KworkError(f"Failed to parse response: {e}") from e + async def _request( self, method: str, @@ -377,60 +372,60 @@ class KworkClient: ) -> dict[str, Any]: """ Make HTTP request. - + Args: method: HTTP method endpoint: API endpoint **kwargs: Additional arguments for httpx - + Returns: Response JSON data """ http_client = self._get_httpx_client() - + try: response = await http_client.request(method, endpoint, **kwargs) return self._handle_response(response) except httpx.RequestError as e: - raise KworkNetworkError(f"Request failed: {e}") - + raise KworkNetworkError(f"Request failed: {e}") from e + # ========== Catalog Endpoints ========== - + class CatalogAPI: """ API каталога кворков. - + Предоставляет доступ к каталогу услуг Kwork: - Поиск и фильтрация кворков - Получение детальной информации - Категории и сортировка - + Example: # Получить первую страницу каталога catalog = await client.catalog.get_list(page=1) - + # Фильтрация по категории catalog = await client.catalog.get_list(category_id=5) - + # Детали конкретного кворка details = await client.catalog.get_details(kwork_id=12345) """ - + def __init__(self, client: "KworkClient"): self.client = client - + async def get_list( self, page: int = 1, - category_id: Optional[int] = None, + category_id: int | None = None, sort: str = "recommend", ) -> CatalogResponse: """ Получить список кворков из каталога. - + Основной эндпоинт для поиска и просмотра кворков. Возвращает пагинированный список с возможностью фильтрации. - + Args: page: Номер страницы для пагинации (начиная с 1). category_id: ID категории для фильтрации. @@ -442,24 +437,24 @@ class KworkClient: - "rating" — по рейтингу - "reviews" — по количеству отзывов - "newest" — по дате создания - + Returns: CatalogResponse содержащий: - kworks: список кворков на странице - pagination: информация о пагинации - filters: доступные фильтры - sort_options: доступные опции сортировки - + Example: # Первая страница, сортировка по цене response = await client.catalog.get_list( page=1, sort="price_asc" ) - + for kwork in response.kworks: print(f"{kwork.title}: {kwork.price} RUB") - + # Пагинация if response.pagination and response.pagination.has_next: next_page = await client.catalog.get_list(page=2) @@ -474,26 +469,26 @@ class KworkClient: }, ) return CatalogResponse.model_validate(data) - + async def get_details(self, kwork_id: int) -> KworkDetails: """ Получить полную информацию о кворке. - + Возвращает расширенную информацию о кворке включая: - Полное описание и требования - Сроки выполнения и количество правок - Дополнительные опции (features) - FAQ от продавца - + Args: kwork_id: Уникальный идентификатор кворка. - + Returns: KworkDetails с полной информацией о кворке. - + Raises: KworkNotFoundError: Если кворк с таким ID не найден. - + Example: details = await client.catalog.get_details(12345) print(f"Название: {details.title}") @@ -507,25 +502,25 @@ class KworkClient: json={"kwork_id": kwork_id}, ) return KworkDetails.model_validate(data) - + async def get_details_extra(self, kwork_id: int) -> dict[str, Any]: """ Получить дополнительные детали кворка. - + Возвращает расширенную информацию, которая не включена в основной ответ get_details(). Может содержать: - Дополнительные изображения - Видео обзоры - Детали пакетов услуг - Статистику продаж - + Args: kwork_id: Уникальный идентификатор кворка. - + Returns: Словарь с дополнительными данными. Структура зависит от конкретного кворка и не гарантируется стабильной. - + Note: Этот эндпоинт возвращает "сырые" данные без валидации. Структура ответа может измениться без предупреждения. @@ -535,60 +530,60 @@ class KworkClient: "/getKworkDetailsExtra", json={"kwork_id": kwork_id}, ) - + # ========== Projects Endpoints ========== - + class ProjectsAPI: """ API биржи проектов (фриланс заказы). - + Предоставляет доступ к заказам на фриланс: - Просмотр открытых проектов - Заказы где вы заказчик (payer) - Заказы где вы исполнитель (worker) - + Example: # Новые проекты projects = await client.projects.get_list(page=1) - + # Мои заказы как заказчика my_orders = await client.projects.get_payer_orders() - + # Мои заказы как исполнителя my_work = await client.projects.get_worker_orders() """ - + def __init__(self, client: "KworkClient"): self.client = client - + async def get_list( self, page: int = 1, - category_id: Optional[int] = None, + category_id: int | None = None, ) -> ProjectsResponse: """ Получить список проектов с биржи. - + Основной эндпоинт для просмотра доступных заказов. Возвращает пагинированный список проектов. - + Args: page: Номер страницы (начиная с 1). category_id: ID категории для фильтрации. Если None — все категории. - + Returns: ProjectsResponse содержащий: - projects: список проектов на странице - pagination: информация о пагинации - + Example: # Все новые проекты response = await client.projects.get_list(page=1) - + for project in response.projects: print(f"{project.title}: {project.budget} {project.budget_type}") - + # Только категория "Разработка" dev_projects = await client.projects.get_list( page=1, @@ -604,17 +599,17 @@ class KworkClient: }, ) return ProjectsResponse.model_validate(data) - + async def get_payer_orders(self) -> list[Project]: """ Получить заказы где вы являетесь заказчиком. - + Возвращает все проекты, созданные текущим пользователем, независимо от их статуса (открыт, в работе, завершён). - + Returns: Список проектов где текущий пользователь — заказчик. - + Example: orders = await client.projects.get_payer_orders() for order in orders: @@ -622,17 +617,17 @@ class KworkClient: """ data = await self.client._request("POST", "/payerOrders") return [Project.model_validate(p) for p in data.get("orders", [])] - + async def get_worker_orders(self) -> list[Project]: """ Получить заказы где вы являетесь исполнителем. - + Возвращает все проекты, где текущий пользователь назначен исполнителем. - + Returns: Список проектов где текущий пользователь — исполнитель. - + Example: work = await client.projects.get_worker_orders() active = [p for p in work if p.status == "in_progress"] @@ -640,81 +635,81 @@ class KworkClient: """ data = await self.client._request("POST", "/workerOrders") return [Project.model_validate(p) for p in data.get("orders", [])] - + # ========== User Endpoints ========== - + class UserAPI: """ Пользовательское API. - + Предоставляет доступ к данным текущего пользователя: - Профиль и информация об аккаунте - Отзывы (полученные и оставленные) - Избранные кворки - + Example: # Информация о пользователе info = await client.user.get_info() - + # Мои отзывы reviews = await client.user.get_reviews() - + # Избранные кворки favorites = await client.user.get_favorite_kworks() """ - + def __init__(self, client: "KworkClient"): self.client = client - + async def get_info(self) -> dict[str, Any]: """ Получить информацию о текущем пользователе. - + Возвращает основные данные аккаунта: - ID, username, email - Баланс, рейтинг - Статус верификации - Настройки профиля - + Returns: Словарь с информацией о пользователе. Структура зависит от ответа API. - + Example: info = await client.user.get_info() print(f"User: {info.get('username')}") print(f"Balance: {info.get('balance')} RUB") """ return await self.client._request("POST", "/user") - + async def get_reviews( self, - user_id: Optional[int] = None, + user_id: int | None = None, page: int = 1, ) -> ReviewsResponse: """ Получить отзывы пользователя. - + Если user_id не указан — возвращает отзывы текущего пользователя. Если указан — отзывы другого пользователя по ID. - + Args: user_id: ID пользователя. Если None — текущий пользователь. page: Номер страницы для пагинации (начиная с 1). - + Returns: ReviewsResponse содержащий: - reviews: список отзывов на странице - pagination: информация о пагинации - average_rating: средний рейтинг - + Example: # Мои отзывы my_reviews = await client.user.get_reviews() - + # Отзывы другого пользователя user_reviews = await client.user.get_reviews(user_id=12345) - + # С пагинацией page2 = await client.user.get_reviews(page=2) """ @@ -724,16 +719,16 @@ class KworkClient: json={"user_id": user_id, "page": page}, ) return ReviewsResponse.model_validate(data) - + async def get_favorite_kworks(self) -> list[Kwork]: """ Получить список избранных кворков. - + Возвращает все кворки, добавленные пользователем в избранное. - + Returns: Список избранных кворков. - + Example: favorites = await client.user.get_favorite_kworks() for kwork in favorites: @@ -741,89 +736,89 @@ class KworkClient: """ data = await self.client._request("POST", "/favoriteKworks") return [Kwork.model_validate(k) for k in data.get("kworks", [])] - + # ========== Reference Data Endpoints ========== - + class ReferenceAPI: """ Справочное API. - + Предоставляет доступ к справочным данным Kwork: - Города, страны, часовые пояса - Доступные функции и дополнения - Значки пользователей - + Эти данные редко меняются и могут быть закэшированы. - + Example: # Все страны countries = await client.reference.get_countries() - + # Все города cities = await client.reference.get_cities() - + # Доступные фичи features = await client.reference.get_features() """ - + def __init__(self, client: "KworkClient"): self.client = client - + async def get_cities(self) -> list[City]: """ Получить список всех городов. - + Returns: Список всех городов из справочника Kwork. - + Example: cities = await client.reference.get_cities() moscow = next(c for c in cities if c.name == "Москва") """ data = await self.client._request("POST", "/cities") return [City.model_validate(c) for c in data.get("cities", [])] - + async def get_countries(self) -> list[Country]: """ Получить список всех стран. - + Returns: Список всех стран с кодами и городами. - + Example: countries = await client.reference.get_countries() russia = next(c for c in countries if c.code == "RU") """ data = await self.client._request("POST", "/countries") return [Country.model_validate(c) for c in data.get("countries", [])] - + async def get_timezones(self) -> list[TimeZone]: """ Получить список всех часовых поясов. - + Returns: Список часовых поясов с названиями и смещениями. - + Example: timezones = await client.reference.get_timezones() msks = next(tz for tz in timezones if "Moscow" in tz.name) """ data = await self.client._request("POST", "/timezones") return [TimeZone.model_validate(t) for t in data.get("timezones", [])] - + async def get_features(self) -> list[Feature]: """ Получить доступные дополнительные функции (features). - + Features — это платные дополнения к кворкам: - Увеличенные сроки - Дополнительные правки - Приоритетная поддержка - и т.д. - + Returns: Список доступных features с названиями и ценами. - + Example: features = await client.reference.get_features() for f in features: @@ -831,33 +826,33 @@ class KworkClient: """ data = await self.client._request("POST", "/getAvailableFeatures") return [Feature.model_validate(f) for f in data.get("features", [])] - + async def get_public_features(self) -> list[Feature]: """ Получить публичные функции. - + Аналогично get_features(), но возвращает только публично доступные опции. - + Returns: Список публичных features. """ data = await self.client._request("POST", "/getPublicFeatures") return [Feature.model_validate(f) for f in data.get("features", [])] - + async def get_badges_info(self) -> list[Badge]: """ Получить информацию о значках пользователей. - + Значки (badges) отображают достижения и статусы: - "Профессионал" - "Быстрый ответ" - "Надёжный продавец" - и т.д. - + Returns: Список значков с описаниями и иконками. - + Example: badges = await client.reference.get_badges_info() for badge in badges: @@ -865,67 +860,67 @@ class KworkClient: """ data = await self.client._request("POST", "/getBadgesInfo") return [Badge.model_validate(b) for b in data.get("badges", [])] - + # ========== Notifications & Messages ========== - + class NotificationsAPI: """ API уведомлений и сообщений. - + Предоставляет доступ к системе уведомлений Kwork: - Список уведомлений - Получение новых уведомлений - Диалоги с пользователями - Заблокированные диалоги - + Example: # Все уведомления notifications = await client.notifications.get_list() - + # Новые уведомления new = await client.notifications.fetch() - + # Диалоги dialogs = await client.notifications.get_dialogs() """ - + def __init__(self, client: "KworkClient"): self.client = client - + async def get_list(self) -> NotificationsResponse: """ Получить список всех уведомлений. - + Возвращает все уведомления пользователя с информацией о прочтении. - + Returns: NotificationsResponse содержащий: - notifications: список уведомлений - unread_count: количество непрочитанных - + Example: notifs = await client.notifications.get_list() print(f"Непрочитанных: {notifs.unread_count}") - + for n in notifs.notifications: if not n.is_read: print(f"Новое: {n.title}") """ data = await self.client._request("POST", "/notifications") return NotificationsResponse.model_validate(data) - + async def fetch(self) -> NotificationsResponse: """ Получить новые уведомления. - + Отличается от get_list() тем, что возвращает только уведомления, появившиеся с момента последнего запроса. Также может обновлять счётчик непрочитанных. - + Returns: NotificationsResponse с новыми уведомлениями. - + Example: new_notifs = await client.notifications.fetch() if new_notifs.unread_count > 0: @@ -933,17 +928,17 @@ class KworkClient: """ data = await self.client._request("POST", "/notificationsFetch") return NotificationsResponse.model_validate(data) - + async def get_dialogs(self) -> list[Dialog]: """ Получить список диалогов (чатов). - + Возвращает все активные диалоги пользователя с другими пользователями Kwork. - + Returns: Список диалогов с последней перепиской. - + Example: dialogs = await client.notifications.get_dialogs() for d in dialogs: @@ -951,149 +946,149 @@ class KworkClient: """ data = await self.client._request("POST", "/dialogs") return [Dialog.model_validate(d) for d in data.get("dialogs", [])] - + async def get_blocked_dialogs(self) -> list[Dialog]: """ Получить список заблокированных диалогов. - + Возвращает диалоги с пользователями, которые были заблокированы текущим пользователем. - + Returns: Список заблокированных диалогов. - + Example: blocked = await client.notifications.get_blocked_dialogs() print(f"Заблокировано: {len(blocked)} пользователей") """ data = await self.client._request("POST", "/blockedDialogList") return [Dialog.model_validate(d) for d in data.get("dialogs", [])] - + # ========== Other Endpoints ========== - + class OtherAPI: """ Прочее API. - + Вспомогательные эндпоинты которые не вошли в другие категории: - Пользовательские настройки и предпочтения (wants) - Статусы кворков и заказов - Персональные предложения (offers) - Настройки профиля - Статус онлайн/оффлайн - + Example: # Пользовательские предпочтения wants = await client.other.get_wants() - + # Статус кворков status = await client.other.get_kworks_status() - + # Установить статус оффлайн await client.other.go_offline() """ - + def __init__(self, client: "KworkClient"): self.client = client - + async def get_wants(self) -> dict[str, Any]: """ Получить пользовательские предпочтения (wants). - + Wants — это настройки интересов пользователя: - Предпочитаемые категории - Ключевые слова для мониторинга - Фильтры для поиска - + Returns: Словарь с настройками предпочтений. - + Example: wants = await client.other.get_wants() print(wants) """ return await self.client._request("POST", "/myWants") - + async def get_wants_status(self) -> dict[str, Any]: """ Получить статус предпочтений. - + Returns: Статус wants с метаданными. """ return await self.client._request("POST", "/wantsStatusList") - + async def get_kworks_status(self) -> dict[str, Any]: """ Получить статусы кворков пользователя. - + Возвращает информацию о статусах всех кворков текущего пользователя (активен, на модерации, и т.д.). - + Returns: Словарь со статусами кворков. - + Example: status = await client.other.get_kworks_status() print(status) """ return await self.client._request("POST", "/kworksStatusList") - + async def get_offers(self) -> dict[str, Any]: """ Получить персональные предложения. - + Returns: Список персональных предложений от Kwork. """ return await self.client._request("POST", "/offers") - + async def get_exchange_info(self) -> dict[str, Any]: """ Получить информацию об обмене валюты. - + Returns: Информация о курсах валют и обмене. """ return await self.client._request("POST", "/exchangeInfo") - + async def get_channel(self) -> dict[str, Any]: """ Получить информацию о канале пользователя. - + Returns: Данные канала (если есть). """ return await self.client._request("POST", "/getChannel") - + async def get_in_app_notification(self) -> dict[str, Any]: """ Получить внутриприложенное уведомление. - + Returns: Данные in-app уведомления. """ return await self.client._request("POST", "/getInAppNotification") - + async def get_security_user_data(self) -> dict[str, Any]: """ Получить данные безопасности пользователя. - + Returns: Информация о безопасности аккаунта. """ return await self.client._request("POST", "/getSecurityUserData") - + async def is_dialog_allow(self, user_id: int) -> bool: """ Проверить возможность начала диалога с пользователем. - + Args: user_id: ID пользователя для проверки. - + Returns: True если диалог разрешён, False иначе. - + Example: allowed = await client.other.is_dialog_allow(12345) if allowed: @@ -1105,49 +1100,49 @@ class KworkClient: json={"user_id": user_id}, ) return data.get("allowed", False) - + async def get_viewed_kworks(self) -> list[Kwork]: """ Получить просмотренные кворки. - + Возвращает список кворков, которые пользователь просматривал ранее. - + Returns: Список просмотренных кворков. - + Example: viewed = await client.other.get_viewed_kworks() print(f"Просмотрено: {len(viewed)} кворков") """ data = await self.client._request("POST", "/viewedCatalogKworks") return [Kwork.model_validate(k) for k in data.get("kworks", [])] - + async def get_favorite_categories(self) -> list[int]: """ Получить ID избранных категорий. - + Returns: Список ID категорий, добавленных в избранное. - + Example: cats = await client.other.get_favorite_categories() print(f"Избранные категории: {cats}") """ data = await self.client._request("POST", "/favoriteCategories") return data.get("categories", []) - + async def update_settings(self, settings: dict[str, Any]) -> dict[str, Any]: """ Обновить настройки пользователя. - + Args: settings: Словарь с настройками для обновления. Структура зависит от конкретных настроек. - + Returns: Ответ API с подтверждением обновления. - + Example: await client.other.update_settings({ "email_notifications": True, @@ -1155,52 +1150,54 @@ class KworkClient: }) """ return await self.client._request("POST", "/updateSettings", json=settings) - + async def go_offline(self) -> dict[str, Any]: """ Установить статус пользователя "оффлайн". - + Скрывает онлайн-статус от других пользователей. - + Returns: Подтверждение изменения статуса. - + Example: await client.other.go_offline() """ return await self.client._request("POST", "/offline") - + async def get_actor(self) -> dict[str, Any]: """ Получить информацию об актёре (текущем пользователе). - + Returns: Данные актёра/пользователя. """ return await self.client._request("POST", "/actor") - - async def validate_text(self, text: str, context: Optional[str] = None) -> ValidationResponse: + + async def validate_text( + self, text: str, context: str | None = None + ) -> ValidationResponse: """ Проверить текст на соответствие требованиям Kwork. - + Эндпоинт валидации текста используется для проверки: - Описаний кворков - Текстов проектов - Сообщений и отзывов - + Находит потенциальные проблемы: - Запрещённые слова и контакты - Нарушения правил площадки - Проблемы с форматированием - + Args: text: Текст для проверки. context: Контекст использования (опционально). Например: "kwork_description", "project_text", "message". - + Returns: ValidationResponse с результатами валидации. - + Example: result = await client.other.validate_text( "Отличный сервис, звоните +7-999-000-00-00" @@ -1213,41 +1210,41 @@ class KworkClient: payload = {"text": text} if context: payload["context"] = context - + data = await self.client._request( "POST", "/api/validation/checktext", json=payload, ) return ValidationResponse.model_validate(data) - + # ========== API Property Accessors ========== - + @property def catalog(self) -> CatalogAPI: """API каталога кворков.""" return self.CatalogAPI(self) - + @property def projects(self) -> ProjectsAPI: """API биржи проектов.""" return self.ProjectsAPI(self) - + @property def user(self) -> UserAPI: """Пользовательское API.""" return self.UserAPI(self) - + @property def reference(self) -> ReferenceAPI: """Справочное API.""" return self.ReferenceAPI(self) - + @property def notifications(self) -> NotificationsAPI: """API уведомлений.""" return self.NotificationsAPI(self) - + @property def other(self) -> OtherAPI: """Прочее API.""" diff --git a/src/kwork_api/errors.py b/src/kwork_api/errors.py index 4004127..4d5d702 100644 --- a/src/kwork_api/errors.py +++ b/src/kwork_api/errors.py @@ -13,7 +13,7 @@ └── KworkNetworkError (ошибки сети) """ -from typing import Any, Optional +from typing import Any __all__ = [ "KworkError", @@ -29,25 +29,25 @@ __all__ = [ class KworkError(Exception): """ Базовое исключение для всех ошибок Kwork API. - + Все остальные исключения наследуются от этого класса. - + Attributes: message: Сообщение об ошибке. response: Оригинальный HTTP response (если есть). - + Example: try: await client.catalog.get_list() except KworkError as e: print(f"Ошибка: {e.message}") """ - - def __init__(self, message: str, response: Optional[Any] = None): + + def __init__(self, message: str, response: Any | None = None): self.message = message self.response = response super().__init__(self.message) - + def __str__(self) -> str: return f"KworkError: {self.message}" @@ -55,22 +55,22 @@ class KworkError(Exception): class KworkAuthError(KworkError): """ Ошибка аутентификации/авторизации. - + Возникает при: - Неверном логине или пароле - Истёкшем или невалидном токене - Отсутствии прав доступа (403) - + Example: try: client = await KworkClient.login("user", "wrong_password") except KworkAuthError: print("Неверные учётные данные") """ - - def __init__(self, message: str = "Authentication failed", response: Optional[Any] = None): + + def __init__(self, message: str = "Authentication failed", response: Any | None = None): super().__init__(message, response) - + def __str__(self) -> str: return f"KworkAuthError: {self.message}" @@ -78,28 +78,28 @@ class KworkAuthError(KworkError): class KworkApiError(KworkError): """ Ошибка HTTP запроса к API (4xx, 5xx). - + Базовый класс для HTTP ошибок API. Содержит код статуса. - + Attributes: status_code: HTTP код ответа (400, 404, 500, etc.) - + Example: try: await client.catalog.get_details(999999) except KworkApiError as e: print(f"HTTP {e.status_code}: {e.message}") """ - + def __init__( self, message: str, - status_code: Optional[int] = None, - response: Optional[Any] = None, + status_code: int | None = None, + response: Any | None = None, ): self.status_code = status_code super().__init__(message, response) - + def __str__(self) -> str: if self.status_code: return f"KworkApiError [{self.status_code}]: {self.message}" @@ -109,46 +109,46 @@ class KworkApiError(KworkError): class KworkNotFoundError(KworkApiError): """ Ресурс не найден (404). - + Возникает при запросе несуществующего кворка, пользователя или другого ресурса. - + Example: try: await client.catalog.get_details(999999) except KworkNotFoundError: print("Кворк не найден") """ - - def __init__(self, resource: str, response: Optional[Any] = None): + + def __init__(self, resource: str, response: Any | None = None): super().__init__(f"Resource not found: {resource}", 404, response) class KworkRateLimitError(KworkApiError): """ Превышен лимит запросов (429). - + Возникает при слишком частых запросах к API. Рекомендуется сделать паузу перед повторным запросом. - + Attributes: retry_after: Время ожидания в секундах (если указано сервером). - + Example: import asyncio - + try: await client.catalog.get_list() except KworkRateLimitError as e: wait_time = e.retry_after or 5 await asyncio.sleep(wait_time) """ - + def __init__( self, message: str = "Rate limit exceeded", - response: Optional[Any] = None, - retry_after: Optional[int] = None, + response: Any | None = None, + retry_after: int | None = None, ): self.retry_after = retry_after super().__init__(message, 429, response) @@ -157,12 +157,12 @@ class KworkRateLimitError(KworkApiError): class KworkValidationError(KworkApiError): """ Ошибка валидации (400). - + Возникает при некорректных данных запроса. - + Attributes: fields: Словарь ошибок по полям {field: [errors]}. - + Example: try: await client.catalog.get_list(page=-1) @@ -171,16 +171,16 @@ class KworkValidationError(KworkApiError): for field, errors in e.fields.items(): print(f"{field}: {errors[0]}") """ - + def __init__( self, message: str = "Validation failed", - fields: Optional[dict[str, list[str]]] = None, - response: Optional[Any] = None, + fields: dict[str, list[str]] | None = None, + response: Any | None = None, ): self.fields = fields or {} super().__init__(message, 400, response) - + def __str__(self) -> str: if self.fields: field_errors = ", ".join(f"{k}: {v[0]}" for k, v in self.fields.items()) @@ -191,22 +191,22 @@ class KworkValidationError(KworkApiError): class KworkNetworkError(KworkError): """ Ошибка сети/подключения. - + Возникает при: - Отсутствии соединения - Таймауте запроса - Ошибке DNS - Проблемах с SSL - + Example: try: await client.catalog.get_list() except KworkNetworkError: print("Проверьте подключение к интернету") """ - - def __init__(self, message: str = "Network error", response: Optional[Any] = None): + + def __init__(self, message: str = "Network error", response: Any | None = None): super().__init__(message, response) - + def __str__(self) -> str: return f"KworkNetworkError: {self.message}" diff --git a/src/kwork_api/models.py b/src/kwork_api/models.py index d69c0fc..6a70fa2 100644 --- a/src/kwork_api/models.py +++ b/src/kwork_api/models.py @@ -6,7 +6,7 @@ Pydantic модели для ответов Kwork API. """ from datetime import datetime -from typing import Any, Optional +from typing import Any from pydantic import BaseModel, Field @@ -14,47 +14,49 @@ from pydantic import BaseModel, Field class KworkUser(BaseModel): """ Информация о пользователе Kwork. - + Attributes: id: Уникальный ID пользователя. username: Имя пользователя (логин). avatar_url: URL аватара или None. is_online: Статус онлайн. rating: Рейтинг пользователя (0-5). - + Example: user = KworkUser(id=123, username="seller", rating=4.9) print(f"{user.username}: {user.rating} ★") """ + id: int username: str - avatar_url: Optional[str] = None + avatar_url: str | None = None is_online: bool = False - rating: Optional[float] = None + rating: float | None = None class KworkCategory(BaseModel): """ Категория кворков. - + Attributes: id: Уникальный ID категории. name: Название категории. slug: URL-safe идентификатор. parent_id: ID родительской категории для вложенности. """ + id: int name: str slug: str - parent_id: Optional[int] = None + parent_id: int | None = None class Kwork(BaseModel): """ Кворк — услуга на Kwork. - + Базовая модель кворка с основной информацией. - + Attributes: id: Уникальный ID кворка. title: Заголовок кворка. @@ -69,26 +71,27 @@ class Kwork(BaseModel): created_at: Дата создания. updated_at: Дата последнего обновления. """ + id: int title: str - description: Optional[str] = None + description: str | None = None price: float currency: str = "RUB" - category_id: Optional[int] = None - seller: Optional[KworkUser] = None + category_id: int | None = None + seller: KworkUser | None = None images: list[str] = Field(default_factory=list) - rating: Optional[float] = None + rating: float | None = None reviews_count: int = 0 - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None + created_at: datetime | None = None + updated_at: datetime | None = None class KworkDetails(Kwork): """ Расширенная информация о кворке. - + Наследует все поля Kwork плюс дополнительные детали. - + Attributes: full_description: Полное описание услуги. requirements: Требования к заказчику. @@ -97,10 +100,11 @@ class KworkDetails(Kwork): features: Список дополнительных опций. faq: Список вопросов и ответов. """ - full_description: Optional[str] = None - requirements: Optional[str] = None - delivery_time: Optional[int] = None - revisions: Optional[int] = None + + full_description: str | None = None + requirements: str | None = None + delivery_time: int | None = None + revisions: int | None = None features: list[str] = Field(default_factory=list) faq: list[dict[str, str]] = Field(default_factory=list) @@ -108,7 +112,7 @@ class KworkDetails(Kwork): class PaginationInfo(BaseModel): """ Информация о пагинации. - + Attributes: current_page: Текущая страница (начиная с 1). total_pages: Общее количество страниц. @@ -117,6 +121,7 @@ class PaginationInfo(BaseModel): has_next: Есть ли следующая страница. has_prev: Есть ли предыдущая страница. """ + current_page: int = 1 total_pages: int = 1 total_items: int = 0 @@ -128,23 +133,24 @@ class PaginationInfo(BaseModel): class CatalogResponse(BaseModel): """ Ответ API каталога кворков. - + Attributes: kworks: Список кворков на странице. pagination: Информация о пагинации. filters: Доступные фильтры. sort_options: Доступные опции сортировки. """ + kworks: list[Kwork] = Field(default_factory=list) - pagination: Optional[PaginationInfo] = None - filters: Optional[dict[str, Any]] = None + pagination: PaginationInfo | None = None + filters: dict[str, Any] | None = None sort_options: list[str] = Field(default_factory=list) class Project(BaseModel): """ Проект (заказ на бирже фриланса). - + Attributes: id: Уникальный ID проекта. title: Заголовок проекта. @@ -159,16 +165,17 @@ class Project(BaseModel): bids_count: Количество откликов. skills: Требуемые навыки. """ + id: int title: str - description: Optional[str] = None - budget: Optional[float] = None + description: str | None = None + budget: float | None = None budget_type: str = "fixed" - category_id: Optional[int] = None - customer: Optional[KworkUser] = None + category_id: int | None = None + customer: KworkUser | None = None status: str = "open" - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None + created_at: datetime | None = None + updated_at: datetime | None = None bids_count: int = 0 skills: list[str] = Field(default_factory=list) @@ -176,19 +183,20 @@ class Project(BaseModel): class ProjectsResponse(BaseModel): """ Ответ API списка проектов. - + Attributes: projects: Список проектов. pagination: Информация о пагинации. """ + projects: list[Project] = Field(default_factory=list) - pagination: Optional[PaginationInfo] = None + pagination: PaginationInfo | None = None class Review(BaseModel): """ Отзыв о кворке или проекте. - + Attributes: id: Уникальный ID отзыва. rating: Оценка от 1 до 5. @@ -197,32 +205,34 @@ class Review(BaseModel): kwork_id: ID кворка (если отзыв о кворке). created_at: Дата создания. """ + id: int rating: int = Field(ge=1, le=5) - comment: Optional[str] = None - author: Optional[KworkUser] = None - kwork_id: Optional[int] = None - created_at: Optional[datetime] = None + comment: str | None = None + author: KworkUser | None = None + kwork_id: int | None = None + created_at: datetime | None = None class ReviewsResponse(BaseModel): """ Ответ API списка отзывов. - + Attributes: reviews: Список отзывов. pagination: Информация о пагинации. average_rating: Средний рейтинг. """ + reviews: list[Review] = Field(default_factory=list) - pagination: Optional[PaginationInfo] = None - average_rating: Optional[float] = None + pagination: PaginationInfo | None = None + average_rating: float | None = None class Notification(BaseModel): """ Уведомление пользователя. - + Attributes: id: Уникальный ID уведомления. type: Тип уведомления: "message", "order", "system", etc. @@ -232,23 +242,25 @@ class Notification(BaseModel): created_at: Дата создания. link: Ссылка для перехода (если есть). """ + id: int type: str title: str message: str is_read: bool = False - created_at: Optional[datetime] = None - link: Optional[str] = None + created_at: datetime | None = None + link: str | None = None class NotificationsResponse(BaseModel): """ Ответ API списка уведомлений. - + Attributes: notifications: Список уведомлений. unread_count: Количество непрочитанных уведомлений. """ + notifications: list[Notification] = Field(default_factory=list) unread_count: int = 0 @@ -256,7 +268,7 @@ class NotificationsResponse(BaseModel): class Dialog(BaseModel): """ Диалог (чат) с пользователем. - + Attributes: id: Уникальный ID диалога. participant: Собеседник. @@ -264,17 +276,18 @@ class Dialog(BaseModel): unread_count: Количество непрочитанных сообщений. updated_at: Время последнего сообщения. """ + id: int - participant: Optional[KworkUser] = None - last_message: Optional[str] = None + participant: KworkUser | None = None + last_message: str | None = None unread_count: int = 0 - updated_at: Optional[datetime] = None + updated_at: datetime | None = None class AuthResponse(BaseModel): """ Ответ API аутентификации. - + Attributes: success: Успешность аутентификации. user_id: ID пользователя. @@ -282,80 +295,86 @@ class AuthResponse(BaseModel): web_auth_token: Токен для последующих запросов. message: Сообщение (например, об ошибке). """ + success: bool - user_id: Optional[int] = None - username: Optional[str] = None - web_auth_token: Optional[str] = None - message: Optional[str] = None + user_id: int | None = None + username: str | None = None + web_auth_token: str | None = None + message: str | None = None class ErrorDetail(BaseModel): """ Детали ошибки API. - + Attributes: code: Код ошибки. message: Сообщение об ошибке. field: Поле, вызвавшее ошибку (если применимо). """ + code: str message: str - field: Optional[str] = None + field: str | None = None class APIErrorResponse(BaseModel): """ Стандартный ответ API об ошибке. - + Attributes: success: Всегда False для ошибок. errors: Список деталей ошибок. message: Общее сообщение об ошибке. """ + success: bool = False errors: list[ErrorDetail] = Field(default_factory=list) - message: Optional[str] = None + message: str | None = None class City(BaseModel): """ Город из справочника. - + Attributes: id: Уникальный ID города. name: Название города. country_id: ID страны. """ + id: int name: str - country_id: Optional[int] = None + country_id: int | None = None class Country(BaseModel): """ Страна из справочника. - + Attributes: id: Уникальный ID страны. name: Название страны. code: Код страны (ISO). cities: Список городов в стране. """ + id: int name: str - code: Optional[str] = None + code: str | None = None cities: list[City] = Field(default_factory=list) class TimeZone(BaseModel): """ Часовой пояс. - + Attributes: id: Уникальный ID. name: Название пояса. offset: Смещение от UTC (например, "+03:00"). """ + id: int name: str offset: str @@ -364,7 +383,7 @@ class TimeZone(BaseModel): class Feature(BaseModel): """ Дополнительная функция (feature) для кворка. - + Attributes: id: Уникальный ID функции. name: Название. @@ -372,9 +391,10 @@ class Feature(BaseModel): price: Стоимость в рублях. type: Тип: "extra", "premium", etc. """ + id: int name: str - description: Optional[str] = None + description: str | None = None price: float type: str @@ -382,40 +402,42 @@ class Feature(BaseModel): class Badge(BaseModel): """ Значок (достижение) пользователя. - + Attributes: id: Уникальный ID значка. name: Название значка. description: Описание достижения. icon_url: URL иконки значка. """ + id: int name: str - description: Optional[str] = None - icon_url: Optional[str] = None + description: str | None = None + icon_url: str | None = None # Generic response wrapper class DataResponse(BaseModel): """ Универсальный ответ API с данными. - + Используется как обёртка для различных ответов API. - + Attributes: success: Успешность запроса. data: Полезные данные (словарь). message: Дополнительное сообщение. """ + success: bool = True - data: Optional[dict[str, Any]] = None - message: Optional[str] = None + data: dict[str, Any] | None = None + message: str | None = None class ValidationIssue(BaseModel): """ Проблема, найденная при валидации текста. - + Attributes: type: Тип проблемы: "error", "warning", "suggestion". code: Код ошибки (например, "SPELLING", "GRAMMAR", "LENGTH"). @@ -423,19 +445,20 @@ class ValidationIssue(BaseModel): position: Позиция в тексте (если применимо). suggestion: Предлагаемое исправление (если есть). """ + type: str = "error" code: str message: str - position: Optional[int] = None - suggestion: Optional[str] = None + position: int | None = None + suggestion: str | None = None class ValidationResponse(BaseModel): """ Ответ API валидации текста. - + Используется для эндпоинта /api/validation/checktext. - + Attributes: success: Успешность валидации. is_valid: Текст проходит валидацию (нет критических ошибок). @@ -443,8 +466,9 @@ class ValidationResponse(BaseModel): score: Оценка качества текста (0-100, если доступна). message: Дополнительное сообщение. """ + success: bool = True is_valid: bool = True issues: list[ValidationIssue] = Field(default_factory=list) - score: Optional[int] = None - message: Optional[str] = None + score: int | None = None + message: str | None = None diff --git a/test-results/report.html b/test-results/report.html new file mode 100644 index 0000000..48c7737 --- /dev/null +++ b/test-results/report.html @@ -0,0 +1,1094 @@ + + + + + report.html + + + + +

report.html

+

Report generated on 29-Mar-2026 at 07:54:55 by pytest-html + v4.2.0

+
+

Environment

+
+
+ + + + + +
+
+

Summary

+
+
+

16 tests took 00:00:02.

+

(Un)check the boxes to filter the results.

+
+ +
+
+
+
+ + 0 Failed, + + 16 Passed, + + 0 Skipped, + + 0 Expected failures, + + 0 Unexpected passes, + + 0 Errors, + + 0 Reruns + + 0 Retried, +
+
+  /  +
+
+
+
+
+
+
+
+ + + + + + + + + +
ResultTestDurationLinks
+
+
+ +
+ + \ No newline at end of file diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 1057118..1af71d7 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -5,8 +5,9 @@ E2E тесты для Kwork API. """ import os -import pytest from pathlib import Path + +import pytest from dotenv import load_dotenv # Загружаем .env @@ -39,21 +40,17 @@ def slowmo(request): slowmo = request.config.getoption("--slowmo", default=0) if slowmo > 0: import time + time.sleep(slowmo) def pytest_configure(config): """Регистрация маркера e2e.""" - config.addinivalue_line( - "markers", "e2e: mark test as end-to-end (requires credentials)" - ) + config.addinivalue_line("markers", "e2e: mark test as end-to-end (requires credentials)") def pytest_addoption(parser): """Добавляет опцию --slowmo.""" parser.addoption( - "--slowmo", - type=float, - default=0, - help="Delay between tests in seconds (for rate limiting)" + "--slowmo", type=float, default=0, help="Delay between tests in seconds (for rate limiting)" ) diff --git a/tests/e2e/test_auth.py b/tests/e2e/test_auth.py index db42b8b..39ff119 100644 --- a/tests/e2e/test_auth.py +++ b/tests/e2e/test_auth.py @@ -3,6 +3,7 @@ E2E тесты аутентификации. """ import pytest + from kwork_api import KworkClient from kwork_api.errors import KworkAuthError @@ -11,10 +12,9 @@ from kwork_api.errors import KworkAuthError async def test_login_success(require_credentials): """E2E: Успешная аутентификация.""" client = await KworkClient.login( - username=require_credentials["username"], - password=require_credentials["password"] + username=require_credentials["username"], password=require_credentials["password"] ) - + try: assert client.token is not None assert len(client.token) > 0 @@ -26,10 +26,7 @@ async def test_login_success(require_credentials): async def test_login_invalid_credentials(): """E2E: Неверные credentials.""" with pytest.raises(KworkAuthError): - await KworkClient.login( - username="invalid_user_12345", - password="invalid_pass_12345" - ) + await KworkClient.login(username="invalid_user_12345", password="invalid_pass_12345") @pytest.mark.e2e @@ -37,12 +34,11 @@ async def test_restore_session(require_credentials): """E2E: Восстановление сессии из токена.""" # First login client1 = await KworkClient.login( - username=require_credentials["username"], - password=require_credentials["password"] + username=require_credentials["username"], password=require_credentials["password"] ) token = client1.token await client1.aclose() - + # Restore from token client2 = KworkClient(token=token) try: diff --git a/tests/integration/test_real_api.py b/tests/integration/test_real_api.py index fb54297..6ed651b 100644 --- a/tests/integration/test_real_api.py +++ b/tests/integration/test_real_api.py @@ -6,77 +6,76 @@ Skip these tests in CI/CD or when running unit tests only. Usage: pytest tests/integration/ -m integration - + Or with credentials: KWORK_USERNAME=user KWORK_PASSWORD=pass pytest tests/integration/ -m integration """ import os -from typing import Optional import pytest -from kwork_api import KworkClient, KworkAuthError +from kwork_api import KworkAuthError, KworkClient @pytest.fixture(scope="module") -def client() -> Optional[KworkClient]: +def client() -> KworkClient | None: """ Create authenticated client for integration tests. - + Requires KWORK_USERNAME and KWORK_PASSWORD environment variables. Skip tests if not provided. """ username = os.getenv("KWORK_USERNAME") password = os.getenv("KWORK_PASSWORD") - + if not username or not password: pytest.skip("KWORK_USERNAME and KWORK_PASSWORD not set") - + # Create client import asyncio - + async def create_client(): return await KworkClient.login(username, password) - + return asyncio.run(create_client()) @pytest.mark.integration class TestAuthentication: """Test authentication with real API.""" - + def test_login_with_credentials(self): """Test login with real credentials.""" username = os.getenv("KWORK_USERNAME") password = os.getenv("KWORK_PASSWORD") - + if not username or not password: pytest.skip("Credentials not set") - + import asyncio - + async def login(): client = await KworkClient.login(username, password) assert client._token is not None assert "userId" in client._cookies await client.close() return True - + result = asyncio.run(login()) assert result - + def test_invalid_credentials(self): """Test login with invalid credentials.""" import asyncio - + async def try_login(): try: await KworkClient.login("invalid_user_12345", "wrong_password") return False except KworkAuthError: return True - + result = asyncio.run(try_login()) assert result # Should raise auth error @@ -84,43 +83,43 @@ class TestAuthentication: @pytest.mark.integration class TestCatalogAPI: """Test catalog endpoints with real API.""" - + def test_get_catalog_list(self, client: KworkClient): """Test getting catalog list.""" if not client: pytest.skip("No client") - + import asyncio - + async def fetch(): result = await client.catalog.get_list(page=1) return result - + result = asyncio.run(fetch()) - + assert result.kworks is not None assert len(result.kworks) > 0 assert result.pagination is not None - + def test_get_kwork_details(self, client: KworkClient): """Test getting kwork details.""" if not client: pytest.skip("No client") - + import asyncio - + async def fetch(): # First get a kwork ID from catalog catalog = await client.catalog.get_list(page=1) if not catalog.kworks: return None - + kwork_id = catalog.kworks[0].id details = await client.catalog.get_details(kwork_id) return details - + result = asyncio.run(fetch()) - + if result: assert result.id is not None assert result.title is not None @@ -130,61 +129,63 @@ class TestCatalogAPI: @pytest.mark.integration class TestProjectsAPI: """Test projects endpoints with real API.""" - + def test_get_projects_list(self, client: KworkClient): """Test getting projects list.""" if not client: pytest.skip("No client") - + import asyncio - + async def fetch(): return await client.projects.get_list(page=1) - + result = asyncio.run(fetch()) - + assert result.projects is not None @pytest.mark.integration class TestReferenceAPI: """Test reference data endpoints.""" - + def test_get_cities(self, client: KworkClient): """Test getting cities.""" if not client: pytest.skip("No client") - + import asyncio - + async def fetch(): return await client.reference.get_cities() - + result = asyncio.run(fetch()) - + assert isinstance(result, list) # Kwork has many cities, should have at least some assert len(result) > 0 - + def test_get_countries(self, client: KworkClient): """Test getting countries.""" if not client: pytest.skip("No client") - + import asyncio + result = asyncio.run(client.reference.get_countries()) - + assert isinstance(result, list) assert len(result) > 0 - + def test_get_timezones(self, client: KworkClient): """Test getting timezones.""" if not client: pytest.skip("No client") - + import asyncio + result = asyncio.run(client.reference.get_timezones()) - + assert isinstance(result, list) assert len(result) > 0 @@ -192,15 +193,16 @@ class TestReferenceAPI: @pytest.mark.integration class TestUserAPI: """Test user endpoints.""" - + def test_get_user_info(self, client: KworkClient): """Test getting current user info.""" if not client: pytest.skip("No client") - + import asyncio + result = asyncio.run(client.user.get_info()) - + assert isinstance(result, dict) # Should have user data assert result # Not empty @@ -209,36 +211,36 @@ class TestUserAPI: @pytest.mark.integration class TestErrorHandling: """Test error handling with real API.""" - + def test_invalid_kwork_id(self, client: KworkClient): """Test getting non-existent kwork.""" if not client: pytest.skip("No client") - + import asyncio - + async def fetch(): try: await client.catalog.get_details(999999999) return False except Exception: return True - - result = asyncio.run(fetch()) + + asyncio.run(fetch()) # May or may not raise error depending on API behavior @pytest.mark.integration class TestRateLimiting: """Test rate limiting behavior.""" - + def test_multiple_requests(self, client: KworkClient): """Test making multiple requests.""" if not client: pytest.skip("No client") - + import asyncio - + async def fetch_multiple(): results = [] for page in range(1, 4): @@ -247,9 +249,9 @@ class TestRateLimiting: # Small delay to avoid rate limiting await asyncio.sleep(0.5) return results - + results = asyncio.run(fetch_multiple()) - + assert len(results) == 3 for result in results: assert result.kworks is not None diff --git a/tests/unit/__pycache__/test_client.cpython-312-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_client.cpython-312-pytest-9.0.2.pyc index d56b82f8b01fce11c02a77d88a45f54302445593..a606c3ae8cb024d5b2eb9233d2d8f147f93f5581 100644 GIT binary patch delta 4484 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