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

Rework #30

Open
wants to merge 14 commits into
base: dev
Choose a base branch
from
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ backend/.envs
# backend/nango/models.py
backend/api/Author
backend/api/Book
backend/nango/models/test.py

# -------------------------------------------------------------------------------------------------
# Frontend
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.5.7
rev: v0.8.0
hooks:
# Run the formatter.
- id: ruff-format
Expand Down
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ nango:
cp -r ./backend/.nango_front/ts_types ./frontend/src/api/nango_front/
ruff format

rework:
docker exec nango_django python manage.py nango

route:
docker exec nango_django python manage.py bridge -r

Expand Down
4 changes: 2 additions & 2 deletions backend/nango/bridge/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,12 +287,12 @@ def get_model_settings(self, model: ModelBase) -> dict[str, any] | None:

def setup_api_for_model(self, models_data: dict[ModelBase, dict[fields, str]]) -> None:
"""Setup API files for a given model."""
for model in models_data:
for model in models_data: # noqa: PLC0206
model_name = model.__name__

# Check settings for folder construction
settings = self.get_model_settings(model)
if settings is None or settings["serializers"] is None and settings["view"] is None:
if settings is None or (settings["serializers"] is None and settings["view"] is None):
continue

self.make_api_architecture(model_name=model_name)
Expand Down
10 changes: 10 additions & 0 deletions backend/nango/cogs/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Cogs are nango's plugins that give it flexibility.

Every Nango function is linked to a cog.

Note: Order of the __all__ matches the order of cogs execution.
"""

from .serializer_cog import SerializerCog

__all__ = ["SerializerCog"]
119 changes: 119 additions & 0 deletions backend/nango/cogs/serializer_cog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
from django.db.models import fields as django_fields
from django.db.models.fields.reverse_related import ManyToOneRel, OneToOneRel

from nango.utils import AbstractCog


class SerializerCog(AbstractCog):
"""Generates a serializer from each django's models.

Settings:
--------
- speedy (bool ; default = False): A faster serializer than rest_framework.serializers.Serializer, but with less features.
- selected_fields (list[str] ; default = []): list of selected fields' name to serialize.
- excluded_fields (list[str] ; default = []): list of fields' name to not serialize.
"""

id = "serializer"

# Fields to NEVER serialize.
_forbidden_fields: tuple[str] = (
"password",
"outstandingtoken",
)

def _get_drf_imports(self) -> list[str]:
"""Return list of imports from rest_framework module."""
if self.settings.get("speedy", False):
error_msg: str = "The SerializerCog speedy feature is not implemented yet."
raise NotImplementedError(error_msg)

return [
"from rest_framework import serializers" if not self.settings.get("speedy", False) else "...",
]

def _get_base_imports(self) -> list[str]:
"""Return basics imports."""
return [
"from __future__ import annotations",
"from typing import ClassVar",
]

def get_model_fields(self) -> dict[str, django_fields.Field]:
"""Return the model fields to serialize.

Returns:
-------
```
{
"field_name": field,
...,
}
```
"""
selected_fields_name: list[str] = self.settings.get("selected_fields", [])
if selected_fields_name:
return {field_name: getattr(self.model, field_name) for field_name in selected_fields_name}

model_fields_list: dict[str, django_fields.Field] = {}

# Select all fields except excluded_fields
excluded_fields_name: list[str] = self.settings.get("excluded_fields", [])
for field in self.model._meta.get_fields(): # noqa: SLF001
# Get field's name
if isinstance(field, ManyToOneRel | OneToOneRel):
# External Key point at actual model
field_name = field.related_name if field.related_name is not None else f"{field.related_model.__name__.lower()}"
else:
field_name = getattr(field, "field_name", None) or getattr(field, "name", None)

if field_name in excluded_fields_name or field_name in self._forbidden_fields:
continue

# Basic behavior
model_fields_list[field_name] = field

return model_fields_list

def get_serializer_name(self) -> dict[str]:
"""Return name of generated serializers.

Returns:
-------
```
{
"cached_serializer_name": str,
"customizable_serializer_name": str,
}
```
"""
return {
"cached_serializer_name": f"{self.model.__name__}NangoSerializer",
"customizable_serializer_name": f"{self.model.__name__}Serializer",
}

def generate_cached_serializer(self) -> None:
"""Generate the serializer file (for the given model) that will be cached in the _nango_cache."""
content: str = f"""
{'\n'.join(self._get_base_imports())}
{'\n'.join(self._get_drf_imports())}
{self._get_model_import()}

class {self.get_serializer_name().get('cached_serializer_name')}(serializer.Serializer):
\"\"\"...generated...\"\"\"

