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.
Files changed (70) hide show
  1. curlpro/__init__.py +61 -0
  2. curlpro/_completions.py +142 -0
  3. curlpro/_ffi.py +302 -0
  4. curlpro/aio.py +404 -0
  5. curlpro/cookies.py +308 -0
  6. curlpro/encoding.py +95 -0
  7. curlpro/expect.py +176 -0
  8. curlpro/headers.py +89 -0
  9. curlpro/lib/curlpro.dll +0 -0
  10. curlpro/lib/curlpro.h +150 -0
  11. curlpro/profiles/chrome-100-windows.json +74 -0
  12. curlpro/profiles/chrome-101-windows.json +74 -0
  13. curlpro/profiles/chrome-104-windows.json +74 -0
  14. curlpro/profiles/chrome-107-windows.json +74 -0
  15. curlpro/profiles/chrome-110-windows.json +77 -0
  16. curlpro/profiles/chrome-116-windows.json +77 -0
  17. curlpro/profiles/chrome-118-linux.json +388 -0
  18. curlpro/profiles/chrome-119-linux.json +391 -0
  19. curlpro/profiles/chrome-119-macos.json +391 -0
  20. curlpro/profiles/chrome-120-linux.json +388 -0
  21. curlpro/profiles/chrome-120-macos.json +391 -0
  22. curlpro/profiles/chrome-123-macos.json +388 -0
  23. curlpro/profiles/chrome-124-macos.json +394 -0
  24. curlpro/profiles/chrome-131-android.json +78 -0
  25. curlpro/profiles/chrome-131-macos.json +394 -0
  26. curlpro/profiles/chrome-133-macos.json +394 -0
  27. curlpro/profiles/chrome-136-macos.json +78 -0
  28. curlpro/profiles/chrome-142-macos.json +78 -0
  29. curlpro/profiles/chrome-150-macos.json +397 -0
  30. curlpro/profiles/chrome-151-windows.json +332 -0
  31. curlpro/profiles/chrome-152-android.json +375 -0
  32. curlpro/profiles/chrome-152-windows.json +130 -0
  33. curlpro/profiles/chrome-98-windows.json +369 -0
  34. curlpro/profiles/chrome-99-android.json +74 -0
  35. curlpro/profiles/chrome-99-windows.json +74 -0
  36. curlpro/profiles/edge-101-windows.json +74 -0
  37. curlpro/profiles/edge-118-linux.json +388 -0
  38. curlpro/profiles/edge-119-linux.json +388 -0
  39. curlpro/profiles/edge-120-linux.json +391 -0
  40. curlpro/profiles/edge-98-windows.json +74 -0
  41. curlpro/profiles/edge-99-windows.json +74 -0
  42. curlpro/profiles/firefox-133-macos.json +395 -0
  43. curlpro/profiles/firefox-135-macos.json +398 -0
  44. curlpro/profiles/firefox-144-macos.json +70 -0
  45. curlpro/profiles/safari-15.3-macos.json +176 -0
  46. curlpro/profiles/safari-15.5-macos.json +176 -0
  47. curlpro/profiles/safari-17-ios.json +66 -0
  48. curlpro/profiles/safari-17-macos.json +66 -0
  49. curlpro/profiles/safari-18.0-ios.json +214 -0
  50. curlpro/profiles/safari-18.0-macos.json +46 -0
  51. curlpro/profiles/safari-18.4-ios.json +74 -0
  52. curlpro/profiles/safari-18.4-macos.json +74 -0
  53. curlpro/profiles/safari-26-ios.json +211 -0
  54. curlpro/profiles/safari-26.0-macos.json +210 -0
  55. curlpro/profiles/safari-26.0.1-macos.json +207 -0
  56. curlpro/profiles/tor-14-macos.json +380 -0
  57. curlpro/profiles/yandex-26.8-android.json +329 -0
  58. curlpro/profiles.py +121 -0
  59. curlpro/proxies.py +50 -0
  60. curlpro/py.typed +0 -0
  61. curlpro/session.py +904 -0
  62. curlpro/stream.py +146 -0
  63. curlpro/timeouts.py +31 -0
  64. curlpro/websocket.py +132 -0
  65. curlpro-0.2.0.dist-info/METADATA +114 -0
  66. curlpro-0.2.0.dist-info/RECORD +70 -0
  67. curlpro-0.2.0.dist-info/WHEEL +5 -0
  68. curlpro-0.2.0.dist-info/licenses/LICENSE +202 -0
  69. curlpro-0.2.0.dist-info/licenses/NOTICE +29 -0
  70. 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"
@@ -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")