openframe-protocol-grpc 0.2.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.
- openframe_protocol_grpc-0.2.0/.gitignore +220 -0
- openframe_protocol_grpc-0.2.0/PKG-INFO +96 -0
- openframe_protocol_grpc-0.2.0/README.md +57 -0
- openframe_protocol_grpc-0.2.0/openframe/protocol/grpc/__init__.py +67 -0
- openframe_protocol_grpc-0.2.0/openframe/protocol/grpc/client.py +121 -0
- openframe_protocol_grpc-0.2.0/openframe/protocol/grpc/generic.proto +18 -0
- openframe_protocol_grpc-0.2.0/openframe/protocol/grpc/generic_pb2.py +40 -0
- openframe_protocol_grpc-0.2.0/openframe/protocol/grpc/generic_pb2_grpc.py +97 -0
- openframe_protocol_grpc-0.2.0/openframe/protocol/grpc/server.py +275 -0
- openframe_protocol_grpc-0.2.0/pyproject.toml +68 -0
- openframe_protocol_grpc-0.2.0/tests/conftest.py +95 -0
- openframe_protocol_grpc-0.2.0/tests/test_client.py +145 -0
- openframe_protocol_grpc-0.2.0/tests/test_conformance.py +123 -0
- openframe_protocol_grpc-0.2.0/tests/test_create_server.py +77 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
*.lcov
|
|
51
|
+
.hypothesis/
|
|
52
|
+
.pytest_cache/
|
|
53
|
+
cover/
|
|
54
|
+
|
|
55
|
+
# Translations
|
|
56
|
+
*.mo
|
|
57
|
+
*.pot
|
|
58
|
+
|
|
59
|
+
# Django stuff:
|
|
60
|
+
*.log
|
|
61
|
+
local_settings.py
|
|
62
|
+
db.sqlite3
|
|
63
|
+
db.sqlite3-journal
|
|
64
|
+
|
|
65
|
+
# Flask stuff:
|
|
66
|
+
instance/
|
|
67
|
+
.webassets-cache
|
|
68
|
+
|
|
69
|
+
# Scrapy stuff:
|
|
70
|
+
.scrapy
|
|
71
|
+
|
|
72
|
+
# Sphinx documentation
|
|
73
|
+
docs/_build/
|
|
74
|
+
|
|
75
|
+
# PyBuilder
|
|
76
|
+
.pybuilder/
|
|
77
|
+
target/
|
|
78
|
+
|
|
79
|
+
# Jupyter Notebook
|
|
80
|
+
.ipynb_checkpoints
|
|
81
|
+
|
|
82
|
+
# IPython
|
|
83
|
+
profile_default/
|
|
84
|
+
ipython_config.py
|
|
85
|
+
|
|
86
|
+
# pyenv
|
|
87
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
88
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
89
|
+
# .python-version
|
|
90
|
+
|
|
91
|
+
# pipenv
|
|
92
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
93
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
94
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
95
|
+
# install all needed dependencies.
|
|
96
|
+
# Pipfile.lock
|
|
97
|
+
|
|
98
|
+
# uv
|
|
99
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
100
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
101
|
+
# commonly ignored for libraries.
|
|
102
|
+
# uv.lock
|
|
103
|
+
|
|
104
|
+
# poetry
|
|
105
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
106
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
107
|
+
# commonly ignored for libraries.
|
|
108
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
109
|
+
# poetry.lock
|
|
110
|
+
# poetry.toml
|
|
111
|
+
|
|
112
|
+
# pdm
|
|
113
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
114
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
115
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
116
|
+
# pdm.lock
|
|
117
|
+
# pdm.toml
|
|
118
|
+
.pdm-python
|
|
119
|
+
.pdm-build/
|
|
120
|
+
|
|
121
|
+
# pixi
|
|
122
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
123
|
+
# pixi.lock
|
|
124
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
125
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
126
|
+
.pixi/*
|
|
127
|
+
!.pixi/config.toml
|
|
128
|
+
|
|
129
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
130
|
+
__pypackages__/
|
|
131
|
+
|
|
132
|
+
# Celery stuff
|
|
133
|
+
celerybeat-schedule*
|
|
134
|
+
celerybeat.pid
|
|
135
|
+
|
|
136
|
+
# Redis
|
|
137
|
+
*.rdb
|
|
138
|
+
*.aof
|
|
139
|
+
*.pid
|
|
140
|
+
|
|
141
|
+
# RabbitMQ
|
|
142
|
+
mnesia/
|
|
143
|
+
rabbitmq/
|
|
144
|
+
rabbitmq-data/
|
|
145
|
+
|
|
146
|
+
# ActiveMQ
|
|
147
|
+
activemq-data/
|
|
148
|
+
|
|
149
|
+
# SageMath parsed files
|
|
150
|
+
*.sage.py
|
|
151
|
+
|
|
152
|
+
# Environments
|
|
153
|
+
.env
|
|
154
|
+
.envrc
|
|
155
|
+
.venv
|
|
156
|
+
env/
|
|
157
|
+
venv/
|
|
158
|
+
ENV/
|
|
159
|
+
env.bak/
|
|
160
|
+
venv.bak/
|
|
161
|
+
|
|
162
|
+
# Spyder project settings
|
|
163
|
+
.spyderproject
|
|
164
|
+
.spyproject
|
|
165
|
+
|
|
166
|
+
# Rope project settings
|
|
167
|
+
.ropeproject
|
|
168
|
+
|
|
169
|
+
# mkdocs/Zensical documentation
|
|
170
|
+
/site
|
|
171
|
+
|
|
172
|
+
# mypy
|
|
173
|
+
.mypy_cache/
|
|
174
|
+
.dmypy.json
|
|
175
|
+
dmypy.json
|
|
176
|
+
|
|
177
|
+
# Pyre type checker
|
|
178
|
+
.pyre/
|
|
179
|
+
|
|
180
|
+
# pytype static type analyzer
|
|
181
|
+
.pytype/
|
|
182
|
+
|
|
183
|
+
# Cython debug symbols
|
|
184
|
+
cython_debug/
|
|
185
|
+
|
|
186
|
+
# PyCharm
|
|
187
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
188
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
189
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
190
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
191
|
+
# .idea/
|
|
192
|
+
|
|
193
|
+
# Abstra
|
|
194
|
+
# Abstra is an AI-powered process automation framework.
|
|
195
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
196
|
+
# Learn more at https://abstra.io/docs
|
|
197
|
+
.abstra/
|
|
198
|
+
|
|
199
|
+
# Visual Studio Code
|
|
200
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore that
|
|
201
|
+
# can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
202
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer, you
|
|
203
|
+
# could uncomment the following to ignore the entire vscode folder
|
|
204
|
+
# .vscode/
|
|
205
|
+
# Temporary file for partial code execution
|
|
206
|
+
tempCodeRunnerFile.py
|
|
207
|
+
|
|
208
|
+
# Ruff stuff:
|
|
209
|
+
.ruff_cache/
|
|
210
|
+
|
|
211
|
+
# PyPI configuration file
|
|
212
|
+
.pypirc
|
|
213
|
+
|
|
214
|
+
# Marimo
|
|
215
|
+
marimo/_static/
|
|
216
|
+
marimo/_lsp/
|
|
217
|
+
__marimo__/
|
|
218
|
+
|
|
219
|
+
# Streamlit
|
|
220
|
+
.streamlit/secrets.toml
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: openframe-protocol-grpc
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: OpenFrame Microservice Suite - gRPC server and client binding for openframe.core.inbound/remote.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Furious-Meteors/openframe-protocol
|
|
6
|
+
Project-URL: Documentation, https://furious-meteors.github.io/openframe-protocol/
|
|
7
|
+
Project-URL: Repository, https://github.com/Furious-Meteors/openframe-protocol
|
|
8
|
+
Project-URL: Changelog, https://github.com/Furious-Meteors/openframe-protocol/blob/production/.github/CHANGELOG.md
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/Furious-Meteors/openframe-protocol/issues
|
|
10
|
+
Author-email: Furious Meteors Engineering <engineering@furiousmeteors.dev>
|
|
11
|
+
Maintainer-email: Furious Meteors Engineering <engineering@furiousmeteors.dev>
|
|
12
|
+
License: MIT
|
|
13
|
+
Keywords: grpc,hexagonal,microservice,openframe,protocol
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: openframe-core<4,>=3.2.1
|
|
26
|
+
Requires-Dist: openframe-protocol-base<1,>=0.1
|
|
27
|
+
Provides-Extra: client
|
|
28
|
+
Requires-Dist: grpcio>=1.60; extra == 'client'
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: grpcio-tools>=1.60; extra == 'dev'
|
|
31
|
+
Requires-Dist: grpcio>=1.60; extra == 'dev'
|
|
32
|
+
Requires-Dist: pydantic>=2.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest-mock>=3.14; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
36
|
+
Provides-Extra: server
|
|
37
|
+
Requires-Dist: grpcio>=1.60; extra == 'server'
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
# openframe-protocol-grpc
|
|
41
|
+
|
|
42
|
+
gRPC server and client binding, the second transport (after HTTP) to prove
|
|
43
|
+
`openframe-protocol-base`'s conformance suites for real. Wires a
|
|
44
|
+
`QueryHandler`/`CommandHandler`/`UseCase` (`openframe.core.inbound`) to a
|
|
45
|
+
`grpc.aio.Server`, and provides `GrpcRemoteQueryClient`/
|
|
46
|
+
`GrpcRemoteCommandClient` for calling another service built on this
|
|
47
|
+
platform — through the same shared error mapper and `RequestContext`
|
|
48
|
+
builder `openframe-protocol-http` uses, not a parallel implementation.
|
|
49
|
+
|
|
50
|
+
One generic RPC (`GenericService.Call`, `{json_payload: string}` in both
|
|
51
|
+
directions) instead of one per route — the domain type travels as JSON
|
|
52
|
+
inside the payload, the gRPC equivalent of `-http`'s dynamic
|
|
53
|
+
`inspect.Signature` trick for avoiding a per-route message type.
|
|
54
|
+
|
|
55
|
+
Install the `[server]` extra for a server, `[client]` extra to call one —
|
|
56
|
+
both pull in `grpcio` (unlike HTTP, there's no separate client-only wire
|
|
57
|
+
library for gRPC, but the extras split is kept for symmetry with every
|
|
58
|
+
other `openframe-protocol-*` package).
|
|
59
|
+
|
|
60
|
+
## Server-side wiring example
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from openframe.protocol.grpc import bind_service
|
|
64
|
+
|
|
65
|
+
server = grpc.aio.server()
|
|
66
|
+
|
|
67
|
+
bind_service(
|
|
68
|
+
server, "get_item",
|
|
69
|
+
handler_factory=get_get_item_handler,
|
|
70
|
+
not_found_when=lambda item: item is None,
|
|
71
|
+
)
|
|
72
|
+
bind_service(
|
|
73
|
+
server, "create_item",
|
|
74
|
+
handler_factory=get_create_item_handler,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
server.add_insecure_port("[::]:50051")
|
|
78
|
+
await server.start()
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Client-side wiring example
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from openframe.protocol.grpc import GrpcRemoteQueryClient
|
|
85
|
+
|
|
86
|
+
get_item = GrpcRemoteQueryClient(target="items-service:50051", route="get_item")
|
|
87
|
+
item = await get_item.execute("abc-123")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Status
|
|
91
|
+
|
|
92
|
+
Experimental, M4 of the project's own build plan. Both
|
|
93
|
+
`TransportConformanceTests` and `RemoteClientConformanceTests`
|
|
94
|
+
(`openframe-protocol-base`) pass against this package for real — the
|
|
95
|
+
point of building it: proving the architecture generalizes past HTTP,
|
|
96
|
+
not just adding a second transport for its own sake.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# openframe-protocol-grpc
|
|
2
|
+
|
|
3
|
+
gRPC server and client binding, the second transport (after HTTP) to prove
|
|
4
|
+
`openframe-protocol-base`'s conformance suites for real. Wires a
|
|
5
|
+
`QueryHandler`/`CommandHandler`/`UseCase` (`openframe.core.inbound`) to a
|
|
6
|
+
`grpc.aio.Server`, and provides `GrpcRemoteQueryClient`/
|
|
7
|
+
`GrpcRemoteCommandClient` for calling another service built on this
|
|
8
|
+
platform — through the same shared error mapper and `RequestContext`
|
|
9
|
+
builder `openframe-protocol-http` uses, not a parallel implementation.
|
|
10
|
+
|
|
11
|
+
One generic RPC (`GenericService.Call`, `{json_payload: string}` in both
|
|
12
|
+
directions) instead of one per route — the domain type travels as JSON
|
|
13
|
+
inside the payload, the gRPC equivalent of `-http`'s dynamic
|
|
14
|
+
`inspect.Signature` trick for avoiding a per-route message type.
|
|
15
|
+
|
|
16
|
+
Install the `[server]` extra for a server, `[client]` extra to call one —
|
|
17
|
+
both pull in `grpcio` (unlike HTTP, there's no separate client-only wire
|
|
18
|
+
library for gRPC, but the extras split is kept for symmetry with every
|
|
19
|
+
other `openframe-protocol-*` package).
|
|
20
|
+
|
|
21
|
+
## Server-side wiring example
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from openframe.protocol.grpc import bind_service
|
|
25
|
+
|
|
26
|
+
server = grpc.aio.server()
|
|
27
|
+
|
|
28
|
+
bind_service(
|
|
29
|
+
server, "get_item",
|
|
30
|
+
handler_factory=get_get_item_handler,
|
|
31
|
+
not_found_when=lambda item: item is None,
|
|
32
|
+
)
|
|
33
|
+
bind_service(
|
|
34
|
+
server, "create_item",
|
|
35
|
+
handler_factory=get_create_item_handler,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
server.add_insecure_port("[::]:50051")
|
|
39
|
+
await server.start()
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Client-side wiring example
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from openframe.protocol.grpc import GrpcRemoteQueryClient
|
|
46
|
+
|
|
47
|
+
get_item = GrpcRemoteQueryClient(target="items-service:50051", route="get_item")
|
|
48
|
+
item = await get_item.execute("abc-123")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Status
|
|
52
|
+
|
|
53
|
+
Experimental, M4 of the project's own build plan. Both
|
|
54
|
+
`TransportConformanceTests` and `RemoteClientConformanceTests`
|
|
55
|
+
(`openframe-protocol-base`) pass against this package for real — the
|
|
56
|
+
point of building it: proving the architecture generalizes past HTTP,
|
|
57
|
+
not just adding a second transport for its own sake.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe.protocol.grpc
|
|
3
|
+
==========================
|
|
4
|
+
gRPC binding, both directions, same two-file schema `-http` established:
|
|
5
|
+
|
|
6
|
+
- `bind_service` (`server.py`, needs the `[server]` extra) — wire a
|
|
7
|
+
`QueryHandler`/`CommandHandler`/`UseCase` to a `grpc.aio.Server` through
|
|
8
|
+
the shared error mapper and `RequestContext` builder in
|
|
9
|
+
`openframe.protocol.base`.
|
|
10
|
+
- `GrpcRemoteQueryClient`/`GrpcRemoteCommandClient` (`client.py`, needs
|
|
11
|
+
the `[client]` extra) — call another service built on this platform,
|
|
12
|
+
the outbound counterpart.
|
|
13
|
+
|
|
14
|
+
Unlike `-http`, both extras pull in the same underlying dependency
|
|
15
|
+
(`grpcio`) — there is no separate client-only wire library the way httpx
|
|
16
|
+
is separate from FastAPI. The `[server]`/`[client]` split is kept anyway,
|
|
17
|
+
for the same reason `-http` has it: so a package that only ever calls out
|
|
18
|
+
(never serves) doesn't need to reason about why a server dependency is
|
|
19
|
+
present, even if today that dependency happens to be identical.
|
|
20
|
+
|
|
21
|
+
Lazily imported (PEP 562, `__getattr__` below), matching `-http`'s own
|
|
22
|
+
`__init__.py` exactly — not because the dependency differs here, but
|
|
23
|
+
because the *schema* is the point: every future `openframe-protocol-*`
|
|
24
|
+
package follows this same lazy-import shape regardless of whether a given
|
|
25
|
+
transport's own extras happen to overlap.
|
|
26
|
+
|
|
27
|
+
`generic_pb2`/`generic_pb2_grpc` (the compiled `.proto` stubs) are
|
|
28
|
+
re-exported too, but not lazily — they need `grpcio` either way, the same
|
|
29
|
+
as everything else in this package, so there is no extras boundary for
|
|
30
|
+
them to respect.
|
|
31
|
+
|
|
32
|
+
.. stability: experimental
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from typing import TYPE_CHECKING
|
|
37
|
+
|
|
38
|
+
from openframe.protocol.grpc import generic_pb2, generic_pb2_grpc
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from openframe.protocol.grpc.client import GrpcRemoteCommandClient, GrpcRemoteQueryClient
|
|
42
|
+
from openframe.protocol.grpc.server import ROUTE_METADATA_KEY, bind_service, create_server
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"bind_service",
|
|
46
|
+
"ROUTE_METADATA_KEY",
|
|
47
|
+
"create_server",
|
|
48
|
+
"GrpcRemoteQueryClient",
|
|
49
|
+
"GrpcRemoteCommandClient",
|
|
50
|
+
"generic_pb2",
|
|
51
|
+
"generic_pb2_grpc",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
_FROM_SERVER = {"bind_service", "ROUTE_METADATA_KEY", "create_server"}
|
|
55
|
+
_FROM_CLIENT = {"GrpcRemoteQueryClient", "GrpcRemoteCommandClient"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def __getattr__(name: str):
|
|
59
|
+
if name in _FROM_SERVER:
|
|
60
|
+
from openframe.protocol.grpc import server
|
|
61
|
+
|
|
62
|
+
return getattr(server, name)
|
|
63
|
+
if name in _FROM_CLIENT:
|
|
64
|
+
from openframe.protocol.grpc import client
|
|
65
|
+
|
|
66
|
+
return getattr(client, name)
|
|
67
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe/protocol/grpc/client.py
|
|
3
|
+
=====================================
|
|
4
|
+
`GrpcRemoteQueryClient`/`GrpcRemoteCommandClient` — gRPC implementations
|
|
5
|
+
of `openframe.core.remote`'s `RemoteQueryClient`/`RemoteCommandClient`,
|
|
6
|
+
the gRPC counterpart of `-http`'s `HttpRemoteQueryClient`/
|
|
7
|
+
`HttpRemoteCommandClient`.
|
|
8
|
+
|
|
9
|
+
No `RequestSpec`-equivalent needed — simpler than the HTTP client's
|
|
10
|
+
`build_request` hook, not a weaker version of it. gRPC has no path/query/
|
|
11
|
+
body split: a request is just one message (`GenericRequest`), so the
|
|
12
|
+
handler's input value is JSON-encoded into `json_payload` directly, no
|
|
13
|
+
hook required to decide where each piece of it goes.
|
|
14
|
+
|
|
15
|
+
Lifecycle, per draft 8's D10 (same reasoning as `-http`'s client.py):
|
|
16
|
+
no `BasePort`/`PluginRegistry` involvement. Each client owns (or borrows)
|
|
17
|
+
a plain `grpc.aio.Channel` and exposes `aclose()` for a caller's own
|
|
18
|
+
lifespan to manage directly.
|
|
19
|
+
|
|
20
|
+
Dependency order:
|
|
21
|
+
protocol/grpc/client → protocol/base (errors, for the reverse mapping), protocol/grpc (generic_pb2*)
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
from typing import Any, Generic, TypeVar
|
|
27
|
+
|
|
28
|
+
import grpc
|
|
29
|
+
|
|
30
|
+
from openframe.core.inbound import RequestContext
|
|
31
|
+
from openframe.protocol.base.errors import ErrorCategory, ErrorEnvelope, raise_for_envelope
|
|
32
|
+
from openframe.protocol.grpc import generic_pb2, generic_pb2_grpc
|
|
33
|
+
from openframe.protocol.grpc.server import ROUTE_METADATA_KEY
|
|
34
|
+
|
|
35
|
+
__all__ = ["GrpcRemoteQueryClient", "GrpcRemoteCommandClient"]
|
|
36
|
+
|
|
37
|
+
TInput = TypeVar("TInput")
|
|
38
|
+
TOutput = TypeVar("TOutput")
|
|
39
|
+
|
|
40
|
+
_STATUS_CODE_TO_CATEGORY: dict[grpc.StatusCode, ErrorCategory] = {
|
|
41
|
+
grpc.StatusCode.NOT_FOUND: ErrorCategory.NOT_FOUND,
|
|
42
|
+
grpc.StatusCode.INVALID_ARGUMENT: ErrorCategory.INVALID,
|
|
43
|
+
grpc.StatusCode.DEADLINE_EXCEEDED: ErrorCategory.TIMEOUT,
|
|
44
|
+
grpc.StatusCode.UNAVAILABLE: ErrorCategory.UNAVAILABLE,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _GrpcRemoteCallMixin:
|
|
49
|
+
"""Shared machinery both client classes below use — not public itself."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
target: str | None = None,
|
|
55
|
+
route: str,
|
|
56
|
+
channel: "grpc.aio.Channel | None" = None,
|
|
57
|
+
remote_name: str | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
if channel is None and target is None:
|
|
60
|
+
raise ValueError("Either target or an existing channel must be provided.")
|
|
61
|
+
self._channel = channel if channel is not None else grpc.aio.insecure_channel(target)
|
|
62
|
+
self._owns_channel = channel is None
|
|
63
|
+
self._stub = generic_pb2_grpc.GenericServiceStub(self._channel)
|
|
64
|
+
self._route = route
|
|
65
|
+
self._remote_name = remote_name or f"grpc:{route}"
|
|
66
|
+
|
|
67
|
+
async def aclose(self) -> None:
|
|
68
|
+
"""Close the underlying channel — only if this instance created it."""
|
|
69
|
+
if self._owns_channel:
|
|
70
|
+
await self._channel.close()
|
|
71
|
+
|
|
72
|
+
async def _call(self, value: Any, context: RequestContext | None) -> Any:
|
|
73
|
+
metadata: list[tuple[str, str]] = [(ROUTE_METADATA_KEY, self._route)]
|
|
74
|
+
if context is not None:
|
|
75
|
+
metadata.append(("x-correlation-id", context.correlation_id))
|
|
76
|
+
|
|
77
|
+
request = generic_pb2.GenericRequest(json_payload=json.dumps(value))
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
response = await self._stub.Call(request, metadata=tuple(metadata))
|
|
81
|
+
except grpc.aio.AioRpcError as exc:
|
|
82
|
+
category = _STATUS_CODE_TO_CATEGORY.get(exc.code(), ErrorCategory.INTERNAL)
|
|
83
|
+
details = exc.details() or ""
|
|
84
|
+
try:
|
|
85
|
+
body = json.loads(details)
|
|
86
|
+
error = body.get("error", body)
|
|
87
|
+
except (json.JSONDecodeError, AttributeError):
|
|
88
|
+
error = {}
|
|
89
|
+
envelope = ErrorEnvelope(
|
|
90
|
+
code=error.get("code", "unknown"),
|
|
91
|
+
message=error.get("message", details or f"{self._remote_name} failed with {exc.code()}"),
|
|
92
|
+
retryable=error.get("retryable", False),
|
|
93
|
+
correlation_id=error.get("correlation_id", ""),
|
|
94
|
+
)
|
|
95
|
+
raise_for_envelope(category, envelope, remote_name=self._remote_name)
|
|
96
|
+
|
|
97
|
+
if not response.json_payload:
|
|
98
|
+
return None
|
|
99
|
+
return json.loads(response.json_payload)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class GrpcRemoteQueryClient(_GrpcRemoteCallMixin, Generic[TInput, TOutput]):
|
|
103
|
+
"""
|
|
104
|
+
`RemoteQueryClient` over gRPC. Satisfies
|
|
105
|
+
`openframe.core.remote.RemoteQueryClient` structurally.
|
|
106
|
+
|
|
107
|
+
Args: see `_GrpcRemoteCallMixin.__init__`.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
async def execute(self, query: TInput, context: RequestContext | None = None) -> TOutput:
|
|
111
|
+
return await self._call(query, context)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class GrpcRemoteCommandClient(_GrpcRemoteCallMixin, Generic[TInput]):
|
|
115
|
+
"""
|
|
116
|
+
`RemoteCommandClient` over gRPC. Satisfies
|
|
117
|
+
`openframe.core.remote.RemoteCommandClient` structurally.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
async def execute(self, command: TInput, context: RequestContext | None = None) -> None:
|
|
121
|
+
await self._call(command, context)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
syntax = "proto3";
|
|
2
|
+
package openframe.protocol.grpc;
|
|
3
|
+
|
|
4
|
+
// One generic message pair instead of one per route — mirrors server.py's
|
|
5
|
+
// own inspect.Signature trick for HTTP: the wire schema doesn't need to
|
|
6
|
+
// know the domain type, only that a JSON-encoded payload travels inside it.
|
|
7
|
+
// The actual domain type is JSON-encoded inside json_payload.
|
|
8
|
+
message GenericRequest {
|
|
9
|
+
string json_payload = 1;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
message GenericResponse {
|
|
13
|
+
string json_payload = 1;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
service GenericService {
|
|
17
|
+
rpc Call(GenericRequest) returns (GenericResponse);
|
|
18
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
3
|
+
# NO CHECKED-IN PROTOBUF GENCODE
|
|
4
|
+
# source: generic.proto
|
|
5
|
+
# Protobuf Python Version: 7.35.1
|
|
6
|
+
"""Generated protocol buffer code."""
|
|
7
|
+
from google.protobuf import descriptor as _descriptor
|
|
8
|
+
from google.protobuf import descriptor_pool as _descriptor_pool
|
|
9
|
+
from google.protobuf import runtime_version as _runtime_version
|
|
10
|
+
from google.protobuf import symbol_database as _symbol_database
|
|
11
|
+
from google.protobuf.internal import builder as _builder
|
|
12
|
+
_runtime_version.ValidateProtobufRuntimeVersion(
|
|
13
|
+
_runtime_version.Domain.PUBLIC,
|
|
14
|
+
7,
|
|
15
|
+
35,
|
|
16
|
+
1,
|
|
17
|
+
'',
|
|
18
|
+
'generic.proto'
|
|
19
|
+
)
|
|
20
|
+
# @@protoc_insertion_point(imports)
|
|
21
|
+
|
|
22
|
+
_sym_db = _symbol_database.Default()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\rgeneric.proto\x12\x17openframe.protocol.grpc\"&\n\x0eGenericRequest\x12\x14\n\x0cjson_payload\x18\x01 \x01(\t\"\'\n\x0fGenericResponse\x12\x14\n\x0cjson_payload\x18\x01 \x01(\t2k\n\x0eGenericService\x12Y\n\x04\x43\x61ll\x12\'.openframe.protocol.grpc.GenericRequest\x1a(.openframe.protocol.grpc.GenericResponseb\x06proto3')
|
|
28
|
+
|
|
29
|
+
_globals = globals()
|
|
30
|
+
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
|
31
|
+
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'generic_pb2', _globals)
|
|
32
|
+
if not _descriptor._USE_C_DESCRIPTORS:
|
|
33
|
+
DESCRIPTOR._loaded_options = None
|
|
34
|
+
_globals['_GENERICREQUEST']._serialized_start=42
|
|
35
|
+
_globals['_GENERICREQUEST']._serialized_end=80
|
|
36
|
+
_globals['_GENERICRESPONSE']._serialized_start=82
|
|
37
|
+
_globals['_GENERICRESPONSE']._serialized_end=121
|
|
38
|
+
_globals['_GENERICSERVICE']._serialized_start=123
|
|
39
|
+
_globals['_GENERICSERVICE']._serialized_end=230
|
|
40
|
+
# @@protoc_insertion_point(module_scope)
|