Skip to content
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

182 changes: 182 additions & 0 deletions django_socio_grpc/management/commands/generategrpcinterface.py
Copy link
Contributor

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

Copy link
Contributor Author

@markdoerr markdoerr Jul 24, 2023

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.

Copy link
Contributor Author

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.

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):
Copy link
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AppCommand

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)



62 changes: 61 additions & 1 deletion django_socio_grpc/management/commands/generateproto.py
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
Expand All @@ -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",
Expand Down Expand Up @@ -59,6 +72,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"):
Expand All @@ -67,6 +81,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"]
Expand All @@ -76,6 +96,7 @@ def handle(self, *args, **options):
self.directory.mkdir(parents=True, exist_ok=True)

registry_instance = RegistrySingleton()


# ----------------------------------------------
# --- Proto Generation Process ---
Expand Down Expand Up @@ -106,17 +127,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}"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would this render your other PR obsolete ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point. Maybe we finish the first PR first :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dear Leni (@legau ),
I will rethink the workflow after what i learned from you today ...
(I think, the first PR still has it's validity, so let's complete this first.)

)
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))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer using pathlib for that

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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):
"""
Expand Down
Loading