reclie 0.1.0__tar.gz
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.
- reclie-0.1.0/LICENSE +21 -0
- reclie-0.1.0/MANIFEST.in +7 -0
- reclie-0.1.0/PKG-INFO +34 -0
- reclie-0.1.0/README.md +12 -0
- reclie-0.1.0/build.zig +101 -0
- reclie-0.1.0/pyproject.toml +66 -0
- reclie-0.1.0/reclie/__init__.py +40 -0
- reclie-0.1.0/reclie/_client.py +183 -0
- reclie-0.1.0/reclie/core/__init__.py +10 -0
- reclie-0.1.0/reclie/core/http.py +144 -0
- reclie-0.1.0/reclie/main.py +6 -0
- reclie-0.1.0/reclie/utils/__init__.py +28 -0
- reclie-0.1.0/reclie/utils/_exceptions.py +47 -0
- reclie-0.1.0/reclie/utils/_ext.py +37 -0
- reclie-0.1.0/reclie/utils/_response.py +103 -0
- reclie-0.1.0/reclie.egg-info/PKG-INFO +34 -0
- reclie-0.1.0/reclie.egg-info/SOURCES.txt +25 -0
- reclie-0.1.0/reclie.egg-info/dependency_links.txt +1 -0
- reclie-0.1.0/reclie.egg-info/requires.txt +1 -0
- reclie-0.1.0/reclie.egg-info/top_level.txt +1 -0
- reclie-0.1.0/setup.cfg +4 -0
- reclie-0.1.0/setup.py +75 -0
- reclie-0.1.0/src/core/http.zig +546 -0
- reclie-0.1.0/src/core/pool.zig +205 -0
- reclie-0.1.0/src/core/transport.zig +253 -0
- reclie-0.1.0/src/py_module.c +43 -0
- reclie-0.1.0/src/root.zig +454 -0
reclie-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EmekadeFirst
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
reclie-0.1.0/MANIFEST.in
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Ship the Zig sources + build script in the sdist so a source build can run
|
|
2
|
+
# `zig build` (requires Zig on the build machine).
|
|
3
|
+
include build.zig
|
|
4
|
+
recursive-include src *.zig *.c
|
|
5
|
+
include LICENSE README.md
|
|
6
|
+
# Do not ship prebuilt binaries in the sdist.
|
|
7
|
+
recursive-exclude reclie *.pyd *.so
|
reclie-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: reclie
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A high-performance async HTTP client with a native Zig core.
|
|
5
|
+
Author: Victor Chibuogwu Chukwuemeka
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/emekadefirst/reclie
|
|
8
|
+
Keywords: http,https,async,client,zig,tls
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: certifi
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
Reclie - pronouced re -cly
|
|
24
|
+
|
|
25
|
+
A High peformance python request client that allows developer to make request in python, utilizing the python simplicity and leveraging on zig to do the heavy lifting, and also bypassing python runtime gil, buffer and bytecode delay
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
with the aim of outperforming all existing client in python. makking super fast light and clean
|
|
29
|
+
|
|
30
|
+
First is to start with HTTP, HTTPS, and support HTTP2
|
|
31
|
+
|
|
32
|
+
Next SSE
|
|
33
|
+
|
|
34
|
+
Lastly WebSocker WS WSS
|
reclie-0.1.0/README.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Reclie - pronouced re -cly
|
|
2
|
+
|
|
3
|
+
A High peformance python request client that allows developer to make request in python, utilizing the python simplicity and leveraging on zig to do the heavy lifting, and also bypassing python runtime gil, buffer and bytecode delay
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
with the aim of outperforming all existing client in python. makking super fast light and clean
|
|
7
|
+
|
|
8
|
+
First is to start with HTTP, HTTPS, and support HTTP2
|
|
9
|
+
|
|
10
|
+
Next SSE
|
|
11
|
+
|
|
12
|
+
Lastly WebSocker WS WSS
|
reclie-0.1.0/build.zig
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
//! Build the reclie native engine (`_reclie`) as a CPython extension.
|
|
2
|
+
//!
|
|
3
|
+
//! Strategy (see src/root.zig): hand-declared Zig externs for the ABI-stable
|
|
4
|
+
//! CPython functions, plus one tiny C shim (`src/py_module.c`) the C compiler
|
|
5
|
+
//! expands. Targets Py_LIMITED_API 3.11, so a single abi3 wheel serves 3.11+.
|
|
6
|
+
//!
|
|
7
|
+
//! Linking libpython differs per OS:
|
|
8
|
+
//! * Windows - link `python3.lib` (the limited-API import lib).
|
|
9
|
+
//! * else - do NOT link libpython; the interpreter resolves those
|
|
10
|
+
//! symbols at import time (`linker_allow_shlib_undefined`).
|
|
11
|
+
//!
|
|
12
|
+
//! Options:
|
|
13
|
+
//! -Dpython="C:/path/PythonXY" convenience: derives Include/ and libs/
|
|
14
|
+
//! -Dpython-include="/path" explicit dir containing Python.h
|
|
15
|
+
//! -Dpython-libs="/path" explicit dir with python3.lib (Windows)
|
|
16
|
+
//!
|
|
17
|
+
//! Output: the built shared library is copied into the package as
|
|
18
|
+
//! `reclie/_reclie.pyd` (Windows) or `reclie/_reclie.abi3.so` (else).
|
|
19
|
+
|
|
20
|
+
const std = @import("std");
|
|
21
|
+
|
|
22
|
+
pub fn build(b: *std.Build) void {
|
|
23
|
+
const target = b.standardTargetOptions(.{});
|
|
24
|
+
const optimize = b.standardOptimizeOption(.{});
|
|
25
|
+
const os_tag = target.result.os.tag;
|
|
26
|
+
|
|
27
|
+
const py_prefix = b.option(
|
|
28
|
+
[]const u8,
|
|
29
|
+
"python",
|
|
30
|
+
"Python install prefix (Windows layout: contains Include/ and libs/)",
|
|
31
|
+
) orelse "C:/Users/PC/AppData/Local/Programs/Python/Python311";
|
|
32
|
+
|
|
33
|
+
const py_include = b.option(
|
|
34
|
+
[]const u8,
|
|
35
|
+
"python-include",
|
|
36
|
+
"Directory containing Python.h",
|
|
37
|
+
) orelse b.pathJoin(&.{ py_prefix, "Include" });
|
|
38
|
+
|
|
39
|
+
const py_libs = b.option(
|
|
40
|
+
[]const u8,
|
|
41
|
+
"python-libs",
|
|
42
|
+
"Directory containing python3.lib (Windows only)",
|
|
43
|
+
) orelse b.pathJoin(&.{ py_prefix, "libs" });
|
|
44
|
+
|
|
45
|
+
const mod = b.createModule(.{
|
|
46
|
+
.root_source_file = b.path("src/root.zig"),
|
|
47
|
+
.target = target,
|
|
48
|
+
.optimize = optimize,
|
|
49
|
+
.link_libc = true,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// CPython headers for the C shim.
|
|
53
|
+
mod.addIncludePath(.{ .cwd_relative = py_include });
|
|
54
|
+
mod.addCSourceFile(.{ .file = b.path("src/py_module.c") });
|
|
55
|
+
|
|
56
|
+
if (os_tag == .windows) {
|
|
57
|
+
// Windows requires linking the limited-API import library.
|
|
58
|
+
mod.addLibraryPath(.{ .cwd_relative = py_libs });
|
|
59
|
+
// `.use_pkg_config = .no`: without this, Zig may consult a stray
|
|
60
|
+
// pkg-config on the machine (e.g. Strawberry Perl's on GitHub's Windows
|
|
61
|
+
// runners) and wrongly link `python3.9`. We just want `python3.lib`
|
|
62
|
+
// from the path above.
|
|
63
|
+
mod.linkSystemLibrary("python3", .{ .use_pkg_config = .no }); // -> python3.lib -> python3.dll
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const lib = b.addLibrary(.{
|
|
67
|
+
.name = "_reclie",
|
|
68
|
+
.linkage = .dynamic,
|
|
69
|
+
.root_module = mod,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (os_tag != .windows) {
|
|
73
|
+
// libpython symbols are undefined until the interpreter loads us. This
|
|
74
|
+
// emits `-fallow-shlib-undefined`, which on ELF permits the undefined
|
|
75
|
+
// symbols and on Mach-O maps to `-undefined dynamic_lookup` (verified:
|
|
76
|
+
// a macOS-targeted dynamic lib with an undefined extern links only with
|
|
77
|
+
// this set). So both Linux and macOS are covered.
|
|
78
|
+
lib.linker_allow_shlib_undefined = true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Copy the built shared library into the Python package under its abi3
|
|
82
|
+
// extension name so `from reclie import _reclie` resolves and packaging
|
|
83
|
+
// picks it up.
|
|
84
|
+
const ext_name = if (os_tag == .windows)
|
|
85
|
+
"reclie/_reclie.pyd"
|
|
86
|
+
else
|
|
87
|
+
"reclie/_reclie.abi3.so";
|
|
88
|
+
|
|
89
|
+
const install_ext = b.addUpdateSourceFiles();
|
|
90
|
+
install_ext.addCopyFileToSource(lib.getEmittedBin(), ext_name);
|
|
91
|
+
install_ext.step.dependOn(&lib.step);
|
|
92
|
+
b.getInstallStep().dependOn(&install_ext.step);
|
|
93
|
+
|
|
94
|
+
b.installArtifact(lib);
|
|
95
|
+
|
|
96
|
+
// `zig build test` — run the bridge + core unit tests.
|
|
97
|
+
const tests = b.addTest(.{ .root_module = mod });
|
|
98
|
+
const run_tests = b.addRunArtifact(tests);
|
|
99
|
+
const test_step = b.step("test", "Run unit tests");
|
|
100
|
+
test_step.dependOn(&run_tests.step);
|
|
101
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "reclie"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A high-performance async HTTP client with a native Zig core."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Victor Chibuogwu Chukwuemeka" }]
|
|
13
|
+
keywords = ["http", "https", "async", "client", "zig", "tls"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: POSIX :: Linux",
|
|
21
|
+
"Operating System :: MacOS",
|
|
22
|
+
"Operating System :: Microsoft :: Windows",
|
|
23
|
+
]
|
|
24
|
+
dependencies = ["certifi"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/emekadefirst/reclie"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools]
|
|
30
|
+
# The compiled extension is produced by `zig build` and placed into the wheel
|
|
31
|
+
# by setup.py's build_ext (into build_lib), so it does NOT need to be listed as
|
|
32
|
+
# package-data — and listing it would wrongly ship a stale platform binary
|
|
33
|
+
# (e.g. a Windows _reclie.pyd) that happens to sit in the source tree.
|
|
34
|
+
packages = ["reclie", "reclie.core", "reclie.utils"]
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# cibuildwheel: build one abi3 wheel per platform (cp311 covers 3.11+).
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
[tool.cibuildwheel]
|
|
40
|
+
build = "cp311-*"
|
|
41
|
+
skip = ["*-musllinux*", "*_i686", "*-win32"]
|
|
42
|
+
build-frontend = "build"
|
|
43
|
+
# Zig is installed per-OS by the workflow / before-all; confirm it's visible.
|
|
44
|
+
before-build = "zig version"
|
|
45
|
+
test-command = "python -c \"import reclie; print('reclie', reclie.__all__)\""
|
|
46
|
+
|
|
47
|
+
[tool.cibuildwheel.linux]
|
|
48
|
+
# manylinux containers don't have Zig; fetch a pinned build into the container.
|
|
49
|
+
before-all = "bash {project}/.github/install-zig.sh"
|
|
50
|
+
# RECLIE_ZIG_TARGET pins glibc to the manylinux policy so auditwheel accepts the
|
|
51
|
+
# wheel (Zig otherwise emits glibc-2.36 relocations even in the 2.28 container).
|
|
52
|
+
environment = { PATH = "/opt/zig:$PATH", RECLIE_ZIG_TARGET = "x86_64-linux-gnu.2.28" }
|
|
53
|
+
# Alias (cibuildwheel appends the arch -> quay.io/pypa/manylinux_2_28_x86_64).
|
|
54
|
+
# auditwheel still runs to retag the wheel manylinux; our .so has no third-party
|
|
55
|
+
# shared libs to bundle (only libpython, resolved by the interpreter).
|
|
56
|
+
manylinux-x86_64-image = "manylinux_2_28"
|
|
57
|
+
|
|
58
|
+
[tool.cibuildwheel.macos]
|
|
59
|
+
# The extension has no third-party dylibs to bundle and the build already tags
|
|
60
|
+
# the wheel macosx_*; delocate has nothing to do, so skip it.
|
|
61
|
+
repair-wheel-command = ""
|
|
62
|
+
|
|
63
|
+
[tool.uv.workspace]
|
|
64
|
+
members = [
|
|
65
|
+
"example",
|
|
66
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""reclie — a high-performance async HTTP client with a native Zig core.
|
|
2
|
+
|
|
3
|
+
v0.1 scope: HTTP/1.1 and HTTP/2 over ``http`` and ``https``
|
|
4
|
+
|
|
5
|
+
Public surface::
|
|
6
|
+
|
|
7
|
+
import reclie
|
|
8
|
+
|
|
9
|
+
client = reclie.Client("api.example.com")
|
|
10
|
+
resp = await client.get("/products")
|
|
11
|
+
data = await resp.json()
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from ._client import Client
|
|
17
|
+
from .core import Method
|
|
18
|
+
from .utils import (
|
|
19
|
+
ConnectionError,
|
|
20
|
+
PoolExhaustedError,
|
|
21
|
+
ProtocolError,
|
|
22
|
+
RecliError,
|
|
23
|
+
RecliHeaders,
|
|
24
|
+
RecliResponse,
|
|
25
|
+
TimeoutError,
|
|
26
|
+
TlsError,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Client",
|
|
31
|
+
"Method",
|
|
32
|
+
"RecliResponse",
|
|
33
|
+
"RecliHeaders",
|
|
34
|
+
"RecliError",
|
|
35
|
+
"ConnectionError",
|
|
36
|
+
"TimeoutError",
|
|
37
|
+
"TlsError",
|
|
38
|
+
"ProtocolError",
|
|
39
|
+
"PoolExhaustedError",
|
|
40
|
+
]
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""The reclie Client facade.
|
|
2
|
+
|
|
3
|
+
A single, long-lived ``Client`` exposes HTTP/1.1 and HTTP/2 over one
|
|
4
|
+
connection pool. The Python layer is intentionally thin: request
|
|
5
|
+
orchestration lives in :mod:`reclie.core`; this class just owns the pool and
|
|
6
|
+
dispatches to it. (SSE and WebSocket are deferred to a later phase.)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
from .core import Headers, Method, Params, request
|
|
14
|
+
from .utils import RecliResponse, TlsError, extension
|
|
15
|
+
|
|
16
|
+
__all__ = ["Client", "Method"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _default_ca_bundle_path() -> Optional[str]:
|
|
20
|
+
"""Return a path to a CA bundle file. Tries ``certifi`` first, then
|
|
21
|
+
``ssl.get_default_verify_paths().cafile``. Returns ``None`` if neither is
|
|
22
|
+
available — Client then refuses to construct with ``tls=True``."""
|
|
23
|
+
try:
|
|
24
|
+
import certifi
|
|
25
|
+
|
|
26
|
+
return certifi.where()
|
|
27
|
+
except ImportError:
|
|
28
|
+
try:
|
|
29
|
+
import ssl
|
|
30
|
+
|
|
31
|
+
return ssl.get_default_verify_paths().cafile
|
|
32
|
+
except Exception:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Client:
|
|
37
|
+
"""Origin-bound, reusable async client for HTTP/1.1 and HTTP/2.
|
|
38
|
+
|
|
39
|
+
Instantiating a ``Client`` allocates the native connection pool. Keep it
|
|
40
|
+
alive for the lifetime of your application; do not create one per request.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
__slots__ = ("host", "port", "tls", "version", "pool_size", "ttl", "timeout", "_ca_path", "_pool")
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
host: str,
|
|
48
|
+
*,
|
|
49
|
+
port: Optional[int] = None,
|
|
50
|
+
tls: Optional[bool] = None,
|
|
51
|
+
version: int = 2,
|
|
52
|
+
pool_size: int = 64,
|
|
53
|
+
ttl: int = 60,
|
|
54
|
+
timeout: float = 30.0,
|
|
55
|
+
ca_bundle: Optional[bytes] = None,
|
|
56
|
+
ca_bundle_path: Optional[str] = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
if version not in (1, 2):
|
|
59
|
+
raise ValueError("version must be 1 or 2")
|
|
60
|
+
|
|
61
|
+
# Scheme/port inference: https on 443, http otherwise.
|
|
62
|
+
if tls is None:
|
|
63
|
+
tls = port is None or port == 443
|
|
64
|
+
if port is None:
|
|
65
|
+
port = 443 if tls else 80
|
|
66
|
+
|
|
67
|
+
self.host = host
|
|
68
|
+
self.port = port
|
|
69
|
+
self.tls = tls
|
|
70
|
+
self.version = version
|
|
71
|
+
self.pool_size = pool_size
|
|
72
|
+
self.ttl = ttl
|
|
73
|
+
self.timeout = timeout
|
|
74
|
+
|
|
75
|
+
# Resolve a CA bundle *file path* for TLS (the native engine loads it).
|
|
76
|
+
# Precedence:
|
|
77
|
+
# 1. Caller-supplied ``ca_bundle_path``
|
|
78
|
+
# 2. Caller-supplied ``ca_bundle`` (raw PEM bytes -> temp file)
|
|
79
|
+
# 3. ``certifi.where()`` if installed
|
|
80
|
+
# 4. ``ssl.get_default_verify_paths().cafile``
|
|
81
|
+
if not tls:
|
|
82
|
+
self._ca_path = ""
|
|
83
|
+
elif ca_bundle_path is not None:
|
|
84
|
+
self._ca_path = ca_bundle_path
|
|
85
|
+
elif ca_bundle is not None:
|
|
86
|
+
import tempfile
|
|
87
|
+
|
|
88
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
89
|
+
prefix="reclie-ca-", suffix=".pem", delete=False
|
|
90
|
+
)
|
|
91
|
+
tmp.write(ca_bundle)
|
|
92
|
+
tmp.close()
|
|
93
|
+
self._ca_path = tmp.name
|
|
94
|
+
else:
|
|
95
|
+
self._ca_path = _default_ca_bundle_path() or ""
|
|
96
|
+
|
|
97
|
+
if tls and not self._ca_path:
|
|
98
|
+
raise TlsError(
|
|
99
|
+
"No CA bundle available for TLS. Install `certifi` or pass "
|
|
100
|
+
"`ca_bundle`/`ca_bundle_path` to Client(...)."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
self._pool = extension().pool_create(
|
|
104
|
+
host, port, tls, version, pool_size, ttl, self._timeout_ms,
|
|
105
|
+
self._ca_path,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def _timeout_ms(self) -> int:
|
|
110
|
+
return int(self.timeout * 1000)
|
|
111
|
+
|
|
112
|
+
# ---- HTTP -----------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
async def get(
|
|
115
|
+
self,
|
|
116
|
+
path: str,
|
|
117
|
+
*,
|
|
118
|
+
headers: Optional[Headers] = None,
|
|
119
|
+
params: Optional[Params] = None,
|
|
120
|
+
) -> RecliResponse:
|
|
121
|
+
return await request(
|
|
122
|
+
self._pool, Method.GET, path,
|
|
123
|
+
version=self.version, timeout_ms=self._timeout_ms,
|
|
124
|
+
headers=headers, params=params,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
async def post(
|
|
128
|
+
self,
|
|
129
|
+
path: str,
|
|
130
|
+
*,
|
|
131
|
+
json: Optional[Any] = None,
|
|
132
|
+
data: Optional[bytes] = None,
|
|
133
|
+
headers: Optional[Headers] = None,
|
|
134
|
+
params: Optional[Params] = None,
|
|
135
|
+
) -> RecliResponse:
|
|
136
|
+
return await request(
|
|
137
|
+
self._pool, Method.POST, path,
|
|
138
|
+
version=self.version, timeout_ms=self._timeout_ms,
|
|
139
|
+
headers=headers, params=params, json=json, data=data,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
async def put(
|
|
143
|
+
self,
|
|
144
|
+
path: str,
|
|
145
|
+
*,
|
|
146
|
+
json: Optional[Any] = None,
|
|
147
|
+
data: Optional[bytes] = None,
|
|
148
|
+
headers: Optional[Headers] = None,
|
|
149
|
+
params: Optional[Params] = None,
|
|
150
|
+
) -> RecliResponse:
|
|
151
|
+
return await request(
|
|
152
|
+
self._pool, Method.PUT, path,
|
|
153
|
+
version=self.version, timeout_ms=self._timeout_ms,
|
|
154
|
+
headers=headers, params=params, json=json, data=data,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
async def patch(
|
|
158
|
+
self,
|
|
159
|
+
path: str,
|
|
160
|
+
*,
|
|
161
|
+
json: Optional[Any] = None,
|
|
162
|
+
data: Optional[bytes] = None,
|
|
163
|
+
headers: Optional[Headers] = None,
|
|
164
|
+
params: Optional[Params] = None,
|
|
165
|
+
) -> RecliResponse:
|
|
166
|
+
return await request(
|
|
167
|
+
self._pool, Method.PATCH, path,
|
|
168
|
+
version=self.version, timeout_ms=self._timeout_ms,
|
|
169
|
+
headers=headers, params=params, json=json, data=data,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def delete(
|
|
173
|
+
self,
|
|
174
|
+
path: str,
|
|
175
|
+
*,
|
|
176
|
+
headers: Optional[Headers] = None,
|
|
177
|
+
params: Optional[Params] = None,
|
|
178
|
+
) -> RecliResponse:
|
|
179
|
+
return await request(
|
|
180
|
+
self._pool, Method.DELETE, path,
|
|
181
|
+
version=self.version, timeout_ms=self._timeout_ms,
|
|
182
|
+
headers=headers, params=params,
|
|
183
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Protocol orchestration for reclie.
|
|
2
|
+
|
|
3
|
+
Scope for v0.1: HTTP/1.1 and HTTP/2 over plain TCP (http) and TLS (https).
|
|
4
|
+
SSE and WebSocket are deferred; they will land here as ``sse`` / ``ws``
|
|
5
|
+
modules once the HTTP core is solid.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .http import Headers, Method, Params, request
|
|
9
|
+
|
|
10
|
+
__all__ = ["Headers", "Method", "Params", "request"]
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""HTTP request orchestration.
|
|
2
|
+
|
|
3
|
+
Builds the request descriptor (path + query, flat header list, byte body),
|
|
4
|
+
creates the ``asyncio.Future``, and hands everything to the native engine. The
|
|
5
|
+
engine performs all I/O off the GIL and settles the Future via
|
|
6
|
+
``loop.call_soon_threadsafe``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import enum
|
|
13
|
+
import json as _json
|
|
14
|
+
from typing import Any, Mapping, Optional, Union
|
|
15
|
+
from urllib.parse import urlencode
|
|
16
|
+
|
|
17
|
+
from ..utils import (
|
|
18
|
+
ConnectionError,
|
|
19
|
+
ProtocolError,
|
|
20
|
+
RecliError,
|
|
21
|
+
RecliResponse,
|
|
22
|
+
TimeoutError,
|
|
23
|
+
TlsError,
|
|
24
|
+
extension,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = ["Method", "Headers", "Params", "request"]
|
|
28
|
+
|
|
29
|
+
# Error-kind ordinals shared with the Zig engine (src/root.zig). 0 == success.
|
|
30
|
+
_ERR_CLASSES = {
|
|
31
|
+
1: ConnectionError,
|
|
32
|
+
2: TimeoutError,
|
|
33
|
+
3: TlsError,
|
|
34
|
+
4: ProtocolError,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _settle(
|
|
39
|
+
future: "asyncio.Future[RecliResponse]",
|
|
40
|
+
status: int,
|
|
41
|
+
headers: dict,
|
|
42
|
+
body: bytes,
|
|
43
|
+
err_kind: int,
|
|
44
|
+
err_msg: str,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Resolve ``future`` from the engine callback.
|
|
47
|
+
|
|
48
|
+
Scheduled via ``loop.call_soon_threadsafe`` from the Zig I/O thread, so this
|
|
49
|
+
always runs on the event-loop thread — the only safe place to touch a Future.
|
|
50
|
+
"""
|
|
51
|
+
if future.cancelled():
|
|
52
|
+
return
|
|
53
|
+
if err_kind:
|
|
54
|
+
exc_cls = _ERR_CLASSES.get(err_kind, RecliError)
|
|
55
|
+
future.set_exception(exc_cls(err_msg))
|
|
56
|
+
else:
|
|
57
|
+
future.set_result(RecliResponse(status, headers, body=body))
|
|
58
|
+
|
|
59
|
+
Headers = Mapping[str, str]
|
|
60
|
+
Params = Mapping[str, Union[str, int, float, bool]]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Method(enum.IntEnum):
|
|
64
|
+
"""HTTP methods, matching the Zig ``Method`` enum ordinals."""
|
|
65
|
+
|
|
66
|
+
GET = 0
|
|
67
|
+
POST = 1
|
|
68
|
+
PUT = 2
|
|
69
|
+
PATCH = 3
|
|
70
|
+
DELETE = 4
|
|
71
|
+
HEAD = 5
|
|
72
|
+
OPTIONS = 6
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_path(path: str, params: Optional[Params]) -> str:
|
|
76
|
+
if not params:
|
|
77
|
+
return path
|
|
78
|
+
query = urlencode({k: str(v) for k, v in params.items()})
|
|
79
|
+
sep = "&" if "?" in path else "?"
|
|
80
|
+
return f"{path}{sep}{query}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _prepare_body(
|
|
84
|
+
headers: Optional[Headers],
|
|
85
|
+
json: Optional[Any],
|
|
86
|
+
data: Optional[bytes],
|
|
87
|
+
) -> tuple[list[tuple[str, str]], bytes]:
|
|
88
|
+
"""Normalize headers + body into a flat header list and raw byte body."""
|
|
89
|
+
if json is not None and data is not None:
|
|
90
|
+
raise ValueError("Pass either `json` or `data`, not both.")
|
|
91
|
+
|
|
92
|
+
header_list: list[tuple[str, str]] = []
|
|
93
|
+
has_content_type = False
|
|
94
|
+
if headers:
|
|
95
|
+
for key, value in headers.items():
|
|
96
|
+
if key.lower() == "content-type":
|
|
97
|
+
has_content_type = True
|
|
98
|
+
header_list.append((key, str(value)))
|
|
99
|
+
|
|
100
|
+
if json is not None:
|
|
101
|
+
body = _json.dumps(json, separators=(",", ":")).encode("utf-8")
|
|
102
|
+
if not has_content_type:
|
|
103
|
+
header_list.append(("Content-Type", "application/json"))
|
|
104
|
+
elif data is not None:
|
|
105
|
+
body = data
|
|
106
|
+
else:
|
|
107
|
+
body = b""
|
|
108
|
+
|
|
109
|
+
return header_list, body
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def request(
|
|
113
|
+
pool: Any,
|
|
114
|
+
method: Method,
|
|
115
|
+
path: str,
|
|
116
|
+
*,
|
|
117
|
+
version: int,
|
|
118
|
+
timeout_ms: int,
|
|
119
|
+
headers: Optional[Headers] = None,
|
|
120
|
+
params: Optional[Params] = None,
|
|
121
|
+
json: Optional[Any] = None,
|
|
122
|
+
data: Optional[bytes] = None,
|
|
123
|
+
) -> RecliResponse:
|
|
124
|
+
"""Issue a single HTTP request and await its response."""
|
|
125
|
+
loop = asyncio.get_running_loop()
|
|
126
|
+
future: asyncio.Future[RecliResponse] = loop.create_future()
|
|
127
|
+
|
|
128
|
+
full_path = _build_path(path, params)
|
|
129
|
+
header_list, body = _prepare_body(headers, json, data)
|
|
130
|
+
|
|
131
|
+
# Non-blocking hand-off: the engine drops the GIL, performs all I/O,
|
|
132
|
+
# then settles `future` via loop.call_soon_threadsafe.
|
|
133
|
+
extension().submit(
|
|
134
|
+
pool,
|
|
135
|
+
int(method),
|
|
136
|
+
full_path,
|
|
137
|
+
header_list,
|
|
138
|
+
body,
|
|
139
|
+
version,
|
|
140
|
+
timeout_ms,
|
|
141
|
+
future,
|
|
142
|
+
loop,
|
|
143
|
+
)
|
|
144
|
+
return await future
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Internal helpers: extension loader, response types, exceptions.
|
|
2
|
+
|
|
3
|
+
This subpackage aggregates the implementation details so the top-level
|
|
4
|
+
package can re-export the public surface from one place.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._exceptions import (
|
|
8
|
+
ConnectionError,
|
|
9
|
+
PoolExhaustedError,
|
|
10
|
+
ProtocolError,
|
|
11
|
+
RecliError,
|
|
12
|
+
TimeoutError,
|
|
13
|
+
TlsError,
|
|
14
|
+
)
|
|
15
|
+
from ._ext import extension
|
|
16
|
+
from ._response import RecliHeaders, RecliResponse
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"extension",
|
|
20
|
+
"RecliHeaders",
|
|
21
|
+
"RecliResponse",
|
|
22
|
+
"RecliError",
|
|
23
|
+
"ConnectionError",
|
|
24
|
+
"TimeoutError",
|
|
25
|
+
"TlsError",
|
|
26
|
+
"ProtocolError",
|
|
27
|
+
"PoolExhaustedError",
|
|
28
|
+
]
|