Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]

steps:
- uses: actions/checkout@v7
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/install.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]

steps:
- uses: actions/checkout@v7
Expand Down
7 changes: 4 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,9 @@ keywords = [ "captcha",
"imperva",
"binance"
]
license = {text = "MIT License"}
license = "MIT"
license-files = ["LICENSE"]
classifiers = [
"License :: OSI Approved :: MIT License",
"License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)",
"Development Status :: 5 - Production/Stable",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
Expand All @@ -78,6 +77,8 @@ classifiers = [
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Framework :: AsyncIO",
"Operating System :: Unix",
"Operating System :: Microsoft :: Windows",
Expand Down
2 changes: 1 addition & 1 deletion src/python3_capsolver/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.2.1"
__version__ = "1.2.2"
11 changes: 8 additions & 3 deletions src/python3_capsolver/core/aio_captcha_instrument.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import asyncio
import logging
from typing import Optional
from typing import TYPE_CHECKING
from urllib import parse

import aiohttp
Expand All @@ -11,6 +13,9 @@
from .serializer import CaptchaResponseSer
from .captcha_instrument import CaptchaInstrumentBase

if TYPE_CHECKING:
from .base import CaptchaParams

__all__ = ("AIOCaptchaInstrument",)


Expand All @@ -19,7 +24,7 @@ class AIOCaptchaInstrument(CaptchaInstrumentBase):
Instrument for working with async captcha
"""

def __init__(self, captcha_params: "CaptchaParams"):
def __init__(self, captcha_params: CaptchaParams):
super().__init__()
self.captcha_params = captcha_params
self.created_task_data = CaptchaResponseSer
Expand Down Expand Up @@ -99,7 +104,7 @@ async def __get_result(self, url_postfix: str = EndpointPostfixEnm.GET_TASK_RESU

@staticmethod
async def send_post_request(
payload: Optional[dict] = None,
payload: dict | None = None,
url_postfix: EndpointPostfixEnm = EndpointPostfixEnm.GET_BALANCE,
) -> dict:
"""
Expand Down
11 changes: 8 additions & 3 deletions src/python3_capsolver/core/sio_captcha_instrument.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import time
import logging
from typing import Optional
from typing import TYPE_CHECKING
from urllib import parse

import requests
Expand All @@ -12,6 +14,9 @@
from .serializer import CaptchaResponseSer
from .captcha_instrument import CaptchaInstrumentBase

if TYPE_CHECKING:
from .base import CaptchaParams

__all__ = ("SIOCaptchaInstrument",)


Expand All @@ -20,7 +25,7 @@ class SIOCaptchaInstrument(CaptchaInstrumentBase):
Instrument for working with sync captcha
"""

def __init__(self, captcha_params: "CaptchaParams"):
def __init__(self, captcha_params: CaptchaParams):
super().__init__()
self.captcha_params = captcha_params
self.created_task_data = CaptchaResponseSer
Expand Down Expand Up @@ -106,7 +111,7 @@ def __get_result(self, url_postfix: str = EndpointPostfixEnm.GET_TASK_RESULT.val

@staticmethod
def send_post_request(
payload: Optional[dict] = None,
payload: dict | None = None,
session: requests.Session = requests.Session(),
url_postfix: EndpointPostfixEnm = EndpointPostfixEnm.GET_BALANCE,
) -> dict:
Expand Down
2 changes: 2 additions & 0 deletions tests/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ tests/
├── conftest.py # BaseTest class, rate-limiting fixtures
├── test_core.py # CaptchaParams, enums, context managers
├── test_instrument.py # FileInstrument, CaptchaInstrumentBase
├── test_sio_captcha_instrument.py # SIOCaptchaInstrument: mocked sync task processing/polling
├── test_aio_captcha_instrument.py # AIOCaptchaInstrument: mocked async task processing/polling
├── test_control.py # Control: get_balance, create_task, get_task_result
├── test_recaptcha.py # ReCaptcha V2/V3/Enterprise
├── test_cloudflare.py # Cloudflare Turnstile/Challenge
Expand Down
186 changes: 186 additions & 0 deletions tests/test_aio_captcha_instrument.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from tests.conftest import BaseTest
from python3_capsolver.core.base import CaptchaParams
from python3_capsolver.core.enum import CaptchaTypeEnm, EndpointPostfixEnm
from python3_capsolver.core.const import REQUEST_URL
from python3_capsolver.core.aio_captcha_instrument import AIOCaptchaInstrument

API_KEY_STUB = "unit-test-api-key-000000000000000000000000"
CREATE_TASK_URL = "https://unit.test/createTask"
GET_TASK_RESULT_URL = "https://unit.test/getTaskResult"


def create_captcha_params() -> CaptchaParams:
"""
Build params pointing to a fake API host with zero sleep between polls:
no real request can leave the mocked `aiohttp.ClientSession.post` boundary
"""
return CaptchaParams(
api_key=API_KEY_STUB,
captcha_type=CaptchaTypeEnm.Control,
sleep_time=0,
request_url="https://unit.test/",
)


def aio_response(payload=None, status: int = 200, reason: str = "unit test failure") -> MagicMock:
"""
Build fake response for the `aiohttp` boundary, usable as async context manager.

For non-parsable bodies (``payload is None``) any ``json()`` call raises -
an error status must never be parsed as a success body
"""
response = MagicMock()
response.status = status
response.reason = reason
if payload is None:
response.json = AsyncMock(side_effect=AssertionError("body of an error response must not be parsed"))
else:
response.json = AsyncMock(return_value=payload)
response.__aenter__.return_value = response
return response


class TestAIOCaptchaInstrument(BaseTest):
"""
Mocked unit tests for AIOCaptchaInstrument
"""

@patch("python3_capsolver.core.aio_captcha_instrument.aiohttp.ClientSession.post")
async def test_returns_created_result_without_polling_when_task_is_ready(self, mock_post):
mock_post.return_value = aio_response(
{"errorId": 0, "taskId": "task-ready-1", "status": "ready", "solution": {"text": "wg6x"}}
)

result = await AIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()

assert result["errorId"] == 0
assert result["status"] == "ready"
assert result["taskId"] == "task-ready-1"
assert result["solution"] == {"text": "wg6x"}
# task was solved at creation - no `getTaskResult` polling must follow
mock_post.assert_called_once()
assert mock_post.call_args.args[0] == CREATE_TASK_URL
assert mock_post.call_args.kwargs["json"]["task"] == {"type": "Control"}
assert mock_post.call_args.kwargs["json"]["clientKey"] == API_KEY_STUB

@patch("python3_capsolver.core.aio_captcha_instrument.aiohttp.ClientSession.post")
async def test_polls_get_task_result_until_ready(self, mock_post):
mock_post.side_effect = [
aio_response({"errorId": 0, "taskId": "task-42", "status": "processing"}),
aio_response({"errorId": 0, "taskId": "task-42", "status": "processing"}),
aio_response({"errorId": 0, "taskId": "task-42", "status": "ready", "solution": {"text": "ab12"}}),
]

result = await AIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()

assert result["errorId"] == 0
assert result["status"] == "ready"
assert result["solution"] == {"text": "ab12"}
assert [call.args[0] for call in mock_post.call_args_list] == [
CREATE_TASK_URL,
GET_TASK_RESULT_URL,
GET_TASK_RESULT_URL,
]
# created taskId must be propagated to every polling request
assert mock_post.call_args_list[1].kwargs["json"]["taskId"] == "task-42"
assert mock_post.call_args_list[2].kwargs["json"]["taskId"] == "task-42"

@patch("python3_capsolver.core.aio_captcha_instrument.aiohttp.ClientSession.post")
async def test_returns_failed_result_when_polling_reports_failure(self, mock_post):
mock_post.side_effect = [
aio_response({"errorId": 0, "taskId": "task-42", "status": "processing"}),
aio_response({"errorId": 1, "errorCode": "ERROR_TASK_NOT_FOUND", "status": "failed"}),
]

result = await AIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()

assert result["errorId"] == 1
assert result["status"] == "failed"
assert result["errorCode"] == "ERROR_TASK_NOT_FOUND"
assert len(mock_post.call_args_list) == 2

@patch("python3_capsolver.core.aio_captcha_instrument.aiohttp.ClientSession.post")
async def test_marks_result_failed_when_task_creation_is_rejected(self, mock_post):
mock_post.return_value = aio_response(
{"errorId": 1, "errorCode": "ERROR_KEY_DOES_NOT_EXIST", "errorDescription": "Client key is invalid"}
)

result = await AIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()

assert result["errorId"] == 1
assert result["status"] == "failed"
assert result["errorCode"] == "ERROR_KEY_DOES_NOT_EXIST"
assert result["errorDescription"] == "Client key is invalid"
# rejected creation must not trigger result polling
mock_post.assert_called_once()

@patch("python3_capsolver.core.aio_captcha_instrument.aiohttp.ClientSession.post")
async def test_parses_error_payload_when_status_code_is_valid_error_status(self, mock_post):
mock_post.return_value = aio_response({"errorId": 1, "errorCode": "ERROR_WRONG_USERKEY"}, status=400)

result = await AIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()

assert result["status"] == "failed"
assert result["errorCode"] == "ERROR_WRONG_USERKEY"

@patch("python3_capsolver.core.aio_captcha_instrument.aiohttp.ClientSession.post")
async def test_returns_unsolvable_default_when_server_never_finishes(self, mock_post):
mock_post.side_effect = [aio_response({"errorId": 0, "taskId": "task-silent", "status": "processing"})] * 16

result = await AIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()

assert result["errorId"] == 1
assert result["errorCode"] == "ERROR_CAPTCHA_UNSOLVABLE"
assert result["errorDescription"] == "Captcha not recognized"
assert result["status"] == "failed"
assert result["taskId"] == "task-silent"
# 1 `createTask` + 15 polling attempts, then the instrument gives up
assert len(mock_post.call_args_list) == 16

@pytest.mark.parametrize("bad_status", [404, 500])
@patch("python3_capsolver.core.aio_captcha_instrument.aiohttp.ClientSession.post")
async def test_raises_when_create_task_returns_invalid_status(self, mock_post, bad_status):
mock_post.return_value = aio_response(status=bad_status, reason="Internal Server Error")

with pytest.raises(ValueError):
await AIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()

mock_post.assert_called_once()

@patch("python3_capsolver.core.aio_captcha_instrument.aiohttp.ClientSession.post")
async def test_raises_when_get_task_result_returns_invalid_status(self, mock_post):
mock_post.side_effect = [
aio_response({"errorId": 0, "taskId": "task-42", "status": "processing"}),
aio_response(status=503, reason="Service Unavailable"),
]

with pytest.raises(ValueError):
await AIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()

assert len(mock_post.call_args_list) == 2

@patch("python3_capsolver.core.aio_captcha_instrument.aiohttp.ClientSession.post")
async def test_send_post_request_returns_decoded_payload(self, mock_post):
mock_post.return_value = aio_response({"errorId": 0, "balance": 107.7})

result = await AIOCaptchaInstrument.send_post_request(
payload={"clientKey": API_KEY_STUB},
url_postfix=EndpointPostfixEnm.GET_BALANCE,
)

assert result == {"errorId": 0, "balance": 107.7}
assert mock_post.call_args.args[0] == f"{REQUEST_URL}/{EndpointPostfixEnm.GET_BALANCE.value}"
assert mock_post.call_args.kwargs["json"] == {"clientKey": API_KEY_STUB}

@patch("python3_capsolver.core.aio_captcha_instrument.aiohttp.ClientSession.post")
async def test_send_post_request_raises_for_error_status(self, mock_post):
mock_post.return_value = aio_response(status=403)

with pytest.raises(ValueError):
await AIOCaptchaInstrument.send_post_request(payload={"clientKey": API_KEY_STUB})

mock_post.assert_called_once()
18 changes: 16 additions & 2 deletions tests/test_instrument.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from unittest.mock import MagicMock, patch

import pytest

from tests.conftest import BaseTest
Expand All @@ -11,8 +13,8 @@ class TestFileInstrument(BaseTest):
"""

def test_file_processing_exist(self):
assert "file_processing" in FileInstrument.__dict__.keys()
assert "aio_file_processing" in FileInstrument.__dict__.keys()
assert "file_processing" in FileInstrument.__dict__
assert "aio_file_processing" in FileInstrument.__dict__

def test_captcha_base64(self):
assert self.read_image_as_str() == FileInstrument().file_processing(captcha_base64=self.read_image())
Expand Down Expand Up @@ -55,3 +57,15 @@ def test_file_processing_err(self):
async def test_aio_file_processing_err(self):
with pytest.raises(ValueError):
await FileInstrument().aio_file_processing()

@patch("python3_capsolver.core.captcha_instrument.aiohttp.ClientSession.get")
async def test_aio_captcha_link_download_err(self, mock_get):
mock_response = MagicMock()
mock_response.status = 404
mock_response.__aenter__.return_value = mock_response
mock_get.return_value = mock_response

result = await FileInstrument().aio_file_processing(captcha_link=self.image_captcha_url_example)

# unreachable link returns empty content instead of raising
assert result == ""
Loading
Loading