class Meta:
model = {self.model.__name__}
"""
print(content) # noqa: T201

def generated_customizable_serializer(self) -> None:
"""Generate the serializer file (for the given model) that use the cached serializer and that can be custom by the dev."""

def run(self) -> None:
"""Generate a serializer for each django's model."""
super().run()
self._get_model_fields()

self.generate_cached_serializer()
self.generated_customizable_serializer()
18 changes: 18 additions & 0 deletions backend/nango/management/commands/nango.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""CLI to run Nango's bridge."""

from __future__ import annotations

from django.core.management.base import BaseCommand, CommandParser

from nango.utils import CogsRunner


class Command(BaseCommand):
"""Cli to manage the bridge."""

def add_arguments(self, parser: CommandParser) -> None: # noqa: D102
...

def handle(self, *args: list, **options: dict) -> None: # noqa: ARG002
"""Handle argument and run Nango's cogs."""
CogsRunner().run_cogs()
11 changes: 10 additions & 1 deletion backend/nango/utils.py → backend/nango/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
from nango.utils.abstract_cog import AbstractCog
from nango.utils.cogs_runner import CogsRunner

__all__ = [
"AbstractCog",
"CogsRunner",
]


def setup_django() -> None:
"""Setup django environment."""
"""Setup Django's environment."""
import os
import sys
from pathlib import Path
Expand Down
47 changes: 47 additions & 0 deletions backend/nango/utils/abstract_cog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from __future__ import annotations

import re
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from django.db import models


class AbstractCog(ABC):
"""Cog's base class, that gives the same basic functionalities for all cogs.

If settings are not defined or is `{}`, the default settings will be used.
Otherwise, if the settings are `None`, the Cog will not run.
If one of the settings is not specified, the default value will be used.
"""

id = None

def __init__(self, model: models.Model, settings: dict[str, any]) -> None:
"""Setup the cogs.

Elements of the setup:

- Settings
"""
self.model = model
self.settings = settings.get(self.id, {})

@abstractmethod
def run(self) -> None:
"""Execute the cog logic."""
if not self.is_executable():
return

def is_executable(self) -> bool:
"""Indicate if the cog can run or not."""
return self.settings is not None

def _get_model_import(self) -> str:
"""Return the model import line for file generation."""
regex = r"(?<=')(.*)(?=\.)"
match = re.search(regex, str(self.model))
import_path = match.group(1)

return f"from {import_path} import {self.model.__name__}"
68 changes: 68 additions & 0 deletions backend/nango/utils/cogs_runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from __future__ import annotations

from collections.abc import Callable
from importlib import import_module
from typing import TYPE_CHECKING

from django.apps import apps
from django.conf import settings

from nango import cogs

if TYPE_CHECKING:
from django.db import models


class CogsRunner:
"""Load and execute cogs.

For execution order, follow the nango.cogs.__all__ order.

Settings:
--------
Nango's settings can be defined for each cog at the model level.
A model can have a staticmethod called `nango` that returns a None (for no generation) or a dict (with settings for each cogs).
"""

def get_cog_classes(self) -> list[Callable]:
"""Return cogs's classes to execute."""
return [getattr(import_module("nango.cogs"), cog_str) for cog_str in cogs.__all__]

def get_local_model_classes(self) -> list[models.Model]:
"""Get all models for apps defined in settings.LOCAL_APPS."""
model_list: list[models.Model] = []

for app_name in settings.LOCAL_APPS:
app_config = apps.app_configs.get(app_name)
model_list.extend(app_config.get_models())
return model_list

def get_settings_from_model(self, model: models.Model) -> dict[str, any]:
"""Return settings defined in a model."""
model_nango_method: Callable | None = getattr(model, "nango", None)

if not isinstance(model_nango_method, Callable):
if model_nango_method is None:
# No settings defined in the model
return {}

error_msg: str = f"{model}.nango must be a staticmethod, not a property."
raise TypeError(error_msg)

return model_nango_method()

def run_cogs(self) -> None:
"""Run all existing cogs."""
cogs_classes = self.get_cog_classes()

for model in self.get_local_model_classes():
for cog in cogs_classes:
model_nango_settings = self.get_settings_from_model(model)

# This model doesn't want to run any cogs.
if model_nango_settings is None:
continue

# Instantiate the cogs with settings and run it
instantiated_cog = cog(model=model, settings=model_nango_settings)
instantiated_cog.run()
2 changes: 1 addition & 1 deletion backend/requirements/development.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ watchfiles==0.24.0 # https://github.com/samuelcolvin/watchfiles

# Code quality
# ------------------------------------------------------------------------------
ruff==0.6.9
ruff==0.8.0
pre-commit==4.0.1
coverage==7.6.2 # https://github.com/nedbat/coveragepy

Expand Down