From 6e6f4a1c5b837df93e422561708b9c8f2d83d713 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 15:23:07 +0000 Subject: [PATCH 1/3] Add ruff, mypy, and prek-based CI linting - Add .pre-commit-config.yaml with ruff (lint + format) and mypy hooks - Add ruff and mypy tool configuration to pyproject.toml - Add prek-action lint job to GitHub Actions workflow - Replace Docker-based flake8/black lint targets with ruff in Makefile - Add lint/typecheck environments to tox.ini - Fix type annotations: properly type Job class attributes, BaseTask.type, on_check callback parameter, and __exit__ return type - Fix ruff issues: remove unused Union import, sort imports, remove unicode literals, use f-strings, add stacklevel to warnings.warn - Exclude examples/ from ruff (documentation scripts with intentional patterns) https://claude.ai/code/session_01NzfYD4hhH5nqhu8mgnMXPt --- .github/workflows/pythonpackage.yml | 12 +++ .pre-commit-config.yaml | 13 ++++ Makefile | 12 ++- docs/conf.py | 23 +++--- examples/antigate.py | 5 +- examples/app_stat.py | 3 +- examples/balance.py | 4 +- examples/funcaptcha_request.py | 9 ++- examples/funcaptcha_selenium.py | 11 +-- examples/funcaptcha_selenium_callback.py | 15 ++-- examples/hcaptcha_request.py | 3 +- examples/hcaptcha_request_proxy.py | 6 +- examples/recaptcha3_request.py | 8 +- examples/recaptcha_request.py | 3 +- examples/recaptcha_selenium.py | 8 +- examples/recaptcha_selenium_callback.py | 10 +-- examples/remote_image.py | 3 +- examples/text_stream.py | 2 +- pyproject.toml | 18 +++++ python_anticaptcha/__init__.py | 34 ++++---- python_anticaptcha/base.py | 98 ++++++++++++------------ python_anticaptcha/exceptions.py | 16 ++-- python_anticaptcha/tasks.py | 62 +++++++++++---- tests/test_base.py | 10 +-- tests/test_examples.py | 26 +++---- tests/test_tasks.py | 60 +++++++-------- tox.ini | 11 +++ 27 files changed, 277 insertions(+), 208 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index e8c0537..3450524 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -3,6 +3,18 @@ name: Python package on: [push] jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - uses: j178/prek-action@v1 + test: runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..70c4a7f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,13 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.10 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.15.0 + hooks: + - id: mypy + additional_dependencies: [types-requests>=2.31] diff --git a/Makefile b/Makefile index acbb680..0fd4c2a 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ CHROMEDRIVER_VERSION=99.0.4844.17 CHROMEDRIVER_DIR=${PWD}/chromedriver -.PHONY: lint fmt build docs install test test_e2e chromedriver +.PHONY: lint fmt typecheck build docs install test test_e2e chromedriver build: python -m build @@ -34,11 +34,15 @@ clean: rm -r build chromedriver lint: - docker run --rm -v /$$(pwd):/apps alpine/flake8 ./ - docker run --rm -v /$$(pwd):/data cytopia/black --check ./ + ruff check . + ruff format --check . fmt: - docker run --rm -v /$$(pwd):/data cytopia/black ./ + ruff check --fix . + ruff format . + +typecheck: + mypy python_anticaptcha docs: sphinx-build -W docs /dev/shm/sphinx \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index dfe5b67..75d79fb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Configuration file for the Sphinx documentation builder. # @@ -20,13 +19,13 @@ # -- Project information ----------------------------------------------------- -project = u"python-anticaptcha" -copyright = u"2018-2026, Adam Dobrawy" -author = u"Adam Dobrawy" +project = "python-anticaptcha" +copyright = "2018-2026, Adam Dobrawy" +author = "Adam Dobrawy" # Version is managed by setuptools-scm; leave empty for Sphinx -version = u"" -release = u"" +version = "" +release = "" # -- General configuration --------------------------------------------------- @@ -69,7 +68,7 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path . -exclude_patterns = [u"_build", "Thumbs.db", ".DS_Store"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" @@ -143,8 +142,8 @@ ( master_doc, "python-anticaptcha.tex", - u"python-anticaptcha Documentation", - u"Adam Dobrawy", + "python-anticaptcha Documentation", + "Adam Dobrawy", "manual", ), ] @@ -154,9 +153,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, "python-anticaptcha", u"python-anticaptcha Documentation", [author], 1) -] +man_pages = [(master_doc, "python-anticaptcha", "python-anticaptcha Documentation", [author], 1)] # -- Options for Texinfo output ---------------------------------------------- @@ -168,7 +165,7 @@ ( master_doc, "python-anticaptcha", - u"python-anticaptcha Documentation", + "python-anticaptcha Documentation", author, "python-anticaptcha", "One line description of project.", diff --git a/examples/antigate.py b/examples/antigate.py index 7c9e036..5c90b60 100644 --- a/examples/antigate.py +++ b/examples/antigate.py @@ -1,9 +1,8 @@ +import json from os import environ -from re import TEMPLATE from python_anticaptcha import AnticaptchaClient -from python_anticaptcha.tasks import AntiGateTask, AntiGateTaskProxyless -import json +from python_anticaptcha.tasks import AntiGateTaskProxyless api_key = environ["KEY"] diff --git a/examples/app_stat.py b/examples/app_stat.py index 4ecd264..8edc3d6 100644 --- a/examples/app_stat.py +++ b/examples/app_stat.py @@ -1,8 +1,9 @@ from os import environ from pprint import pprint -from python_anticaptcha import AnticaptchaClient, ImageToTextTask from sys import argv +from python_anticaptcha import AnticaptchaClient + api_key = environ["KEY"] soft_id = argv[1] diff --git a/examples/balance.py b/examples/balance.py index c145491..297e298 100644 --- a/examples/balance.py +++ b/examples/balance.py @@ -1,7 +1,7 @@ from os import environ from pprint import pprint -from python_anticaptcha import AnticaptchaClient, ImageToTextTask -from sys import argv + +from python_anticaptcha import AnticaptchaClient api_key = environ["KEY"] diff --git a/examples/funcaptcha_request.py b/examples/funcaptcha_request.py index 10d73da..4ebf2b9 100644 --- a/examples/funcaptcha_request.py +++ b/examples/funcaptcha_request.py @@ -1,7 +1,8 @@ -from six.moves.urllib import parse -import requests -from os import environ import re +from os import environ + +import requests +from six.moves.urllib import parse from python_anticaptcha import AnticaptchaClient, FunCaptchaTask @@ -50,7 +51,7 @@ def get_token(form_html): def form_submit(token): return requests.post( - url="{}/verify".format(url), + url=f"{url}/verify", data={"name": "xx", "fc-token": token}, proxies={ "http": proxy_url, diff --git a/examples/funcaptcha_selenium.py b/examples/funcaptcha_selenium.py index 5fa7e5f..606e827 100644 --- a/examples/funcaptcha_selenium.py +++ b/examples/funcaptcha_selenium.py @@ -1,13 +1,10 @@ -from six.moves.urllib.parse import quote -from six.moves.urllib import parse - -import requests -from os import environ import re +from os import environ from random import choice + from selenium.webdriver.common.by import By -from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.support import expected_conditions as EC +from six.moves.urllib import parse +from six.moves.urllib.parse import quote from python_anticaptcha import AnticaptchaClient, FunCaptchaTask diff --git a/examples/funcaptcha_selenium_callback.py b/examples/funcaptcha_selenium_callback.py index 30f784c..a347d18 100644 --- a/examples/funcaptcha_selenium_callback.py +++ b/examples/funcaptcha_selenium_callback.py @@ -1,12 +1,13 @@ +import gzip +import os import re import time -from six.moves.urllib.parse import quote -import os from os import environ -import gzip -from python_anticaptcha import AnticaptchaClient, FunCaptchaProxylessTask from selenium.webdriver.common.by import By +from six.moves.urllib.parse import quote + +from python_anticaptcha import AnticaptchaClient, FunCaptchaProxylessTask api_key = environ["KEY"] site_key_pattern = 'public_key: "(.+?)",' @@ -47,7 +48,7 @@ def form_submit(driver, token): ) time.sleep(1) # as example call callback - not required in that example - driver.execute_script("ArkoseEnforcement.funcaptchaCallback[0]('{}')".format(token)) + driver.execute_script(f"ArkoseEnforcement.funcaptchaCallback[0]('{token}')") driver.find_element(By.ID, "submit-btn").click() time.sleep(1) @@ -62,9 +63,9 @@ def get_sitekey(driver): def custom(req, req_body, res, res_body): if not req.path: return - if not "arkoselabs" in req.path: + if "arkoselabs" not in req.path: return - if not res.headers.get("Content-Type", None) in [ + if res.headers.get("Content-Type", None) not in [ "text/javascript", "application/javascript", ]: diff --git a/examples/hcaptcha_request.py b/examples/hcaptcha_request.py index f97f84b..df94fdc 100644 --- a/examples/hcaptcha_request.py +++ b/examples/hcaptcha_request.py @@ -1,7 +1,8 @@ import re -import requests from os import environ +import requests + from python_anticaptcha import AnticaptchaClient, HCaptchaTaskProxyless api_key = environ["KEY"] diff --git a/examples/hcaptcha_request_proxy.py b/examples/hcaptcha_request_proxy.py index f271ac3..168146e 100644 --- a/examples/hcaptcha_request_proxy.py +++ b/examples/hcaptcha_request_proxy.py @@ -1,9 +1,9 @@ -from six.moves.urllib import parse - import re -import requests from os import environ +import requests +from six.moves.urllib import parse + from python_anticaptcha import AnticaptchaClient, HCaptchaTask api_key = environ["KEY"] diff --git a/examples/recaptcha3_request.py b/examples/recaptcha3_request.py index c99d9ff..a06cac4 100644 --- a/examples/recaptcha3_request.py +++ b/examples/recaptcha3_request.py @@ -1,12 +1,14 @@ import re -import requests from os import environ + +import requests from six.moves.urllib_parse import urljoin + from python_anticaptcha import AnticaptchaClient, RecaptchaV3TaskProxyless api_key = environ["KEY"] -site_key_pattern = "grecaptcha.execute\('(.+?)'" -action_name_pattern = "\{action: '(.+?)'\}" +site_key_pattern = r"grecaptcha.execute\('(.+?)'" +action_name_pattern = r"\{action: '(.+?)'\}" url = "https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php" client = AnticaptchaClient(api_key) session = requests.Session() diff --git a/examples/recaptcha_request.py b/examples/recaptcha_request.py index e207e88..9b917b1 100644 --- a/examples/recaptcha_request.py +++ b/examples/recaptcha_request.py @@ -1,7 +1,8 @@ import re -import requests from os import environ +import requests + from python_anticaptcha import AnticaptchaClient, NoCaptchaTaskProxylessTask api_key = environ["KEY"] diff --git a/examples/recaptcha_selenium.py b/examples/recaptcha_selenium.py index 73844ca..dc46a6d 100644 --- a/examples/recaptcha_selenium.py +++ b/examples/recaptcha_selenium.py @@ -5,9 +5,7 @@ api_key = environ["KEY"] invisible_captcha = True -url = "https://www.google.com/recaptcha/api2/demo?invisible={}".format( - str(invisible_captcha) -) +url = f"https://www.google.com/recaptcha/api2/demo?invisible={str(invisible_captcha)}" EXPECTED_RESULT = "Verification Success... Hooray!" client = AnticaptchaClient(api_key) @@ -33,9 +31,9 @@ def process(driver): def form_submit(driver, token): driver.execute_script( - "document.getElementById('g-recaptcha-response').innerHTML='{}';".format(token) + f"document.getElementById('g-recaptcha-response').innerHTML='{token}';" ) - driver.execute_script("onSuccess('{}')".format(token)) + driver.execute_script(f"onSuccess('{token}')") time.sleep(1) diff --git a/examples/recaptcha_selenium_callback.py b/examples/recaptcha_selenium_callback.py index 2046f84..6004ca3 100644 --- a/examples/recaptcha_selenium_callback.py +++ b/examples/recaptcha_selenium_callback.py @@ -1,8 +1,8 @@ +import gzip +import os import re import time -import os from os import environ -import gzip from python_anticaptcha import AnticaptchaClient, NoCaptchaTaskProxylessTask @@ -37,9 +37,9 @@ def process(driver): def form_submit(driver, token): driver.execute_script( - "document.getElementById('g-recaptcha-response').innerHTML='{}';".format(token) + f"document.getElementById('g-recaptcha-response').innerHTML='{token}';" ) - driver.execute_script("grecaptcha.recaptchaCallback[0]('{}')".format(token)) + driver.execute_script(f"grecaptcha.recaptchaCallback[0]('{token}')") time.sleep(1) @@ -51,7 +51,7 @@ def get_sitekey(driver): from seleniumwire import webdriver # Import from seleniumwire def custom(req, req_body, res, res_body): - if not req.path or not "recaptcha" in req.path: + if not req.path or "recaptcha" not in req.path: return if not res.headers.get("Content-Type", None) == "text/javascript": return diff --git a/examples/remote_image.py b/examples/remote_image.py index 8793927..feb38d5 100644 --- a/examples/remote_image.py +++ b/examples/remote_image.py @@ -1,6 +1,7 @@ -import requests from os import environ +import requests + from python_anticaptcha import AnticaptchaClient, ImageToTextTask api_key = environ["KEY"] diff --git a/examples/text_stream.py b/examples/text_stream.py index 070b754..a8c133f 100644 --- a/examples/text_stream.py +++ b/examples/text_stream.py @@ -5,7 +5,7 @@ api_key = environ["KEY"] DIR = os.path.dirname(os.path.abspath(__file__)) -IMAGE = "{}/captcha_ms.jpeg".format(DIR) +IMAGE = f"{DIR}/captcha_ms.jpeg" EXPECTED_RESULT = "56nn2" diff --git a/pyproject.toml b/pyproject.toml index 94ed9b9..82ec05b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,24 @@ python_anticaptcha = ["py.typed"] [tool.setuptools-scm] fallback_version = "0.0.0" +[tool.ruff] +target-version = "py39" +line-length = 120 +extend-exclude = ["examples"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM"] + +[tool.mypy] +python_version = "3.9" +warn_unused_configs = true +disallow_untyped_defs = true +no_implicit_optional = true + +[[tool.mypy.overrides]] +module = "requests.*" +ignore_missing_imports = true + [tool.pytest.ini_options] testpaths = ["tests"] markers = [ diff --git a/python_anticaptcha/__init__.py b/python_anticaptcha/__init__.py index 1cb621f..76044a9 100644 --- a/python_anticaptcha/__init__.py +++ b/python_anticaptcha/__init__.py @@ -1,34 +1,32 @@ -from importlib.metadata import version, PackageNotFoundError +import contextlib +from importlib.metadata import PackageNotFoundError, version from .base import AnticaptchaClient, Job +from .exceptions import AnticaptchaException from .proxy import Proxy from .tasks import ( - NoCaptchaTaskProxylessTask, - RecaptchaV2TaskProxyless, - NoCaptchaTask, - RecaptchaV2Task, + AntiGateTask, + AntiGateTaskProxyless, FunCaptchaProxylessTask, FunCaptchaTask, - ImageToTextTask, - RecaptchaV3TaskProxyless, - HCaptchaTaskProxyless, + GeeTestTask, + GeeTestTaskProxyless, HCaptchaTask, - RecaptchaV2EnterpriseTaskProxyless, + HCaptchaTaskProxyless, + ImageToTextTask, + NoCaptchaTask, + NoCaptchaTaskProxylessTask, RecaptchaV2EnterpriseTask, - GeeTestTaskProxyless, - GeeTestTask, - AntiGateTaskProxyless, - AntiGateTask, + RecaptchaV2EnterpriseTaskProxyless, + RecaptchaV2Task, + RecaptchaV2TaskProxyless, + RecaptchaV3TaskProxyless, ) -from .exceptions import AnticaptchaException AnticatpchaException = AnticaptchaException -try: +with contextlib.suppress(PackageNotFoundError): __version__ = version(__name__) -except PackageNotFoundError: - # package is not installed - pass __all__ = [ "AnticaptchaClient", diff --git a/python_anticaptcha/base.py b/python_anticaptcha/base.py index f620957..0822c7a 100644 --- a/python_anticaptcha/base.py +++ b/python_anticaptcha/base.py @@ -1,14 +1,15 @@ from __future__ import annotations +import json import os -import requests import time -import json import warnings from types import TracebackType -from typing import Any - +from typing import Any, Callable, Literal from urllib.parse import urljoin + +import requests + from .exceptions import AnticaptchaException from .tasks import BaseTask @@ -17,9 +18,9 @@ class Job: - client = None - task_id = None - _last_result = None + client: AnticaptchaClient + task_id: int + _last_result: dict[str, Any] | None = None def __init__(self, client: AnticaptchaClient, task_id: int) -> None: self.client = client @@ -30,32 +31,40 @@ def _update(self) -> None: def check_is_ready(self) -> bool: self._update() + assert self._last_result is not None return self._last_result["status"] == "ready" def get_solution_response(self) -> str: # Recaptcha + assert self._last_result is not None return self._last_result["solution"]["gRecaptchaResponse"] def get_solution(self) -> dict[str, Any]: + assert self._last_result is not None return self._last_result["solution"] def get_token_response(self) -> str: # Funcaptcha + assert self._last_result is not None return self._last_result["solution"]["token"] def get_answers(self) -> dict[str, str]: + assert self._last_result is not None return self._last_result["solution"]["answers"] def get_captcha_text(self) -> str: # Image + assert self._last_result is not None return self._last_result["solution"]["text"] def get_cells_numbers(self) -> list[int]: + assert self._last_result is not None return self._last_result["solution"]["cellNumbers"] def report_incorrect(self) -> bool: warnings.warn( "report_incorrect is deprecated, use report_incorrect_image instead", DeprecationWarning, + stacklevel=2, ) - return self.client.reportIncorrectImage() + return self.client.reportIncorrectImage(self.task_id) def report_incorrect_image(self) -> bool: return self.client.reportIncorrectImage(self.task_id) @@ -69,7 +78,11 @@ def __repr__(self) -> str: return f"" return f"" - def join(self, maximum_time: int | None = None, on_check=None) -> None: + def join( + self, + maximum_time: int | None = None, + on_check: Callable[[int, str | None], None] | None = None, + ) -> None: """Poll for task completion, blocking until ready or timeout. :param maximum_time: Maximum seconds to wait (default: ``MAXIMUM_JOIN_TIME``). @@ -84,15 +97,14 @@ def join(self, maximum_time: int | None = None, on_check=None) -> None: while not self.check_is_ready(): time.sleep(SLEEP_EVERY_CHECK_FINISHED) elapsed_time += SLEEP_EVERY_CHECK_FINISHED - if on_check is not None: + if on_check is not None and self._last_result is not None: on_check(elapsed_time, self._last_result.get("status")) if elapsed_time > maximum_time: raise AnticaptchaException( None, 250, - "The execution time exceeded a maximum time of {} seconds. It takes {} seconds.".format( - maximum_time, elapsed_time - ), + f"The execution time exceeded a maximum time of {maximum_time} seconds." + f" It takes {elapsed_time} seconds.", ) @@ -109,7 +121,11 @@ class AnticaptchaClient: response_timeout = 5 def __init__( - self, client_key: str | None = None, language_pool: str = "en", host: str = "api.anti-captcha.com", use_ssl: bool = True, + self, + client_key: str | None = None, + language_pool: str = "en", + host: str = "api.anti-captcha.com", + use_ssl: bool = True, ) -> None: self.client_key = client_key or os.environ.get("ANTICAPTCHA_API_KEY") if not self.client_key: @@ -119,9 +135,7 @@ def __init__( "API key required. Pass client_key or set ANTICAPTCHA_API_KEY env var.", ) self.language_pool = language_pool - self.base_url = "{proto}://{host}/".format( - proto="https" if use_ssl else "http", host=host - ) + self.base_url = "{proto}://{host}/".format(proto="https" if use_ssl else "http", host=host) self.session = requests.Session() def __enter__(self) -> AnticaptchaClient: @@ -132,7 +146,7 @@ def __exit__( exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None, - ) -> bool: + ) -> Literal[False]: self.session.close() return False @@ -141,28 +155,23 @@ def close(self) -> None: def __repr__(self) -> str: from urllib.parse import urlparse + host = urlparse(self.base_url).hostname or self.base_url return f"" @property def client_ip(self) -> str: if not hasattr(self, "_client_ip"): - self._client_ip = self.session.get( - "https://api.myip.com", timeout=self.response_timeout - ).json()["ip"] + self._client_ip = self.session.get("https://api.myip.com", timeout=self.response_timeout).json()["ip"] return self._client_ip def _check_response(self, response: dict[str, Any]) -> None: if response.get("errorId", False) == 11: - response[ - "errorDescription" - ] = "{} Your missing IP address is probably {}.".format( + response["errorDescription"] = "{} Your missing IP address is probably {}.".format( response["errorDescription"], self.client_ip ) if response.get("errorId", False): - raise AnticaptchaException( - response["errorId"], response["errorCode"], response["errorDescription"] - ) + raise AnticaptchaException(response["errorId"], response["errorCode"], response["errorDescription"]) def createTask(self, task: BaseTask) -> Job: request = { @@ -183,14 +192,12 @@ def createTaskSmee(self, task: BaseTask, timeout: int = MAXIMUM_JOIN_TIME) -> Jo """ Beta method to stream response from smee.io """ - response = self.session.head( - "https://smee.io/new", timeout=self.response_timeout - ) + response = self.session.head("https://smee.io/new", timeout=self.response_timeout) smee_url = response.headers["Location"] - task = task.serialize() + task_data = task.serialize() request = { "clientKey": self.client_key, - "task": task, + "task": task_data, "softId": self.SOFT_ID, "languagePool": self.language_pool, "callbackUrl": smee_url, @@ -212,20 +219,17 @@ def createTaskSmee(self, task: BaseTask, timeout: int = MAXIMUM_JOIN_TIME) -> Jo if '"host":"smee.io"' not in content: continue payload = json.loads(content.split(":", maxsplit=1)[1].strip()) - if "taskId" not in payload["body"] or str(payload["body"]["taskId"]) != str( - response["taskId"] - ): + if "taskId" not in payload["body"] or str(payload["body"]["taskId"]) != str(response["taskId"]): continue r.close() job = Job(client=self, task_id=response["taskId"]) job._last_result = payload["body"] return job + raise AnticaptchaException(None, 250, "No matching task response received from smee.io") def getTaskResult(self, task_id: int) -> dict[str, Any]: request = {"clientKey": self.client_key, "taskId": task_id} - response = self.session.post( - urljoin(self.base_url, self.TASK_RESULT_URL), json=request - ).json() + response = self.session.post(urljoin(self.base_url, self.TASK_RESULT_URL), json=request).json() self._check_response(response) return response @@ -234,33 +238,25 @@ def getBalance(self) -> float: "clientKey": self.client_key, "softId": self.SOFT_ID, } - response = self.session.post( - urljoin(self.base_url, self.BALANCE_URL), json=request - ).json() + response = self.session.post(urljoin(self.base_url, self.BALANCE_URL), json=request).json() self._check_response(response) return response["balance"] def getAppStats(self, soft_id: int, mode: str) -> dict[str, Any]: request = {"clientKey": self.client_key, "softId": soft_id, "mode": mode} - response = self.session.post( - urljoin(self.base_url, self.APP_STAT_URL), json=request - ).json() + response = self.session.post(urljoin(self.base_url, self.APP_STAT_URL), json=request).json() self._check_response(response) return response def reportIncorrectImage(self, task_id: int) -> bool: request = {"clientKey": self.client_key, "taskId": task_id} - response = self.session.post( - urljoin(self.base_url, self.REPORT_IMAGE_URL), json=request - ).json() + response = self.session.post(urljoin(self.base_url, self.REPORT_IMAGE_URL), json=request).json() self._check_response(response) - return response.get("status", False) != False + return bool(response.get("status", False)) def reportIncorrectRecaptcha(self, task_id: int) -> bool: request = {"clientKey": self.client_key, "taskId": task_id} - response = self.session.post( - urljoin(self.base_url, self.REPORT_RECAPTCHA_URL), json=request - ).json() + response = self.session.post(urljoin(self.base_url, self.REPORT_RECAPTCHA_URL), json=request).json() self._check_response(response) return response["status"] == "success" diff --git a/python_anticaptcha/exceptions.py b/python_anticaptcha/exceptions.py index de73595..c9f3a31 100644 --- a/python_anticaptcha/exceptions.py +++ b/python_anticaptcha/exceptions.py @@ -2,10 +2,14 @@ class AnticaptchaException(Exception): - def __init__(self, error_id: int | str | None, error_code: int | str, error_description: str, *args: object) -> None: - super().__init__( - "[{}:{}]{}".format(error_code, error_id, error_description) - ) + def __init__( + self, + error_id: int | str | None, + error_code: int | str, + error_description: str, + *args: object, + ) -> None: + super().__init__(f"[{error_code}:{error_id}]{error_description}") self.error_description = error_description self.error_id = error_id self.error_code = error_code @@ -17,9 +21,7 @@ def __init__(self, error_id: int | str | None, error_code: int | str, error_desc class InvalidWidthException(AnticaptchaException): def __init__(self, width: int) -> None: self.width = width - msg = "Invalid width (%s). Can be one of these: 100, 50, 33, 25." % ( - self.width, - ) + msg = f"Invalid width ({self.width}). Can be one of these: 100, 50, 33, 25." super().__init__("AC-1", 1, msg) diff --git a/python_anticaptcha/tasks.py b/python_anticaptcha/tasks.py index 5c483e3..f123498 100644 --- a/python_anticaptcha/tasks.py +++ b/python_anticaptcha/tasks.py @@ -2,19 +2,18 @@ import base64 from pathlib import Path -from typing import Any, BinaryIO, Union +from typing import Any, BinaryIO class BaseTask: - type = None + type: str | None = None def serialize(self, **result: Any) -> dict[str, Any]: result["type"] = self.type return result def __repr__(self) -> str: - attrs = {k: v for k, v in self.__dict__.items() - if not k.startswith("_") and v is not None} + attrs = {k: v for k, v in self.__dict__.items() if not k.startswith("_") and v is not None} fields = " ".join(f"{k}={v!r}" for k, v in attrs.items()) return f"<{self.__class__.__name__} {fields}>" @@ -103,9 +102,7 @@ class RecaptchaV2TaskProxyless(NoCaptchaTaskProxylessTask): type = "RecaptchaV2TaskProxyless" -class NoCaptchaTask( - ProxyMixin, UserAgentMixin, CookieMixin, NoCaptchaTaskProxylessTask -): +class NoCaptchaTask(ProxyMixin, UserAgentMixin, CookieMixin, NoCaptchaTaskProxylessTask): type = "NoCaptchaTask" @@ -121,7 +118,13 @@ class FunCaptchaProxylessTask(BaseTask): data = None def __init__( - self, website_url: str, website_key: str, subdomain: str | None = None, data: str | None = None, *args: Any, **kwargs: Any, + self, + website_url: str, + website_key: str, + subdomain: str | None = None, + data: str | None = None, + *args: Any, + **kwargs: Any, ) -> None: self.websiteURL = website_url self.websiteKey = website_key @@ -158,7 +161,7 @@ class ImageToTextTask(BaseTask): def __init__( self, - image: Union[str, Path, bytes, BinaryIO], + image: str | Path | bytes | BinaryIO, phrase: bool | None = None, case: bool | None = None, numeric: int | None = None, @@ -218,7 +221,14 @@ class RecaptchaV3TaskProxyless(BaseTask): isEnterprise = False def __init__( - self, website_url: str, website_key: str, min_score: float, page_action: str, is_enterprise: bool = False, *args: Any, **kwargs: Any, + self, + website_url: str, + website_key: str, + min_score: float, + page_action: str, + is_enterprise: bool = False, + *args: Any, + **kwargs: Any, ) -> None: self.websiteURL = website_url self.websiteKey = website_key @@ -265,7 +275,15 @@ class RecaptchaV2EnterpriseTaskProxyless(BaseTask): enterprisePayload = None apiDomain = None - def __init__(self, website_url: str, website_key: str, enterprise_payload: dict[str, Any] | None, api_domain: str | None, *args: Any, **kwargs: Any) -> None: + def __init__( + self, + website_url: str, + website_key: str, + enterprise_payload: dict[str, Any] | None, + api_domain: str | None, + *args: Any, + **kwargs: Any, + ) -> None: self.websiteURL = website_url self.websiteKey = website_key self.enterprisePayload = enterprise_payload @@ -283,9 +301,7 @@ def serialize(self, **result: Any) -> dict[str, Any]: return data -class RecaptchaV2EnterpriseTask( - ProxyMixin, UserAgentMixin, CookieMixin, RecaptchaV2EnterpriseTaskProxyless -): +class RecaptchaV2EnterpriseTask(ProxyMixin, UserAgentMixin, CookieMixin, RecaptchaV2EnterpriseTaskProxyless): type = "RecaptchaV2EnterpriseTask" @@ -298,7 +314,14 @@ class GeeTestTaskProxyless(BaseTask): geetestGetLib = None def __init__( - self, website_url: str, gt: str, challenge: str, subdomain: str | None = None, lib: str | None = None, *args: Any, **kwargs: Any, + self, + website_url: str, + gt: str, + challenge: str, + subdomain: str | None = None, + lib: str | None = None, + *args: Any, + **kwargs: Any, ) -> None: self.websiteURL = website_url self.gt = gt @@ -329,7 +352,14 @@ class AntiGateTaskProxyless(BaseTask): templateName = None variables = None - def __init__(self, website_url: str, template_name: str, variables: dict[str, Any], *args: Any, **kwargs: Any) -> None: + def __init__( + self, + website_url: str, + template_name: str, + variables: dict[str, Any], + *args: Any, + **kwargs: Any, + ) -> None: self.websiteURL = website_url self.templateName = template_name self.variables = variables diff --git a/tests/test_base.py b/tests/test_base.py index 5c97223..69ee832 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,8 +1,8 @@ -from unittest.mock import patch, MagicMock -import os +from unittest.mock import MagicMock, patch + import pytest -from python_anticaptcha.base import AnticaptchaClient, Job, SLEEP_EVERY_CHECK_FINISHED +from python_anticaptcha.base import SLEEP_EVERY_CHECK_FINISHED, AnticaptchaClient, Job from python_anticaptcha.exceptions import AnticaptchaException @@ -66,9 +66,7 @@ def test_error_id_11_appends_ip(self): "errorCode": "ERROR_IP_NOT_ALLOWED", "errorDescription": "IP not allowed", } - with patch.object( - type(self.client), "client_ip", new_callable=lambda: property(lambda self: "5.6.7.8") - ): + with patch.object(type(self.client), "client_ip", new_callable=lambda: property(lambda self: "5.6.7.8")): with pytest.raises(AnticaptchaException) as exc_info: self.client._check_response(response) assert "5.6.7.8" in exc_info.value.error_description diff --git a/tests/test_examples.py b/tests/test_examples.py index d3573bf..d7f6c0e 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,25 +1,20 @@ -# -*- coding: utf-8 -*- +import os +from contextlib import contextmanager from unittest import TestCase, skipIf -from retry import retry -import os import pytest - -from python_anticaptcha import AnticatpchaException -from contextlib import contextmanager +from retry import retry def missing_key(*args, **kwargs): return skipIf( "KEY" not in os.environ, - "Missing KEY environment variable. " "Unable to connect Anti-captcha.com", + "Missing KEY environment variable. Unable to connect Anti-captcha.com", )(*args, **kwargs) def missing_proxy(*args, **kwargs): - return skipIf( - "PROXY_URL" not in os.environ, "Missing PROXY_URL environment variable" - )(*args, **kwargs) + return skipIf("PROXY_URL" not in os.environ, "Missing PROXY_URL environment variable")(*args, **kwargs) @pytest.mark.e2e @@ -87,19 +82,18 @@ class RecaptchaSeleniumtTestCase(TestCase): # Anticaptcha responds is not fully reliable. @retry(tries=6) def test_process(self): - from examples import recaptcha_selenium from selenium.webdriver.chrome.options import Options + from examples import recaptcha_selenium + options = Options() options.headless = True - options.add_experimental_option('prefs', {'intl.accept_languages': 'en_US'}) - + options.add_experimental_option("prefs", {"intl.accept_languages": "en_US"}) + with open_driver( options=options, ) as driver: - self.assertIn( - recaptcha_selenium.EXPECTED_RESULT, recaptcha_selenium.process(driver) - ) + self.assertIn(recaptcha_selenium.EXPECTED_RESULT, recaptcha_selenium.process(driver)) @pytest.mark.e2e diff --git a/tests/test_tasks.py b/tests/test_tasks.py index c26540b..046400c 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -1,22 +1,22 @@ import io from python_anticaptcha.tasks import ( - NoCaptchaTaskProxylessTask, - RecaptchaV2TaskProxyless, - NoCaptchaTask, - RecaptchaV2Task, + AntiGateTask, + AntiGateTaskProxyless, FunCaptchaProxylessTask, FunCaptchaTask, - ImageToTextTask, - RecaptchaV3TaskProxyless, - HCaptchaTaskProxyless, + GeeTestTask, + GeeTestTaskProxyless, HCaptchaTask, - RecaptchaV2EnterpriseTaskProxyless, + HCaptchaTaskProxyless, + ImageToTextTask, + NoCaptchaTask, + NoCaptchaTaskProxylessTask, RecaptchaV2EnterpriseTask, - GeeTestTaskProxyless, - GeeTestTask, - AntiGateTaskProxyless, - AntiGateTask, + RecaptchaV2EnterpriseTaskProxyless, + RecaptchaV2Task, + RecaptchaV2TaskProxyless, + RecaptchaV3TaskProxyless, ) PROXY_KWARGS = dict( @@ -32,18 +32,14 @@ class TestNoCaptchaTaskProxylessTask: def test_type(self): - task = NoCaptchaTaskProxylessTask( - website_url="https://example.com", website_key="key123" - ) + task = NoCaptchaTaskProxylessTask(website_url="https://example.com", website_key="key123") data = task.serialize() assert data["type"] == "NoCaptchaTaskProxyless" assert data["websiteURL"] == "https://example.com" assert data["websiteKey"] == "key123" def test_optional_fields_omitted(self): - task = NoCaptchaTaskProxylessTask( - website_url="https://example.com", website_key="key123" - ) + task = NoCaptchaTaskProxylessTask(website_url="https://example.com", website_key="key123") data = task.serialize() assert "websiteSToken" not in data assert "isInvisible" not in data @@ -65,9 +61,7 @@ def test_optional_fields_included(self): class TestRecaptchaV2TaskProxyless: def test_type(self): - task = RecaptchaV2TaskProxyless( - website_url="https://example.com", website_key="key123" - ) + task = RecaptchaV2TaskProxyless(website_url="https://example.com", website_key="key123") assert task.serialize()["type"] == "RecaptchaV2TaskProxyless" @@ -102,9 +96,7 @@ def test_type(self): class TestFunCaptchaProxylessTask: def test_type_and_required_fields(self): - task = FunCaptchaProxylessTask( - website_url="https://example.com", website_key="pubkey" - ) + task = FunCaptchaProxylessTask(website_url="https://example.com", website_key="pubkey") data = task.serialize() assert data["type"] == "FunCaptchaTaskProxyless" assert data["websiteURL"] == "https://example.com" @@ -171,9 +163,15 @@ def test_optional_fields_omitted(self): def test_optional_fields_included(self): task = ImageToTextTask( - b"data", phrase=True, case=True, numeric=1, - math=False, min_length=3, max_length=10, - comment="solve this", website_url="https://example.com", + b"data", + phrase=True, + case=True, + numeric=1, + math=False, + min_length=3, + max_length=10, + comment="solve this", + website_url="https://example.com", ) data = task.serialize() assert data["phrase"] is True @@ -215,9 +213,7 @@ def test_enterprise_flag(self): class TestHCaptchaTaskProxyless: def test_serialize(self): - task = HCaptchaTaskProxyless( - website_url="https://example.com", website_key="hkey" - ) + task = HCaptchaTaskProxyless(website_url="https://example.com", website_key="hkey") data = task.serialize() assert data["type"] == "HCaptchaTaskProxyless" assert data["websiteURL"] == "https://example.com" @@ -277,9 +273,7 @@ def test_type(self): class TestGeeTestTaskProxyless: def test_required_fields(self): - task = GeeTestTaskProxyless( - website_url="https://example.com", gt="gt123", challenge="ch456" - ) + task = GeeTestTaskProxyless(website_url="https://example.com", gt="gt123", challenge="ch456") data = task.serialize() assert data["type"] == "GeeTestTaskProxyless" assert data["websiteURL"] == "https://example.com" diff --git a/tox.ini b/tox.ini index 6e64824..4f1b65e 100644 --- a/tox.ini +++ b/tox.ini @@ -9,3 +9,14 @@ commands = pytest {posargs} passenv = KEY, PROXY_URL deps = .[tests] commands = pytest -m e2e --override-ini="addopts=" {posargs} + +[testenv:lint] +skip_install = true +deps = + ruff>=0.9 + mypy>=1.13 + types-requests>=2.31 +commands = + ruff check . + ruff format --check . + mypy python_anticaptcha From 2fdf8e6aa21f4c6dc5dc4585a36e1b85c2cda73b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 18:29:42 +0000 Subject: [PATCH 2/3] Scope mypy pre-commit hook to python_anticaptcha package only The hook was checking all files (tests, examples, docs) which have untyped functions. Restrict to the package directory only. https://claude.ai/code/session_01NzfYD4hhH5nqhu8mgnMXPt --- .pre-commit-config.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 70c4a7f..dd3ad6d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,3 +11,6 @@ repos: hooks: - id: mypy additional_dependencies: [types-requests>=2.31] + files: ^python_anticaptcha/ + pass_filenames: false + args: [python_anticaptcha] From 2814c5b48f492727ceb4de31956ba922a52f1f7e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 18:31:59 +0000 Subject: [PATCH 3/3] Fix mypy error from reused response variable in createTaskSmee Rename second assignment to create_response to avoid type conflict with the Response object from the initial HEAD request. https://claude.ai/code/session_01NzfYD4hhH5nqhu8mgnMXPt --- python_anticaptcha/base.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python_anticaptcha/base.py b/python_anticaptcha/base.py index 87ae0f8..461e66a 100644 --- a/python_anticaptcha/base.py +++ b/python_anticaptcha/base.py @@ -215,21 +215,21 @@ def createTaskSmee(self, task: BaseTask, timeout: int = MAXIMUM_JOIN_TIME) -> Jo stream=True, timeout=(self.response_timeout, timeout), ) - response = self.session.post( + create_response = self.session.post( url=urljoin(self.base_url, self.CREATE_TASK_URL), json=request, timeout=self.response_timeout, ).json() - self._check_response(response) + self._check_response(create_response) for line in r.iter_lines(): content = line.decode("utf-8") if '"host":"smee.io"' not in content: continue payload = json.loads(content.split(":", maxsplit=1)[1].strip()) - if "taskId" not in payload["body"] or str(payload["body"]["taskId"]) != str(response["taskId"]): + if "taskId" not in payload["body"] or str(payload["body"]["taskId"]) != str(create_response["taskId"]): continue r.close() - job = Job(client=self, task_id=response["taskId"]) + job = Job(client=self, task_id=create_response["taskId"]) job._last_result = payload["body"] return job raise AnticaptchaException(None, 250, "No matching task response received from smee.io")