====================
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``.