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/live-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python: ['3.10', '3.11', '3.12', '3.13']
python: ['3.9', '3.10', '3.11', '3.12', '3.13']

steps:
- uses: actions/checkout@v6
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
python: ['3.10', '3.11', '3.12', '3.13']
python: ['3.9', '3.10', '3.11', '3.12', '3.13']

steps:
- uses: actions/checkout@v6
Expand All @@ -39,7 +39,7 @@ jobs:
enable-cache: true

- name: Install dependencies
run: uv sync --locked --all-extras --dev
run: uv sync --locked --dev --extra dev

- name: Run unit tests with coverage
run: >
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]

### Added
- Python 3.9 support (`requires-python = ">=3.9"`)
- `schema.py`: replaced `@dataclass(slots=True)` (Python 3.10+) with a
version-conditional equivalent; slots are still used on Python 3.10+
- `_batcher.py`: removed `zip(..., strict=True)` (Python 3.10+); the
length mismatch is already guarded by an explicit check above the loop
- Dev dependencies: added Python-version-conditional variants for
`pytest` and `pytest-asyncio` so the lock file resolves on Python 3.9
- CI: added Python 3.9 to the unit-test and live-test matrices
2 changes: 1 addition & 1 deletion cycletls/_batcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ def _flush(self, batch: List[_PendingRequest]) -> None:
pending.future.set_exception(exc)
return

for pending, result in zip(batch, results, strict=True):
for pending, result in zip(batch, results):
if not pending.future.done():
pending.future.set_result(result)

Expand Down
3 changes: 2 additions & 1 deletion cycletls/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import json
import math
import re
import sys
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
Expand Down Expand Up @@ -260,7 +261,7 @@ class SSEEvent:
retry: Optional[int] = None


@dataclass(slots=True)
@dataclass(**{"slots": True} if sys.version_info >= (3, 10) else {})
class Response:
"""Enhanced response model with binary data and cookies."""

