diff --git a/.gitignore b/.gitignore index d5a026e..d35293d 100644 --- a/.gitignore +++ b/.gitignore @@ -95,6 +95,7 @@ backend/.envs # backend/nango/models.py backend/api/Author backend/api/Book +backend/nango/models/test.py # ------------------------------------------------------------------------------------------------- # Frontend diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fdf1c20..bfe64ff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/Makefile b/Makefile index 9cdf540..b477472 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/backend/nango/bridge/bridge.py b/backend/nango/bridge/bridge.py index 575d7be..9835bbf 100644 --- a/backend/nango/bridge/bridge.py +++ b/backend/nango/bridge/bridge.py @@ -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) diff --git a/backend/nango/cogs/__init__.py b/backend/nango/cogs/__init__.py new file mode 100644 index 0000000..03205e4 --- /dev/null +++ b/backend/nango/cogs/__init__.py @@ -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"] diff --git a/backend/nango/cogs/serializer_cog.py b/backend/nango/cogs/serializer_cog.py new file mode 100644 index 0000000..3e9420f --- /dev/null +++ b/backend/nango/cogs/serializer_cog.py @@ -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() diff --git a/backend/nango/management/commands/nango.py b/backend/nango/management/commands/nango.py new file mode 100644 index 0000000..930be4b --- /dev/null +++ b/backend/nango/management/commands/nango.py @@ -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() diff --git a/backend/nango/utils.py b/backend/nango/utils/__init__.py similarity index 66% rename from backend/nango/utils.py rename to backend/nango/utils/__init__.py index 17b674a..55b72c3 100644 --- a/backend/nango/utils.py +++ b/backend/nango/utils/__init__.py @@ -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 diff --git a/backend/nango/utils/abstract_cog.py b/backend/nango/utils/abstract_cog.py new file mode 100644 index 0000000..869c749 --- /dev/null +++ b/backend/nango/utils/abstract_cog.py @@ -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__}" diff --git a/backend/nango/utils/cogs_runner.py b/backend/nango/utils/cogs_runner.py new file mode 100644 index 0000000..99da94e --- /dev/null +++ b/backend/nango/utils/cogs_runner.py @@ -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() diff --git a/backend/requirements/development.txt b/backend/requirements/development.txt index e888718..3cf1152 100644 --- a/backend/requirements/development.txt +++ b/backend/requirements/development.txt @@ -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