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

report.html

+

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

+
+

Environment

+
+
+ + + + + +
+
+

Summary

+
+
+

16 tests took 00:00:02.

+

(Un)check the boxes to filter the results.

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