Expand Down
13 changes: 8 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
Expand All @@ -36,7 +37,7 @@ keywords = [
# Ecosystem compatibility
"requests", "akamai-fingerprint", "cloudflare-bypass",
]
requires-python = ">=3.10"
requires-python = ">=3.9"
dependencies = [
"cffi>=1.15.0",
"orjson>=3.9.0",
Expand Down Expand Up @@ -100,7 +101,7 @@ path = "hatch_build.py"

[tool.ruff]
line-length = 100
target-version = "py310"
target-version = "py39"
exclude = [
"__pycache__",
".git",
Expand Down Expand Up @@ -128,7 +129,7 @@ ignore = [
known-first-party = ["cycletls"]

[tool.pyright]
pythonVersion = "3.10"
pythonVersion = "3.9"
exclude = [
"build/",
"dist/",
Expand Down Expand Up @@ -191,8 +192,10 @@ directory = "htmlcov"

[dependency-groups]
dev = [
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
"pytest>=9.0.2; python_version >= '3.10'",
"pytest>=7.0.0,<9; python_version < '3.10'",
"pytest-asyncio>=1.3.0; python_version >= '3.10'",
"pytest-asyncio>=0.21.0,<1; python_version < '3.10'",
"ruff>=0.14.14",
]

2 changes: 2 additions & 0 deletions tests/test_async_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
"""

import pytest

pytestmark = pytest.mark.live
import cycletls
from cycletls import AsyncCycleTLS

Expand Down
2 changes: 2 additions & 0 deletions tests/test_async_concurrent.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import cycletls
from cycletls import AsyncCycleTLS

pytestmark = pytest.mark.live


class TestAsyncConcurrent:
"""Test concurrent async request execution."""
Expand Down
2 changes: 2 additions & 0 deletions tests/test_async_improvements.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

import pytest

pytestmark = pytest.mark.live

from cycletls import CycleTLS, AsyncCycleTLS


Expand Down
2 changes: 2 additions & 0 deletions tests/test_callback_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
"""

import pytest

pytestmark = pytest.mark.live
import asyncio
import sys
import os
Expand Down
2 changes: 2 additions & 0 deletions tests/test_edge_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
"""

import pytest

pytestmark = pytest.mark.live
from cycletls import CycleTLS


Expand Down
9 changes: 9 additions & 0 deletions tests/test_insecure_skip_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@
_NETWORK_ERROR_PHRASES = ("eof", "server closed", "connection reset", "connection refused", "i/o timeout")


def _skip_if_network_error(exc: BaseException) -> None:
"""Skip the test if *exc* is a transient network error rather than a TLS/cert error."""
msg = str(exc).lower()
if any(phrase in msg for phrase in _NETWORK_ERROR_PHRASES):
pytest.skip(f"badssl.com network error (not a TLS error): {exc}")

_NETWORK_ERROR_PHRASES = ("eof", "server closed", "connection reset", "connection refused", "i/o timeout")


def _skip_if_network_error(exc: BaseException) -> None:
"""Skip the test if *exc* is a transient network error rather than a TLS/cert error."""
msg = str(exc).lower()
Expand Down
53 changes: 34 additions & 19 deletions tests/test_multiple_instances.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,38 @@
"""

import pytest

pytestmark = pytest.mark.live
import threading
from cycletls import CycleTLS


def _no_reuse_client():
"""Return a CycleTLS instance that never reuses connections (avoids stale-pool errors)."""
client = CycleTLS()
_orig = client.request

def _patched(method, url, **kwargs):
kwargs.setdefault("enable_connection_reuse", False)
return _orig(method, url, **kwargs)

client.request = _patched
return client


class TestMultipleInstances:
"""Test creating and managing multiple CycleTLS instances."""

def test_create_multiple_instances(self):
"""Should be able to create multiple CycleTLS instances."""
with CycleTLS() as client1, CycleTLS() as client2:
with _no_reuse_client() as client1, _no_reuse_client() as client2:
assert client1 is not None
assert client2 is not None
assert client1 is not client2

def test_instances_can_make_independent_requests(self, httpbin_url):
"""Multiple instances should be able to make independent requests."""
with CycleTLS() as client1, CycleTLS() as client2:
with _no_reuse_client() as client1, _no_reuse_client() as client2:
# Make requests with both instances
response1 = client1.get(f"{httpbin_url}/get")
response2 = client2.get(f"{httpbin_url}/get")
Expand All @@ -35,8 +50,8 @@ def test_instances_can_make_independent_requests(self, httpbin_url):

def test_instances_work_after_one_is_closed(self, httpbin_url):
"""Closing one instance should not affect others."""
client1 = CycleTLS()
client2 = CycleTLS()
client1 = _no_reuse_client()
client2 = _no_reuse_client()

try:
# Make request with both
Expand Down Expand Up @@ -65,7 +80,7 @@ def test_instances_have_isolated_ja3(self, httpbin_url):
chrome_ja3 = "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0"
firefox_ja3 = "771,4865-4867-4866-49195-49199-52393-52392-49196-49200-49162-49161-49171-49172-51-57-47-53-10,0-23-65281-10-11-35-16-5-51-43-13-45-28-21,29-23-24-25-256-257,0"

with CycleTLS() as client1, CycleTLS() as client2:
with _no_reuse_client() as client1, _no_reuse_client() as client2:
# Use different JA3 for each instance
response1 = client1.get(f"{httpbin_url}/get", ja3=chrome_ja3)
response2 = client2.get(f"{httpbin_url}/get", ja3=firefox_ja3)
Expand All @@ -75,7 +90,7 @@ def test_instances_have_isolated_ja3(self, httpbin_url):

def test_instances_have_isolated_headers(self, httpbin_url):
"""Each instance should maintain its own headers."""
with CycleTLS() as client1, CycleTLS() as client2:
with _no_reuse_client() as client1, _no_reuse_client() as client2:
response1 = client1.get(
f"{httpbin_url}/headers",
headers={"X-Instance": "client1"}
Expand All @@ -98,7 +113,7 @@ def test_instances_have_isolated_headers(self, httpbin_url):

def test_instances_have_isolated_cookies(self, httpbin_url):
"""Each instance should maintain its own cookie jar."""
with CycleTLS() as client1, CycleTLS() as client2:
with _no_reuse_client() as client1, _no_reuse_client() as client2:
# Set different cookies for each instance
response1 = client1.get(f"{httpbin_url}/cookies/set?cookie1=value1")
response2 = client2.get(f"{httpbin_url}/cookies/set?cookie2=value2")
Expand All @@ -115,7 +130,7 @@ class TestConcurrentOperations:

def test_concurrent_requests_different_instances(self, httpbin_url):
"""Should handle concurrent requests from different instances."""
with CycleTLS() as client1, CycleTLS() as client2:
with _no_reuse_client() as client1, _no_reuse_client() as client2:
results = {}

def make_request(client, name):
Expand All @@ -138,7 +153,7 @@ def make_request(client, name):

def test_concurrent_requests_same_instance(self, httpbin_url):
"""Should handle concurrent requests from the same instance."""
with CycleTLS() as client:
with _no_reuse_client() as client:
results = []

def make_request(url):
Expand All @@ -163,7 +178,7 @@ def make_request(url):

def test_parallel_large_requests(self, httpbin_url):
"""Should handle parallel large requests."""
with CycleTLS() as client1, CycleTLS() as client2:
with _no_reuse_client() as client1, _no_reuse_client() as client2:
results = {}

def make_large_request(client, name, size):
Expand Down Expand Up @@ -204,7 +219,7 @@ class TestResourceCleanup:

def test_explicit_close(self, httpbin_url):
"""Test explicit closing of instances."""
client = CycleTLS()
client = _no_reuse_client()

response = client.get(f"{httpbin_url}/get")
assert response.status_code == 200
Expand All @@ -218,7 +233,7 @@ def test_explicit_close(self, httpbin_url):

def test_multiple_close_calls(self, httpbin_url):
"""Test that multiple close calls don't cause errors."""
client = CycleTLS()
client = _no_reuse_client()

response = client.get(f"{httpbin_url}/get")
assert response.status_code == 200
Expand All @@ -232,7 +247,7 @@ def test_multiple_close_calls(self, httpbin_url):

def test_cleanup_after_error(self, httpbin_url):
"""Test cleanup after errors."""
with CycleTLS() as client:
with _no_reuse_client() as client:
# Make a request that will fail - use a clearly invalid URL scheme
# or a reserved IP that won't route
try:
Expand All @@ -249,7 +264,7 @@ def test_cleanup_after_error(self, httpbin_url):

def test_context_manager_cleanup(self, httpbin_url):
"""Test context manager properly cleans up resources."""
with CycleTLS() as client:
with _no_reuse_client() as client:
response = client.get(f"{httpbin_url}/get")
assert response.status_code == 200

Expand All @@ -267,7 +282,7 @@ def test_create_many_instances_sequential(self):

try:
for i in range(5):
client = CycleTLS()
client = _no_reuse_client()
instances.append(client)

assert len(instances) == 5
Expand All @@ -281,13 +296,13 @@ def test_create_many_instances_sequential(self):
def test_reuse_after_close(self, httpbin_url):
"""Test creating a new instance after closing previous one."""
# Create and close first instance
client1 = CycleTLS()
client1 = _no_reuse_client()
response1 = client1.get(f"{httpbin_url}/get")
assert response1.status_code == 200
client1.close()

# Create new instance
client2 = CycleTLS()
client2 = _no_reuse_client()
try:
response2 = client2.get(f"{httpbin_url}/get")
assert response2.status_code == 200
Expand All @@ -300,7 +315,7 @@ class TestThreadSafety:

def test_single_instance_multiple_threads(self, httpbin_url):
"""Test using a single instance from multiple threads."""
with CycleTLS() as client:
with _no_reuse_client() as client:
results = []
errors = []

Expand All @@ -326,7 +341,7 @@ def make_request(request_id):

def test_multiple_instances_multiple_threads(self, httpbin_url):
"""Test multiple instances each used by different threads."""
clients = [CycleTLS() for _ in range(3)]
clients = [_no_reuse_client() for _ in range(3)]
results = []

def make_request(client, request_id):
Expand Down
Loading
Loading