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
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+ | No results found. Check the filters. |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Summary
+
+
+
16 tests took 00:00:02.
+
(Un)check the boxes to filter the results.
+
+
+
+
+
+
+
+
+
+
+
+
+ | Result |
+ Test |
+ Duration |
+ Links |
+
+
+
+
+
+
\ 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"