-
Notifications
You must be signed in to change notification settings - Fork 25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
WIP: Feature/automated full interface generation #181
Changes from all commits
4237aa6
76c5bae
73e10b0
c7b0dc5
39d7fb2
280c14c
7d12b06
5f75635
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,182 @@ | ||
# -*- coding: utf-8 -*- | ||
""" | ||
The Django socio gRPC interface Generator is a which can automatically generate | ||
(scaffold) a Django grpc interface for you. By doing this it will introspect your | ||
models and automatically generate an table with properties like: | ||
|
||
- `fields` for all local fields | ||
|
||
""" | ||
|
||
import re | ||
import os | ||
import logging | ||
|
||
from django.apps import apps | ||
from django.conf import settings | ||
from django.core.management.base import LabelCommand, CommandError | ||
from django.db import models | ||
|
||
|
||
# Configurable constants | ||
MAX_LINE_WIDTH = getattr(settings, 'MAX_LINE_WIDTH', 120) | ||
INDENT_WIDTH = getattr(settings, 'INDENT_WIDTH', 4) | ||
|
||
|
||
class Command(LabelCommand): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If it's by app, couldn't it be AppCommand ? It would automatically handle some of your logic below There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Looks like a good suggestion @legau . I will have a look into that... |
||
help = '''Generate all required gRPC interface files, like serializers, services and `handlers.py` for the given app (models)''' | ||
# args = "[app_name]" | ||
can_import_settings = True | ||
|
||
def add_arguments(self, parser): | ||
parser.add_argument('app_name', help='Name of the app to generate the gRPC interface for') | ||
#parser.add_argument('model_name', nargs='*') | ||
|
||
#@signalcommand | ||
def handle(self, *args, **options): | ||
self.app_name = options['app_name'] | ||
|
||
logging.warning("!! only a scaffold is generated, please check/add content to the generated files !!!") | ||
|
||
try: | ||
app = apps.get_app_config(self.app_name) | ||
except LookupError: | ||
self.stderr.write('This command requires an existing app name as argument') | ||
self.stderr.write('Available apps:') | ||
app_labels = [app.label for app in apps.get_app_configs()] | ||
for label in sorted(app_labels): | ||
self.stderr.write(' %s' % label) | ||
return | ||
|
||
model_res = [] | ||
# for arg in options['model_name']: | ||
# model_res.append(re.compile(arg, re.IGNORECASE)) | ||
|
||
GRPCInterfaceApp(app, model_res, **options) | ||
|
||
#self.stdout.write() | ||
|
||
|
||
class GRPCInterfaceApp(): | ||
def __init__(self, app_config, model_res, **options): | ||
self.app_config = app_config | ||
self.model_res = model_res | ||
self.options = options | ||
self.app_name = options['app_name'] | ||
|
||
self.serializers_str = "" | ||
self.services_str = "" | ||
self.handler_str = "" | ||
self.model_names = [model.__name__ for model in self.app_config.get_models()] | ||
|
||
self.generate_serializers() | ||
self.generate_services() | ||
self.generate_handlers() | ||
|
||
|
||
def generate_serializer_imports(self): | ||
self.serializers_str += f"""## generated with django-socio-grpc generateprpcinterface {self.app_name} (LARA-version) | ||
|
||
import logging | ||
|
||
from django_socio_grpc import proto_serializers | ||
#import {self.app_name}.grpc.{self.app_name}_pb2 as {self.app_name}_pb2 | ||
|
||
from {self.app_name}.models import {', '.join(self.model_names)}\n\n""" | ||
|
||
def generate_serializers(self): | ||
self.generate_serializer_imports() | ||
|
||
# generate serializer classes | ||
|
||
for model in self.app_config.get_models(): | ||
fields = [field.name for field in model._meta.fields if "_id" not in field.name] | ||
# fields_param_str = ", ".join([f"{field}=None" for field in fields]) | ||
# fields_str = ",".join([f"\n{4 * INDENT_WIDTH * ' '}'{field}'" for field in fields]) | ||
fields_str = ", ".join([f"{field}'" for field in fields]) | ||
|
||
self.serializers_str += f"""class {model.__name__.capitalize()}ProtoSerializer(proto_serializers.ModelProtoSerializer): | ||
class Meta: | ||
model = {model.__name__} | ||
# proto_class = {self.app_name}_pb2.{model.__name__.capitalize()}Response \n | ||
# proto_class_list = {self.app_name}_pb2.{model.__name__.capitalize()}ListResponse \n | ||
|
||
fields = '__all__' # [{fields_str}] \n\n""" | ||
|
||
# check, if serializer.py exists | ||
# then ask, if we should append to file | ||
|
||
if os.path.isfile("serializers.py"): | ||
append = input("serializers.py already exists, append to file? (y/n) ") | ||
if append.lower() == "y": | ||
with open("serializers.py", "a") as f: | ||
f.write(self.serializers_str) | ||
else: | ||
# write sef.serializers_str to file | ||
with open("serializers.py", "w") as f: | ||
f.write(self.serializers_str) | ||
|
||
def generate_services_imports(self): | ||
self.services_str += f"""## generated with django-socio-grpc generateprpcinterface {self.app_name} (LARA-version) | ||
|
||
from django_socio_grpc import generics | ||
from .serializers import {', '.join([model.capitalize() + "ProtoSerializer" for model in self.model_names])}\n\n | ||
from {self.app_name}.models import {', '.join(self.model_names)}\n\n""" | ||
|
||
|
||
def generate_services(self): | ||
self.generate_services_imports() | ||
|
||
# generate service classes | ||
for model in self.model_names: | ||
self.services_str += f"""class {model.capitalize()}Service(generics.ModelService): | ||
queryset = {model}.objects.all() | ||
serializer_class = {model.capitalize()}ProtoSerializer\n\n""" | ||
|
||
# check, if services.py exists | ||
# then ask, if we should append to file | ||
|
||
if os.path.isfile("services.py"): | ||
append = input("services.py already exists, append to file? (y/n) ") | ||
if append.lower() == "y": | ||
with open("services.py", "a") as f: | ||
f.write(self.services_str) | ||
else: | ||
# write self.services_str to file | ||
with open("services.py", "w") as f: | ||
f.write(self.services_str) | ||
|
||
|
||
def generate_handler_imports(self): | ||
self.handler_str += f"""# generated with django-socio-grpc generateprpcinterface {self.app_name} (LARA-version) | ||
|
||
#import logging | ||
from django_socio_grpc.services.app_handler_registry import AppHandlerRegistry | ||
from {self.app_name}.grpc.services import {', '.join([model.capitalize() + "Service" for model in self.model_names])}\n\n""" | ||
|
||
def generate_handlers(self): | ||
self.generate_handler_imports() | ||
|
||
# generate handler functions | ||
self.handler_str += f"""def grpc_handlers(server): | ||
app_registry = AppHandlerRegistry("{self.app_name}", server)\n""" | ||
|
||
for model in self.model_names: | ||
self.handler_str += f""" | ||
app_registry.register({model.capitalize()}Service)\n""" | ||
|
||
# check, if handlers.py exists | ||
# then ask, if we should append to file | ||
|
||
if os.path.isfile("handlers.py"): | ||
append = input("handlers.py already exists, append to file? (y/n) ") | ||
if append.lower() == "y": | ||
with open("handlers.py", "a") as f: | ||
f.write(self.handler_str) | ||
else: | ||
# write self.handler_str to file | ||
with open("handlers.py", "w") as f: | ||
f.write(self.handler_str) | ||
|
||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,8 @@ | ||
import logging | ||
import asyncio | ||
import os | ||
from pathlib import Path | ||
from grpc_tools import protoc | ||
|
||
from asgiref.sync import async_to_sync | ||
from django.conf import settings | ||
|
@@ -16,11 +18,22 @@ class Command(BaseCommand): | |
help = "Generates proto." | ||
|
||
def add_arguments(self, parser): | ||
parser.add_argument( | ||
"--build-interface", | ||
"-b", | ||
action="store", | ||
help="build complete default gRPC interface for an apps, please provide app name", | ||
) | ||
parser.add_argument( | ||
"--project", | ||
"-p", | ||
help="specify Django project. Use DJANGO_SETTINGS_MODULE by default", | ||
) | ||
parser.add_argument( | ||
"--app-name", | ||
"-a", | ||
help="specify a Django app for which to create the interface", | ||
) | ||
parser.add_argument( | ||
"--dry-run", | ||
"-dr", | ||
|
@@ -58,6 +71,7 @@ def handle(self, *args, **options): | |
async_to_sync(grpc_settings.ROOT_HANDLERS_HOOK)(None) | ||
else: | ||
grpc_settings.ROOT_HANDLERS_HOOK(None) | ||
self.app_name = options["app_name"] | ||
self.project_name = options["project"] | ||
if not self.project_name: | ||
if not os.environ.get("DJANGO_SETTINGS_MODULE"): | ||
|
@@ -66,6 +80,12 @@ def handle(self, *args, **options): | |
) | ||
self.project_name = os.environ.get("DJANGO_SETTINGS_MODULE").split(".")[0] | ||
|
||
# if app name is provide, we build the default interface for this app with all services | ||
self.app_interface_to_build = options["build_interface"] | ||
# create_handler() | ||
# create_serializers() | ||
# create_services() | ||
|
||
self.dry_run = options["dry_run"] | ||
self.generate_pb2 = not options["no_generate_pb2"] | ||
self.check = options["check"] | ||
|
@@ -75,6 +95,7 @@ def handle(self, *args, **options): | |
self.directory.mkdir(parents=True, exist_ok=True) | ||
|
||
registry_instance = RegistrySingleton() | ||
|
||
|
||
# ---------------------------------------------- | ||
# --- Proto Generation Process --- | ||
|
@@ -104,17 +125,56 @@ def handle(self, *args, **options): | |
|
||
if self.directory: | ||
file_path = self.directory / f"{app_name}.proto" | ||
proto_path = self.directory | ||
else: | ||
file_path = registry.get_proto_path() | ||
proto_path = registry.get_grpc_folder() | ||
file_path.parent.mkdir(parents=True, exist_ok=True) | ||
self.check_or_write(file_path, proto, registry.app_name) | ||
|
||
if self.generate_pb2: | ||
if not settings.BASE_DIR: | ||
raise ProtobufGenerationException(detail="No BASE_DIR in settings") | ||
os.system( | ||
f"python -m grpc_tools.protoc --proto_path={settings.BASE_DIR} --python_out=./ --grpc_python_out=./ {file_path}" | ||
f"python -m grpc_tools.protoc --proto_path={proto_path} --python_out={proto_path} --grpc_python_out={proto_path} {file_path}" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. would this render your other PR obsolete ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. good point. Maybe we finish the first PR first :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dear Leni (@legau ), |
||
) | ||
command = ['grpc_tools.protoc'] | ||
command.append(f'--proto_path={str(proto_path)}') | ||
command.append(f'--python_out={str(proto_path)}') | ||
command.append(f'--grpc_python_out={str(proto_path)}') | ||
command.append(str(file_path)) # The proto file | ||
|
||
# if protoc.main(command) != 0: | ||
# logging.error( | ||
# f'Failed to compile .proto code for from file "{file_path}" using the command `{command}`' | ||
# ) | ||
# return False | ||
# else: | ||
# logging.info( | ||
# f'Successfully compiled "{file_path}"' | ||
# ) | ||
# correcting protoc rel. import bug | ||
# | ||
(pb2_files, _) = os.path.splitext(os.path.basename(file_path)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prefer using pathlib for that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @legau - indeed, pathlib is the better choice. if you decide to use the buf-workflow for interface generation, this hack will be obsolete anyway. Let's discuss this tomorrow. |
||
pb2_file = pb2_files + '_pb2.py' | ||
pb2_module = pb2_files + '_pb2' | ||
|
||
pb2_grpc_file = pb2_files + '_pb2_grpc.py' | ||
|
||
pb2_file_path = os.path.join(proto_path, pb2_file) | ||
pb2_grpc_file_path = os.path.join(proto_path, pb2_grpc_file) | ||
|
||
with open(pb2_grpc_file_path, 'r', encoding='utf-8') as file_in: | ||
print(f'Correcting imports of {pb2_grpc_file_path}') | ||
|
||
replaced_text = file_in.read() | ||
|
||
replaced_text = replaced_text.replace(f'import {pb2_module}', | ||
f'from . import {pb2_module}') | ||
|
||
with open(pb2_grpc_file_path, 'w', encoding='utf-8') as file_out: | ||
file_out.write(replaced_text) | ||
|
||
|
||
def check_or_write(self, file: Path, proto, app_name): | ||
""" | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you provide the workflow you have in mind with this ? I have trouble understanding what are the requirements and what stage should you launch the command
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, @legau,
the goal should be to generate as much of the gRPC API as possible automatically:
For this Merge Request, the workflow would look like:
add the django_socio_grpc app to the django settings of a new project with one or several apps
cd to my app directory containing the model.py
then run
./manage.py generategrpcinterface <my_app>
This will generate a grpc directory within the app, containing three files:
handler.py serializers.py and services.py
and fill it with default content.
Then the protogenerator needs to be executed:
./mange.py generateproto # this should work after fixing #178
Finally one needs to remove the comments in serializer.py to assign the
proto_class and proto_class_list
By that one would have a good starting point for further extensions of the interface.
This is extremly handy, if you have a project with many apps and many fields in the models.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the future, one should consider the buf workflow for the .proto compilation / API generation.
Also custom services could be added based on the .proto file in the future.