pygrpc-native 0.2.0__tar.gz → 0.3.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.
Files changed (48) hide show
  1. pygrpc_native-0.3.0/CHANGELOG.md +62 -0
  2. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/CONTRIBUTING.md +7 -5
  3. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/Cargo.lock +1 -1
  4. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/Cargo.toml +1 -1
  5. pygrpc_native-0.3.0/PKG-INFO +169 -0
  6. pygrpc_native-0.3.0/README.md +154 -0
  7. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/benchmarks/README.md +2 -1
  8. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/benchmarks/echo.py +4 -2
  9. pygrpc_native-0.2.0/README.md → pygrpc_native-0.3.0/docs/low-level-api.md +41 -119
  10. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/python/pygrpc_native/_native.pyi +3 -0
  11. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/src/lib.rs +48 -1
  12. pygrpc_native-0.3.0/src/metrics.rs +270 -0
  13. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/src/server.rs +146 -18
  14. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_api.py +33 -2
  15. pygrpc_native-0.3.0/tests/test_metrics.py +122 -0
  16. pygrpc_native-0.2.0/PKG-INFO +0 -241
  17. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.github/CODEOWNERS +0 -0
  18. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  19. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  20. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  21. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  22. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.github/dependabot.yml +0 -0
  23. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.github/workflows/ci.yml +0 -0
  24. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.github/workflows/dco.yml +0 -0
  25. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.github/workflows/release.yml +0 -0
  26. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.github/workflows/stale.yml +0 -0
  27. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.gitignore +0 -0
  28. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.pre-commit-config.yaml +0 -0
  29. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/CODE_OF_CONDUCT.md +0 -0
  30. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/DCO +0 -0
  31. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/LICENSE +0 -0
  32. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/SECURITY.md +0 -0
  33. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/pyproject.toml +0 -0
  34. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/python/pygrpc_native/__init__.py +0 -0
  35. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/python/pygrpc_native/_server.py +0 -0
  36. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/python/pygrpc_native/aio.py +0 -0
  37. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/python/pygrpc_native/py.typed +0 -0
  38. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_add_methods.py +0 -0
  39. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_aio.py +0 -0
  40. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_bearer_auth.py +0 -0
  41. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_call_details.py +0 -0
  42. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_calls.py +0 -0
  43. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_delay_stream_headers.py +0 -0
  44. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_fallback.py +0 -0
  45. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_front.py +0 -0
  46. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_response_metadata.py +0 -0
  47. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_server.py +0 -0
  48. {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_stream_try_send.py +0 -0
@@ -0,0 +1,62 @@
1
+ # Changelog
2
+
3
+ All notable changes are listed here. The project follows
4
+ [semantic versioning](https://semver.org); see
5
+ [Compatibility](README.md#compatibility) for what that covers.
6
+
7
+ ## 0.3.0 (2026-10-26)
8
+
9
+ Everything here is an addition, so code written for 0.1 and 0.2 keeps working.
10
+
11
+ ### Added
12
+
13
+ - `NativeGrpcFront(metrics=True)` and `metrics()`: call counts and latencies
14
+ per method and status, recorded in Rust without the GIL. The keys
15
+ `metrics()` returns are covered by the compatibility promise. (#29, #31)
16
+
17
+ ### Changed
18
+
19
+ - The low-level API reference moved from the README to
20
+ [docs/low-level-api.md](docs/low-level-api.md). (#31)
21
+
22
+ ## 0.2.0 (2026-10-25)
23
+
24
+ Everything here is an addition, so code written for 0.1 keeps working.
25
+
26
+ ### Added
27
+
28
+ - `pygrpc_native.server()` and `pygrpc_native.aio.server()` take the same
29
+ arguments as `grpc.server()` and `grpc.aio.server()` and serve grpcio-tools
30
+ generated servicers unchanged. Status codes, metadata, deadlines and
31
+ cancellation behave as in grpcio, and the aio server runs on uvloop too.
32
+ (#17, #18, #20)
33
+ - Methods the front doesn't serve itself, such as client-streaming and
34
+ bidirectional methods or server reflection, go to a grpcio server behind it.
35
+ (#19)
36
+ - `NativeGrpcFront` can require a bearer token (`bearer_token_sha256`,
37
+ `unauthenticated_message`). Calls without one are rejected in Rust before
38
+ they reach Python. (#23)
39
+ - `call_details` and `recv_batch_details()` report each call's request
40
+ metadata, peer and deadline. (#10)
41
+ - `add_methods()` registers methods after the front is bound. (#11)
42
+ - `stream_try_send()` for callers that can't block, such as an event loop.
43
+ (#12)
44
+ - `respond()`, `fail()` and `stream_end()` take response metadata, and `fail()`
45
+ can send `grpc-status-details-bin`. (#13)
46
+ - `delay_stream_headers` and `stream_send_initial_metadata()` hold a stream's
47
+ headers back until its first message. (#14)
48
+ - With `call_details`, a cancelled unary call or one that misses its deadline
49
+ is reported as an `EVENT_CANCEL`. (#16)
50
+
51
+ ### Changed
52
+
53
+ - The extension module moved to `pygrpc_native._native`, so the
54
+ `pygrpc_native.pygrpc_native` attribute is gone. Import names from
55
+ `pygrpc_native` as before. (#15)
56
+
57
+ ## 0.1.0 (2026-10-24)
58
+
59
+ First release: `NativeGrpcFront`, a gRPC server whose HTTP/2 and gRPC work
60
+ runs in Rust, outside the GIL. Unary and server-streaming methods, gzip
61
+ requests, deadlines and batched calls to Python. abi3 wheels for CPython 3.10
62
+ and later on Linux (x86_64, aarch64) and macOS (arm64, x86_64).
@@ -59,20 +59,22 @@ commit without it.
59
59
  ## What a PR needs to merge
60
60
 
61
61
  The DCO, lint, rust and python checks have to pass, and a PR needs one
62
- approving review with every review thread resolved. `main` keeps a linear
63
- history: PRs are squash-merged with the PR title as the commit title, and
64
- force pushes and branch deletion are blocked. Issues and PRs with no activity
62
+ approving review with every review thread resolved. While the project has a
63
+ single maintainer, the maintainer's own PRs are merged once the checks pass.
64
+ `main` keeps a linear history: PRs are squash-merged with the PR title as the
65
+ commit title, and force pushes and branch deletion are blocked. Issues and PRs with no activity
65
66
  for 60 days are marked stale and closed 30 days later.
66
67
 
67
68
  ## Releases
68
69
 
69
70
  Releases follow semantic versioning. Existing users call the low-level API
70
71
  directly, so `tests/test_api.py` pins its names, signatures and event tuples;
71
- change it only together with a breaking release.
72
+ change it only together with a new major version.
72
73
 
73
74
  Before a release:
74
75
 
75
- 1. Bump the version in `Cargo.toml`.
76
+ 1. Bump the version in `Cargo.toml` and move the changes into a new section
77
+ of `CHANGELOG.md`.
76
78
  2. Build the wheel and run the tests of known downstream projects against it.
77
79
  3. Push a `vX.Y.Z` tag. The release workflow builds the wheels and publishes
78
80
  them to PyPI.
@@ -392,7 +392,7 @@ dependencies = [
392
392
 
393
393
  [[package]]
394
394
  name = "pygrpc_native"
395
- version = "0.2.0"
395
+ version = "0.3.0"
396
396
  dependencies = [
397
397
  "bytes",
398
398
  "crossbeam-channel",
@@ -2,7 +2,7 @@
2
2
 
3
3
  [package]
4
4
  name = "pygrpc_native"
5
- version = "0.2.0"
5
+ version = "0.3.0"
6
6
  edition = "2021"
7
7
  license = "Apache-2.0"
8
8
  description = "A gRPC server in Rust that hands calls to Python handlers in batches"
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: pygrpc-native
3
+ Version: 0.3.0
4
+ Classifier: Programming Language :: Python :: 3
5
+ Classifier: Programming Language :: Rust
6
+ Classifier: Operating System :: POSIX :: Linux
7
+ Classifier: Operating System :: MacOS
8
+ License-File: LICENSE
9
+ Summary: A gRPC server in Rust that hands calls to Python handlers in batches
10
+ License-Expression: Apache-2.0
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
13
+ Project-URL: Repository, https://github.com/theminjunchoi/pygrpc-native
14
+
15
+ # pygrpc-native
16
+
17
+ A gRPC server for Python whose HTTP/2 and gRPC work runs in Rust, outside the
18
+ GIL. Your handlers stay in Python.
19
+
20
+ grpcio runs its own Python code for every call, under the same GIL as your
21
+ handlers. pygrpc-native does the framing, flow control, deadlines and
22
+ cancellation on a tokio runtime instead, and gives Python each call as an
23
+ event: an id, the method, the request bytes and who sent it. You decode the
24
+ protobuf, run the handler and send back the encoded response. Clients and the
25
+ wire format are the same as with grpcio.
26
+
27
+ ```
28
+ grpc client ──HTTP/2──▶ tokio threads (no GIL)
29
+ │ path → method id
30
+ ▼
31
+ event queue ──▶ recv_batch() in Python
32
+ ◀── respond(call_id, bytes) from any thread
33
+ ```
34
+
35
+ Calls reach Python in batches, so a burst takes one trip across the GIL
36
+ rather than one per call.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install pygrpc-native
42
+ ```
43
+
44
+ Wheels are published for Linux (x86_64, aarch64) and macOS (arm64,
45
+ x86_64) and work on CPython 3.10 and later. To build from a checkout:
46
+
47
+ ```bash
48
+ pip install maturin
49
+ maturin develop --release
50
+ ```
51
+
52
+ ## With grpcio servicers
53
+
54
+ `pygrpc_native.server()` takes the same arguments as `grpc.server()` and
55
+ serves servicers from grpcio-tools generated code (1.62 or later) unchanged:
56
+
57
+ ```python
58
+ from concurrent import futures
59
+ import pygrpc_native
60
+
61
+ server = pygrpc_native.server(futures.ThreadPoolExecutor(16))
62
+ greeter_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
63
+ server.add_insecure_port("[::]:50051")
64
+ server.start()
65
+ server.wait_for_termination()
66
+ ```
67
+
68
+ Status codes, error messages, metadata, deadlines, cancellation and
69
+ `ServicerContext` behave as they do in grpcio.
70
+
71
+ Unary and server-streaming methods are served natively on one insecure port.
72
+ The rest, such as client-streaming and bidirectional methods, server
73
+ reflection, or handlers added only with `add_generic_rpc_handlers`, go to a
74
+ grpcio server on a Unix socket behind it. They work, but they aren't any
75
+ faster, and `peer()` in them is the socket.
76
+
77
+ Interceptors, `maximum_concurrent_rpcs`, TLS, response compression, and server
78
+ options other than the message size limits and client ping settings raise an
79
+ error rather than being ignored. There's one known difference: if a unary
80
+ handler calls `send_initial_metadata()` and then fails, the metadata goes out
81
+ with the status, so the client sees it as trailing metadata.
82
+
83
+ `pygrpc_native.aio.server()` does the same for `grpc.aio.server()`:
84
+
85
+ ```python
86
+ import pygrpc_native.aio
87
+
88
+ server = pygrpc_native.aio.server()
89
+ greeter_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
90
+ server.add_insecure_port("[::]:50051")
91
+ await server.start()
92
+ await server.wait_for_termination()
93
+ ```
94
+
95
+ Handlers run on the event loop that called `start()`, uvloop included.
96
+ `abort()` raises `grpc.aio.AbortError`, and a handler whose client cancels or
97
+ whose deadline passes gets `asyncio.CancelledError`, as in grpc.aio. Streams
98
+ can yield from an async generator or call `context.write()`, and handlers
99
+ that aren't coroutines run in `migration_thread_pool`. The fallback,
100
+ the unsupported options and the unary metadata difference are the same as
101
+ for the sync server.
102
+
103
+ ## Low-level API
104
+
105
+ `NativeGrpcFront` is the layer under `server()`. Each call reaches Python as
106
+ an event that you answer by id:
107
+
108
+ ```python
109
+ import threading
110
+ from pygrpc_native import EVENT_CALL, NativeGrpcFront
111
+
112
+ front = NativeGrpcFront("0.0.0.0:50051", [("/echo.Echo/Say", False)])
113
+ front.start()
114
+
115
+ def serve():
116
+ while (events := front.recv_batch(64)) is not None:
117
+ for kind, call_id, method_id, payload, client_key in events:
118
+ if kind == EVENT_CALL:
119
+ front.respond(call_id, payload)
120
+
121
+ threading.Thread(target=serve).start()
122
+ ```
123
+
124
+ Streaming, call details, response metadata, bearer tokens and metrics are
125
+ covered in [docs/low-level-api.md](https://github.com/theminjunchoi/pygrpc-native/blob/main/docs/low-level-api.md).
126
+
127
+ ## Compatibility
128
+
129
+ pygrpc-native follows semantic versioning, and the low-level API stays
130
+ compatible within 0.x as well. `tests/test_api.py` pins the 0.1 names,
131
+ signatures and event tuples of `NativeGrpcFront`: they won't change or go
132
+ away, and new parameters only come after the existing ones, with a default.
133
+ The keys `metrics()` returns won't be renamed or removed within 0.x either,
134
+ though new ones may be added. `server()` and `aio.server()` follow grpcio's
135
+ API, and any difference from grpcio's behavior is a bug unless it is listed in
136
+ this README. Changes are listed in [CHANGELOG.md](CHANGELOG.md).
137
+
138
+ ## Limitations
139
+
140
+ - `NativeGrpcFront` serves unary and server-streaming methods only; other
141
+ kinds can go through the fallback server. `server()` and `aio.server()`
142
+ send them there on their own.
143
+ - No TLS yet, and grpcio interceptors don't run on the listed methods. Bearer
144
+ tokens are the only authentication, and only on `NativeGrpcFront`.
145
+ - Clients of a Unix socket server must set `grpc.default_authority`, since
146
+ the socket path grpc-core sends as `:authority` is not valid HTTP/2.
147
+ - Like grpc-core, binding a Unix socket path replaces any socket file
148
+ already there.
149
+ - Per connection: at most 1024 concurrent streams and 16 KiB of request
150
+ metadata. A connection that hasn't finished the HTTP/2 preface after 120 s
151
+ is dropped.
152
+
153
+ ## Benchmarks
154
+
155
+ With an empty handler on an Apple M4 Pro, `pygrpc_native.server()` answers
156
+ about 1.5x the calls per second of `grpc.server()` from one client thread and
157
+ about 5x from 32. On a 40-vCPU Linux container the low-level API answered
158
+ about 12x at 32 threads. See [benchmarks](https://github.com/theminjunchoi/pygrpc-native/blob/main/benchmarks/README.md) for the
159
+ numbers and how they were measured.
160
+
161
+ ## Contributing
162
+
163
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Every commit needs a DCO sign-off
164
+ (`git commit -s`).
165
+
166
+ ## License
167
+
168
+ Apache-2.0
169
+
@@ -0,0 +1,154 @@
1
+ # pygrpc-native
2
+
3
+ A gRPC server for Python whose HTTP/2 and gRPC work runs in Rust, outside the
4
+ GIL. Your handlers stay in Python.
5
+
6
+ grpcio runs its own Python code for every call, under the same GIL as your
7
+ handlers. pygrpc-native does the framing, flow control, deadlines and
8
+ cancellation on a tokio runtime instead, and gives Python each call as an
9
+ event: an id, the method, the request bytes and who sent it. You decode the
10
+ protobuf, run the handler and send back the encoded response. Clients and the
11
+ wire format are the same as with grpcio.
12
+
13
+ ```
14
+ grpc client ──HTTP/2──▶ tokio threads (no GIL)
15
+ │ path → method id
16
+ ▼
17
+ event queue ──▶ recv_batch() in Python
18
+ ◀── respond(call_id, bytes) from any thread
19
+ ```
20
+
21
+ Calls reach Python in batches, so a burst takes one trip across the GIL
22
+ rather than one per call.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pip install pygrpc-native
28
+ ```
29
+
30
+ Wheels are published for Linux (x86_64, aarch64) and macOS (arm64,
31
+ x86_64) and work on CPython 3.10 and later. To build from a checkout:
32
+
33
+ ```bash
34
+ pip install maturin
35
+ maturin develop --release
36
+ ```
37
+
38
+ ## With grpcio servicers
39
+
40
+ `pygrpc_native.server()` takes the same arguments as `grpc.server()` and
41
+ serves servicers from grpcio-tools generated code (1.62 or later) unchanged:
42
+
43
+ ```python
44
+ from concurrent import futures
45
+ import pygrpc_native
46
+
47
+ server = pygrpc_native.server(futures.ThreadPoolExecutor(16))
48
+ greeter_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
49
+ server.add_insecure_port("[::]:50051")
50
+ server.start()
51
+ server.wait_for_termination()
52
+ ```
53
+
54
+ Status codes, error messages, metadata, deadlines, cancellation and
55
+ `ServicerContext` behave as they do in grpcio.
56
+
57
+ Unary and server-streaming methods are served natively on one insecure port.
58
+ The rest, such as client-streaming and bidirectional methods, server
59
+ reflection, or handlers added only with `add_generic_rpc_handlers`, go to a
60
+ grpcio server on a Unix socket behind it. They work, but they aren't any
61
+ faster, and `peer()` in them is the socket.
62
+
63
+ Interceptors, `maximum_concurrent_rpcs`, TLS, response compression, and server
64
+ options other than the message size limits and client ping settings raise an
65
+ error rather than being ignored. There's one known difference: if a unary
66
+ handler calls `send_initial_metadata()` and then fails, the metadata goes out
67
+ with the status, so the client sees it as trailing metadata.
68
+
69
+ `pygrpc_native.aio.server()` does the same for `grpc.aio.server()`:
70
+
71
+ ```python
72
+ import pygrpc_native.aio
73
+
74
+ server = pygrpc_native.aio.server()
75
+ greeter_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
76
+ server.add_insecure_port("[::]:50051")
77
+ await server.start()
78
+ await server.wait_for_termination()
79
+ ```
80
+
81
+ Handlers run on the event loop that called `start()`, uvloop included.
82
+ `abort()` raises `grpc.aio.AbortError`, and a handler whose client cancels or
83
+ whose deadline passes gets `asyncio.CancelledError`, as in grpc.aio. Streams
84
+ can yield from an async generator or call `context.write()`, and handlers
85
+ that aren't coroutines run in `migration_thread_pool`. The fallback,
86
+ the unsupported options and the unary metadata difference are the same as
87
+ for the sync server.
88
+
89
+ ## Low-level API
90
+
91
+ `NativeGrpcFront` is the layer under `server()`. Each call reaches Python as
92
+ an event that you answer by id:
93
+
94
+ ```python
95
+ import threading
96
+ from pygrpc_native import EVENT_CALL, NativeGrpcFront
97
+
98
+ front = NativeGrpcFront("0.0.0.0:50051", [("/echo.Echo/Say", False)])
99
+ front.start()
100
+
101
+ def serve():
102
+ while (events := front.recv_batch(64)) is not None:
103
+ for kind, call_id, method_id, payload, client_key in events:
104
+ if kind == EVENT_CALL:
105
+ front.respond(call_id, payload)
106
+
107
+ threading.Thread(target=serve).start()
108
+ ```
109
+
110
+ Streaming, call details, response metadata, bearer tokens and metrics are
111
+ covered in [docs/low-level-api.md](https://github.com/theminjunchoi/pygrpc-native/blob/main/docs/low-level-api.md).
112
+
113
+ ## Compatibility
114
+
115
+ pygrpc-native follows semantic versioning, and the low-level API stays
116
+ compatible within 0.x as well. `tests/test_api.py` pins the 0.1 names,
117
+ signatures and event tuples of `NativeGrpcFront`: they won't change or go
118
+ away, and new parameters only come after the existing ones, with a default.
119
+ The keys `metrics()` returns won't be renamed or removed within 0.x either,
120
+ though new ones may be added. `server()` and `aio.server()` follow grpcio's
121
+ API, and any difference from grpcio's behavior is a bug unless it is listed in
122
+ this README. Changes are listed in [CHANGELOG.md](CHANGELOG.md).
123
+
124
+ ## Limitations
125
+
126
+ - `NativeGrpcFront` serves unary and server-streaming methods only; other
127
+ kinds can go through the fallback server. `server()` and `aio.server()`
128
+ send them there on their own.
129
+ - No TLS yet, and grpcio interceptors don't run on the listed methods. Bearer
130
+ tokens are the only authentication, and only on `NativeGrpcFront`.
131
+ - Clients of a Unix socket server must set `grpc.default_authority`, since
132
+ the socket path grpc-core sends as `:authority` is not valid HTTP/2.
133
+ - Like grpc-core, binding a Unix socket path replaces any socket file
134
+ already there.
135
+ - Per connection: at most 1024 concurrent streams and 16 KiB of request
136
+ metadata. A connection that hasn't finished the HTTP/2 preface after 120 s
137
+ is dropped.
138
+
139
+ ## Benchmarks
140
+
141
+ With an empty handler on an Apple M4 Pro, `pygrpc_native.server()` answers
142
+ about 1.5x the calls per second of `grpc.server()` from one client thread and
143
+ about 5x from 32. On a 40-vCPU Linux container the low-level API answered
144
+ about 12x at 32 threads. See [benchmarks](https://github.com/theminjunchoi/pygrpc-native/blob/main/benchmarks/README.md) for the
145
+ numbers and how they were measured.
146
+
147
+ ## Contributing
148
+
149
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Every commit needs a DCO sign-off
150
+ (`git commit -s`).
151
+
152
+ ## License
153
+
154
+ Apache-2.0
@@ -15,7 +15,8 @@ payload bytes. The server and the clients share the machine.
15
15
 
16
16
  Median of three runs, calls/s with the client CPU per call in brackets.
17
17
 
18
- Apple M4 Pro (14 cores), Python 3.12.13, grpcio 1.84.1, pygrpc-native main:
18
+ Apple M4 Pro (14 cores), Python 3.12.13, grpcio 1.84.1, pygrpc-native 0.2.0
19
+ (measured at #20):
19
20
 
20
21
  | server | 1x1 | 4x4 | 8x4 |
21
22
  |---|---|---|---|
@@ -7,7 +7,7 @@ pygrpc_native.server() and pygrpc_native.aio.server(), or native for the
7
7
  low-level API.
8
8
 
9
9
  DUR sets the seconds measured (default 5) and SIZE the payload bytes
10
- (default 64).
10
+ (default 64). METRICS=1 turns on the native front's metrics.
11
11
  """
12
12
 
13
13
  import multiprocessing as mp
@@ -33,7 +33,9 @@ def serve(kind: str, port: int) -> None:
33
33
  if kind == "native":
34
34
  from pygrpc_native import EVENT_CALL, NativeGrpcFront
35
35
 
36
- front = NativeGrpcFront(target, [(METHOD, False)])
36
+ # Only when asked, so the script still runs against older releases.
37
+ extra = {"metrics": True} if os.environ.get("METRICS") == "1" else {}
38
+ front = NativeGrpcFront(target, [(METHOD, False)], **extra)
37
39
  front.start()
38
40
  while (events := front.recv_batch(256)) is not None:
39
41
  for ev_kind, call_id, _, payload, _ in events:
@@ -1,90 +1,11 @@
1
- # pygrpc-native
1
+ # Low-level API
2
2
 
3
- A gRPC server for Python whose HTTP/2 and gRPC work runs in Rust, outside the
4
- GIL. Your handlers stay in Python.
3
+ `NativeGrpcFront` is the layer under `pygrpc_native.server()`. Each call
4
+ reaches Python as an event, and you answer it by id, so you choose how calls
5
+ are batched, which threads run the handlers, and how calls from the same
6
+ client are routed.
5
7
 
6
- grpcio runs its own Python code for every call, under the same GIL as your
7
- handlers. pygrpc-native does the framing, flow control, deadlines and
8
- cancellation on a tokio runtime instead, and gives Python each call as an
9
- event: an id, the method, the request bytes and who sent it. You decode the
10
- protobuf, run the handler and send back the encoded response. Clients and the
11
- wire format are the same as with grpcio.
12
-
13
- ```
14
- grpc client ──HTTP/2──▶ tokio threads (no GIL)
15
- │ path → method id
16
- ▼
17
- event queue ──▶ recv_batch() in Python
18
- ◀── respond(call_id, bytes) from any thread
19
- ```
20
-
21
- Calls reach Python in batches, so a burst takes one trip across the GIL
22
- rather than one per call.
23
-
24
- ## Install
25
-
26
- ```bash
27
- pip install pygrpc-native
28
- ```
29
-
30
- Wheels are published for Linux (x86_64, aarch64) and macOS (arm64,
31
- x86_64) and work on CPython 3.10 and later. To build from a checkout:
32
-
33
- ```bash
34
- pip install maturin
35
- maturin develop --release
36
- ```
37
-
38
- ## With grpcio servicers
39
-
40
- `pygrpc_native.server()` takes the same arguments as `grpc.server()` and
41
- serves servicers from grpcio-tools generated code (1.62 or later) unchanged:
42
-
43
- ```python
44
- from concurrent import futures
45
- import pygrpc_native
46
-
47
- server = pygrpc_native.server(futures.ThreadPoolExecutor(16))
48
- greeter_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
49
- server.add_insecure_port("[::]:50051")
50
- server.start()
51
- server.wait_for_termination()
52
- ```
53
-
54
- Status codes, error messages, metadata, deadlines, cancellation and
55
- `ServicerContext` behave as they do in grpcio. Unary and server-streaming
56
- methods are served natively on one insecure port. Everything else, such as
57
- client-streaming and bidirectional methods, server reflection or handlers
58
- added only with `add_generic_rpc_handlers`, is passed to a grpcio server on a
59
- Unix socket behind it, so it works but doesn't get faster, and `peer()` there
60
- is the socket. Interceptors, `maximum_concurrent_rpcs`, TLS, response
61
- compression and server options other than the message size limits and client
62
- ping settings raise an error instead of being ignored. One difference
63
- remains: a unary handler that calls `send_initial_metadata()` and then fails
64
- sends the metadata with the status instead of before it, so the client sees
65
- it as trailing metadata.
66
-
67
- `pygrpc_native.aio.server()` does the same for `grpc.aio.server()`:
68
-
69
- ```python
70
- import pygrpc_native.aio
71
-
72
- server = pygrpc_native.aio.server()
73
- greeter_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
74
- server.add_insecure_port("[::]:50051")
75
- await server.start()
76
- await server.wait_for_termination()
77
- ```
78
-
79
- Handlers run on the event loop that called `start()`, uvloop included.
80
- `abort()` raises `grpc.aio.AbortError`, and a handler whose client cancels or
81
- whose deadline passes gets `asyncio.CancelledError`, as in grpc.aio. Streams
82
- can yield from an async generator or call `context.write()`, and handlers
83
- that aren't coroutines run in `migration_thread_pool`. The fallback,
84
- the unsupported options and the unary metadata difference are the same as
85
- for the sync server.
86
-
87
- ## Low-level example
8
+ ## Example
88
9
 
89
10
  ```python
90
11
  import threading
@@ -115,9 +36,9 @@ In a real server `payload` is a serialized protobuf message: decode it with
115
36
  the generated class, run the handler (in a thread pool, if it blocks) and
116
37
  pass the serialized reply to `respond`.
117
38
 
118
- ## API
39
+ ## Reference
119
40
 
120
- `NativeGrpcFront(target, methods, io_threads=2, client_key_metadata=None, call_details=False, delay_stream_headers=False, bearer_token_sha256=None, unauthenticated_message="Unauthenticated")`
41
+ `NativeGrpcFront(target, methods, io_threads=2, client_key_metadata=None, call_details=False, delay_stream_headers=False, bearer_token_sha256=None, unauthenticated_message="Unauthenticated", metrics=False)`
121
42
  binds `host:port` (every address the host resolves to), `unix:path` or
122
43
  `unix:///path`. Port 0 picks a free port. `methods` lists
123
44
  `(path, server_streaming)` pairs, where a path is `/package.Service/Method`
@@ -139,6 +60,7 @@ before `start()`, so the port can be bound before the methods are known.
139
60
  | `stream_send_initial_metadata(call_id, metadata)` | send a held-back stream's headers now |
140
61
  | `stream_end(call_id, trailing_metadata=None)` | finish a stream with OK; a stream cut short by `close()` ends with UNAVAILABLE |
141
62
  | `stream_open(call_id)` | whether the client is still reading a stream |
63
+ | `metrics()` | call counts and latencies per method and status; `None` without `metrics=True` |
142
64
  | `close()` | stop without waiting for calls in flight; their clients get UNAVAILABLE |
143
65
 
144
66
  `recv_batch` returns `(kind, call_id, method_id, payload, client_key)`
@@ -188,39 +110,39 @@ front = NativeGrpcFront(
188
110
  `grpc.Server` on a Unix socket and its methods are proxied there, with its
189
111
  interceptors.
190
112
 
191
- ## Limitations
113
+ ## Metrics
192
114
 
193
- - Unary and server-streaming methods only. Other kinds can go through the
194
- fallback server.
195
- - No TLS yet, and grpcio interceptors don't run on the listed methods. Bearer
196
- tokens are the only authentication, and only on `NativeGrpcFront`.
197
- - Clients of a Unix socket server must set `grpc.default_authority`, since
198
- the socket path grpc-core sends as `:authority` is not valid HTTP/2.
199
- - Like grpc-core, binding a Unix socket path replaces any socket file
200
- already there.
201
- - Per connection: at most 1024 concurrent streams and 16 KiB of request
202
- metadata. A connection that hasn't finished the HTTP/2 preface after 120 s
203
- is dropped.
115
+ With `metrics=True`, the front counts calls like gRFC A66's
116
+ `grpc.server.call.started` and `grpc.server.call.duration`. It counts each
117
+ call when it starts, and records its status and duration when the trailers go
118
+ out. A call the client resets counts as CANCELLED, so one the client drops at
119
+ its deadline can show up as CANCELLED or DEADLINE_EXCEEDED, depending on which
120
+ side notices first. Calls to paths you didn't list count under `"other"`, so
121
+ unknown paths can't add entries. Calls forwarded to the fallback aren't
122
+ counted here; the fallback server counts them. Each tokio thread keeps its own
123
+ counters, which costs about 70 ns per call, and `metrics()` adds them up when
124
+ you call it:
204
125
 
205
- ## Benchmarks
206
-
207
- With an empty handler, pygrpc-native answers about 1.6x the calls per second
208
- of grpcio's servers from one client thread, and 4x to 12x from 16 or 32. See
209
- [benchmarks](benchmarks/README.md).
210
-
211
- ## Tests
212
-
213
- ```bash
214
- cargo test --locked --no-default-features
215
- maturin develop --release
216
- pytest tests
126
+ ```python
127
+ {
128
+ "start_time_unix_nano": 1761436800000000000,
129
+ "bounds": [0.0, 1e-05, ..., 100.0], # A66's latency buckets, in seconds
130
+ "methods": {
131
+ "/echo.Echo/Say": {
132
+ "started": 1042, # still running: started minus the counts
133
+ "status": {
134
+ 0: {"count": 1040, "sum": 0.21, "min": 0.00009, "max": 0.0031,
135
+ "buckets": [0, 0, 0, 12, ...]}, # one more than bounds
136
+ },
137
+ },
138
+ },
139
+ }
217
140
  ```
218
141
 
219
- ## Contributing
220
-
221
- See [CONTRIBUTING.md](CONTRIBUTING.md). Every commit needs a DCO sign-off
222
- (`git commit -s`).
223
-
224
- ## License
225
-
226
- Apache-2.0
142
+ Each entry in `buckets` is the number of calls in that bucket, not a running
143
+ total. A call goes in the first bucket whose bound is at least its duration,
144
+ and the last bucket holds anything slower than the last bound. OpenTelemetry
145
+ histograms use the same layout, while Prometheus `le` buckets are cumulative,
146
+ so add them up from the first one. Nothing resets: the totals count up from
147
+ `start_time_unix_nano`. The status keys are gRPC status code numbers, with 0
148
+ for OK. A66 labels statuses by name instead.
@@ -1,4 +1,5 @@
1
1
  from collections.abc import Sequence
2
+ from typing import Any
2
3
 
3
4
  Metadata = Sequence[tuple[str, str | bytes]]
4
5
 
@@ -30,6 +31,7 @@ class NativeGrpcFront:
30
31
  delay_stream_headers: bool = False,
31
32
  bearer_token_sha256: Sequence[bytes] | None = None,
32
33
  unauthenticated_message: str = "Unauthenticated",
34
+ metrics: bool = False,
33
35
  ) -> None: ...
34
36
  @property
35
37
  def bound_port(self) -> int: ...
@@ -65,4 +67,5 @@ class NativeGrpcFront:
65
67
  self, call_id: int, trailing_metadata: Metadata | None = None
66
68
  ) -> None: ...
67
69
  def stream_open(self, call_id: int) -> bool: ...
70
+ def metrics(self) -> dict[str, Any] | None: ...
68
71
  def close(self) -> None: ...