From ccc670b79cf22ac7035643c953c411052966d999 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 23 Mar 2026 02:48:44 +0000 Subject: [PATCH] Initial commit: kwork-api v0.1.0 Features: - Full API coverage (45 endpoints from HAR analysis) - Async/await support with httpx - Pydantic models for all responses - Clear error handling (KworkAuthError, KworkApiError, etc.) - Session management (cookies + web_auth_token) - Unit tests with respx mocks - Integration tests template - JSON logging support via structlog Endpoints implemented: - Authentication: signIn, getWebAuthToken - Catalog: catalogMainv2, getKworkDetails, getKworkDetailsExtra - Projects: projects, payerOrders, workerOrders - User: user, userReviews, favoriteKworks - Reference: cities, countries, timezones, features, badges - Notifications: notifications, notificationsFetch, dialogs - Other: 30+ additional endpoints Tests: 13 passed, 79% coverage --- .coverage | Bin 0 -> 73728 bytes README.md | 267 +++++++ pyproject.toml | 99 +++ src/kwork_api/__init__.py | 23 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 745 bytes .../__pycache__/client.cpython-312.pyc | Bin 0 -> 29083 bytes .../__pycache__/errors.cpython-312.pyc | Bin 0 -> 5590 bytes .../__pycache__/models.cpython-312.pyc | Bin 0 -> 9959 bytes src/kwork_api/client.py | 599 ++++++++++++++++ src/kwork_api/errors.py | 90 +++ src/kwork_api/models.py | 206 ++++++ tests/integration/test_real_api.py | 255 +++++++ .../test_client.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 23436 bytes tests/unit/test_client.py | 242 +++++++ uv.lock | 654 ++++++++++++++++++ 15 files changed, 2435 insertions(+) create mode 100644 .coverage create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/kwork_api/__init__.py create mode 100644 src/kwork_api/__pycache__/__init__.cpython-312.pyc create mode 100644 src/kwork_api/__pycache__/client.cpython-312.pyc create mode 100644 src/kwork_api/__pycache__/errors.cpython-312.pyc create mode 100644 src/kwork_api/__pycache__/models.cpython-312.pyc create mode 100644 src/kwork_api/client.py create mode 100644 src/kwork_api/errors.py create mode 100644 src/kwork_api/models.py create mode 100644 tests/integration/test_real_api.py create mode 100644 tests/unit/__pycache__/test_client.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/unit/test_client.py create mode 100644 uv.lock diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..baf938cfb954b77d2c46d69e465db0cd4f3e428e GIT binary patch literal 73728 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.26.0", + "pydantic>=2.0.0", + "structlog>=24.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-cov>=4.0.0", + "pytest-asyncio>=0.23.0", + "respx>=0.20.0", + "ruff>=0.3.0", +] + +[project.urls] +Homepage = "http://5.188.26.192:3000/claw/kwork-api" +Repository = "http://5.188.26.192:3000/claw/kwork-api.git" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/kwork_api"] + +[tool.uv] +dev-dependencies = [ + "pytest>=8.0.0", + "pytest-cov>=4.0.0", + "pytest-asyncio>=0.23.0", + "respx>=0.20.0", + "ruff>=0.3.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] +asyncio_mode = "auto" +markers = [ + "unit: Unit tests with mocks", + "integration: Integration tests with real API", +] +addopts = "-v --cov=kwork_api --cov-report=term-missing" + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long +] + +[tool.coverage.run] +source = ["kwork_api"] +branch = true + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] diff --git a/src/kwork_api/__init__.py b/src/kwork_api/__init__.py new file mode 100644 index 0000000..7876631 --- /dev/null +++ b/src/kwork_api/__init__.py @@ -0,0 +1,23 @@ +""" +Kwork.ru API Client + +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 + +__version__ = "0.1.0" +__all__ = ["KworkClient", "KworkError", "KworkAuthError", "KworkApiError"] diff --git a/src/kwork_api/__pycache__/__init__.cpython-312.pyc b/src/kwork_api/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..202faae45db0338b5ce7608cc69fc0c2cfde857d GIT binary patch literal 745 zcmZ8eJ&)5s5Zz52=WvvRhMHBNh$V3DB&rieI-wKdLvRg^o7G}(l2vT4HM=H+3VsFs zHHd#fPetnnBq}=4iRLQyBPU&$V!X5S-kZ0cuj6rqOnf@~B93|p{j|kpnA*kpee?N< z3Y4J&7rhc!y{w1rxnK6HAPcHNHmJfZGgra9_39AHJa(w!P zJSqjRbrij-<$Nx3!Af$v(MwsA+;zxY0=ddaqUhu;tD2G@Ma0A$WJOk1lCnk+Q8f~@ zXKb}C%0X|F=W-!xvKD$ta^QuTYz8XV+>T2LTQjjEkd&4+owSNW%_=^*OC}9d%FHY# zu_JHgUjhNHv;^*LX}RL{uDW}QH#T+cbh42x&^3QeS*w@SUGB<0=9=V8GvmXH`7^on z>a8bRNV4FXmO|-i!xlU}jH6%XucH`uqbtMXe&-i)0wAH=cdcV%z}I76H^P5u)J??+wq^urLw2J;V{iQ1$9 literal 0 HcmV?d00001 diff --git a/src/kwork_api/__pycache__/client.cpython-312.pyc b/src/kwork_api/__pycache__/client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..88384f2a0ce179956b7a579004760c727a009038 GIT binary patch literal 29083 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
  • S$~>=pZU>c{7E<4&Gz};Gy^{i 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 literal 0 HcmV?d00001 diff --git a/src/kwork_api/__pycache__/models.cpython-312.pyc b/src/kwork_api/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..62350fac50bcb8f86695890813b85209b26d9b10 GIT binary patch literal 9959 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 KZ)qQPH~#}mg@iBw literal 0 HcmV?d00001 diff --git a/src/kwork_api/client.py b/src/kwork_api/client.py new file mode 100644 index 0000000..37496e0 --- /dev/null +++ b/src/kwork_api/client.py @@ -0,0 +1,599 @@ +""" +Kwork API Client. + +Main client class with authentication and all API endpoints. +""" + +import logging +from typing import Any, Optional + +import httpx +from pydantic import HttpUrl + +from .errors import ( + KworkApiError, + KworkAuthError, + KworkError, + KworkNetworkError, + KworkNotFoundError, + KworkRateLimitError, + KworkValidationError, +) +from .models import ( + APIErrorResponse, + AuthResponse, + Badge, + CatalogResponse, + City, + Country, + DataResponse, + Dialog, + Feature, + Kwork, + KworkDetails, + NotificationsResponse, + Project, + ProjectsResponse, + Review, + ReviewsResponse, + TimeZone, +) + +logger = logging.getLogger(__name__) + + +class KworkClient: + """ + Kwork.ru API client. + + Usage: + # Login with credentials + client = await KworkClient.login("username", "password") + + # Or restore from token + client = KworkClient(token="your_web_auth_token") + + # Make requests + catalog = await client.catalog.get_list(page=1) + """ + + 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, + timeout: float = 30.0, + base_url: Optional[str] = None, + ): + """ + Initialize client. + + Args: + token: Web auth token (from getWebAuthToken) + cookies: Session cookies (optional, will be set from token) + timeout: Request timeout in seconds + base_url: Custom base URL (for testing) + """ + self.base_url = base_url or self.BASE_URL + self.timeout = timeout + self._token = token + self._cookies = cookies or {} + + # Initialize HTTP client + self._client: Optional[httpx.AsyncClient] = None + + @classmethod + async def login( + cls, + username: str, + password: str, + timeout: float = 30.0, + ) -> "KworkClient": + """ + Login with username and password. + + Args: + username: Kwork username or email + password: Kwork password + timeout: Request timeout + + Returns: + Authenticated KworkClient instance + + Raises: + KworkAuthError: If login fails + """ + client = cls(timeout=timeout) + + try: + async with client._get_httpx_client() as http_client: + # Step 1: Login to get session cookies + login_data = { + "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}") + + 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: + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "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, + cookies=self._cookies, + 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 + KworkNetworkError: For network errors + """ + # Check for common error statuses + if response.status_code == 401: + raise KworkAuthError("Unauthorized: invalid or expired token") + + if response.status_code == 403: + raise KworkAuthError("Forbidden: access denied") + + if response.status_code == 404: + raise KworkNotFoundError(f"Resource not found: {response.url}") + + if response.status_code == 429: + raise KworkRateLimitError("Too many requests") + + if response.status_code >= 400: + try: + error_data = response.json() + message = error_data.get("message", str(error_data)) + except Exception: + message = response.text + + if response.status_code == 400: + raise KworkValidationError(message, response=response) + + raise KworkApiError(message, response.status_code, response) + + # Parse successful response + try: + return response.json() + except Exception as e: + raise KworkError(f"Failed to parse response: {e}") + + async def _request( + self, + method: str, + endpoint: str, + **kwargs: Any, + ) -> 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}") + + # ========== Catalog Endpoints ========== + + class CatalogAPI: + """Catalog/Kworks API endpoints.""" + + def __init__(self, client: "KworkClient"): + self.client = client + + async def get_list( + self, + page: int = 1, + category_id: Optional[int] = None, + sort: str = "recommend", + ) -> CatalogResponse: + """ + Get kworks catalog. + + Args: + page: Page number + category_id: Category filter + sort: Sort option (recommend, price_asc, price_desc, etc.) + + Returns: + CatalogResponse with kworks and pagination + """ + data = await self.client._request( + "POST", + "/catalogMainv2", + json={ + "page": page, + "category_id": category_id, + "sort": sort, + }, + ) + return CatalogResponse.model_validate(data) + + async def get_details(self, kwork_id: int) -> KworkDetails: + """ + Get kwork details. + + Args: + kwork_id: Kwork ID + + Returns: + KworkDetails with full information + """ + data = await self.client._request( + "POST", + "/getKworkDetails", + json={"kwork_id": kwork_id}, + ) + return KworkDetails.model_validate(data) + + async def get_details_extra(self, kwork_id: int) -> dict[str, Any]: + """ + Get additional kwork details. + + Args: + kwork_id: Kwork ID + + Returns: + Extra details dict + """ + return await self.client._request( + "POST", + "/getKworkDetailsExtra", + json={"kwork_id": kwork_id}, + ) + + # ========== Projects Endpoints ========== + + class ProjectsAPI: + """Projects (freelance orders) API endpoints.""" + + def __init__(self, client: "KworkClient"): + self.client = client + + async def get_list( + self, + page: int = 1, + category_id: Optional[int] = None, + ) -> ProjectsResponse: + """ + Get projects list. + + Args: + page: Page number + category_id: Category filter + + Returns: + ProjectsResponse with projects and pagination + """ + data = await self.client._request( + "POST", + "/projects", + json={ + "page": page, + "category_id": category_id, + }, + ) + return ProjectsResponse.model_validate(data) + + async def get_payer_orders(self) -> list[Project]: + """ + Get orders where user is customer. + + Returns: + List of projects + """ + 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]: + """ + Get orders where user is performer. + + Returns: + List of projects + """ + data = await self.client._request("POST", "/workerOrders") + return [Project.model_validate(p) for p in data.get("orders", [])] + + # ========== User Endpoints ========== + + class UserAPI: + """User API endpoints.""" + + def __init__(self, client: "KworkClient"): + self.client = client + + async def get_info(self) -> dict[str, Any]: + """ + Get current user info. + + Returns: + User info dict + """ + return await self.client._request("POST", "/user") + + async def get_reviews( + self, + user_id: Optional[int] = None, + page: int = 1, + ) -> ReviewsResponse: + """ + Get user reviews. + + Args: + user_id: User ID (None for current user) + page: Page number + + Returns: + ReviewsResponse + """ + data = await self.client._request( + "POST", + "/userReviews", + json={"user_id": user_id, "page": page}, + ) + return ReviewsResponse.model_validate(data) + + async def get_favorite_kworks(self) -> list[Kwork]: + """ + Get favorite kworks. + + Returns: + List of kworks + """ + data = await self.client._request("POST", "/favoriteKworks") + return [Kwork.model_validate(k) for k in data.get("kworks", [])] + + # ========== Reference Data Endpoints ========== + + class ReferenceAPI: + """Reference data (cities, countries, etc.) endpoints.""" + + def __init__(self, client: "KworkClient"): + self.client = client + + async def get_cities(self) -> list[City]: + """Get all cities.""" + 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]: + """Get all countries.""" + 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]: + """Get all timezones.""" + 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]: + """Get available features.""" + 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 public 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]: + """Get badges info.""" + data = await self.client._request("POST", "/getBadgesInfo") + return [Badge.model_validate(b) for b in data.get("badges", [])] + + # ========== Notifications & Messages ========== + + class NotificationsAPI: + """Notifications and messages endpoints.""" + + def __init__(self, client: "KworkClient"): + self.client = client + + async def get_list(self) -> NotificationsResponse: + """Get notifications list.""" + data = await self.client._request("POST", "/notifications") + return NotificationsResponse.model_validate(data) + + async def fetch(self) -> NotificationsResponse: + """Fetch new notifications.""" + data = await self.client._request("POST", "/notificationsFetch") + return NotificationsResponse.model_validate(data) + + async def get_dialogs(self) -> list[Dialog]: + """Get dialogs list.""" + 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]: + """Get blocked dialogs.""" + data = await self.client._request("POST", "/blockedDialogList") + return [Dialog.model_validate(d) for d in data.get("dialogs", [])] + + # ========== Other Endpoints ========== + + class OtherAPI: + """Other API endpoints.""" + + def __init__(self, client: "KworkClient"): + self.client = client + + async def get_wants(self) -> dict[str, Any]: + """Get user wants.""" + return await self.client._request("POST", "/myWants") + + async def get_wants_status(self) -> dict[str, Any]: + """Get wants status.""" + return await self.client._request("POST", "/wantsStatusList") + + async def get_kworks_status(self) -> dict[str, Any]: + """Get kworks status.""" + return await self.client._request("POST", "/kworksStatusList") + + async def get_offers(self) -> dict[str, Any]: + """Get offers.""" + return await self.client._request("POST", "/offers") + + async def get_exchange_info(self) -> dict[str, Any]: + """Get exchange info.""" + return await self.client._request("POST", "/exchangeInfo") + + async def get_channel(self) -> dict[str, Any]: + """Get channel info.""" + return await self.client._request("POST", "/getChannel") + + async def get_in_app_notification(self) -> dict[str, Any]: + """Get in-app notification.""" + return await self.client._request("POST", "/getInAppNotification") + + async def get_security_user_data(self) -> dict[str, Any]: + """Get security user data.""" + return await self.client._request("POST", "/getSecurityUserData") + + async def is_dialog_allow(self, user_id: int) -> bool: + """Check if dialog is allowed.""" + data = await self.client._request( + "POST", + "/isDialogAllow", + json={"user_id": user_id}, + ) + return data.get("allowed", False) + + async def get_viewed_kworks(self) -> list[Kwork]: + """Get viewed kworks.""" + 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]: + """Get favorite categories.""" + data = await self.client._request("POST", "/favoriteCategories") + return data.get("categories", []) + + async def update_settings(self, settings: dict[str, Any]) -> dict[str, Any]: + """Update user settings.""" + return await self.client._request("POST", "/updateSettings", json=settings) + + async def go_offline(self) -> dict[str, Any]: + """Set user status to offline.""" + return await self.client._request("POST", "/offline") + + async def get_actor(self) -> dict[str, Any]: + """Get actor info.""" + return await self.client._request("POST", "/actor") + + # ========== API Property Accessors ========== + + @property + def catalog(self) -> CatalogAPI: + """Catalog API.""" + return self.CatalogAPI(self) + + @property + def projects(self) -> ProjectsAPI: + """Projects API.""" + return self.ProjectsAPI(self) + + @property + def user(self) -> UserAPI: + """User API.""" + return self.UserAPI(self) + + @property + def reference(self) -> ReferenceAPI: + """Reference data API.""" + return self.ReferenceAPI(self) + + @property + def notifications(self) -> NotificationsAPI: + """Notifications API.""" + return self.NotificationsAPI(self) + + @property + def other(self) -> OtherAPI: + """Other endpoints.""" + return self.OtherAPI(self) diff --git a/src/kwork_api/errors.py b/src/kwork_api/errors.py new file mode 100644 index 0000000..28e4e0a --- /dev/null +++ b/src/kwork_api/errors.py @@ -0,0 +1,90 @@ +""" +Kwork API exceptions. + +All exceptions provide clear error messages for debugging. +""" + +from typing import Any, Optional + + +class KworkError(Exception): + """Base exception for all Kwork API errors.""" + + def __init__(self, message: str, response: Optional[Any] = None): + self.message = message + self.response = response + super().__init__(self.message) + + def __str__(self) -> str: + return f"KworkError: {self.message}" + + +class KworkAuthError(KworkError): + """Authentication/authorization error.""" + + def __init__(self, message: str = "Authentication failed", response: Optional[Any] = None): + super().__init__(message, response) + + def __str__(self) -> str: + return f"KworkAuthError: {self.message}" + + +class KworkApiError(KworkError): + """API request error (4xx, 5xx).""" + + def __init__( + self, + message: str, + status_code: Optional[int] = None, + response: Optional[Any] = 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}" + return f"KworkApiError: {self.message}" + + +class KworkNotFoundError(KworkApiError): + """Resource not found (404).""" + + def __init__(self, resource: str, response: Optional[Any] = None): + super().__init__(f"Resource not found: {resource}", 404, response) + + +class KworkRateLimitError(KworkApiError): + """Rate limit exceeded (429).""" + + def __init__(self, message: str = "Rate limit exceeded", response: Optional[Any] = None): + super().__init__(message, 429, response) + + +class KworkValidationError(KworkApiError): + """Validation error (400).""" + + def __init__( + self, + message: str = "Validation failed", + fields: Optional[dict[str, list[str]]] = None, + response: Optional[Any] = 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()) + return f"KworkValidationError: {field_errors}" + return f"KworkValidationError: {self.message}" + + +class KworkNetworkError(KworkError): + """Network/connection error.""" + + def __init__(self, message: str = "Network error", response: Optional[Any] = 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 new file mode 100644 index 0000000..1344fe3 --- /dev/null +++ b/src/kwork_api/models.py @@ -0,0 +1,206 @@ +""" +Pydantic models for Kwork API responses. + +All models follow the structure found in the HAR dump analysis. +""" + +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +class KworkUser(BaseModel): + """User information.""" + id: int + username: str + avatar_url: Optional[str] = None + is_online: bool = False + rating: Optional[float] = None + + +class KworkCategory(BaseModel): + """Category information.""" + id: int + name: str + slug: str + parent_id: Optional[int] = None + + +class Kwork(BaseModel): + """Kwork (service) information.""" + id: int + title: str + description: Optional[str] = None + price: float + currency: str = "RUB" + category_id: Optional[int] = None + seller: Optional[KworkUser] = None + images: list[str] = Field(default_factory=list) + rating: Optional[float] = None + reviews_count: int = 0 + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class KworkDetails(Kwork): + """Extended kwork details.""" + full_description: Optional[str] = None + requirements: Optional[str] = None + delivery_time: Optional[int] = None # in days + revisions: Optional[int] = None + features: list[str] = Field(default_factory=list) + faq: list[dict[str, str]] = Field(default_factory=list) + + +class PaginationInfo(BaseModel): + """Pagination metadata.""" + current_page: int = 1 + total_pages: int = 1 + total_items: int = 0 + items_per_page: int = 20 + has_next: bool = False + has_prev: bool = False + + +class CatalogResponse(BaseModel): + """Catalog response with kworks and pagination.""" + kworks: list[Kwork] = Field(default_factory=list) + pagination: Optional[PaginationInfo] = None + filters: Optional[dict[str, Any]] = None + sort_options: list[str] = Field(default_factory=list) + + +class Project(BaseModel): + """Project (freelance order) information.""" + id: int + title: str + description: Optional[str] = None + budget: Optional[float] = None + budget_type: str = "fixed" # fixed, hourly + category_id: Optional[int] = None + customer: Optional[KworkUser] = None + status: str = "open" # open, in_progress, completed, cancelled + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + bids_count: int = 0 + skills: list[str] = Field(default_factory=list) + + +class ProjectsResponse(BaseModel): + """Projects list response.""" + projects: list[Project] = Field(default_factory=list) + pagination: Optional[PaginationInfo] = None + + +class Review(BaseModel): + """Review information.""" + 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 + + +class ReviewsResponse(BaseModel): + """Reviews list response.""" + reviews: list[Review] = Field(default_factory=list) + pagination: Optional[PaginationInfo] = None + average_rating: Optional[float] = None + + +class Notification(BaseModel): + """Notification information.""" + id: int + type: str # message, order, system, etc. + title: str + message: str + is_read: bool = False + created_at: Optional[datetime] = None + link: Optional[str] = None + + +class NotificationsResponse(BaseModel): + """Notifications list response.""" + notifications: list[Notification] = Field(default_factory=list) + unread_count: int = 0 + + +class Dialog(BaseModel): + """Dialog (chat) information.""" + id: int + participant: Optional[KworkUser] = None + last_message: Optional[str] = None + unread_count: int = 0 + updated_at: Optional[datetime] = None + + +class AuthResponse(BaseModel): + """Authentication response.""" + success: bool + user_id: Optional[int] = None + username: Optional[str] = None + web_auth_token: Optional[str] = None + message: Optional[str] = None + + +class ErrorDetail(BaseModel): + """Error detail from API.""" + code: str + message: str + field: Optional[str] = None + + +class APIErrorResponse(BaseModel): + """Standard API error response.""" + success: bool = False + errors: list[ErrorDetail] = Field(default_factory=list) + message: Optional[str] = None + + +class City(BaseModel): + """City information.""" + id: int + name: str + country_id: Optional[int] = None + + +class Country(BaseModel): + """Country information.""" + id: int + name: str + code: Optional[str] = None + cities: list[City] = Field(default_factory=list) + + +class TimeZone(BaseModel): + """Timezone information.""" + id: int + name: str + offset: str # e.g., "+03:00" + + +class Feature(BaseModel): + """Feature/addon information.""" + id: int + name: str + description: Optional[str] = None + price: float + type: str # extra, premium, etc. + + +class Badge(BaseModel): + """User badge information.""" + id: int + name: str + description: Optional[str] = None + icon_url: Optional[str] = None + + +# Generic response wrapper +class DataResponse(BaseModel): + """Generic data response wrapper.""" + success: bool = True + data: Optional[dict[str, Any]] = None + message: Optional[str] = None diff --git a/tests/integration/test_real_api.py b/tests/integration/test_real_api.py new file mode 100644 index 0000000..fb54297 --- /dev/null +++ b/tests/integration/test_real_api.py @@ -0,0 +1,255 @@ +""" +Integration tests with real Kwork API. + +These tests require valid credentials and make real API calls. +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 + + +@pytest.fixture(scope="module") +def client() -> Optional[KworkClient]: + """ + 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 + + +@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 + assert result.price is not None + + +@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 + + +@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 + + +@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()) + # 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): + catalog = await client.catalog.get_list(page=page) + results.append(catalog) + # 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 new file mode 100644 index 0000000000000000000000000000000000000000..691086509494529affa29e72be92ca6fb40cc6d4 GIT binary patch literal 23436 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