Skip to content

Commit c4ddacb

Browse files
committed
v1.2.0: fix bugs, update curl_cffi, deprecate python 3.9
1 parent 81f2fea commit c4ddacb

File tree

7 files changed

+114
-69
lines changed

7 files changed

+114
-69
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ with requests.Session() as s:
8686
You can get extra information from the curl response info:
8787
```python
8888
import requests
89-
from curl_adapter import PyCurlAdapter, CurlInfo
89+
from curl_adapter import PyCurlAdapter, get_curl_info
9090

9191
with requests.Session() as s:
9292
s.mount("http://", PyCurlAdapter())
@@ -96,7 +96,7 @@ with requests.Session() as s:
9696

9797
body = response.text
9898

99-
curl_info: CurlInfo = response.curl_info
99+
curl_info = get_curl_info(response)
100100

101101
print(
102102
curl_info

curl_adapter/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
__all__ = [
1010
"CurlCffiAdapter",
1111
"PyCurlAdapter",
12-
"CurlInfo"
12+
"CurlInfo",
13+
"get_curl_info"
1314
]
1415

1516

16-
from .base_adapter import CurlInfo
17+
from .base_adapter import CurlInfo, get_curl_info
1718
from .curl_cffi import CurlCffiAdapter
1819
from .pycurl import PyCurlAdapter

curl_adapter/base_adapter.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,15 @@ class CurlInfo(TypedDict):
8181
namelookup_time: int
8282
has_used_proxy: int
8383

84+
85+
def get_curl_info(response: requests.Response) -> CurlInfo:
86+
return response.curl_info
87+
8488
class Response(requests.Response):
8589
curl_info: CurlInfo
8690
wait_for_body: typing.Callable[[], None]
8791

8892
class BaseCurlAdapter(BaseAdapter):
89-
90-
_local = threading.local()
9193

9294
def __init__(self,
9395
curl_class: typing.Union[curl_cffi.Curl, pycurl.Curl],
@@ -96,7 +98,6 @@ def __init__(self,
9698
use_thread_local_curl=True,
9799
stream_handler: CurlStreamHandlerBase=None
98100
):
99-
100101
self.curl_class: typing.Union[curl_cffi.Curl, pycurl.Curl] = curl_class
101102
self.debug = debug
102103
self.use_curl_content_decoding = use_curl_content_decoding
@@ -106,6 +107,14 @@ def __init__(self,
106107
self.stream_handler = (stream_handler or CurlStreamHandler)
107108

108109
if self.use_thread_local_curl:
110+
self._local = threading.local()
111+
try:
112+
from .stream.handler._thread_env import _THREAD_ENV
113+
if _THREAD_ENV == "gevent":
114+
from gevent.local import local as gevent_local
115+
self._local = gevent_local()
116+
except Exception:
117+
pass
109118
self._local.curl = self.curl_class()
110119
else:
111120
self._curl = self.curl_class()
@@ -121,16 +130,20 @@ def curl(self) -> typing.Union[curl_cffi.Curl, pycurl.Curl]:
121130
return self._local.curl
122131
return self._curl
123132

124-
def reset_curl(self):
133+
def reset_curl(self, curl: typing.Union[curl_cffi.Curl, pycurl.Curl, None] = None):
125134
'''
126135
Close current handle and open a new curl handle
127136
'''
128-
self.curl.close()
137+
if curl is None:
138+
curl = self.curl
139+
curl.close()
129140

130141
if self.use_thread_local_curl:
131142
self._local.curl = self.curl_class()
143+
return self._local.curl
132144
else:
133145
self._curl = self.curl_class()
146+
return self._curl
134147

135148
def enable_debug(self):
136149
if self.debug:
@@ -566,14 +579,14 @@ def set_curl_options(self,
566579
def send(
567580
self, request: requests.PreparedRequest, stream=False, timeout=None, verify=True, cert=None, proxies=None
568581
):
569-
self.reset_curl()
582+
curl = self.reset_curl()
570583

571-
self.cert_verify(self.curl, request.url, verify, cert)
584+
self.cert_verify(curl, request.url, verify, cert)
572585

573586
url = self.request_url(request, proxies)
574587

575588
self.set_curl_options(
576-
self.curl,
589+
curl,
577590
request=request,
578591
url=url,
579592
timeout=timeout,
@@ -583,29 +596,29 @@ def send(
583596
try:
584597
# Save headers when received
585598
header_buffer = BytesIO()
586-
self.curl.setopt(CurlOpt.HEADERDATA, header_buffer)
599+
curl.setopt(CurlOpt.HEADERDATA, header_buffer)
587600

588601
# Callbacks for retrieving & saving curl info object
589602
curl_info_dict: CurlInfo = {}
590603

591604
# Perform curl request with threading, and return body in a 'read' like class type (by simply using Curl.WRITEFUNCTION callback)
592605
start_curl_stream = (
593606
self.stream_handler(
594-
curl_instance=self.curl,
607+
curl_instance=curl,
595608
callback_after_perform=lambda curl: curl_info_dict.update(self.parse_info(curl)),
596609
timeout=timeout,
597610
debug=self.debug
598611
)
599612
).start()
600613

601614
# Headers are already available
602-
curl_info_dict.update(self.parse_info(self.curl, headers_only=True))
615+
curl_info_dict.update(self.parse_info(curl, headers_only=True))
603616

604617
if self.debug:
605618
print("[DEBUG] Curl Start Elapsed Time: ", time.time() - a)
606619

607620
# Headers are available after start, parse them
608-
parsed_headers = self.parse_headers(self.curl, header_buffer)
621+
parsed_headers = self.parse_headers(curl, header_buffer)
609622

610623
curl_stream_res = CurlStreamResponse(
611624
url=url,
@@ -616,7 +629,7 @@ def send(
616629
**parsed_headers
617630
)
618631

619-
return self.build_response(self.curl, curl_stream_res, parsed_headers, request, wait_for_body=start_curl_stream._wait_for_body, curl_info_dict=curl_info_dict)
632+
return self.build_response(curl, curl_stream_res, parsed_headers, request, wait_for_body=start_curl_stream._wait_for_body, curl_info_dict=curl_info_dict)
620633
except OSError as e:
621634
raise ConnectionError(e, request=request)
622635

curl_adapter/curl_cffi.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,11 @@ def set_curl_options(self, curl, request, url, timeout, proxies):
191191
)
192192

193193
def reset_curl(self):
194-
if hasattr(self.curl, 'clean_handles_and_buffers'):
194+
curl = self.curl
195+
if hasattr(curl, 'clean_handles_and_buffers'):
195196
# curl_cffi >= 0.14.0: clean_after_perform() was renamed to clean_handles_and_buffers()
196-
self.curl.clean_handles_and_buffers()
197-
elif hasattr(self.curl, 'clean_after_perform'):
197+
curl.clean_handles_and_buffers()
198+
elif hasattr(curl, 'clean_after_perform'):
198199
# curl_cffi < 0.14.0
199-
self.curl.clean_after_perform()
200-
return super().reset_curl()
200+
curl.clean_after_perform()
201+
return super().reset_curl(curl=curl)

curl_adapter/stream/sockets/curl_cffi_socket.py

Lines changed: 62 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -32,63 +32,81 @@
3232
GEVENT_READ = 1
3333
GEVENT_WRITE = 2
3434

35+
_TIMER_CALLBACK_RETURNS_INT = ffi.typeof(lib.timer_function).result.cname != "void"
36+
_SOCKET_CALLBACK_RETURNS_INT = ffi.typeof(lib.socket_function).result.cname != "void"
37+
38+
39+
def _timer_callback_return():
40+
return 0 if _TIMER_CALLBACK_RETURNS_INT else None
41+
42+
43+
def _socket_callback_return():
44+
return 0 if _SOCKET_CALLBACK_RETURNS_INT else None
45+
46+
3547
@ffi.def_extern()
3648
def timer_function(curlm, timeout_ms: int, clientp: "GeventCurlCffi"):
49+
try:
50+
gevent_curl: "GeventCurlCffi" = ffi.from_handle(clientp)
51+
52+
if gevent_curl._timer:
53+
gevent_curl._timer.kill(block=False)
54+
gevent_curl._timer = None
55+
56+
if not gevent_curl._curl_multi:
57+
# Don't schedule timers if multi handle is gone.
58+
return _timer_callback_return()
3759

38-
gevent_curl: "GeventCurlCffi" = ffi.from_handle(clientp)
39-
40-
if gevent_curl._timer:
41-
gevent_curl._timer.kill(block=False)
42-
gevent_curl._timer = None
43-
44-
if not gevent_curl._curl_multi:
45-
# Don't schedule timers if multi handle is gone.
46-
return 0
47-
48-
if timeout_ms == -1:
49-
# A timeout_ms value of -1 means you should delete the timer, we already did.
50-
return 0
51-
52-
if timeout_ms == 0:
53-
# immediate timeout; invoke directly
54-
gevent_curl._timer = gevent.spawn(
55-
gevent_curl._process_data,
56-
CURL_SOCKET_TIMEOUT,
57-
CURL_POLL_NONE
58-
)
59-
elif timeout_ms > 0:
60-
# schedule one timer
61-
# spawn a greenlet to run after timeout_ms milliseconds
62-
gevent_curl._timer = gevent.spawn_later(
60+
if timeout_ms == -1:
61+
# A timeout_ms value of -1 means you should delete the timer, we already did.
62+
return _timer_callback_return()
63+
64+
if timeout_ms == 0:
65+
# immediate timeout; invoke directly
66+
gevent_curl._timer = gevent.spawn(
67+
gevent_curl._process_data,
68+
CURL_SOCKET_TIMEOUT,
69+
CURL_POLL_NONE
70+
)
71+
elif timeout_ms > 0:
72+
# spawn a greenlet to run after timeout_ms milliseconds
73+
gevent_curl._timer = gevent.spawn_later(
6374
timeout_ms / 1000.0,
6475
gevent_curl._process_data,
6576
CURL_SOCKET_TIMEOUT,
6677
CURL_POLL_NONE,
67-
)
68-
69-
return 0
70-
78+
)
79+
except Exception:
80+
# Never raise from CFFI callbacks; Windows can show "Python-CFFI error" dialogs.
81+
return _timer_callback_return()
82+
83+
return _timer_callback_return()
84+
7185
@ffi.def_extern()
7286
def socket_function(curlm, sockfd: int, what: int, clientp: "GeventCurlCffi", data: Any):
73-
gevent_curl: "GeventCurlCffi" = ffi.from_handle(clientp)
74-
want_read = bool(what & CURL_POLL_IN)
75-
want_write = bool(what & CURL_POLL_OUT)
87+
try:
88+
gevent_curl: "GeventCurlCffi" = ffi.from_handle(clientp)
89+
want_read = bool(what & CURL_POLL_IN)
90+
want_write = bool(what & CURL_POLL_OUT)
7691

77-
# compute the new mask for gevent
78-
new_mask = 0
79-
if want_read: new_mask |= GEVENT_READ
80-
if want_write: new_mask |= GEVENT_WRITE
92+
# compute the new mask for gevent
93+
new_mask = 0
94+
if want_read:
95+
new_mask |= GEVENT_READ
96+
if want_write:
97+
new_mask |= GEVENT_WRITE
8198

82-
# teardown if libcurl says “remove”
83-
if what & CURL_POLL_REMOVE:
84-
gevent_curl._update_watcher(sockfd, 0)
85-
return 0
99+
if what & CURL_POLL_REMOVE:
100+
gevent_curl._update_watcher(sockfd, 0)
101+
return _socket_callback_return()
86102

87-
# otherwise install/update the watcher
88-
gevent_curl._update_watcher(sockfd, new_mask)
103+
gevent_curl._update_watcher(sockfd, new_mask)
104+
except Exception:
105+
# Never raise from CFFI callbacks; Windows can show "Python-CFFI error" dialogs.
106+
return _socket_callback_return()
107+
108+
return _socket_callback_return()
89109

90-
return 0
91-
92110
class GeventCurlCffi:
93111
'''
94112
Usage:

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,16 @@ build-backend = "setuptools.build_meta"
55

66
[project]
77
name = "curl_adapter"
8-
version = "1.1.0"
8+
version = "1.2.0"
99
description = "A curl HTTP adapter switch for requests library — make browser-like requests with custom TLS fingerprints."
1010
readme = "README.md"
11-
requires-python = ">=3.9"
11+
requires-python = ">=3.10"
1212
license = {file = "LICENSE"}
1313
authors = [{name = "Elis K.", email = "github@elis.cc"}]
1414
keywords = ["curl", "requests", "adapter", "tls fingerprint", "pycurl", "curl_cffi", "curl impersonate"]
1515
dependencies = [
1616
"requests",
17-
"curl_cffi >= 0.11.0",
17+
"curl_cffi >= 0.14.0",
1818
"pycurl >= 7.45.5",
1919
"brotli"
2020
]

tests/test_general.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,3 +202,15 @@ def test_ssl_no_verify(self, curl_adapter):
202202
assert r.status_code == 200
203203

204204

205+
@pytest.mark.parametrize("adapter_class", [CurlCffiAdapter, PyCurlAdapter])
206+
def test_thread_local_curl_isolated_per_adapter_instance(adapter_class):
207+
adapter_one = adapter_class(use_thread_local_curl=True, stream_handler=CurlStreamHandlerBase)
208+
adapter_two = adapter_class(use_thread_local_curl=True, stream_handler=CurlStreamHandlerBase)
209+
210+
try:
211+
assert adapter_one.curl is not adapter_two.curl
212+
finally:
213+
adapter_one.close()
214+
adapter_two.close()
215+
216+

0 commit comments

Comments
 (0)