Skip to content

Commit 36817e2

Browse files
author
Stan Misiurev
committed
- Shutdown signals
- Reflection API - github actions
1 parent 8da2e93 commit 36817e2

File tree

12 files changed

+138
-18
lines changed

12 files changed

+138
-18
lines changed

.github/workflows/python-publish.yml

+1-6
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,7 @@ jobs:
5050
# For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules
5151
environment:
5252
name: pypi
53-
# OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status:
54-
# url: https://pypi.org/p/YOURPROJECT
55-
#
56-
# ALTERNATIVE: if your GitHub Release name is the PyPI project version string
57-
# ALTERNATIVE: exactly, uncomment the following line instead:
58-
# url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }}
53+
url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }}
5954

6055
steps:
6156
- name: Retrieve release distributions

.github/workflows/test.yml

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: Test with tox
2+
3+
on: [push]
4+
5+
jobs:
6+
build:
7+
8+
runs-on: ubuntu-latest
9+
strategy:
10+
matrix:
11+
python: ["3.9", "3.11", "3.13"]
12+
13+
steps:
14+
- uses: actions/checkout@v4
15+
- name: Setup Python
16+
uses: actions/setup-python@v5
17+
with:
18+
python-version: ${{ matrix.python }}
19+
- name: Install tox and any other packages
20+
run: pip install tox
21+
- name: Run tox
22+
# Run tox using the version of Python in `PATH`
23+
run: tox -e py

README.md

+1-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
# django-grpc
22

