diff --git a/.gitea/workflows/pr-check.yml b/.gitea/workflows/pr-check.yml index 0e177e8..0faedb1 100644 --- a/.gitea/workflows/pr-check.yml +++ b/.gitea/workflows/pr-check.yml @@ -9,7 +9,35 @@ concurrency: cancel-in-progress: true jobs: + lint: + name: 📏 Lint & Type Check + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Use system Python + run: | + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Install dependencies (with dev) + env: + UV_NO_PROGRESS: "1" + run: uv sync --group dev + + - name: Run linting (Ruff) + run: uv run ruff check src/kwork_api tests/ + + - name: Check formatting (Ruff) + run: uv run ruff format --check src/kwork_api tests/ + + - name: Run type checking (MyPy) + run: uv run mypy src/kwork_api + test: + name: 🧪 Tests runs-on: ubuntu-latest timeout-minutes: 15 @@ -62,14 +90,9 @@ jobs: name: coverage-report path: coverage-html/ retention-days: 7 - - - name: Run linting - run: uv run ruff check src/kwork_api tests/ - - - name: Run formatter check - run: uv run ruff format --check src/kwork_api tests/ security: + name: 🔒 Security runs-on: ubuntu-latest timeout-minutes: 10 @@ -88,7 +111,15 @@ jobs: env: UV_NO_PROGRESS: "1" run: | - uv pip compile pyproject.toml --no-dev -o requirements-prod.txt && uv run pip-audit --format json --output audit-results.json -r requirements-prod.txt && test ! -s audit-results.json || test "$(cat audit-results.json)" = "[]" + uv pip compile pyproject.toml --no-deps -o requirements-prod.txt + # pip-audit returns exit code 1 if vulnerabilities found, 0 if none + if uv run pip-audit --progress-spinner off --format json --output audit-results.json -r requirements-prod.txt; then + echo "✅ No vulnerabilities found" + rm -f audit-results.json + else + echo "❌ Found vulnerabilities - see security-audit artifact" + exit 1 + fi - name: Upload audit log uses: actions/upload-artifact@v3 diff --git a/README.md b/README.md index f564732..67c5da9 100644 --- a/README.md +++ b/README.md @@ -270,3 +270,4 @@ Use at your own risk and respect Kwork's terms of service. ## CI Test Testing Gitea Actions workflow. +# Test pre-commit diff --git a/pyproject.toml b/pyproject.toml index 0bed2e1..9b59974 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dev = [ "respx>=0.20.0", # Linting & formatting "ruff>=0.3.0", + "mypy>=1.8.0", # CI tools "python-semantic-release>=9.0.0", "pip-audit>=2.7.0", @@ -130,3 +131,21 @@ token = { env = "GH_TOKEN" } [tool.semantic_release.publish] dist_glob_patterns = ["dist/*"] upload_to_vcs_release = true + +# ========== MyPy Configuration ========== + +[tool.mypy] +python_version = "3.10" +strict = true +warn_return_any = true +warn_unused_ignores = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_configs = true +show_error_codes = true +pretty = true +files = ["src/kwork_api"] +exclude = ["tests/"] 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 0e5b60d..3c3b9a2 100644 Binary files a/src/kwork_api/__pycache__/__init__.cpython-312.pyc and b/src/kwork_api/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/kwork_api/__pycache__/client.cpython-312.pyc b/src/kwork_api/__pycache__/client.cpython-312.pyc index 455e83c..cf3c83d 100644 Binary files a/src/kwork_api/__pycache__/client.cpython-312.pyc and b/src/kwork_api/__pycache__/client.cpython-312.pyc differ diff --git a/src/kwork_api/__pycache__/errors.cpython-312.pyc b/src/kwork_api/__pycache__/errors.cpython-312.pyc index adda591..8091491 100644 Binary files a/src/kwork_api/__pycache__/errors.cpython-312.pyc and b/src/kwork_api/__pycache__/errors.cpython-312.pyc differ diff --git a/src/kwork_api/__pycache__/models.cpython-312.pyc b/src/kwork_api/__pycache__/models.cpython-312.pyc index 624181c..573e0ea 100644 Binary files a/src/kwork_api/__pycache__/models.cpython-312.pyc and b/src/kwork_api/__pycache__/models.cpython-312.pyc differ diff --git a/src/kwork_api/client.py b/src/kwork_api/client.py index 60ce889..58a176c 100644 --- a/src/kwork_api/client.py +++ b/src/kwork_api/client.py @@ -5,10 +5,9 @@ Main client class with authentication and all API endpoints. """ import logging -from typing import Any, Optional +from typing import Any import httpx -from pydantic import HttpUrl from .errors import ( KworkApiError, @@ -20,13 +19,10 @@ from .errors import ( KworkValidationError, ) from .models import ( - APIErrorResponse, - AuthResponse, Badge, CatalogResponse, City, Country, - DataResponse, Dialog, Feature, Kwork, @@ -34,7 +30,6 @@ from .models import ( NotificationsResponse, Project, ProjectsResponse, - Review, ReviewsResponse, TimeZone, ValidationResponse, @@ -46,32 +41,32 @@ logger = logging.getLogger(__name__) class KworkClient: """ Асинхронный клиент для Kwork.ru API. - + Предоставляет доступ ко всем основным эндпоинтам Kwork API: - Каталог кворков и поиск - Биржа проектов (фриланс заказы) - Пользовательские данные и отзывы - Уведомления и сообщения - Справочные данные (города, страны, категории) - + Аутентификация: Клиент использует двухэтапную аутентификацию Kwork: 1. POST /signIn — получение session cookies 2. POST /getWebAuthToken — получение web_auth_token - + Примеры использования: # Вход по логину/паролю async with await KworkClient.login("username", "password") as client: catalog = await client.catalog.get_list(page=1) - + # Восстановление сессии по токену client = KworkClient(token="saved_web_auth_token") user_info = await client.user.get_info() - + # Работа с проектами projects = await client.projects.get_list(page=1) my_orders = await client.projects.get_payer_orders() - + Attributes: catalog: Доступ к каталогу кворков projects: Доступ к бирже проектов @@ -79,29 +74,29 @@ class KworkClient: reference: Справочные данные notifications: Уведомления и сообщения other: Прочие эндпоинты - + Note: Клиент поддерживает context manager для автоматического закрытия соединения. Рекомендуется использовать `async with` для корректного освобождения ресурсов. """ - + BASE_URL = "https://api.kwork.ru" LOGIN_URL = "https://kwork.ru/signIn" TOKEN_URL = "https://kwork.ru/getWebAuthToken" - + def __init__( self, - token: Optional[str] = None, - cookies: Optional[dict[str, str]] = None, + token: str | None = None, + cookies: dict[str, str] | None = None, timeout: float = 30.0, - base_url: Optional[str] = None, + base_url: str | None = None, ): """ Инициализация клиента. - + Создаёт неаутентифицированный клиент или восстанавливает сессию по ранее полученному токену. - + Args: token: Web auth token, полученный через `getWebAuthToken` или `login()`. Если указан, автоматически добавляется в cookies. @@ -109,17 +104,17 @@ class KworkClient: Обычно не требуется — устанавливаются автоматически из token. timeout: Таймаут HTTP запросов в секундах. По умолчанию 30 секунд. base_url: Кастомный базовый URL. Используется только для тестирования. - + Example: # Новый клиент без аутентификации client = KworkClient() - + # Восстановление сессии client = KworkClient(token="eyJ0eXAiOiJKV1QiLCJhbGc...") - + # Клиент с кастомным таймаутом client = KworkClient(timeout=60.0) - + Note: Для полноценной работы API требуется аутентификация. Используйте `login()` или передайте сохранённый token. @@ -128,61 +123,61 @@ class KworkClient: self.timeout = timeout self._token = token self._cookies = cookies or {} - + # Initialize HTTP client - self._client: Optional[httpx.AsyncClient] = None - + self._client: httpx.AsyncClient | None = None + @property - def token(self) -> 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 | 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,35 @@ 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() + result = response.json() + return result if isinstance(result, dict) else {} 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 +373,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 +438,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 +470,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 +503,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 +531,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 +600,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 +618,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 +636,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 +720,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 +737,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 +827,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 +861,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 +929,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 +947,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: @@ -1104,50 +1100,51 @@ class KworkClient: "/isDialogAllow", json={"user_id": user_id}, ) - return data.get("allowed", False) - + return bool(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", []) - + categories = data.get("categories", []) + return [int(cat) for cat in categories] if categories else [] + 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 +1152,52 @@ 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 393204b..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,50 +109,60 @@ 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: - await asyncio.sleep(5) # Пауза 5 секунд + 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): + + def __init__( + self, + message: str = "Rate limit exceeded", + response: Any | None = None, + retry_after: int | None = None, + ): + self.retry_after = retry_after super().__init__(message, 429, response) class KworkValidationError(KworkApiError): """ Ошибка валидации (400). - + Возникает при некорректных данных запроса. - + Attributes: fields: Словарь ошибок по полям {field: [errors]}. - + Example: try: await client.catalog.get_list(page=-1) @@ -161,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()) @@ -181,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 d56b82f..a606c3a 100644 Binary files a/tests/unit/__pycache__/test_client.cpython-312-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_client.cpython-312-pytest-9.0.2.pyc differ diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index b3e6507..8e24f6c 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -8,25 +8,27 @@ import pytest import respx from httpx import Response -from kwork_api import KworkClient, KworkAuthError, KworkApiError -from kwork_api.models import CatalogResponse, Kwork, ValidationResponse, ValidationIssue +from kwork_api import KworkApiError, KworkAuthError, KworkClient, KworkNetworkError +from kwork_api.models import CatalogResponse, ValidationResponse class TestAuthentication: """Test authentication flows.""" - + @respx.mock async def test_login_success(self): """Test successful login.""" import httpx - + # Mock login endpoint login_route = respx.post("https://kwork.ru/signIn") - login_route.mock(return_value=httpx.Response( - 200, - headers={"Set-Cookie": "userId=12345; slrememberme=token123"}, - )) - + login_route.mock( + return_value=httpx.Response( + 200, + headers={"Set-Cookie": "userId=12345; slrememberme=token123"}, + ) + ) + # Mock token endpoint token_route = respx.post("https://kwork.ru/getWebAuthToken").mock( return_value=Response( @@ -34,53 +36,53 @@ class TestAuthentication: json={"web_auth_token": "test_token_abc123"}, ) ) - + # Login client = await KworkClient.login("testuser", "testpass") - + # Verify assert login_route.called assert token_route.called assert client._token == "test_token_abc123" - + @respx.mock async def test_login_invalid_credentials(self): """Test login with invalid credentials.""" respx.post("https://kwork.ru/signIn").mock( return_value=Response(401, json={"error": "Invalid credentials"}) ) - + with pytest.raises(KworkAuthError): await KworkClient.login("wrong", "wrong") - + @respx.mock async def test_login_no_userid(self): """Test login without userId in cookies.""" import httpx - + respx.post("https://kwork.ru/signIn").mock( return_value=httpx.Response(200, headers={"Set-Cookie": "other=value"}) ) - + with pytest.raises(KworkAuthError, match="no userId"): await KworkClient.login("test", "test") - + @respx.mock async def test_login_no_token(self): """Test login without web_auth_token in response.""" import httpx - + respx.post("https://kwork.ru/signIn").mock( return_value=httpx.Response(200, headers={"Set-Cookie": "userId=123"}) ) - + respx.post("https://kwork.ru/getWebAuthToken").mock( return_value=Response(200, json={"other": "data"}) ) - + with pytest.raises(KworkAuthError, match="No web_auth_token"): await KworkClient.login("test", "test") - + def test_init_with_token(self): """Test client initialization with token.""" client = KworkClient(token="test_token") @@ -89,12 +91,12 @@ class TestAuthentication: class TestCatalogAPI: """Test catalog endpoints.""" - + @respx.mock async def test_get_catalog(self): """Test getting catalog list.""" client = KworkClient(token="test") - + mock_data = { "kworks": [ {"id": 1, "title": "Test Kwork", "price": 1000.0}, @@ -106,23 +108,23 @@ class TestCatalogAPI: "total_items": 100, }, } - + respx.post(f"{client.base_url}/catalogMainv2").mock( return_value=Response(200, json=mock_data) ) - + result = await client.catalog.get_list(page=1) - + assert isinstance(result, CatalogResponse) assert len(result.kworks) == 2 assert result.kworks[0].id == 1 assert result.pagination.total_pages == 5 - + @respx.mock async def test_get_kwork_details(self): """Test getting kwork details.""" client = KworkClient(token="test") - + mock_data = { "id": 123, "title": "Detailed Kwork", @@ -130,38 +132,38 @@ class TestCatalogAPI: "full_description": "Full description here", "delivery_time": 3, } - + respx.post(f"{client.base_url}/getKworkDetails").mock( return_value=Response(200, json=mock_data) ) - + result = await client.catalog.get_details(123) - + assert result.id == 123 assert result.full_description == "Full description here" assert result.delivery_time == 3 - + @respx.mock async def test_catalog_error(self): """Test catalog API error handling.""" client = KworkClient(token="test") - + respx.post(f"{client.base_url}/catalogMainv2").mock( return_value=Response(400, json={"message": "Invalid category"}) ) - + with pytest.raises(KworkApiError): await client.catalog.get_list(category_id=99999) class TestProjectsAPI: """Test projects endpoints.""" - + @respx.mock async def test_get_projects(self): """Test getting projects list.""" client = KworkClient(token="test") - + mock_data = { "projects": [ { @@ -174,106 +176,102 @@ class TestProjectsAPI: ], "pagination": {"current_page": 1}, } - - respx.post(f"{client.base_url}/projects").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{client.base_url}/projects").mock(return_value=Response(200, json=mock_data)) + result = await client.projects.get_list() - + assert len(result.projects) == 1 assert result.projects[0].budget == 10000.0 class TestErrorHandling: """Test error handling.""" - + @respx.mock async def test_404_error(self): """Test 404 error handling.""" client = KworkClient(token="test") - - respx.post(f"{client.base_url}/getKworkDetails").mock( - return_value=Response(404) - ) - + + respx.post(f"{client.base_url}/getKworkDetails").mock(return_value=Response(404)) + with pytest.raises(KworkApiError) as exc_info: await client.catalog.get_details(999) - + assert exc_info.value.status_code == 404 - + @respx.mock async def test_401_error(self): """Test 401 error handling.""" client = KworkClient(token="invalid") - - respx.post(f"{client.base_url}/catalogMainv2").mock( - return_value=Response(401) - ) - + + respx.post(f"{client.base_url}/catalogMainv2").mock(return_value=Response(401)) + with pytest.raises(KworkAuthError): await client.catalog.get_list() - + @respx.mock async def test_network_error(self): """Test network error handling.""" + import httpx + client = KworkClient(token="test") - + respx.post(f"{client.base_url}/catalogMainv2").mock( - side_effect=Exception("Connection refused") + side_effect=httpx.RequestError("Connection refused", request=None) ) - - with pytest.raises(Exception): + + with pytest.raises(KworkNetworkError): await client.catalog.get_list() class TestContextManager: """Test async context manager.""" - + async def test_context_manager(self): """Test using client as context manager.""" async with KworkClient(token="test") as client: assert client._client is None # Not created yet - + # Client should be created on first request # (but we don't make actual requests in this test) - + # Client should be closed after context assert client._client is None or client._client.is_closed class TestValidationAPI: """Test text validation endpoint.""" - + @respx.mock async def test_validate_text_success(self): """Test successful text validation.""" client = KworkClient(token="test") - + mock_data = { "success": True, "is_valid": True, "issues": [], "score": 95, } - + respx.post(f"{client.base_url}/api/validation/checktext").mock( return_value=Response(200, json=mock_data) ) - + result = await client.other.validate_text("Хороший текст для кворка") - + assert isinstance(result, ValidationResponse) assert result.success is True assert result.is_valid is True assert len(result.issues) == 0 assert result.score == 95 - + @respx.mock async def test_validate_text_with_issues(self): """Test text validation with found issues.""" client = KworkClient(token="test") - + mock_data = { "success": True, "is_valid": False, @@ -293,40 +291,40 @@ class TestValidationAPI: ], "score": 45, } - + respx.post(f"{client.base_url}/api/validation/checktext").mock( return_value=Response(200, json=mock_data) ) - + result = await client.other.validate_text( "Звоните +7-999-000-00-00", context="kwork_description", ) - + assert result.is_valid is False assert len(result.issues) == 2 assert result.issues[0].code == "CONTACT_INFO" assert result.issues[0].type == "error" assert result.issues[1].type == "warning" assert result.score == 45 - + @respx.mock async def test_validate_text_empty(self): """Test validation of empty text.""" client = KworkClient(token="test") - + mock_data = { "success": False, "is_valid": False, "message": "Текст не может быть пустым", "issues": [], } - + respx.post(f"{client.base_url}/api/validation/checktext").mock( return_value=Response(200, json=mock_data) ) - + result = await client.other.validate_text("") - + assert result.success is False assert result.message is not None diff --git a/tests/unit/test_client_extended.py b/tests/unit/test_client_extended.py index 1d3d489..d9e9bf8 100644 --- a/tests/unit/test_client_extended.py +++ b/tests/unit/test_client_extended.py @@ -8,17 +8,17 @@ import pytest import respx from httpx import Response -from kwork_api import KworkClient, KworkAuthError, KworkApiError +from kwork_api import KworkApiError, KworkClient from kwork_api.models import ( - NotificationsResponse, - Kwork, - Dialog, + Badge, City, Country, - TimeZone, + Dialog, Feature, - Badge, + Kwork, + NotificationsResponse, Project, + TimeZone, ) BASE_URL = "https://api.kwork.ru" @@ -26,22 +26,22 @@ BASE_URL = "https://api.kwork.ru" class TestClientProperties: """Test client properties and initialization.""" - + def test_token_property(self): """Test token property getter.""" client = KworkClient(token="test_token_123") assert client.token == "test_token_123" - + def test_token_property_none(self): """Test token property when no token.""" client = KworkClient() assert client.token is None - + def test_cookies_property_empty(self): """Test cookies property with no cookies.""" client = KworkClient(token="test") assert client.cookies == {} - + def test_cookies_property_with_cookies(self): """Test cookies property returns copy.""" client = KworkClient(token="test", cookies={"userId": "123", "session": "abc"}) @@ -49,14 +49,14 @@ class TestClientProperties: assert cookies == {"userId": "123", "session": "abc"} cookies["modified"] = "value" assert "modified" not in client.cookies - + def test_credentials_property(self): """Test credentials property returns token and cookies.""" client = KworkClient(token="test_token", cookies={"userId": "123"}) creds = client.credentials assert creds["token"] == "test_token" assert creds["cookies"] == {"userId": "123"} - + def test_credentials_property_no_cookies(self): """Test credentials with no cookies.""" client = KworkClient(token="test_token") @@ -67,136 +67,131 @@ class TestClientProperties: class TestCatalogAPIExtended: """Extended tests for CatalogAPI.""" - + @respx.mock async def test_get_details_extra(self): """Test get_details_extra endpoint.""" client = KworkClient(token="test") - + mock_data = { "id": 456, "title": "Extra Details Kwork", "extra_field": "extra_value", } - + respx.post(f"{BASE_URL}/getKworkDetailsExtra").mock( return_value=Response(200, json=mock_data) ) - + result = await client.catalog.get_details_extra(456) - + assert result["id"] == 456 assert result["extra_field"] == "extra_value" class TestProjectsAPIExtended: """Extended tests for ProjectsAPI.""" - + @respx.mock async def test_get_payer_orders(self): """Test getting payer orders.""" client = KworkClient(token="test") - + mock_data = { "orders": [ {"id": 101, "title": "Order 1", "amount": 5000.0, "status": "active"}, ] } - - respx.post(f"{BASE_URL}/payerOrders").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/payerOrders").mock(return_value=Response(200, json=mock_data)) + result = await client.projects.get_payer_orders() - + assert isinstance(result, list) assert len(result) == 1 assert isinstance(result[0], Project) - + @respx.mock async def test_get_worker_orders(self): """Test getting worker orders.""" client = KworkClient(token="test") - + mock_data = { "orders": [ {"id": 202, "title": "Worker Order", "amount": 3000.0, "status": "completed"}, ] } - - respx.post(f"{BASE_URL}/workerOrders").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/workerOrders").mock(return_value=Response(200, json=mock_data)) + result = await client.projects.get_worker_orders() - + assert isinstance(result, list) assert len(result) == 1 class TestUserAPI: """Tests for UserAPI.""" - + @respx.mock async def test_get_info(self): """Test getting user info.""" client = KworkClient(token="test") - + mock_data = { "userId": 12345, "username": "testuser", "email": "test@example.com", "balance": 50000.0, } - - respx.post(f"{BASE_URL}/user").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/user").mock(return_value=Response(200, json=mock_data)) + result = await client.user.get_info() - + assert result["userId"] == 12345 assert result["username"] == "testuser" - + @respx.mock async def test_get_reviews(self): """Test getting user reviews.""" client = KworkClient(token="test") - + mock_data = { "reviews": [ - {"id": 1, "rating": 5, "comment": "Great work!", "author": {"id": 999, "username": "client1"}}, + { + "id": 1, + "rating": 5, + "comment": "Great work!", + "author": {"id": 999, "username": "client1"}, + }, ], "pagination": {"current_page": 1, "total_pages": 5}, } - - respx.post(f"{BASE_URL}/userReviews").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/userReviews").mock(return_value=Response(200, json=mock_data)) + result = await client.user.get_reviews(user_id=12345, page=1) - + assert len(result.reviews) == 1 assert result.reviews[0].rating == 5 - + @respx.mock async def test_get_favorite_kworks(self): """Test getting favorite kworks.""" client = KworkClient(token="test") - + mock_data = { "kworks": [ {"id": 100, "title": "Favorite Kwork 1", "price": 2000.0}, {"id": 101, "title": "Favorite Kwork 2", "price": 3000.0}, ] } - - respx.post(f"{BASE_URL}/favoriteKworks").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/favoriteKworks").mock(return_value=Response(200, json=mock_data)) + result = await client.user.get_favorite_kworks() - + assert isinstance(result, list) assert len(result) == 2 assert isinstance(result[0], Kwork) @@ -204,133 +199,129 @@ class TestUserAPI: class TestReferenceAPI: """Tests for ReferenceAPI.""" - + @respx.mock async def test_get_cities(self): """Test getting cities list.""" client = KworkClient(token="test") - + mock_data = { "cities": [ {"id": 1, "name": "Москва", "country_id": 1}, {"id": 2, "name": "Санкт-Петербург", "country_id": 1}, ] } - - respx.post(f"{BASE_URL}/cities").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/cities").mock(return_value=Response(200, json=mock_data)) + result = await client.reference.get_cities() - + assert isinstance(result, list) assert len(result) == 2 assert isinstance(result[0], City) - + @respx.mock async def test_get_countries(self): """Test getting countries list.""" client = KworkClient(token="test") - + mock_data = { "countries": [ {"id": 1, "name": "Россия", "code": "RU"}, {"id": 2, "name": "Беларусь", "code": "BY"}, ] } - - respx.post(f"{BASE_URL}/countries").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/countries").mock(return_value=Response(200, json=mock_data)) + result = await client.reference.get_countries() - + assert isinstance(result, list) assert len(result) == 2 assert isinstance(result[0], Country) - + @respx.mock async def test_get_timezones(self): """Test getting timezones list.""" client = KworkClient(token="test") - + mock_data = { "timezones": [ {"id": 1, "name": "Europe/Moscow", "offset": "+03:00"}, {"id": 2, "name": "Europe/Kaliningrad", "offset": "+02:00"}, ] } - - respx.post(f"{BASE_URL}/timezones").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/timezones").mock(return_value=Response(200, json=mock_data)) + result = await client.reference.get_timezones() - + assert isinstance(result, list) assert len(result) == 2 assert isinstance(result[0], TimeZone) - + @respx.mock async def test_get_features(self): """Test getting features list.""" client = KworkClient(token="test") - + mock_data = { "features": [ {"id": 1, "name": "Feature 1", "category_id": 5, "price": 1000, "type": "extra"}, {"id": 2, "name": "Feature 2", "category_id": 5, "price": 2000, "type": "extra"}, ] } - + respx.post(f"{BASE_URL}/getAvailableFeatures").mock( return_value=Response(200, json=mock_data) ) - + result = await client.reference.get_features() - + assert isinstance(result, list) assert len(result) == 2 assert isinstance(result[0], Feature) - + @respx.mock async def test_get_public_features(self): """Test getting public features list.""" client = KworkClient(token="test") - + mock_data = { "features": [ - {"id": 10, "name": "Public Feature", "is_public": True, "price": 500, "type": "extra"}, + { + "id": 10, + "name": "Public Feature", + "is_public": True, + "price": 500, + "type": "extra", + }, ] } - - respx.post(f"{BASE_URL}/getPublicFeatures").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/getPublicFeatures").mock(return_value=Response(200, json=mock_data)) + result = await client.reference.get_public_features() - + assert isinstance(result, list) assert len(result) == 1 - + @respx.mock async def test_get_badges_info(self): """Test getting badges info.""" client = KworkClient(token="test") - + mock_data = { "badges": [ {"id": 1, "name": "Pro Seller", "icon_url": "https://example.com/badge1.png"}, {"id": 2, "name": "Verified", "icon_url": "https://example.com/badge2.png"}, ] } - - respx.post(f"{BASE_URL}/getBadgesInfo").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/getBadgesInfo").mock(return_value=Response(200, json=mock_data)) + result = await client.reference.get_badges_info() - + assert isinstance(result, list) assert len(result) == 2 assert isinstance(result[0], Badge) @@ -338,406 +329,400 @@ class TestReferenceAPI: class TestNotificationsAPI: """Tests for NotificationsAPI.""" - + @respx.mock async def test_get_list(self): """Test getting notifications list.""" client = KworkClient(token="test") - + mock_data = { "notifications": [ - {"id": 1, "type": "order", "title": "New Order", "message": "New order received", "is_read": False}, - {"id": 2, "type": "message", "title": "New Message", "message": "You have a new message", "is_read": True}, + { + "id": 1, + "type": "order", + "title": "New Order", + "message": "New order received", + "is_read": False, + }, + { + "id": 2, + "type": "message", + "title": "New Message", + "message": "You have a new message", + "is_read": True, + }, ], "unread_count": 5, } - - respx.post(f"{BASE_URL}/notifications").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/notifications").mock(return_value=Response(200, json=mock_data)) + result = await client.notifications.get_list() - + assert isinstance(result, NotificationsResponse) assert result.unread_count == 5 - + @respx.mock async def test_fetch(self): """Test fetching notifications.""" client = KworkClient(token="test") - + mock_data = { "notifications": [ - {"id": 3, "type": "system", "title": "System Update", "message": "System update available", "is_read": False}, + { + "id": 3, + "type": "system", + "title": "System Update", + "message": "System update available", + "is_read": False, + }, ], "unread_count": 1, } - + respx.post(f"{BASE_URL}/notificationsFetch").mock( return_value=Response(200, json=mock_data) ) - + result = await client.notifications.fetch() - + assert isinstance(result, NotificationsResponse) assert len(result.notifications) == 1 - + @respx.mock async def test_get_dialogs(self): """Test getting dialogs.""" client = KworkClient(token="test") - + mock_data = { "dialogs": [ {"id": 1, "user_id": 100, "last_message": "Hello", "unread": 2}, {"id": 2, "user_id": 200, "last_message": "Hi", "unread": 0}, ] } - - respx.post(f"{BASE_URL}/dialogs").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/dialogs").mock(return_value=Response(200, json=mock_data)) + result = await client.notifications.get_dialogs() - + assert isinstance(result, list) assert len(result) == 2 assert isinstance(result[0], Dialog) - + @respx.mock async def test_get_blocked_dialogs(self): """Test getting blocked dialogs.""" client = KworkClient(token="test") - + mock_data = { "dialogs": [ {"id": 99, "user_id": 999, "last_message": "Spam", "blocked": True}, ] } - - respx.post(f"{BASE_URL}/blockedDialogList").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/blockedDialogList").mock(return_value=Response(200, json=mock_data)) + result = await client.notifications.get_blocked_dialogs() - + assert isinstance(result, list) assert len(result) == 1 class TestOtherAPI: """Tests for OtherAPI.""" - + @respx.mock async def test_get_wants(self): """Test getting wants.""" client = KworkClient(token="test") - + mock_data = { "wants": [{"id": 1, "title": "I need a logo"}], "count": 1, } - - respx.post(f"{BASE_URL}/myWants").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/myWants").mock(return_value=Response(200, json=mock_data)) + result = await client.other.get_wants() - + assert "wants" in result assert result["count"] == 1 - + @respx.mock async def test_get_wants_status(self): """Test getting wants status.""" client = KworkClient(token="test") - + mock_data = { "active_wants": 5, "completed_wants": 10, } - - respx.post(f"{BASE_URL}/wantsStatusList").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/wantsStatusList").mock(return_value=Response(200, json=mock_data)) + result = await client.other.get_wants_status() - + assert result["active_wants"] == 5 - + @respx.mock async def test_get_kworks_status(self): """Test getting kworks status.""" client = KworkClient(token="test") - + mock_data = { "active_kworks": 3, "total_sales": 50, } - - respx.post(f"{BASE_URL}/kworksStatusList").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/kworksStatusList").mock(return_value=Response(200, json=mock_data)) + result = await client.other.get_kworks_status() - + assert result["active_kworks"] == 3 - + @respx.mock async def test_get_offers(self): """Test getting offers.""" client = KworkClient(token="test") - + mock_data = { "offers": [{"id": 1, "title": "Special offer"}], } - - respx.post(f"{BASE_URL}/offers").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/offers").mock(return_value=Response(200, json=mock_data)) + result = await client.other.get_offers() - + assert "offers" in result - + @respx.mock async def test_get_exchange_info(self): """Test getting exchange info.""" client = KworkClient(token="test") - + mock_data = { "usd_rate": 90.5, "eur_rate": 98.2, } - - respx.post(f"{BASE_URL}/exchangeInfo").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/exchangeInfo").mock(return_value=Response(200, json=mock_data)) + result = await client.other.get_exchange_info() - + assert result["usd_rate"] == 90.5 - + @respx.mock async def test_get_channel(self): """Test getting channel info.""" client = KworkClient(token="test") - + mock_data = { "channel_id": "main", "name": "Main Channel", } - - respx.post(f"{BASE_URL}/getChannel").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/getChannel").mock(return_value=Response(200, json=mock_data)) + result = await client.other.get_channel() - + assert result["channel_id"] == "main" - + @respx.mock async def test_get_in_app_notification(self): """Test getting in-app notifications.""" client = KworkClient(token="test") - + mock_data = { "notifications": [{"id": 1, "message": "App update"}], } - + respx.post(f"{BASE_URL}/getInAppNotification").mock( return_value=Response(200, json=mock_data) ) - + result = await client.other.get_in_app_notification() - + assert "notifications" in result - + @respx.mock async def test_get_security_user_data(self): """Test getting security user data.""" client = KworkClient(token="test") - + mock_data = { "two_factor_enabled": True, "last_login": "2024-01-01T00:00:00Z", } - + respx.post(f"{BASE_URL}/getSecurityUserData").mock( return_value=Response(200, json=mock_data) ) - + result = await client.other.get_security_user_data() - + assert result["two_factor_enabled"] is True - + @respx.mock async def test_is_dialog_allow_true(self): """Test is_dialog_allow returns True.""" client = KworkClient(token="test") - + respx.post(f"{BASE_URL}/isDialogAllow").mock( return_value=Response(200, json={"allowed": True}) ) - + result = await client.other.is_dialog_allow(12345) - + assert result is True - + @respx.mock async def test_is_dialog_allow_false(self): """Test is_dialog_allow returns False.""" client = KworkClient(token="test") - + respx.post(f"{BASE_URL}/isDialogAllow").mock( return_value=Response(200, json={"allowed": False}) ) - + result = await client.other.is_dialog_allow(67890) - + assert result is False - + @respx.mock async def test_get_viewed_kworks(self): """Test getting viewed kworks.""" client = KworkClient(token="test") - + mock_data = { "kworks": [ {"id": 500, "title": "Viewed Kwork", "price": 1500.0}, ] } - + respx.post(f"{BASE_URL}/viewedCatalogKworks").mock( return_value=Response(200, json=mock_data) ) - + result = await client.other.get_viewed_kworks() - + assert isinstance(result, list) assert len(result) == 1 assert isinstance(result[0], Kwork) - + @respx.mock async def test_get_favorite_categories(self): """Test getting favorite categories.""" client = KworkClient(token="test") - + mock_data = { "categories": [1, 5, 10, 15], } - + respx.post(f"{BASE_URL}/favoriteCategories").mock( return_value=Response(200, json=mock_data) ) - + result = await client.other.get_favorite_categories() - + assert isinstance(result, list) assert 1 in result assert 5 in result - + @respx.mock async def test_update_settings(self): """Test updating settings.""" client = KworkClient(token="test") - + mock_data = { "success": True, "updated": {"notifications_enabled": False}, } - - respx.post(f"{BASE_URL}/updateSettings").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/updateSettings").mock(return_value=Response(200, json=mock_data)) + settings = {"notifications_enabled": False, "theme": "dark"} result = await client.other.update_settings(settings) - + assert result["success"] is True - + @respx.mock async def test_go_offline(self): """Test going offline.""" client = KworkClient(token="test") - + mock_data = { "success": True, "status": "offline", } - - respx.post(f"{BASE_URL}/offline").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/offline").mock(return_value=Response(200, json=mock_data)) + result = await client.other.go_offline() - + assert result["success"] is True assert result["status"] == "offline" - + @respx.mock async def test_get_actor(self): """Test getting actor info.""" client = KworkClient(token="test") - + mock_data = { "actor_id": 123, "name": "Test Actor", } - - respx.post(f"{BASE_URL}/actor").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/actor").mock(return_value=Response(200, json=mock_data)) + result = await client.other.get_actor() - + assert result["actor_id"] == 123 class TestClientInternals: """Tests for internal client methods.""" - + def test_handle_response_success(self): """Test _handle_response with successful response.""" import httpx + client = KworkClient(token="test") - + response = httpx.Response(200, json={"success": True, "data": "test"}) result = client._handle_response(response) - + assert result["success"] is True assert result["data"] == "test" - + def test_handle_response_error(self): """Test _handle_response with error response.""" import httpx + client = KworkClient(token="test") - + response = httpx.Response(400, json={"message": "Bad request"}) - + with pytest.raises(KworkApiError) as exc_info: client._handle_response(response) - + assert exc_info.value.status_code == 400 - + @respx.mock async def test_request_method(self): """Test _request method directly.""" client = KworkClient(token="test") - + mock_data = {"result": "success"} - - respx.post(f"{BASE_URL}/test-endpoint").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/test-endpoint").mock(return_value=Response(200, json=mock_data)) + result = await client._request("POST", "/test-endpoint", json={"param": "value"}) - + assert result["result"] == "success" - + async def test_context_manager_creates_client(self): """Test that context manager creates httpx client.""" async with KworkClient(token="test") as client: assert client.token == "test" - + assert client._client is None or client._client.is_closed diff --git a/uv.lock b/uv.lock index 5f2bd19..b06cc69 100644 --- a/uv.lock +++ b/uv.lock @@ -585,6 +585,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "mypy" }, { name = "pip-audit" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -609,6 +610,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "mypy", specifier = ">=1.8.0" }, { name = "pip-audit", specifier = ">=2.7.0" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.23.0" }, @@ -624,6 +626,91 @@ docs = [ { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.3" }, ] +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, + { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, + { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, + { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, + { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, + { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +] + [[package]] name = "license-expression" version = "30.4.4" @@ -941,6 +1028,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, ] +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "packageurl-python" version = "0.17.6"