Extending the converter
Code for third party libraries may not be put in the right place by the standard
conversation process - for example, by default a django-ninja @api.get(..)
function
will not be recognised as a view, so will end up in unused.py
.
To avoid this, nanodjango has a converter plugin system. If you are a library maintainer
who wants to control their part of the conversion process, the converter can be released
as part of the library; if you are a library user, you can either release your own
package, or suggest it as a candidate to be shipped with nanodjango as part of
nanodjango.convert.contrib
.
Using your plugin
Using locally
During development, or if you don’t plan on distributing your plugin, you can tell
nanodjango about plugin modules with the convert --plugin=<path>
argument:
nanodjango app.py convert project --name=example --plugin=myplugin.py
This will import app.py
, then myplugin .py
to register any plugins, then start
the conversion process.
Because plugins are automatically registered when they’re imported, you could also
import or define them in your app.py
.
Distributing the plugin
If you are distributing the plugin yourself, you can have nanodjango detect it automatically by adding an entry point for it.
If your project is called myproject
, put the plugin in a file in your project (the
recommended name is nanodjango.py
, but it could be anything), then specify its
module dot path in the entry point.
setup.py:
setup(
...
entry_points={
"nanodjango": [
"myproject = myproject.nanodjango"
]
},
)
pyproject.toml:
[project.entry-points.nanodjango]
myproject = "myproject.nanodjango"
Any converter plugins defined in that file will be detected.
Writing a plugin
Your plugin should subclass nanodjango.convert.plugin.ConverterPlugin
.
This will automatically register your plugin once it has been imported.
Plugins will run in the order they are imported; this could vary, so ensure plugins are independent.
Plugin methods are called throughout the conversion process, allowing you to hook in and claim or modify objects, or otherwise modify the generated files.
All methods receive the current
Converter
instance as the first argument.Some methods have additional positional arguments. Those should be returned in the same order, and will be passed on to the next plugin, or back to the originating function.
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.
Tutorial
nanodjango comes with plugins for common third-party libraries, including
django-ninja
. 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:
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
, and subclass the
ConverterPlugin
. 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 build_app_models_done
hook:
class NinjaConverter(ConverterPlugin):
def build_app_models_done(self, converter: Converter):
...
This will automatically register the plugin once the file is imported, and 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:
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:
import ast
from nanodjango.convert.plugin import ConverterPlugin, Resolver
class NinjaConverter(ConverterPlugin):
def build_app_models_done(self, 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:
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:
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
:
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:
def build_app_models_done(self, 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
.