grpchealth-py 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.
@@ -0,0 +1,219 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity granting the License.
13
+
14
+ "Legal Entity" shall mean the union of the acting entity and all
15
+ other entities that control, are controlled by, or are under common
16
+ control with that entity. For the purposes of this definition,
17
+ "control" means (i) the power, direct or indirect, to cause the
18
+ direction or management of such entity, whether by contract or
19
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
20
+ outstanding shares, or (iii) beneficial ownership of such entity.
21
+
22
+ "You" (or "Your") shall mean an individual or Legal Entity
23
+ exercising permissions granted by this License.
24
+
25
+ "Source" shall mean the preferred form for making modifications,
26
+ including but not limited to software source code, documentation
27
+ source, and configuration files.
28
+
29
+ "Object" shall mean any form resulting from mechanical
30
+ transformation or translation of a Source form, including but
31
+ not limited to compiled object code, generated documentation,
32
+ and conversions to other media types.
33
+
34
+ "Work" shall mean the work of authorship, whether in Source or
35
+ Object form, made available under the License, as indicated by a
36
+ copyright notice that is included in or attached to the work
37
+ (which shall not include communications that are solely limited to
38
+ and are identified as being limited to a portion of the work).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based upon (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and derivative works thereof.
47
+
48
+ "Contributor" shall mean Licensor and any individual or Legal Entity
49
+ on behalf of whom a Contribution has been made to Licensor. For the
50
+ purposes of this definition, "Contribution" shall mean any work of
51
+ authorship, including the original version of the Work and any
52
+ modifications or additions to that Work or Derivative Works thereof,
53
+ that is intentionally submitted to Licensor for inclusion in the Work
54
+ by the copyright owner or by an individual or Legal Entity authorized
55
+ to submit on behalf of the copyright owner. For the purposes of this
56
+ definition, "submitted" means any form of electronic, verbal, or
57
+ written communication sent to the Licensor or its representatives,
58
+ including but not limited to communication on electronic mailing
59
+ lists, source code control systems, and issue tracking systems that
60
+ are managed by, or on behalf of, the Licensor for the purpose of
61
+ discussing and improving the Work, but excluding communication that
62
+ is conspicuously marked or otherwise designated in writing by the
63
+ copyright owner as "Not a Contribution."
64
+
65
+ "Contributor" shall mean Licensor and any individual or Legal Entity
66
+ on behalf of whom a Contribution has been made to Licensor. For the
67
+ purposes of this definition, "Contribution" shall mean any work of
68
+ authorship, including the original version of the Work and any
69
+ modifications or additions to that Work or Derivative Works thereof,
70
+ that is intentionally submitted to Licensor for inclusion in the Work
71
+ by the copyright owner or by an individual or Legal Entity authorized
72
+ to submit on behalf of the copyright owner. For the purposes of this
73
+ definition, "submitted" means any form of electronic, verbal, or
74
+ written communication sent to the Licensor or its representatives,
75
+ including but not limited to communication on electronic mailing
76
+ lists, source code control systems, and issue tracking systems that
77
+ are managed by, or on behalf of, the Licensor for the purpose of
78
+ discussing and improving the Work, but excluding communication that
79
+ is conspicuously marked or otherwise designated in writing by the
80
+ copyright owner as "Not a Contribution."
81
+
82
+ 2. Grant of Copyright License. Subject to the terms and conditions of
83
+ this License, each Contributor hereby grants to You a perpetual,
84
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
85
+ copyright license to use, reproduce, modify, distribute, and prepare
86
+ Derivative Works of, publicly display, publicly perform, sublicense,
87
+ and distribute the Work and such Derivative Works in source or object
88
+ form.
89
+
90
+ 3. Grant of Patent License. Subject to the terms and conditions of
91
+ this License, each Contributor hereby grants to You a perpetual,
92
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
93
+ (except as stated in this section) patent license to make, have made,
94
+ use, offer to sell, sell, import, and otherwise transfer the Work,
95
+ where such license applies only to those patent claims licensable
96
+ by such Contributor that are necessarily infringed by their
97
+ Contribution(s) alone or by combination of their Contribution(s)
98
+ with the Work to which such Contribution(s) was submitted. If You
99
+ institute patent litigation against any entity (including a
100
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
101
+ or a Contribution incorporated within the Work constitutes direct
102
+ or contributory patent infringement, then any patent licenses
103
+ granted to You under this License for that Work shall terminate
104
+ as of the date such litigation is filed.
105
+
106
+ 4. Redistribution. You may reproduce and distribute copies of the
107
+ Work or Derivative Works thereof in any medium, with or without
108
+ modifications, and in source or object form, provided that You
109
+ meet the following conditions:
110
+
111
+ (a) You must give any other recipients of the Work or
112
+ Derivative Works a copy of this License; and
113
+
114
+ (b) You must cause any modified files to carry prominent notices
115
+ stating that You changed the files; and
116
+
117
+ (c) You must retain, in the Source form of any Derivative Works
118
+ that You distribute, all copyright, trademark, patent,
119
+ attribution and other notices from the Source form of the Work,
120
+ excluding those notices that do not pertain to any part of
121
+ the Derivative Works; and
122
+
123
+ (d) If the Work includes a "NOTICE" text file as part of its
124
+ distribution, then any Derivative Works that You distribute must
125
+ include a readable copy of the attribution notices contained
126
+ within such NOTICE file, excluding those notices that do not
127
+ pertain to any part of the Derivative Works, in at least one
128
+ of the following places: within a NOTICE text file distributed
129
+ as part of the Derivative Works; within the Source form or
130
+ documentation, if provided along with the Derivative Works; or,
131
+ within a display generated by the Derivative Works, if and
132
+ wherever such third-party notices normally appear. The contents
133
+ of the NOTICE file are for informational purposes only and
134
+ do not modify the License. You may add Your own attribution
135
+ notices within Derivative Works that You distribute, alongside
136
+ or as an addendum to the NOTICE text from the Work, provided
137
+ that such additional attribution notices cannot be construed
138
+ as modifying the License.
139
+
140
+ You may add Your own copyright notice to Your modifications and
141
+ may provide additional or different license terms and conditions
142
+ for use, reproduction, or distribution of Your modifications, or
143
+ for any such Derivative Works as a whole, provided Your use,
144
+ reproduction, and distribution of the Work otherwise complies with
145
+ the conditions stated in this License.
146
+
147
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
148
+ any Contribution intentionally submitted for inclusion in the Work
149
+ by You to the Licensor shall be under the terms and conditions of
150
+ this License, without any additional terms or conditions.
151
+ Notwithstanding the above, nothing herein shall supersede or modify
152
+ the terms of any separate license agreement you may have executed
153
+ with Licensor regarding such Contributions.
154
+
155
+ 6. Trademarks. This License does not grant permission to use the trade
156
+ names, trademarks, service marks, or product names of the Licensor,
157
+ except as required for reasonable and customary use in describing the
158
+ origin of the Work and reproducing the content of the NOTICE file.
159
+
160
+ 7. Disclaimer of Warranty. Unless required by applicable law or
161
+ agreed to in writing, Licensor provides the Work (and each
162
+ Contributor provides its Contributions) on an "AS IS" BASIS,
163
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
164
+ implied, including, without limitation, any warranties or conditions
165
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
166
+ PARTICULAR PURPOSE. You are solely responsible for determining the
167
+ appropriateness of using or redistributing the Work and assume any
168
+ risks associated with Your exercise of permissions under this License.
169
+
170
+ 8. Limitation of Liability. In no event and under no legal theory,
171
+ whether in tort (including negligence), contract, or otherwise,
172
+ unless required by applicable law (such as deliberate and grossly
173
+ negligent acts) or agreed to in writing, shall any Contributor be
174
+ liable to You for damages, including any direct, indirect, special,
175
+ incidental, or consequential damages of any character arising as a
176
+ result of this License or out of the use or inability to use the
177
+ Work (including but not limited to damages for loss of goodwill,
178
+ work stoppage, computer failure or malfunction, or any and all
179
+ other commercial damages or losses), even if such Contributor
180
+ has been advised of the possibility of such damages.
181
+
182
+ 9. Accepting Warranty or Support. Unless agreed to otherwise,
183
+ no Contributor is required to provide support, warranties,
184
+ indemnities, or other obligations to You. However, if You elect to
185
+ provide support, warranties, indemnities, or other obligations to
186
+ one or more recipients of Covered Software, You may do so only on
187
+ Your own behalf, and not on behalf of any Contributor. You must make
188
+ it absolutely clear that any such warranty, support, indemnity, or
189
+ other obligation is offered by You alone, and You hereby agree to
190
+ indemnify every Contributor for any liability incurred by such
191
+ Contributor as a result of warranty, support, indemnity or other
192
+ obligation You offer.
193
+
194
+ END OF TERMS AND CONDITIONS
195
+
196
+ APPENDIX: How to apply the Apache License to your work.
197
+
198
+ To apply the Apache License to your work, attach the following
199
+ boilerplate notice, with the fields enclosed by brackets "[]"
200
+ replaced with your own identifying information. (Don't include
201
+ the brackets!) The text should be enclosed in the appropriate
202
+ comment syntax for the file format. We also recommend that a
203
+ file or class name and description of purpose be included on the
204
+ same "printed page" as the copyright notice for easier
205
+ identification within third-party archives.
206
+
207
+ Copyright 2026 DataKit, LLC
208
+
209
+ Licensed under the Apache License, Version 2.0 (the "License");
210
+ you may not use this file except in compliance with the License.
211
+ You may obtain a copy of the License at
212
+
213
+ http://www.apache.org/licenses/LICENSE-2.0
214
+
215
+ Unless required by applicable law or agreed to in writing, software
216
+ distributed under the License is distributed on an "AS IS" BASIS,
217
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
218
+ See the License for the specific language governing permissions and
219
+ limitations under the License.
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: grpchealth-py
3
+ Version: 0.1.0
4
+ Summary: An idiomatic Python implementation of gRPC Service Health compatible with ConnectRPC
5
+ Author: Jordan Fowler
6
+ Author-email: Jordan Fowler <jordan@withdatakit.com>
7
+ License-Expression: Apache-2.0
8
+ License-File: LICENSE
9
+ Requires-Dist: connectrpc>=0.12.1
10
+ Requires-Dist: protobuf-py>=0.5.0
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+
14
+ # grpchealth-py
15
+
16
+ An idiomatic Python implementation of [gRPC's health checking protocol][grpc-health]
17
+ (`grpc.health.v1`), built on [`protobuf-py`][protobuf-py] and
18
+ [`connectrpc`][connect-python]. Modeled on Buf's Go reference implementation,
19
+ [`grpchealth-go`][grpchealth-go].
20
+
21
+ Since the health service here is just a `ConnectASGIApplication` like any
22
+ other, it works over whichever of `connectrpc`'s protocols your own services
23
+ already speak -- Connect, gRPC, and gRPC-Web -- with no extra configuration.
24
+
25
+ [grpc-health]: https://github.com/grpc/grpc/blob/master/doc/health-checking.md
26
+ [protobuf-py]: https://github.com/bufbuild/protobuf-py
27
+ [connect-python]: https://github.com/connectrpc/connect-py
28
+ [grpchealth-go]: https://github.com/connectrpc/grpchealth-go
29
+
30
+ ## Install
31
+
32
+ ```sh
33
+ uv add grpchealth-py
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ `mount_health_handler()` takes a `Checker` -- optionally one that also
39
+ implements `Watcher`, to support the streaming `Watch` RPC -- and returns a
40
+ ready-to-mount `ConnectASGIApplication`:
41
+
42
+ ```python
43
+ from starlette.applications import Starlette
44
+ from starlette.routing import Mount
45
+ from grpchealth.checker import StaticChecker
46
+ from grpchealth.server import mount_health_handler
47
+
48
+ health = mount_health_handler(StaticChecker("acme.greet.v1.GreetService"))
49
+
50
+ app = Starlette(routes=[Mount(health.path, app=health)])
51
+ ```
52
+
53
+ `Watch` is a server-streaming RPC, which benefits from an HTTP/2-capable ASGI
54
+ server. As of this writing, [`pyvoy`][pyvoy] is the server most reliably
55
+ passing the Connect conformance suite's HTTP/2 and streaming tests, so it's
56
+ the recommended way to serve `app` above:
57
+
58
+ ```python
59
+ import asyncio
60
+ import pyvoy
61
+
62
+
63
+ async def main() -> None:
64
+ server = pyvoy.PyvoyServer("myapp:app", port=8080)
65
+ await server.start()
66
+ await server.wait()
67
+
68
+
69
+ asyncio.run(main())
70
+ ```
71
+
72
+ You aren't required to use `pyvoy` -- any ASGI server with working HTTP/2 and
73
+ streaming support will do -- but not every ASGI server reliably supports both
74
+ today.
75
+
76
+ [pyvoy]: https://github.com/curioswitch/pyvoy
77
+
78
+ ## Client
79
+
80
+ `Client` calls the gRPC health-checking API against a server. It structurally
81
+ satisfies `Checker` (and, via `watch`, `Watcher`), so it can be used anywhere
82
+ either is expected:
83
+
84
+ ```python
85
+ import asyncio
86
+
87
+ from grpchealth.checker import CheckRequest
88
+ from grpchealth.client import Client
89
+
90
+
91
+ async def main() -> None:
92
+ async with Client("http://localhost:8080") as client:
93
+ response = await client.check(
94
+ CheckRequest(service="acme.greet.v1.GreetService")
95
+ )
96
+ print(response.status)
97
+
98
+ async for response in client.watch(
99
+ CheckRequest(service="acme.greet.v1.GreetService")
100
+ ):
101
+ print(response.status)
102
+
103
+
104
+ asyncio.run(main())
105
+ ```
106
+
107
+ ## Development
108
+
109
+ ```sh
110
+ uv run -- buf generate # regenerate src/grpchealth/_gen from the upstream schema
111
+ uv run pytest
112
+ ```
@@ -0,0 +1,99 @@
1
+ # grpchealth-py
2
+
3
+ An idiomatic Python implementation of [gRPC's health checking protocol][grpc-health]
4
+ (`grpc.health.v1`), built on [`protobuf-py`][protobuf-py] and
5
+ [`connectrpc`][connect-python]. Modeled on Buf's Go reference implementation,
6
+ [`grpchealth-go`][grpchealth-go].
7
+
8
+ Since the health service here is just a `ConnectASGIApplication` like any
9
+ other, it works over whichever of `connectrpc`'s protocols your own services
10
+ already speak -- Connect, gRPC, and gRPC-Web -- with no extra configuration.
11
+
12
+ [grpc-health]: https://github.com/grpc/grpc/blob/master/doc/health-checking.md
13
+ [protobuf-py]: https://github.com/bufbuild/protobuf-py
14
+ [connect-python]: https://github.com/connectrpc/connect-py
15
+ [grpchealth-go]: https://github.com/connectrpc/grpchealth-go
16
+
17
+ ## Install
18
+
19
+ ```sh
20
+ uv add grpchealth-py
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ `mount_health_handler()` takes a `Checker` -- optionally one that also
26
+ implements `Watcher`, to support the streaming `Watch` RPC -- and returns a
27
+ ready-to-mount `ConnectASGIApplication`:
28
+
29
+ ```python
30
+ from starlette.applications import Starlette
31
+ from starlette.routing import Mount
32
+ from grpchealth.checker import StaticChecker
33
+ from grpchealth.server import mount_health_handler
34
+
35
+ health = mount_health_handler(StaticChecker("acme.greet.v1.GreetService"))
36
+
37
+ app = Starlette(routes=[Mount(health.path, app=health)])
38
+ ```
39
+
40
+ `Watch` is a server-streaming RPC, which benefits from an HTTP/2-capable ASGI
41
+ server. As of this writing, [`pyvoy`][pyvoy] is the server most reliably
42
+ passing the Connect conformance suite's HTTP/2 and streaming tests, so it's
43
+ the recommended way to serve `app` above:
44
+
45
+ ```python
46
+ import asyncio
47
+ import pyvoy
48
+
49
+
50
+ async def main() -> None:
51
+ server = pyvoy.PyvoyServer("myapp:app", port=8080)
52
+ await server.start()
53
+ await server.wait()
54
+
55
+
56
+ asyncio.run(main())
57
+ ```
58
+
59
+ You aren't required to use `pyvoy` -- any ASGI server with working HTTP/2 and
60
+ streaming support will do -- but not every ASGI server reliably supports both
61
+ today.
62
+
63
+ [pyvoy]: https://github.com/curioswitch/pyvoy
64
+
65
+ ## Client
66
+
67
+ `Client` calls the gRPC health-checking API against a server. It structurally
68
+ satisfies `Checker` (and, via `watch`, `Watcher`), so it can be used anywhere
69
+ either is expected:
70
+
71
+ ```python
72
+ import asyncio
73
+
74
+ from grpchealth.checker import CheckRequest
75
+ from grpchealth.client import Client
76
+
77
+
78
+ async def main() -> None:
79
+ async with Client("http://localhost:8080") as client:
80
+ response = await client.check(
81
+ CheckRequest(service="acme.greet.v1.GreetService")
82
+ )
83
+ print(response.status)
84
+
85
+ async for response in client.watch(
86
+ CheckRequest(service="acme.greet.v1.GreetService")
87
+ ):
88
+ print(response.status)
89
+
90
+
91
+ asyncio.run(main())
92
+ ```
93
+
94
+ ## Development
95
+
96
+ ```sh
97
+ uv run -- buf generate # regenerate src/grpchealth/_gen from the upstream schema
98
+ uv run pytest
99
+ ```
@@ -0,0 +1,54 @@
1
+ [project]
2
+ name = "grpchealth-py"
3
+ version = "0.1.0"
4
+ description = "An idiomatic Python implementation of gRPC Service Health compatible with ConnectRPC"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.12"
9
+ dependencies = [
10
+ "connectrpc>=0.12.1",
11
+ "protobuf-py>=0.5.0",
12
+ ]
13
+
14
+ [[project.authors]]
15
+ name = "Jordan Fowler"
16
+ email = "jordan@withdatakit.com"
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.12.17,<0.13.0"]
20
+ build-backend = "uv_build"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "buf-bin>=1.73.0",
25
+ "protoc-gen-connectrpc>=0.12.1",
26
+ "protoc-gen-py>=0.5.0",
27
+ "pytest>=9.1.1",
28
+ "pytest-asyncio>=1.4.0",
29
+ "pyvoy>=1.1.0",
30
+ "ruff>=0.17.3",
31
+ "starlette>=1.6.0",
32
+ ]
33
+
34
+ [tool.pytest.ini_options]
35
+ asyncio_mode = "auto"
36
+
37
+ [tool.uv.build-backend]
38
+ module-name = ["grpchealth"]
39
+
40
+ [tool.ruff]
41
+ extend-exclude = ["src/grpchealth/_gen"]
42
+
43
+ [tool.ruff.lint]
44
+ select = [
45
+ "E",
46
+ "F",
47
+ "I",
48
+ "UP",
49
+ "B",
50
+ ]
51
+ ignore = [
52
+ "E501",
53
+ "UP046",
54
+ ]
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "grpchealth-py"
3
+ version = "0.1.0"
4
+ description = "An idiomatic Python implementation of gRPC Service Health compatible with ConnectRPC"
5
+ readme = "README.md"
6
+ authors = [{ name = "Jordan Fowler", email = "jordan@withdatakit.com" }]
7
+ license = "Apache-2.0"
8
+ license-files = ["LICENSE"]
9
+ requires-python = ">=3.12"
10
+ dependencies = ["connectrpc>=0.12.1", "protobuf-py>=0.5.0"]
11
+
12
+ [build-system]
13
+ requires = ["uv_build>=0.12.17,<0.13.0"]
14
+ build-backend = "uv_build"
15
+
16
+ [dependency-groups]
17
+ dev = [
18
+ "buf-bin>=1.73.0",
19
+ "protoc-gen-connectrpc>=0.12.1",
20
+ "protoc-gen-py>=0.5.0",
21
+ "pytest>=9.1.1",
22
+ "pytest-asyncio>=1.4.0",
23
+ "pyvoy>=1.1.0",
24
+ "ruff>=0.17.3",
25
+ "starlette>=1.6.0",
26
+ ]
27
+
28
+ [tool.pytest.ini_options]
29
+ asyncio_mode = "auto"
30
+
31
+ [tool.uv.build-backend]
32
+ module-name = ["grpchealth"]
33
+
34
+ [tool.ruff]
35
+ extend-exclude = ["src/grpchealth/_gen"]
36
+
37
+ [tool.ruff.lint]
38
+ select = ["E", "F", "I", "UP", "B"]
39
+ ignore = ["E501", "UP046"]
@@ -0,0 +1,24 @@
1
+ """An idiomatic Python implementation of gRPC Service Health, compatible with ConnectRPC."""
2
+
3
+ from grpchealth.checker import (
4
+ Checker,
5
+ CheckRequest,
6
+ CheckResponse,
7
+ StaticChecker,
8
+ Status,
9
+ Watcher,
10
+ )
11
+ from grpchealth.client import Client
12
+ from grpchealth.server import HealthService, mount_health_handler
13
+
14
+ __all__ = [
15
+ "CheckRequest",
16
+ "CheckResponse",
17
+ "Checker",
18
+ "Client",
19
+ "HealthService",
20
+ "StaticChecker",
21
+ "Status",
22
+ "Watcher",
23
+ "mount_health_handler",
24
+ ]
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,307 @@
1
+ # Generated from grpc/health/v1/health.proto. DO NOT EDIT.
2
+ # Generated by protoc-gen-connectrpc-py v0.12.1 with parameter "".
3
+ # ruff: noqa: PGH004
4
+ # ruff: noqa
5
+ # fmt: off
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Protocol, TYPE_CHECKING
10
+
11
+ from connectrpc.client import ConnectClient, ConnectClientSync
12
+ from connectrpc.code import Code
13
+ from connectrpc.errors import ConnectError
14
+ from connectrpc.method import IdempotencyLevel, MethodInfo
15
+ from connectrpc.server import ConnectASGIApplication, ConnectWSGIApplication, DEFAULT_READ_MAX_BYTES, Endpoint, EndpointSync
16
+ from protobuf import DescService
17
+
18
+ from . import health_pb
19
+ from .health_pb import HealthCheckRequest, HealthCheckResponse
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import AsyncGenerator, AsyncIterator, Iterable, Iterator, Mapping
23
+
24
+ from connectrpc.codec import Codec
25
+ from connectrpc.compression import Compression
26
+ from connectrpc.interceptor import Interceptor, InterceptorSync
27
+ from connectrpc.request import Headers, RequestContext
28
+
29
+
30
+ class Health(Protocol):
31
+ async def check(self, request: HealthCheckRequest, ctx: RequestContext[HealthCheckRequest, HealthCheckResponse], /) -> HealthCheckResponse:
32
+ """
33
+ If the requested service is unknown, the call will fail with status
34
+ NOT_FOUND.
35
+ """
36
+ raise ConnectError(Code.UNIMPLEMENTED, 'Not implemented')
37
+
38
+ def watch(self, request: HealthCheckRequest, ctx: RequestContext[HealthCheckRequest, HealthCheckResponse], /) -> AsyncIterator[HealthCheckResponse]:
39
+ """
40
+ Performs a watch for the serving status of the requested service.
41
+ The server will immediately send back a message indicating the current
42
+ serving status. It will then subsequently send a new message whenever
43
+ the service's serving status changes.
44
+
45
+ If the requested service is unknown when the call is received, the
46
+ server will send a message setting the serving status to
47
+ SERVICE_UNKNOWN but will *not* terminate the call. If at some
48
+ future point, the serving status of the service becomes known, the
49
+ server will send a new message with the service's serving status.
50
+
51
+ If the call terminates with status UNIMPLEMENTED, then clients
52
+ should assume this method is not supported and should not retry the
53
+ call. If the call terminates with any other status (including OK),
54
+ clients should retry the call with appropriate exponential backoff.
55
+ """
56
+ raise ConnectError(Code.UNIMPLEMENTED, 'Not implemented')
57
+
58
+ @classmethod
59
+ def desc(cls) -> DescService:
60
+ """Returns the descriptor for this service."""
61
+ return next(s for s in health_pb.desc().services if s.type_name == 'grpc.health.v1.Health')
62
+
63
+ class HealthASGIApplication(ConnectASGIApplication[Health]):
64
+ def __init__(
65
+ self,
66
+ service: Health | AsyncGenerator[Health],
67
+ *,
68
+ interceptors: Iterable[Interceptor] = (),
69
+ read_max_bytes: int | None = DEFAULT_READ_MAX_BYTES,
70
+ compressions: Iterable[Compression] | None = None,
71
+ codecs: Iterable[Codec] | None = None,
72
+ ) -> None:
73
+ super().__init__(
74
+ service=service,
75
+ endpoints=lambda svc: {
76
+ "/grpc.health.v1.Health/Check": Endpoint.unary(
77
+ method=MethodInfo(
78
+ name="Check",
79
+ service_name="grpc.health.v1.Health",
80
+ input=HealthCheckRequest,
81
+ output=HealthCheckResponse,
82
+ idempotency_level=IdempotencyLevel.UNKNOWN,
83
+ ),
84
+ function=svc.check,
85
+ ),
86
+ "/grpc.health.v1.Health/Watch": Endpoint.server_stream(
87
+ method=MethodInfo(
88
+ name="Watch",
89
+ service_name="grpc.health.v1.Health",
90
+ input=HealthCheckRequest,
91
+ output=HealthCheckResponse,
92
+ idempotency_level=IdempotencyLevel.UNKNOWN,
93
+ ),
94
+ function=svc.watch,
95
+ ),
96
+ },
97
+ interceptors=interceptors,
98
+ read_max_bytes=read_max_bytes,
99
+ compressions=compressions,
100
+ codecs=codecs,
101
+ )
102
+
103
+ @property
104
+ def path(self) -> str:
105
+ """Returns the URL path to mount the application to when serving multiple applications."""
106
+ return "/grpc.health.v1.Health"
107
+
108
+
109
+ class HealthClient(ConnectClient):
110
+ async def check(
111
+ self,
112
+ request: HealthCheckRequest,
113
+ *,
114
+ headers: Headers | Mapping[str, str] | None = None,
115
+ timeout_ms: int | None = None,
116
+ ) -> HealthCheckResponse:
117
+ """
118
+ If the requested service is unknown, the call will fail with status
119
+ NOT_FOUND.
120
+ """
121
+ return await self.execute_unary(
122
+ request=request,
123
+ method=MethodInfo(
124
+ name="Check",
125
+ service_name="grpc.health.v1.Health",
126
+ input=HealthCheckRequest,
127
+ output=HealthCheckResponse,
128
+ idempotency_level=IdempotencyLevel.UNKNOWN,
129
+ ),
130
+ headers=headers,
131
+ timeout_ms=timeout_ms,
132
+ )
133
+
134
+ def watch(
135
+ self,
136
+ request: HealthCheckRequest,
137
+ *,
138
+ headers: Headers | Mapping[str, str] | None = None,
139
+ timeout_ms: int | None = None,
140
+ ) -> AsyncIterator[HealthCheckResponse]:
141
+ """
142
+ Performs a watch for the serving status of the requested service.
143
+ The server will immediately send back a message indicating the current
144
+ serving status. It will then subsequently send a new message whenever
145
+ the service's serving status changes.
146
+
147
+ If the requested service is unknown when the call is received, the
148
+ server will send a message setting the serving status to
149
+ SERVICE_UNKNOWN but will *not* terminate the call. If at some
150
+ future point, the serving status of the service becomes known, the
151
+ server will send a new message with the service's serving status.
152
+
153
+ If the call terminates with status UNIMPLEMENTED, then clients
154
+ should assume this method is not supported and should not retry the
155
+ call. If the call terminates with any other status (including OK),
156
+ clients should retry the call with appropriate exponential backoff.
157
+ """
158
+ return self.execute_server_stream(
159
+ request=request,
160
+ method=MethodInfo(
161
+ name="Watch",
162
+ service_name="grpc.health.v1.Health",
163
+ input=HealthCheckRequest,
164
+ output=HealthCheckResponse,
165
+ idempotency_level=IdempotencyLevel.UNKNOWN,
166
+ ),
167
+ headers=headers,
168
+ timeout_ms=timeout_ms,
169
+ )
170
+
171
+ class HealthSync(Protocol):
172
+ def check(self, request: HealthCheckRequest, ctx: RequestContext[HealthCheckRequest, HealthCheckResponse], /) -> HealthCheckResponse:
173
+ """
174
+ If the requested service is unknown, the call will fail with status
175
+ NOT_FOUND.
176
+ """
177
+ raise ConnectError(Code.UNIMPLEMENTED, 'Not implemented')
178
+
179
+ def watch(self, request: HealthCheckRequest, ctx: RequestContext[HealthCheckRequest, HealthCheckResponse], /) -> Iterator[HealthCheckResponse]:
180
+ """
181
+ Performs a watch for the serving status of the requested service.
182
+ The server will immediately send back a message indicating the current
183
+ serving status. It will then subsequently send a new message whenever
184
+ the service's serving status changes.
185
+
186
+ If the requested service is unknown when the call is received, the
187
+ server will send a message setting the serving status to
188
+ SERVICE_UNKNOWN but will *not* terminate the call. If at some
189
+ future point, the serving status of the service becomes known, the
190
+ server will send a new message with the service's serving status.
191
+
192
+ If the call terminates with status UNIMPLEMENTED, then clients
193
+ should assume this method is not supported and should not retry the
194
+ call. If the call terminates with any other status (including OK),
195
+ clients should retry the call with appropriate exponential backoff.
196
+ """
197
+ raise ConnectError(Code.UNIMPLEMENTED, 'Not implemented')
198
+
199
+ @classmethod
200
+ def desc(cls) -> DescService:
201
+ """Returns the descriptor for this service."""
202
+ return next(s for s in health_pb.desc().services if s.type_name == 'grpc.health.v1.Health')
203
+
204
+ class HealthWSGIApplication(ConnectWSGIApplication):
205
+ def __init__(
206
+ self,
207
+ service: HealthSync,
208
+ interceptors: Iterable[InterceptorSync] = (),
209
+ read_max_bytes: int | None = DEFAULT_READ_MAX_BYTES,
210
+ compressions: Iterable[Compression] | None = None,
211
+ codecs: Iterable[Codec] | None = None,
212
+ ) -> None:
213
+ super().__init__(
214
+ endpoints={
215
+ "/grpc.health.v1.Health/Check": EndpointSync.unary(
216
+ method=MethodInfo(
217
+ name="Check",
218
+ service_name="grpc.health.v1.Health",
219
+ input=HealthCheckRequest,
220
+ output=HealthCheckResponse,
221
+ idempotency_level=IdempotencyLevel.UNKNOWN,
222
+ ),
223
+ function=service.check,
224
+ ),
225
+ "/grpc.health.v1.Health/Watch": EndpointSync.server_stream(
226
+ method=MethodInfo(
227
+ name="Watch",
228
+ service_name="grpc.health.v1.Health",
229
+ input=HealthCheckRequest,
230
+ output=HealthCheckResponse,
231
+ idempotency_level=IdempotencyLevel.UNKNOWN,
232
+ ),
233
+ function=service.watch,
234
+ ),
235
+ },
236
+ interceptors=interceptors,
237
+ read_max_bytes=read_max_bytes,
238
+ compressions=compressions,
239
+ codecs=codecs,
240
+ )
241
+
242
+ @property
243
+ def path(self) -> str:
244
+ """Returns the URL path to mount the application to when serving multiple applications."""
245
+ return "/grpc.health.v1.Health"
246
+
247
+
248
+ class HealthClientSync(ConnectClientSync):
249
+ def check(
250
+ self,
251
+ request: HealthCheckRequest,
252
+ *,
253
+ headers: Headers | Mapping[str, str] | None = None,
254
+ timeout_ms: int | None = None,
255
+ ) -> HealthCheckResponse:
256
+ """
257
+ If the requested service is unknown, the call will fail with status
258
+ NOT_FOUND.
259
+ """
260
+ return self.execute_unary(
261
+ request=request,
262
+ method=MethodInfo(
263
+ name="Check",
264
+ service_name="grpc.health.v1.Health",
265
+ input=HealthCheckRequest,
266
+ output=HealthCheckResponse,
267
+ idempotency_level=IdempotencyLevel.UNKNOWN,
268
+ ),
269
+ headers=headers,
270
+ timeout_ms=timeout_ms,
271
+ )
272
+ def watch(
273
+ self,
274
+ request: HealthCheckRequest,
275
+ *,
276
+ headers: Headers | Mapping[str, str] | None = None,
277
+ timeout_ms: int | None = None,
278
+ ) -> Iterator[HealthCheckResponse]:
279
+ """
280
+ Performs a watch for the serving status of the requested service.
281
+ The server will immediately send back a message indicating the current
282
+ serving status. It will then subsequently send a new message whenever
283
+ the service's serving status changes.
284
+
285
+ If the requested service is unknown when the call is received, the
286
+ server will send a message setting the serving status to
287
+ SERVICE_UNKNOWN but will *not* terminate the call. If at some
288
+ future point, the serving status of the service becomes known, the
289
+ server will send a new message with the service's serving status.
290
+
291
+ If the call terminates with status UNIMPLEMENTED, then clients
292
+ should assume this method is not supported and should not retry the
293
+ call. If the call terminates with any other status (including OK),
294
+ clients should retry the call with appropriate exponential backoff.
295
+ """
296
+ return self.execute_server_stream(
297
+ request=request,
298
+ method=MethodInfo(
299
+ name="Watch",
300
+ service_name="grpc.health.v1.Health",
301
+ input=HealthCheckRequest,
302
+ output=HealthCheckResponse,
303
+ idempotency_level=IdempotencyLevel.UNKNOWN,
304
+ ),
305
+ headers=headers,
306
+ timeout_ms=timeout_ms,
307
+ )
@@ -0,0 +1,120 @@
1
+ # Generated from grpc/health/v1/health.proto. DO NOT EDIT.
2
+ # Generated by protoc-gen-py v0.5.0 with parameter "".
3
+ # ruff: noqa: PGH004
4
+ # ruff: noqa
5
+ # fmt: off
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Literal, TYPE_CHECKING, TypeAlias
10
+
11
+ from protobuf import Enum, Message
12
+ from protobuf._codegen import file_desc
13
+
14
+ if TYPE_CHECKING:
15
+ from protobuf import DescFile
16
+
17
+
18
+ _HealthCheckRequestFields: TypeAlias = Literal["service"]
19
+
20
+ class HealthCheckRequest(Message[_HealthCheckRequestFields]):
21
+ """
22
+ ```proto
23
+ message grpc.health.v1.HealthCheckRequest
24
+ ```
25
+
26
+ Attributes:
27
+ service:
28
+ ```proto
29
+ string service = 1;
30
+ ```
31
+ """
32
+
33
+ __slots__ = ("service",)
34
+
35
+ if TYPE_CHECKING:
36
+
37
+ def __init__(
38
+ self,
39
+ *,
40
+ service: str = "",
41
+ ) -> None:
42
+ pass
43
+
44
+ service: str
45
+
46
+ _HealthCheckResponseFields: TypeAlias = Literal["status"]
47
+
48
+ class HealthCheckResponse(Message[_HealthCheckResponseFields]):
49
+ """
50
+ ```proto
51
+ message grpc.health.v1.HealthCheckResponse
52
+ ```
53
+
54
+ Attributes:
55
+ status:
56
+ ```proto
57
+ grpc.health.v1.HealthCheckResponse.ServingStatus status = 1;
58
+ ```
59
+ """
60
+
61
+ __slots__ = ("status",)
62
+
63
+ if TYPE_CHECKING:
64
+
65
+ def __init__(
66
+ self,
67
+ *,
68
+ status: HealthCheckResponse.ServingStatus | None = None,
69
+ ) -> None:
70
+ pass
71
+
72
+ status: HealthCheckResponse.ServingStatus
73
+
74
+ class ServingStatus(Enum):
75
+ """
76
+ ```proto
77
+ enum grpc.health.v1.HealthCheckResponse.ServingStatus
78
+ ```
79
+
80
+ Attributes:
81
+ UNKNOWN:
82
+ ```proto
83
+ UNKNOWN = 0
84
+ ```
85
+ SERVING:
86
+ ```proto
87
+ SERVING = 1
88
+ ```
89
+ NOT_SERVING:
90
+ ```proto
91
+ NOT_SERVING = 2
92
+ ```
93
+ SERVICE_UNKNOWN:
94
+ Used only by the Watch method.
95
+
96
+ ```proto
97
+ SERVICE_UNKNOWN = 3
98
+ ```
99
+ """
100
+
101
+ UNKNOWN = 0
102
+ SERVING = 1
103
+ NOT_SERVING = 2
104
+ SERVICE_UNKNOWN = 3
105
+
106
+
107
+ _DESC = file_desc(
108
+ b'\n\x1bgrpc/health/v1/health.proto\x12\x0egrpc.health.v1".\n\x12HealthCheckRequest\x12\x18\n\x07service\x18\x01 \x01(\tR\x07service"\xb1\x01\n\x13HealthCheckResponse\x12I\n\x06status\x18\x01 \x01(\x0e21.grpc.health.v1.HealthCheckResponse.ServingStatusR\x06status"O\n\rServingStatus\x12\x0b\n\x07UNKNOWN\x10\x00\x12\x0b\n\x07SERVING\x10\x01\x12\x0f\n\x0bNOT_SERVING\x10\x02\x12\x13\n\x0fSERVICE_UNKNOWN\x10\x032\xae\x01\n\x06Health\x12P\n\x05Check\x12".grpc.health.v1.HealthCheckRequest\x1a#.grpc.health.v1.HealthCheckResponse\x12R\n\x05Watch\x12".grpc.health.v1.HealthCheckRequest\x1a#.grpc.health.v1.HealthCheckResponse0\x01B\xa9\x01\n\x12com.grpc.health.v1B\x0bHealthProtoP\x01Z,google.golang.org/grpc/health/grpc_health_v1\xa2\x02\x03GHX\xaa\x02\x0eGrpc.Health.V1\xca\x02\x0eGrpc\\Health\\V1\xe2\x02\x1aGrpc\\Health\\V1\\GPBMetadata\xea\x02\x10Grpc::Health::V1b\x06proto3',
109
+ [],
110
+ {
111
+ "HealthCheckRequest": HealthCheckRequest,
112
+ "HealthCheckResponse": HealthCheckResponse,
113
+ "HealthCheckResponse.ServingStatus": HealthCheckResponse.ServingStatus,
114
+ },
115
+ )
116
+
117
+
118
+ def desc() -> DescFile:
119
+ """Returns the descriptor for the file `grpc/health/v1/health.proto`."""
120
+ return _DESC
@@ -0,0 +1,131 @@
1
+ """Health status types and the `Checker`/`Watcher` protocols implementations satisfy.
2
+
3
+ Modeled on Go's `Checker`/`Watcher`/`StaticChecker`
4
+ (`grpchealth-go/grpchealth.go`, `grpchealth-go/static_checker.go`), but with
5
+ Go's callback-plus-stop-function `Watch` replaced by a plain async generator:
6
+ `Watcher.watch()` is an `AsyncIterator` that yields the current status
7
+ immediately, then again each time it may have changed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from dataclasses import dataclass
14
+ from enum import IntEnum
15
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
16
+
17
+ from connectrpc.code import Code
18
+ from connectrpc.errors import ConnectError
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import AsyncIterator
22
+
23
+
24
+ class Status(IntEnum):
25
+ """The serving status of a service, or of the whole process.
26
+
27
+ Mirrors `grpc.health.v1.HealthCheckResponse.ServingStatus`. `SERVICE_UNKNOWN`
28
+ is only ever seen in a `Watch` reply for a service that isn't known; a
29
+ `Checker` itself should never return it from `check()`.
30
+ """
31
+
32
+ UNKNOWN = 0
33
+ SERVING = 1
34
+ NOT_SERVING = 2
35
+ SERVICE_UNKNOWN = 3
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class CheckRequest:
40
+ """A request for the health of a service.
41
+
42
+ An empty `service` asks for the health of the whole process.
43
+ """
44
+
45
+ service: str = ""
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class CheckResponse:
50
+ """Reports the health of a service (or of the whole process)."""
51
+
52
+ status: Status
53
+
54
+
55
+ @runtime_checkable
56
+ class Checker(Protocol):
57
+ """Reports the health of a service. Must be safe to call concurrently."""
58
+
59
+ async def check(self, request: CheckRequest) -> CheckResponse:
60
+ """Returns the health of `request.service`.
61
+
62
+ If the service is unknown, raises a `ConnectError` with `Code.NOT_FOUND`.
63
+ """
64
+ ...
65
+
66
+
67
+ @runtime_checkable
68
+ class Watcher(Checker, Protocol):
69
+ """Extends `Checker` with the ability to stream status changes.
70
+
71
+ `watch()` yields the current status immediately, then again each time it
72
+ may have changed; it raises a `ConnectError` with `Code.NOT_FOUND` if
73
+ `request.service` is unknown.
74
+ """
75
+
76
+ def watch(self, request: CheckRequest) -> AsyncIterator[CheckResponse]:
77
+ """Streams `request.service`'s status until the caller stops iterating."""
78
+ ...
79
+
80
+
81
+ class StaticChecker:
82
+ """A simple `Checker`/`Watcher`.
83
+
84
+ Always reports `Status.SERVING` for the whole process, and a static,
85
+ settable status for each service named at construction (or later, via
86
+ `set_status`).
87
+
88
+ If you have a dynamic list of services, want to ping a database as part
89
+ of your health check, or otherwise need something more specialized,
90
+ write your own `Checker`.
91
+ """
92
+
93
+ def __init__(self, *services: str) -> None:
94
+ self._statuses: dict[str, Status] = dict.fromkeys(services, Status.SERVING)
95
+ self._events: dict[str, asyncio.Event] = {}
96
+
97
+ def set_status(self, service: str, status: Status) -> None:
98
+ """Sets `service`'s health status, registering it if necessary.
99
+
100
+ An empty `service` sets the server-wide status returned to checks
101
+ that don't name a particular service (absent that, such checks
102
+ report `Status.SERVING`).
103
+ """
104
+ self._statuses[service] = status
105
+ event = self._events.get(service)
106
+ if event is not None:
107
+ event.set()
108
+
109
+ async def check(self, request: CheckRequest) -> CheckResponse:
110
+ status = self._statuses.get(request.service)
111
+ if status is not None:
112
+ return CheckResponse(status=status)
113
+ if not request.service:
114
+ return CheckResponse(status=Status.SERVING)
115
+ raise ConnectError(Code.NOT_FOUND, f"unknown service {request.service}")
116
+
117
+ async def watch(self, request: CheckRequest) -> AsyncIterator[CheckResponse]:
118
+ if request.service and request.service not in self._statuses:
119
+ raise ConnectError(Code.NOT_FOUND, f"unknown service {request.service}")
120
+ # Shared per-service, so `set_status` only has to wake whoever's watching it.
121
+ event = self._events.setdefault(request.service, asyncio.Event())
122
+ last: Status | None = None
123
+ while True:
124
+ # Cleared before reading status so a concurrent `set_status` is never missed.
125
+ event.clear()
126
+ status = self._statuses.get(request.service, Status.SERVING)
127
+ if status != last:
128
+ last = status
129
+ yield CheckResponse(status=status)
130
+ else:
131
+ await event.wait()
@@ -0,0 +1,84 @@
1
+ """Client for the gRPC health-checking API.
2
+
3
+ Modeled on Go's `Client` (`grpchealth-go/client.go`): wraps the generated
4
+ `HealthClient`, translating its wire-format request/response messages to and
5
+ from `CheckRequest`/`CheckResponse`, so a `Client` instance structurally
6
+ satisfies `Checker` (and, via `watch`, `Watcher`) and can be used anywhere
7
+ either is expected -- for example, to proxy another server's health status
8
+ through your own.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from grpchealth._gen.grpc.health.v1 import health_connect as _health_connect
16
+ from grpchealth._gen.grpc.health.v1 import health_pb as _health_pb
17
+ from grpchealth.checker import CheckRequest, CheckResponse, Status
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import AsyncIterator
21
+
22
+ from connectrpc.request import Headers
23
+
24
+
25
+ class Client:
26
+ """Calls the gRPC health-checking API against one server.
27
+
28
+ By default, transports use the Connect protocol with the binary Protobuf
29
+ codec; accepts the same keyword options as `connectrpc.client.ConnectClient`
30
+ to configure the gRPC protocol or otherwise.
31
+ """
32
+
33
+ def __init__(self, address: str, **client_options: Any) -> None:
34
+ self._client = _health_connect.HealthClient(address, **client_options)
35
+
36
+ async def check(
37
+ self,
38
+ request: CheckRequest,
39
+ *,
40
+ headers: Headers | None = None,
41
+ timeout_ms: int | None = None,
42
+ ) -> CheckResponse:
43
+ """Reports the health of `request.service`.
44
+
45
+ An empty `request.service` asks for the health of the whole server
46
+ process. If the service is unknown, raises a `ConnectError` with
47
+ `Code.NOT_FOUND`.
48
+ """
49
+ response = await self._client.check(
50
+ _health_pb.HealthCheckRequest(service=request.service),
51
+ headers=headers,
52
+ timeout_ms=timeout_ms,
53
+ )
54
+ return CheckResponse(status=Status(response.status))
55
+
56
+ async def watch(
57
+ self,
58
+ request: CheckRequest,
59
+ *,
60
+ headers: Headers | None = None,
61
+ timeout_ms: int | None = None,
62
+ ) -> AsyncIterator[CheckResponse]:
63
+ """Streams health status changes for `request.service`.
64
+
65
+ Yields the current status immediately, then again each time it may
66
+ have changed. The stream ends when the server closes it or an error
67
+ occurs -- including `Code.UNIMPLEMENTED` if the server doesn't
68
+ support `Watch`.
69
+ """
70
+ async for response in self._client.watch(
71
+ _health_pb.HealthCheckRequest(service=request.service),
72
+ headers=headers,
73
+ timeout_ms=timeout_ms,
74
+ ):
75
+ yield CheckResponse(status=Status(response.status))
76
+
77
+ async def close(self) -> None:
78
+ await self._client.close()
79
+
80
+ async def __aenter__(self) -> Client:
81
+ return self
82
+
83
+ async def __aexit__(self, *exc_info: object) -> None:
84
+ await self.close()
File without changes
@@ -0,0 +1,105 @@
1
+ """Adapts a `Checker`/`Watcher` to the generated `grpc.health.v1.Health` service.
2
+
3
+ Mirrors Go's `Register` (`grpchealth-go/grpchealth.go`): `mount_health_handler`
4
+ builds the `ConnectASGIApplication` for `grpc.health.v1.Health`, wired to
5
+ answer `Check`/`Watch` from a `Checker` you supply. If the `Checker` doesn't
6
+ also implement `Watcher`, `Watch` responds `Code.UNIMPLEMENTED`, same as Go.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from connectrpc.code import Code
14
+ from connectrpc.errors import ConnectError
15
+ from connectrpc.server import DEFAULT_READ_MAX_BYTES, ConnectASGIApplication
16
+
17
+ from grpchealth._gen.grpc.health.v1 import health_connect as _health_connect
18
+ from grpchealth._gen.grpc.health.v1 import health_pb as _health_pb
19
+ from grpchealth.checker import Checker, CheckRequest, Watcher
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import AsyncIterator, Iterable
23
+
24
+ from connectrpc.codec import Codec
25
+ from connectrpc.compression import Compression
26
+ from connectrpc.interceptor import Interceptor
27
+ from connectrpc.request import RequestContext
28
+ from protobuf import DescService
29
+
30
+
31
+ class HealthService:
32
+ """Implements `grpc.health.v1.Health` over a `Checker`."""
33
+
34
+ def __init__(self, checker: Checker) -> None:
35
+ self._checker = checker
36
+ self._watcher = checker if isinstance(checker, Watcher) else None
37
+
38
+ async def check(
39
+ self,
40
+ request: _health_pb.HealthCheckRequest,
41
+ ctx: RequestContext[
42
+ _health_pb.HealthCheckRequest, _health_pb.HealthCheckResponse
43
+ ],
44
+ /,
45
+ ) -> _health_pb.HealthCheckResponse:
46
+ del ctx
47
+ response = await self._checker.check(CheckRequest(service=request.service))
48
+ return _health_pb.HealthCheckResponse(
49
+ status=_health_pb.HealthCheckResponse.ServingStatus(response.status)
50
+ )
51
+
52
+ def watch(
53
+ self,
54
+ request: _health_pb.HealthCheckRequest,
55
+ ctx: RequestContext[
56
+ _health_pb.HealthCheckRequest, _health_pb.HealthCheckResponse
57
+ ],
58
+ /,
59
+ ) -> AsyncIterator[_health_pb.HealthCheckResponse]:
60
+ del ctx
61
+ watcher = self._watcher
62
+ if watcher is None:
63
+ raise ConnectError(
64
+ Code.UNIMPLEMENTED, "watching health state is not supported"
65
+ )
66
+ return self._watch(watcher, request.service)
67
+
68
+ async def _watch(
69
+ self, watcher: Watcher, service: str
70
+ ) -> AsyncIterator[_health_pb.HealthCheckResponse]:
71
+ async for response in watcher.watch(CheckRequest(service=service)):
72
+ yield _health_pb.HealthCheckResponse(
73
+ status=_health_pb.HealthCheckResponse.ServingStatus(response.status)
74
+ )
75
+
76
+ @classmethod
77
+ def desc(cls) -> DescService:
78
+ return _health_connect.Health.desc()
79
+
80
+
81
+ def mount_health_handler(
82
+ checker: Checker,
83
+ *,
84
+ interceptors: Iterable[Interceptor] = (),
85
+ read_max_bytes: int | None = DEFAULT_READ_MAX_BYTES,
86
+ compressions: Iterable[Compression] | None = None,
87
+ codecs: Iterable[Codec] | None = None,
88
+ ) -> ConnectASGIApplication[Any]:
89
+ """Builds the `grpc.health.v1.Health` ASGI application for `checker`.
90
+
91
+ If `checker` also implements `Watcher`, the returned application supports
92
+ the streaming `Watch` RPC; otherwise `Watch` responds `Code.UNIMPLEMENTED`.
93
+
94
+ ```python
95
+ health = mount_health_handler(StaticChecker("acme.user.v1.UserService"))
96
+ app = Starlette(routes=[Mount(health.path, app=health)])
97
+ ```
98
+ """
99
+ return _health_connect.HealthASGIApplication(
100
+ HealthService(checker),
101
+ interceptors=interceptors,
102
+ read_max_bytes=read_max_bytes,
103
+ compressions=compressions,
104
+ codecs=codecs,
105
+ )