3-
[![CircleCI](https://circleci.com/gh/gluk-w/django-grpc.svg?style=svg)](https://circleci.com/gh/gluk-w/django-grpc)
4-
5-
63
Easy way to launch gRPC server with access to Django ORM and other handy stuff.
74
gRPC calls are much faster that traditional HTTP requests because communicate over
85
persistent connection and are compressed. Underlying gRPC library is written in C which
@@ -37,6 +34,7 @@ GRPCSERVER = {
3734
'certificate_chain': 'certificate_chain.pem'
3835
}], # required only if SSL/TLS support is required to be enabled
3936
'async': False # Default: False, if True then gRPC server will start in ASYNC mode
37+
'reflection': False, # Default: False, enables reflection on a gRPC Server (https://grpc.io/docs/guides/reflection/)
4038
}
4139
```
4240

django_grpc/management/commands/grpcserver.py

+26-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import datetime
22
import asyncio
33

4+
from django.core.exceptions import ImproperlyConfigured
45
from django.core.management.base import BaseCommand
56
from django.utils import autoreload
67
from django.conf import settings
8+
9+
from django_grpc.signals import grpc_shutdown
710
from django_grpc.utils import create_server, extract_handlers
811

912

@@ -39,10 +42,14 @@ def handle(self, *args, **options):
3942
self._serve(**options)
4043

4144
def _serve(self, max_workers, port, *args, **kwargs):
45+
"""
46+
Run gRPC server
47+
"""
4248
autoreload.raise_last_exception()
4349
self.stdout.write("gRPC server starting at %s" % datetime.datetime.now())
4450

4551
server = create_server(max_workers, port)
52+
4653
server.start()
4754

4855
self.stdout.write("gRPC server is listening port %s" % port)
@@ -53,10 +60,18 @@ def _serve(self, max_workers, port, *args, **kwargs):
5360
self.stdout.write("* %s" % handler)
5461

5562
server.wait_for_termination()
63+
# Send shutdown signal to all connected receivers
64+
grpc_shutdown.send(None)
5665

5766
def _serve_async(self, max_workers, port, *args, **kwargs):
67+
"""
68+
Run gRPC server in async mode
69+
"""
5870
self.stdout.write("gRPC async server starting at %s" % datetime.datetime.now())
5971

72+
# Coroutines to be invoked when the event loop is shutting down.
73+
_cleanup_coroutines = []
74+
6075
server = create_server(max_workers, port)
6176

6277
async def _main_routine():
@@ -70,4 +85,14 @@ async def _main_routine():
7085

7186
await server.wait_for_termination()
7287

73-
asyncio.get_event_loop().run_until_complete(_main_routine())
88+
async def _graceful_shutdown():
89+
# Send the signal to all connected receivers on server shutdown.
90+
# https://github.com/gluk-w/django-grpc/issues/31
91+
grpc_shutdown.send(None)
92+
93+
loop = asyncio.get_event_loop()
94+
try:
95+
loop.run_until_complete(_main_routine())
96+
finally:
97+
loop.run_until_complete(*_cleanup_coroutines)
98+
loop.close()

django_grpc/signals/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,6 @@
1818
grpc_request_started.connect(reset_queries)
1919
grpc_request_started.connect(close_old_connections)
2020
grpc_request_finished.connect(close_old_connections)
21+
22+
# Triggered when the server receives graceful shut down signal
23+
grpc_shutdown = Signal()

django_grpc/utils.py

+27-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from concurrent import futures
44

55
import grpc
6+
from django.core.exceptions import ImproperlyConfigured
67

78
from django.utils.module_loading import import_string
89
from django_grpc.signals.wrapper import SignalWrapper
@@ -20,6 +21,7 @@ def create_server(max_workers, port, interceptors=None):
2021
options = config.get('options', [])
2122
credentials = config.get('credentials', None)
2223
is_async = config.get('async', False)
24+
need_reflection = config.get('reflection', False)
2325

2426
# create a gRPC server
2527
if is_async is True:
@@ -38,6 +40,9 @@ def create_server(max_workers, port, interceptors=None):
3840

3941
add_servicers(server, servicers_list)
4042

43+
if need_reflection:
44+
enable_reflection(server)
45+
4146
if credentials is None:
4247
server.add_insecure_port('[::]:%s' % port)
4348
else:
@@ -61,7 +66,7 @@ def create_server(max_workers, port, interceptors=None):
6166
return server
6267

6368

64-
def add_servicers(server, servicers_list):
69+
def add_servicers(server, servicers_list: list[str]):
6570
"""
6671
Add servicers to the server
6772
"""
@@ -107,3 +112,24 @@ def extract_handlers(server):
107112
params=params,
108113
abstract=abstract
109114
)
115+
116+
def enable_reflection(server):
117+
"""
118+
Enables gRPC reflection for the server so consumers can discover available services and methods.
119+
https://grpc.io/docs/guides/reflection/
120+
"""
121+
try:
122+
from grpc_reflection.v1alpha import reflection
123+
except ImportError:
124+
raise ImproperlyConfigured(
125+
"Failed to enable gRPC reflection. " +
126+
"Install `grpcio-reflection` package or disable \"reflection\" in settings."
127+
)
128+
129+
service_names = [
130+
handler.service_name()
131+
for handler in server._state.generic_handlers
132+
]
133+
134+
service_names.append(reflection.SERVICE_NAME)
135+
reflection.enable_server_reflection(service_names, server)

poetry.lock

+35-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

+2
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ protobuf = "^5.29.3"
1212
grpcio = "^1.70.0"
1313
grpcio-tools = "^1.70.0"
1414
django = ">=4.2,<5.2"
15+
grpcio-reflection = "^1.70.0"
1516

1617

1718
[tool.poetry.group.dev.dependencies]
1819
bumpversion = "^0.6.0"
1920
wheel = "^0.45.1"
21+
pytest-django = "^4.10.0"
2022

2123
[tool.poetry.group.qa.dependencies]
2224
mirakuru = "^2.5.3"

tests/settings.py

+1
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,5 @@
4040

4141
GRPCSERVER = {
4242
'servicers': ('tests.sampleapp.utils.register_servicer',),
43+
'reflection': False,
4344
}

tests/test_reflection.py

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import pytest
2+
3+
from django_grpc.utils import create_server
4+
5+
6+
def test_reflection(settings):
7+
settings.GRPCSERVER['reflection'] = True
8+
server = create_server(1, 50080)
9+
server.start()
10+
11+
assert len(server._state.generic_handlers) == 2, "Reflection handler must be appended"
12+
assert '/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo' in server._state.generic_handlers[1]._method_handlers
13+
14+
server.stop(True)

tests/test_signals.py

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22
from grpc import RpcError
33

4+
from django_grpc.utils import create_server
45
from tests.helpers import call_hello_method
56

67

tests/test_utils.py

+4-5
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33

44
def test_extract_handlers():
55
server = create_server(1, 50080)
6-
assert list(extract_handlers(server)) == [
7-
'/helloworld.Greeter/SayHello: inner(args, kwargs, response, exc) NOT IMPLEMENTED',
8-
'/helloworld.Greeter/SayHelloStreamReply: ???(???) DOES NOT EXIST',
9-
'/helloworld.Greeter/SayHelloBidiStream: ???(???) DOES NOT EXIST',
10-
]
6+
handers = set(extract_handlers(server))
7+
assert '/helloworld.Greeter/SayHello: inner(args, kwargs, response, exc) NOT IMPLEMENTED' in handers
8+
assert '/helloworld.Greeter/SayHelloStreamReply: ???(???) DOES NOT EXIST' in handers
9+
assert '/helloworld.Greeter/SayHelloBidiStream: ???(???) DOES NOT EXIST' in handers

0 commit comments

Comments
 (0)