==================== Extending nanodjango ==================== Nanodjango provides `pluggy `_ plugin hooks to allow tight integrations with third party packages. If you are a package maintainer who wants to add functionality to nanodjango or control how your code is processed by the conversion process, you can ship a plugin as part of your package. If you are a user of a package which doesn't want to add nanodjango support, you can either release your own plugin package (suggested pypi name ``nanodjango-plugin-otherpackage``), or suggest it as a candidate to be shipped with nanodjango. .. _using-a-plugin: Using a plugin ============== Using locally -------------- During development, or if you don't plan on distributing your plugin, you can tell nanodjango about plugin modules with the ``--plugin=`` argument:: nanodjango --plugin=myplugin.py This will import ``myplugin.py`` and register its hook implementations before running the normal nanodjango command. If you want to define a plugin as part of your nanodjango script, you will need to manually register it - eg ``app.pm.hook.register(sys.modules[__name__])`` if your hook implementations are in the same file. Distributing the plugin ----------------------- If you are distributing the plugin for installation with pip, you can have nanodjango detect it automatically by adding an entry point for it. Put the plugin in a file in your project (the recommended name is ``nanodjango.py``), then specify its module dot path in the entry point. For example, if your project is called ``myproject``: ``setup.py``: .. code-block:: python setup( ... entry_points={ "nanodjango": [ "myproject = myproject.nanodjango" ] }, ) ``pyproject.toml``: .. code-block:: toml [project.entry-points.nanodjango] myproject = "myproject.nanodjango" Any hooks defined in that file will be loaded automatically. Writing a plugin ================ Nanodjango provides a number of hooks that allow you to customise and extend its behaviour. This is designed to allow easy integration with third-party packages, to let them work around nanodjango's limitations, and to provide their own logic for the convert process. If you find you need a new hook, or that an existing hook doesn't work for you, please do submit an issue or PR. Plugins are defined using the ``nanodjango.hookimpl`` decorator, and registered by :ref:`using-a-plugin`. Tutorial: simple ---------------- Let's start with a simple plugin that registers a view at ``/ping`` which returns "pong". We'll use the hook ``django_post_setup`` - this is called after Django is fully set up and ready to use. In your project, create a file called ``nanodjango.py``: .. code-block:: python from nanodjango import Django, hookimpl # Make changes before nanodjango configures Django @hookimpl def django_pre_setup(app: Django): from django.conf import settings # Automatically install my_app app_name = "my_app" if app_name not in settings.INSTALLED_APPS: settings.INSTALLED_APPS.append(app_name) # Add a route @app.route("/ping") def pong(request): return "pong" Tutorial: advanced ------------------ nanodjango comes with plugins for common third-party libraries. One is to help convert ``django-ninja`` so its code goes into an ``api.py`` in the full project structure. We'll build that again to see how it's done. Note that this is for direct use of django-ninja; the ``@app.api`` uses a different mechanism. Create the plugin ~~~~~~~~~~~~~~~~~ Importing djano-ninja and working with it directly in nanodjango would look something like this: .. code-block:: python from ninja import NinjaAPI api = NinjaAPI() @api.get("/add") def add(request, a: int, b: int): return {"result": a + b} app.route("api/", include=api.urls) The converter will recognise the route and put that in our new ``urls.py``, and will know that it references ``api``, which in turns references ``NinjaAPI``, and they will go into ``urls.py`` where they're needed for the url path. However, the converter won't be sure what to do with the ``@api.get(..)`` decorator, because that's not required by the route definition, so that will end up in ``unused.py`` in our new app. However, we want all ninja-related code in ``api.py``, as is Django Ninja convention. For that we need to write a plugin. Lets create a new plugin file, ``django_ninja.py``. We may need models but are unlikely to need views, so we'll build our ``api.py`` right after we've built ``models.py`` using the ``convert_build_app_models_done`` hook: .. code-block:: python from nanodjango import hookimpl @hookimpl def convert_build_app_models_done(converter: Converter): ... Our method will be called after the ``models.py`` has been built. We're passed the ``converter`` instance - this keeps track of the originating source code, and which symbols have been converted up to this point. If you've not worked with Python's abstract syntax trees before, now would be a good time to have a quick skim of the `AST module documentation `_ - but you can get by using the helper function ``nanodjango.convert.utils.pp_ast`` to pretty print the AST object structure as you go. Find NinjaAPI instances ~~~~~~~~~~~~~~~~~~~~~~~ We now want to find all ``NinjaAPI`` instances. We will go through the root level of the app's AST (its globals), looking for a definition of a ``NinjaAPI`` instance. Using ``pp_ast(converter.ast.body)`` on ``examples/ninja_api.py``, we can see it will look something like: .. code-block:: python Assign( targets=[ Name(id='api', ctx=Store())], value=Call( func=Name(id='NinjaAPI', ctx=Load()), args=[], keywords=[])) The title-cased items there (``Assign``, ``Call`` etc) are instances of ``ast`` classes, so you can see we've found an ``ast.Assign`` assignment, into the variable name ``api``, and the value we're assigning is the result of an ``ast.Call`` to ``NinjaAPI`` - in other words, ``api`` is going to be an instance of ``NinjaAPI``. Before we start looking, we're going to create a ``Resolver(converter, ".api")`` instance to keep track of symbols we're claiming for our file. That needs access to the current ``converter``, and also the name of the module we're going to be putting our symbols in, relative to other files in our app - so because we're writing to ``api.py``, it will be ``.api``. We'll also make an ``api_objs = set()`` to keep track of which ``NinjaAPI`` instances we've found, and a ``code`` list to store the code we want in ``api.py``. Putting all this together, we get: .. code-block:: python import ast from nanodjango.convert.plugin import Resolver from nanodjango import hookimpl @hookimpl def convert_build_app_models_done(converter: Converter): resolver = Resolver(converter, ".api") api_objs = set() code = [] for obj_ast in converter.ast.body: if ( isinstance(obj_ast, ast.Assign) and isinstance(obj_ast.value, ast.Call) and isinstance(obj_ast.value.func, ast.Name) and obj_ast.value.func.id == "NinjaAPI" ): # We've found a NinjaAPI instance It could be assigned to multiple targets, so now we've found it, lets loop over its targets and register them with our set and the resolver: .. code-block:: python from nanodjango.convert.utils import collect_references ... if (...): for target in obj_ast.targets: if isinstance(target, ast.Name): name = target.id api_objs.add(name) references = collect_references(obj_ast) resolver.add(name, references) src = ast.unparse(obj_ast) code.append(src) Here we also used ``collect_references`` to find out which other symbols in our app this definition needs - in most cases this will just be a reference to ``NinjaAPI``. We pass these into the resolver so it can track them down later. Find endpoints ~~~~~~~~~~~~~~ That's the ``NinjaAPI`` instance found, now for the endpoint functions it decorates. Using ``pp_ast`` again, the AST object for a decorated function will look like this: .. code-block:: python FunctionDef( name='add', args=arguments(...), body=[...], decorator_list=[ Call( func=Attribute( value=Name(id='api', ctx=Load()), attr='get', ctx=Load()), args=[ Constant(value='/add')], keywords=[])]) You will notice it's an ``ast.FunctionDef``, and that it has a ``decorator_list`` which mentions ``api``, one of the ``NinjaAPI`` instances we found previously. That should be enough to add to our loop. Lets also use the ``get_decorators`` helper from ``nanodjango.convert.utils``: .. code-block:: python from nanodjango.convert.utils import get_decorators ... elif isinstance(obj_ast, ast.FunctionDef): decorators = get_decorators(obj_ast) for decorator in decorators: # If it's been used as ``@decorator()`` then there's a function call # - if it was ``@decorator`` there won't. Standardise to make it # easier to work with if isinstance(decorator, ast.Call): decorator = decorator.func if ( isinstance(decorator, ast.Attribute) and isinstance(decorator.value, ast.Name) and decorator.value.id in api_objs ): resolver.add_object(obj_ast.name) references = collect_references(obj_ast) resolver.add(name, references) src = ast.unparse(obj_ast) code.append(src) Once we've found a decorator using one of the ``api_objs`` symbols we found earlier, we can be pretty sure it's a Ninja endpoint - so we again collect anything it references, register it with the resolver, and store its source code. We've duplicated some logic there, so the final version splits ``resolver.add`` into ``resolver.add_object`` and ``resolver.add_references`` - but this will work. Write the file ~~~~~~~~~~~~~~ Now we've collected all the necessary references and source, we can generate our file: .. code-block:: python @hookimpl def convert_build_app_models_done(converter: Converter): ... if not api_objs: return converter.write_file( converter.app_path / "api.py", resolver.gen_src(), "\n".join(code), ) First we check ``if not api_objs`` - remember this may be active in projects that aren't using django-ninja, so if we didn't find any NinjaAPI definitions, then we're not going to have anything to write to ``api.py``. But if we did, get the converter to write into ``api.py`` in the app dir. We're using ``converter.write_file`` which takes the filename and the lines to write, and then applies black and isort to tidy our code. First we're going to write ``resolver.gen_src()``. Remember we told the resolver the symbols our code referenced? Now it's able to go away build the code it needs to get those symbols into our file - that may mean importing models from ``models.py``, importing third party objects such as ``NinjaAPI``, or just copying in code that hasn't been used before now - eg if we'd referenced a global variable or helper function. Lastly we write the code we found interesting - the ``NinjaAPI`` instantiations and decorated endpoint functions. Note that we didn't do anything with the ``app.route("api/", include=api.urls)`` call - we want that to go into ``urls.py`` so that's the responsibility of the ``build_app_urls`` method. That's going to find the route, and it's going to tell its resolver it needs to find ``api`` - then when ``urls.py`` writes out its ``resolver.gen_src()``, the urls will get a ``from .api import api``.