curlpro 0.2.0__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- curlpro/__init__.py +61 -0
- curlpro/_completions.py +142 -0
- curlpro/_ffi.py +302 -0
- curlpro/aio.py +404 -0
- curlpro/cookies.py +308 -0
- curlpro/encoding.py +95 -0
- curlpro/expect.py +176 -0
- curlpro/headers.py +89 -0
- curlpro/lib/curlpro.dll +0 -0
- curlpro/lib/curlpro.h +150 -0
- curlpro/profiles/chrome-100-windows.json +74 -0
- curlpro/profiles/chrome-101-windows.json +74 -0
- curlpro/profiles/chrome-104-windows.json +74 -0
- curlpro/profiles/chrome-107-windows.json +74 -0
- curlpro/profiles/chrome-110-windows.json +77 -0
- curlpro/profiles/chrome-116-windows.json +77 -0
- curlpro/profiles/chrome-118-linux.json +388 -0
- curlpro/profiles/chrome-119-linux.json +391 -0
- curlpro/profiles/chrome-119-macos.json +391 -0
- curlpro/profiles/chrome-120-linux.json +388 -0
- curlpro/profiles/chrome-120-macos.json +391 -0
- curlpro/profiles/chrome-123-macos.json +388 -0
- curlpro/profiles/chrome-124-macos.json +394 -0
- curlpro/profiles/chrome-131-android.json +78 -0
- curlpro/profiles/chrome-131-macos.json +394 -0
- curlpro/profiles/chrome-133-macos.json +394 -0
- curlpro/profiles/chrome-136-macos.json +78 -0
- curlpro/profiles/chrome-142-macos.json +78 -0
- curlpro/profiles/chrome-150-macos.json +397 -0
- curlpro/profiles/chrome-151-windows.json +332 -0
- curlpro/profiles/chrome-152-android.json +375 -0
- curlpro/profiles/chrome-152-windows.json +130 -0
- curlpro/profiles/chrome-98-windows.json +369 -0
- curlpro/profiles/chrome-99-android.json +74 -0
- curlpro/profiles/chrome-99-windows.json +74 -0
- curlpro/profiles/edge-101-windows.json +74 -0
- curlpro/profiles/edge-118-linux.json +388 -0
- curlpro/profiles/edge-119-linux.json +388 -0
- curlpro/profiles/edge-120-linux.json +391 -0
- curlpro/profiles/edge-98-windows.json +74 -0
- curlpro/profiles/edge-99-windows.json +74 -0
- curlpro/profiles/firefox-133-macos.json +395 -0
- curlpro/profiles/firefox-135-macos.json +398 -0
- curlpro/profiles/firefox-144-macos.json +70 -0
- curlpro/profiles/safari-15.3-macos.json +176 -0
- curlpro/profiles/safari-15.5-macos.json +176 -0
- curlpro/profiles/safari-17-ios.json +66 -0
- curlpro/profiles/safari-17-macos.json +66 -0
- curlpro/profiles/safari-18.0-ios.json +214 -0
- curlpro/profiles/safari-18.0-macos.json +46 -0
- curlpro/profiles/safari-18.4-ios.json +74 -0
- curlpro/profiles/safari-18.4-macos.json +74 -0
- curlpro/profiles/safari-26-ios.json +211 -0
- curlpro/profiles/safari-26.0-macos.json +210 -0
- curlpro/profiles/safari-26.0.1-macos.json +207 -0
- curlpro/profiles/tor-14-macos.json +380 -0
- curlpro/profiles/yandex-26.8-android.json +329 -0
- curlpro/profiles.py +121 -0
- curlpro/proxies.py +50 -0
- curlpro/py.typed +0 -0
- curlpro/session.py +904 -0
- curlpro/stream.py +146 -0
- curlpro/timeouts.py +31 -0
- curlpro/websocket.py +132 -0
- curlpro-0.2.0.dist-info/METADATA +114 -0
- curlpro-0.2.0.dist-info/RECORD +70 -0
- curlpro-0.2.0.dist-info/WHEEL +5 -0
- curlpro-0.2.0.dist-info/licenses/LICENSE +202 -0
- curlpro-0.2.0.dist-info/licenses/NOTICE +29 -0
- curlpro-0.2.0.dist-info/top_level.txt +1 -0
curlpro/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""curlpro — an HTTP client with a browser's network fingerprint.
|
|
2
|
+
|
|
3
|
+
import curlpro
|
|
4
|
+
|
|
5
|
+
curlpro.load_profiles("profiles")
|
|
6
|
+
r = curlpro.get("https://example.com", impersonate="chrome-151-windows")
|
|
7
|
+
print(r.status, r.text[:200])
|
|
8
|
+
|
|
9
|
+
A profile can also be added at runtime, without waiting for a library
|
|
10
|
+
release::
|
|
11
|
+
|
|
12
|
+
curlpro.register_profile(json.load(open("chrome-152-windows.json")))
|
|
13
|
+
|
|
14
|
+
The library reproduces the network layer: the TLS ClientHello, HTTP/2 frames,
|
|
15
|
+
header order and case. The JavaScript fingerprint (canvas, WebGL, navigator)
|
|
16
|
+
belongs to the browser and is not covered here.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from ._ffi import HTTPError, Timeout, CurlProError, WebSocketClosed
|
|
22
|
+
from .expect import Expect, ExpectationFailed
|
|
23
|
+
from .aio import AsyncSession, AsyncStreamResponse, AsyncWebSocket
|
|
24
|
+
from .cookies import Cookie, Cookies
|
|
25
|
+
from .session import Redirect, Response, Session, delete, get, head, options, patch, post, put
|
|
26
|
+
from .profiles import Profile, ensure_loaded, list_profiles, load_profiles, register_profile
|
|
27
|
+
from .stream import StreamResponse
|
|
28
|
+
from .websocket import WebSocket
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AsyncSession",
|
|
32
|
+
"AsyncStreamResponse",
|
|
33
|
+
"AsyncWebSocket",
|
|
34
|
+
"Cookie",
|
|
35
|
+
"Cookies",
|
|
36
|
+
"CurlProError",
|
|
37
|
+
"Expect",
|
|
38
|
+
"ExpectationFailed",
|
|
39
|
+
"HTTPError",
|
|
40
|
+
"Redirect",
|
|
41
|
+
"Timeout",
|
|
42
|
+
"Profile",
|
|
43
|
+
"Response",
|
|
44
|
+
"Session",
|
|
45
|
+
"StreamResponse",
|
|
46
|
+
"WebSocket",
|
|
47
|
+
"WebSocketClosed",
|
|
48
|
+
"delete",
|
|
49
|
+
"ensure_loaded",
|
|
50
|
+
"get",
|
|
51
|
+
"head",
|
|
52
|
+
"list_profiles",
|
|
53
|
+
"load_profiles",
|
|
54
|
+
"options",
|
|
55
|
+
"patch",
|
|
56
|
+
"post",
|
|
57
|
+
"put",
|
|
58
|
+
"register_profile",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
__version__ = "0.1.0"
|
curlpro/_completions.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Collector of finished native calls.
|
|
2
|
+
|
|
3
|
+
An async request goes into a goroutine, and Python learns that it finished
|
|
4
|
+
from here. There is exactly one thread per process, no matter how many calls
|
|
5
|
+
are in flight: it waits inside the native part, where ctypes releases the
|
|
6
|
+
GIL, so the event loop keeps running.
|
|
7
|
+
|
|
8
|
+
The result itself is picked up by the event loop — that call is quick, just a
|
|
9
|
+
memory copy. Reaching into a loop from another thread is only allowed through
|
|
10
|
+
call_soon_threadsafe, and that is how the future is woken.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import threading
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ._ffi import _call, _lib, call_framed_out
|
|
20
|
+
|
|
21
|
+
# How long a single wait inside the native part lasts. Waking up four times a
|
|
22
|
+
# second while idle costs nothing, and the thread exits almost immediately
|
|
23
|
+
# once nobody uses it any more.
|
|
24
|
+
_WAIT_MS = 250
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Completions:
|
|
28
|
+
"""The single per-process collector of results."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._lock = threading.Lock()
|
|
32
|
+
self._waiters: dict[int, tuple[asyncio.AbstractEventLoop, asyncio.Future]] = {}
|
|
33
|
+
# Results that arrived before their waiter: the work can finish
|
|
34
|
+
# between the start call and register — routine for a stream read,
|
|
35
|
+
# which takes microseconds.
|
|
36
|
+
self._ready: dict[int, tuple[Any, bytes]] = {}
|
|
37
|
+
self._thread: threading.Thread | None = None
|
|
38
|
+
self._stop = False
|
|
39
|
+
|
|
40
|
+
def register(self, request_id: int, future: asyncio.Future) -> None:
|
|
41
|
+
loop = asyncio.get_running_loop()
|
|
42
|
+
with self._lock:
|
|
43
|
+
done = self._ready.pop(request_id, None)
|
|
44
|
+
if done is not None:
|
|
45
|
+
# Registration happens on the event loop, so the future can
|
|
46
|
+
# be completed right here, without call_soon_threadsafe.
|
|
47
|
+
future.set_result(done)
|
|
48
|
+
return
|
|
49
|
+
self._waiters[request_id] = (loop, future)
|
|
50
|
+
if self._thread is None or not self._thread.is_alive():
|
|
51
|
+
self._stop = False
|
|
52
|
+
self._thread = threading.Thread(
|
|
53
|
+
target=self._reap, name="curlpro-completions", daemon=True
|
|
54
|
+
)
|
|
55
|
+
self._thread.start()
|
|
56
|
+
|
|
57
|
+
def forget(self, request_id: int) -> None:
|
|
58
|
+
"""Drops the wait: the call was cancelled and nobody needs its result."""
|
|
59
|
+
with self._lock:
|
|
60
|
+
self._waiters.pop(request_id, None)
|
|
61
|
+
self._ready.pop(request_id, None)
|
|
62
|
+
|
|
63
|
+
def _reap(self) -> None:
|
|
64
|
+
while True:
|
|
65
|
+
with self._lock:
|
|
66
|
+
if self._stop or not (self._waiters or self._ready):
|
|
67
|
+
self._thread = None
|
|
68
|
+
return
|
|
69
|
+
rid = int(_lib.curlpro_result_wait(_WAIT_MS))
|
|
70
|
+
if rid == 0:
|
|
71
|
+
continue
|
|
72
|
+
with self._lock:
|
|
73
|
+
entry = self._waiters.pop(rid, None)
|
|
74
|
+
if entry is None:
|
|
75
|
+
# No waiter yet. Work is started and registered in two
|
|
76
|
+
# steps, and a fast one — reading a chunk of the body —
|
|
77
|
+
# can finish in between. The result is set aside for
|
|
78
|
+
# register to pick up.
|
|
79
|
+
#
|
|
80
|
+
# It used to be thrown away here, and such a call hung
|
|
81
|
+
# forever: 24 concurrent stream reads lost one of them.
|
|
82
|
+
#
|
|
83
|
+
# It is taken under the same lock: otherwise register
|
|
84
|
+
# slips between the check and the hand-off, and the
|
|
85
|
+
# waiter and its ready result never meet again.
|
|
86
|
+
#
|
|
87
|
+
# A cancelled result never lands here: cancelling removes
|
|
88
|
+
# the call from the native registry, leaving nothing to take.
|
|
89
|
+
done = _take(rid)
|
|
90
|
+
if done is not None:
|
|
91
|
+
self._ready[rid] = done
|
|
92
|
+
continue
|
|
93
|
+
loop, future = entry
|
|
94
|
+
try:
|
|
95
|
+
loop.call_soon_threadsafe(_settle, future, rid)
|
|
96
|
+
except RuntimeError:
|
|
97
|
+
# The event loop is already closed — nobody to hand it to.
|
|
98
|
+
_take(rid)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _take(request_id: int) -> tuple[Any, bytes] | None:
|
|
102
|
+
try:
|
|
103
|
+
return call_framed_out("curlpro_result_take", request_id)
|
|
104
|
+
except Exception: # noqa: BLE001 — already taken, or the session is closed
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _settle(future: asyncio.Future, request_id: int) -> None:
|
|
109
|
+
"""Runs on the event loop: takes the result and wakes the waiter."""
|
|
110
|
+
if future.done(): # the task was cancelled while the result was on its way
|
|
111
|
+
_take(request_id)
|
|
112
|
+
return
|
|
113
|
+
try:
|
|
114
|
+
payload, content = call_framed_out("curlpro_result_take", request_id)
|
|
115
|
+
except BaseException as exc: # noqa: BLE001 — hand the error to the waiter
|
|
116
|
+
future.set_exception(exc)
|
|
117
|
+
return
|
|
118
|
+
future.set_result((payload, content))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
#: One instance per process: the native ready queue is single too.
|
|
122
|
+
completions = Completions()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def settle(started: dict) -> tuple[Any, bytes]:
|
|
126
|
+
"""Awaits the result of work already started: a request, a read, a receive.
|
|
127
|
+
|
|
128
|
+
Cancelling the task drops the wait and cancels the work natively. For a
|
|
129
|
+
request that frees the connection at once; for a stream read or a message
|
|
130
|
+
receive there is nothing to cancel — whatever was taken off the wire is
|
|
131
|
+
lost, which is why the stream or socket is closed after a cancellation.
|
|
132
|
+
"""
|
|
133
|
+
request_id = int(started["request"])
|
|
134
|
+
loop = asyncio.get_running_loop()
|
|
135
|
+
future: asyncio.Future = loop.create_future()
|
|
136
|
+
completions.register(request_id, future)
|
|
137
|
+
try:
|
|
138
|
+
return await future
|
|
139
|
+
except asyncio.CancelledError:
|
|
140
|
+
completions.forget(request_id)
|
|
141
|
+
_call("curlpro_request_cancel", request_id)
|
|
142
|
+
raise
|
curlpro/_ffi.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Loading the native library and calling it through ctypes.
|
|
2
|
+
|
|
3
|
+
Every export returns a char* holding a JSON envelope
|
|
4
|
+
``{"ok":…, "error":…, "code":…, "data":…}``. That string is allocated in C and
|
|
5
|
+
must be released with ``curlpro_free`` or it leaks. :func:`_call` does the
|
|
6
|
+
releasing, which is why no pointer ever escapes this module.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ctypes
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import platform
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CurlProError(RuntimeError):
|
|
20
|
+
"""An error raised by the native part.
|
|
21
|
+
|
|
22
|
+
``code`` is the machine-readable code when the native side knows one:
|
|
23
|
+
``timeout``, ``session_closed``, ``too_large``, ``ws_closed``,
|
|
24
|
+
``ws_too_big``, ``ws_protocol``. Never branch on the message text — it is
|
|
25
|
+
for humans.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str, code: str | None = None):
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.code = code
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Timeout(CurlProError):
|
|
34
|
+
"""The request ran out of time.
|
|
35
|
+
|
|
36
|
+
A class of its own because a timeout is the one outcome a scraper treats
|
|
37
|
+
differently from other network errors: it retries it.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class HTTPError(CurlProError):
|
|
42
|
+
"""A response with an error status; raised by :meth:`Response.raise_for_status`.
|
|
43
|
+
|
|
44
|
+
That method used to raise a bare RuntimeError, indistinguishable from an
|
|
45
|
+
internal failure. ``response`` stays attached: an error response usually
|
|
46
|
+
carries a body, and that body is the reason to look at it.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, message: str, response=None, code: str | None = None): # noqa: ANN001
|
|
50
|
+
super().__init__(message, code)
|
|
51
|
+
self.response = response
|
|
52
|
+
self.status = getattr(response, "status", None)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class WebSocketClosed(CurlProError):
|
|
56
|
+
"""The WebSocket is closed: by the server's Close frame or by the caller.
|
|
57
|
+
|
|
58
|
+
A class of its own so that ``for message in ws`` stops on a close only,
|
|
59
|
+
while read timeouts and protocol errors reach the caller.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _raise(envelope: dict, name: str) -> None:
|
|
64
|
+
code = envelope.get("code")
|
|
65
|
+
message = envelope.get("error") or f"{name}: unknown error"
|
|
66
|
+
if code == "ws_closed":
|
|
67
|
+
raise WebSocketClosed(message, code)
|
|
68
|
+
if code == "timeout":
|
|
69
|
+
raise Timeout(message, code)
|
|
70
|
+
raise CurlProError(message, code)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _library_name() -> str:
|
|
74
|
+
system = platform.system()
|
|
75
|
+
if system == "Windows":
|
|
76
|
+
return "curlpro.dll"
|
|
77
|
+
if system == "Darwin":
|
|
78
|
+
return "libcurlpro.dylib"
|
|
79
|
+
return "libcurlpro.so"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _candidates() -> list[Path]:
|
|
83
|
+
"""Where the library is looked for, from an explicit path to a source build."""
|
|
84
|
+
name = _library_name()
|
|
85
|
+
out: list[Path] = []
|
|
86
|
+
if env := os.environ.get("CURLPRO_LIBRARY"):
|
|
87
|
+
out.append(Path(env))
|
|
88
|
+
here = Path(__file__).resolve().parent
|
|
89
|
+
out.append(here / "lib" / name) # packed into the wheel
|
|
90
|
+
out.append(here.parent.parent / "dist" / name) # a build from the repository
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _load() -> ctypes.CDLL:
|
|
95
|
+
tried = []
|
|
96
|
+
for path in _candidates():
|
|
97
|
+
if path.is_file():
|
|
98
|
+
return ctypes.CDLL(str(path))
|
|
99
|
+
tried.append(str(path))
|
|
100
|
+
raise CurlProError(
|
|
101
|
+
"native library not found. Looked in:\n "
|
|
102
|
+
+ "\n ".join(tried)
|
|
103
|
+
+ "\nBuild it: CGO_ENABLED=1 go build -buildmode=c-shared -o dist/"
|
|
104
|
+
+ _library_name()
|
|
105
|
+
+ " ./lib\n"
|
|
106
|
+
+ "(cgo needs a C compiler; without CGO_ENABLED=1 the build fails with\n"
|
|
107
|
+
+ "\"build constraints exclude all Go files\", which names neither)\n"
|
|
108
|
+
+ "or point CURLPRO_LIBRARY at an existing build"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_lib = _load()
|
|
113
|
+
|
|
114
|
+
_lib.curlpro_free.argtypes = [ctypes.c_void_p]
|
|
115
|
+
_lib.curlpro_free.restype = None
|
|
116
|
+
|
|
117
|
+
for _name, _args in (
|
|
118
|
+
("curlpro_version", []),
|
|
119
|
+
("curlpro_profiles_list", []),
|
|
120
|
+
("curlpro_profiles_load_dir", [ctypes.c_char_p]),
|
|
121
|
+
("curlpro_profile_register", [ctypes.c_char_p]),
|
|
122
|
+
("curlpro_session_new", [ctypes.c_char_p]),
|
|
123
|
+
("curlpro_session_close", [ctypes.c_longlong]),
|
|
124
|
+
(
|
|
125
|
+
"curlpro_request",
|
|
126
|
+
[ctypes.c_longlong, ctypes.c_char_p, ctypes.c_int, ctypes.POINTER(ctypes.c_int)],
|
|
127
|
+
),
|
|
128
|
+
(
|
|
129
|
+
"curlpro_stream_open",
|
|
130
|
+
[ctypes.c_longlong, ctypes.c_char_p, ctypes.c_int, ctypes.POINTER(ctypes.c_int)],
|
|
131
|
+
),
|
|
132
|
+
("curlpro_stream_close", [ctypes.c_longlong]),
|
|
133
|
+
("curlpro_ws_connect", [ctypes.c_longlong, ctypes.c_char_p]),
|
|
134
|
+
(
|
|
135
|
+
"curlpro_ws_send",
|
|
136
|
+
[ctypes.c_longlong, ctypes.c_char_p, ctypes.c_int, ctypes.POINTER(ctypes.c_int)],
|
|
137
|
+
),
|
|
138
|
+
("curlpro_ws_recv", [ctypes.c_longlong, ctypes.POINTER(ctypes.c_int)]),
|
|
139
|
+
("curlpro_ws_close", [ctypes.c_longlong, ctypes.c_int, ctypes.c_char_p]),
|
|
140
|
+
("curlpro_session_set_header", [ctypes.c_longlong, ctypes.c_char_p, ctypes.c_char_p]),
|
|
141
|
+
("curlpro_session_remove_header", [ctypes.c_longlong, ctypes.c_char_p]),
|
|
142
|
+
("curlpro_session_reset_headers", [ctypes.c_longlong]),
|
|
143
|
+
("curlpro_session_headers", [ctypes.c_longlong]),
|
|
144
|
+
("curlpro_session_cookies", [ctypes.c_longlong]),
|
|
145
|
+
("curlpro_session_set_cookies", [ctypes.c_longlong, ctypes.c_char_p]),
|
|
146
|
+
("curlpro_session_clear_cookies", [ctypes.c_longlong]),
|
|
147
|
+
("curlpro_request_start", [ctypes.c_longlong, ctypes.c_char_p, ctypes.c_int]),
|
|
148
|
+
("curlpro_stream_open_start", [ctypes.c_longlong, ctypes.c_char_p, ctypes.c_int]),
|
|
149
|
+
("curlpro_stream_read_start", [ctypes.c_longlong, ctypes.c_int]),
|
|
150
|
+
("curlpro_ws_connect_start", [ctypes.c_longlong, ctypes.c_char_p]),
|
|
151
|
+
("curlpro_ws_send_start", [ctypes.c_longlong, ctypes.c_char_p, ctypes.c_int]),
|
|
152
|
+
("curlpro_ws_recv_start", [ctypes.c_longlong]),
|
|
153
|
+
("curlpro_result_take", [ctypes.c_longlong, ctypes.POINTER(ctypes.c_int)]),
|
|
154
|
+
("curlpro_request_cancel", [ctypes.c_longlong]),
|
|
155
|
+
("curlpro_debug_counts", []),
|
|
156
|
+
):
|
|
157
|
+
try:
|
|
158
|
+
_fn = getattr(_lib, _name)
|
|
159
|
+
except AttributeError:
|
|
160
|
+
raise CurlProError(
|
|
161
|
+
f"the library exports no {_name}: it was built from an older revision.\n"
|
|
162
|
+
f"Rebuild it: .\\build.ps1 (writes to dist/)"
|
|
163
|
+
) from None
|
|
164
|
+
_fn.argtypes = _args
|
|
165
|
+
# c_void_p and not c_char_p: ctypes converts c_char_p into bytes and
|
|
166
|
+
# loses the original pointer, which curlpro_free needs.
|
|
167
|
+
_fn.restype = ctypes.c_void_p
|
|
168
|
+
|
|
169
|
+
# Waiting for completions and counting in-flight calls return numbers, not
|
|
170
|
+
# pointers. ctypes performs the blocking wait with the GIL released, and the
|
|
171
|
+
# async path stands on that: the collector thread waits without blocking the
|
|
172
|
+
# event loop.
|
|
173
|
+
_lib.curlpro_result_wait.argtypes = [ctypes.c_int]
|
|
174
|
+
_lib.curlpro_result_wait.restype = ctypes.c_longlong
|
|
175
|
+
_lib.curlpro_async_pending.argtypes = []
|
|
176
|
+
_lib.curlpro_async_pending.restype = ctypes.c_longlong
|
|
177
|
+
|
|
178
|
+
# read returns a byte count, not a pointer: the caller owns the buffer.
|
|
179
|
+
_lib.curlpro_stream_read.argtypes = [ctypes.c_longlong, ctypes.c_char_p, ctypes.c_int]
|
|
180
|
+
_lib.curlpro_stream_read.restype = ctypes.c_int
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def stream_read(stream_id: int, size: int) -> bytes:
|
|
184
|
+
"""Reads up to size bytes. An empty result means the body ended."""
|
|
185
|
+
buf = ctypes.create_string_buffer(size)
|
|
186
|
+
n = _lib.curlpro_stream_read(stream_id, buf, size)
|
|
187
|
+
if n < 0:
|
|
188
|
+
raise CurlProError("stream read failed")
|
|
189
|
+
return buf.raw[:n]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _call(name: str, *args: Any) -> Any:
|
|
193
|
+
"""Calls an export, unwraps the envelope and frees the C string."""
|
|
194
|
+
ptr = getattr(_lib, name)(*args)
|
|
195
|
+
if not ptr:
|
|
196
|
+
raise CurlProError(f"{name}: the native side returned NULL")
|
|
197
|
+
try:
|
|
198
|
+
raw = ctypes.string_at(ptr).decode("utf-8")
|
|
199
|
+
finally:
|
|
200
|
+
_lib.curlpro_free(ptr)
|
|
201
|
+
|
|
202
|
+
envelope = json.loads(raw)
|
|
203
|
+
if not envelope.get("ok"):
|
|
204
|
+
_raise(envelope, name)
|
|
205
|
+
return envelope.get("data")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# Minimum version of the native part: major and minor. Raise it together
|
|
209
|
+
# with lib/curlpro.go whenever Python starts depending on a new export or field.
|
|
210
|
+
REQUIRED_VERSION = (0, 12)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _check_version() -> None:
|
|
214
|
+
"""Checks the library version against what this Python expects.
|
|
215
|
+
|
|
216
|
+
Without the check a mismatch is silent: both sides tolerate unknown JSON
|
|
217
|
+
fields, so an older library quietly ignores new options — the request
|
|
218
|
+
goes out without them, and that looks like a logic bug rather than a
|
|
219
|
+
stale build.
|
|
220
|
+
"""
|
|
221
|
+
raw = _call("curlpro_version").get("version", "")
|
|
222
|
+
try:
|
|
223
|
+
got = tuple(int(p) for p in raw.split(".")[:2])
|
|
224
|
+
except ValueError:
|
|
225
|
+
raise CurlProError(f"the library reported an unreadable version {raw!r}") from None
|
|
226
|
+
|
|
227
|
+
if got < REQUIRED_VERSION:
|
|
228
|
+
want = ".".join(str(p) for p in REQUIRED_VERSION)
|
|
229
|
+
raise CurlProError(
|
|
230
|
+
f"library version {raw} is older than the required {want}.x, "
|
|
231
|
+
f"so some options would be silently ignored.\n"
|
|
232
|
+
f"Rebuild it: .\\build.ps1 (writes to dist/)"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
_check_version()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# Request and response bodies travel as binary, separate from the JSON.
|
|
240
|
+
#
|
|
241
|
+
# As a string inside JSON they were not merely copied one extra time:
|
|
242
|
+
# arbitrary bytes are not valid UTF-8, so the response was corrupted — 10,000
|
|
243
|
+
# random bytes came back as 18,502 after the round trip.
|
|
244
|
+
#
|
|
245
|
+
# Frame layout: [uint32 LE JSON length][JSON][raw body].
|
|
246
|
+
_HEADER = 4
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _frame(meta: Any, body: bytes = b"") -> bytes:
|
|
250
|
+
js = encode(meta)
|
|
251
|
+
return len(js).to_bytes(_HEADER, "little") + js + body
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _unframe(name: str, raw: bytes) -> tuple[Any, bytes]:
|
|
255
|
+
if len(raw) < _HEADER:
|
|
256
|
+
raise CurlProError(f"{name}: frame is shorter than its header ({len(raw)} bytes)")
|
|
257
|
+
meta_len = int.from_bytes(raw[:_HEADER], "little")
|
|
258
|
+
envelope = json.loads(raw[_HEADER : _HEADER + meta_len])
|
|
259
|
+
if not envelope.get("ok"):
|
|
260
|
+
_raise(envelope, name)
|
|
261
|
+
return envelope.get("data"), raw[_HEADER + meta_len :]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def call_framed(name: str, *args: Any, body: bytes = b"", meta: Any) -> tuple[Any, bytes]:
|
|
265
|
+
"""Calls an export with frames and returns (data, body)."""
|
|
266
|
+
payload = _frame(meta, body)
|
|
267
|
+
out_len = ctypes.c_int(0)
|
|
268
|
+
ptr = getattr(_lib, name)(*args, payload, len(payload), ctypes.byref(out_len))
|
|
269
|
+
if not ptr:
|
|
270
|
+
raise CurlProError(f"{name}: the native side returned NULL")
|
|
271
|
+
try:
|
|
272
|
+
raw = ctypes.string_at(ptr, out_len.value)
|
|
273
|
+
finally:
|
|
274
|
+
_lib.curlpro_free(ptr)
|
|
275
|
+
return _unframe(name, raw)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def call_with_frame(name: str, *args: Any, body: bytes = b"", meta: Any) -> Any:
|
|
279
|
+
"""A frame going in, a plain JSON envelope coming back.
|
|
280
|
+
|
|
281
|
+
This is how an async request starts: the body goes out as a frame, and
|
|
282
|
+
all that returns is the request number — there is no response yet.
|
|
283
|
+
"""
|
|
284
|
+
payload = _frame(meta, body)
|
|
285
|
+
return _call(name, *args, payload, len(payload))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def call_framed_out(name: str, *args: Any) -> tuple[Any, bytes]:
|
|
289
|
+
"""Like call_framed but without an input frame — for recv-style calls."""
|
|
290
|
+
out_len = ctypes.c_int(0)
|
|
291
|
+
ptr = getattr(_lib, name)(*args, ctypes.byref(out_len))
|
|
292
|
+
if not ptr:
|
|
293
|
+
raise CurlProError(f"{name}: the native side returned NULL")
|
|
294
|
+
try:
|
|
295
|
+
raw = ctypes.string_at(ptr, out_len.value)
|
|
296
|
+
finally:
|
|
297
|
+
_lib.curlpro_free(ptr)
|
|
298
|
+
return _unframe(name, raw)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def encode(obj: Any) -> bytes:
|
|
302
|
+
return json.dumps(obj, ensure_ascii=False).encode("utf-8")
|