Writing Task Plugins for Nikola

Published Sat 22 December 2018 in personal

by Bryan Weber

Nikola is an awesome static website generator built in Python. I recently used it to develop the new website for the Cantera project. The architecture of Nikola is quite interesting. It is essentially a series of plugins that find posts and pages and render the contents to HTML. Nikola supports source files written in reST, Markdown, and Jupyter Notebooks out-of-the-box, and it is relatively easy to add plugins that can process other formats, as well as plugins to accomplish any other task that would be useful for your site.

Nikola plugins can do more than render pages to HTML. There are plugins that enable the commands (nikola build, nikola auto, etc.), plugins that provide shortcodes you can use in a document, for instance, to link to another page on your site by its slug instead of the URL, and a general category of Tasks. I want to write some more about Task plugins here.

Task plugins, as the name implies, accomplish tasks on the source files for the website and are run during the nikola build command (or when the build is invoked automatically, such as during nikola auto). Tasks includes generating tags (and tag pages) for posts, creating image galleries, copying assets (CSS, favicons, etc.) from the source tree to the output, and more.

Creating your own Nikola plugin is quite simple. They are Python scripts that are stored in a plugins directory at the top level of the source for your website, right alongside the pages and posts directories that store your content. To let Nikola know that a particular Python script is a plugin, you have to write a .plugin file, which is an INI-formatted file to tell Nikola where to find the module that it should run. Taking the example from the Nikola documentation:

[Core]
Name = copy_assets
Module = task_copy_assets
...

In the [Core] section, the Name setting must match the name of the Task (see below), and the Module setting is the name of the Python script that should be run (without the .py).

Now, to write the Python for the actual plugin. First, your Task must inherit from the Task class from the nikola.plugin_categories module, or another class that inherits from that class.

from nikola.plugin_categories import Task

class MyTask(Task):

    name = "my_task"

Note that the name class attribute must match the Name in the plugin INI file. In the Task class, there are two instance methods that are important: the set_site method (which is actually important for all Nikola plugins) and the get_tasks method (which is specific to task plugins).

The set_site method has the signature

def set_site(self, site)

where site is the instance of the website that you’re building. The documentation for the site instance is available on the Nikola ReadTheDocs. The set_site method is run when the plugin is loaded and the site is being initialized. This is a good place to do any setup that you need to run your task. For instance, if there are configuration variables that your tasks need access to, it is common to group those into a dictionary in the set_site method. Another common thing to do is inject your task as a dependency to another task. Including the set_site method is optional, but if you do, you should make sure to call the super of the class:

from nikola.plugin_categories import Task

class MyTask(Task):

    name = "my_task"

    def set_site(self, site):
        # Conduct setup
        super(MyTask, self).set_site(site)

The other method that is important is the gen_tasks method. According to the documentation, the gen_tasks method should yield doit tasks. pydoit is an automation library that determines the appropriate order to run dependent tasks. So, for instance, doit will determine that rendering the HTML for your pages is dependent on first parsing the source, and run the appropriate Nikola tasks in the correct order.

A doit task can be most simply represented as a Python dictionary with special keywords as the keys. In particular, the actions keyword specifies what, well, action(s) the task should take. This is most commonly a function that actually does the work required for the task. The reason for this indirection is that doit pre-processes all of the tasks and decides which ones need to be run based on their output changing or not being current. Therefore, we don’t want doit to be forced to run the “task” we want to be accomplished until it is ready to do so. Thus, within the Task context, a sample might look like

from nikola.plugin_categories import Task

class MyTask(Task):

    name = "my_task"

    def gen_tasks(self):
        def action_function(arg1, arg2):
            pass

        yield {
            "actions": [
                (action_function, ["arg1value", "arg2value"])
            ],
        }

This way, Nikola is free to execute the gen_tasks function on every build invocation, but the action_function will only be executed when Nikola passes control to doit and doit determines the result of running the task is out-of-date.

The signature of the action_function is totally arbitrary and can be modified to suit the users needs. Arguments passed to the action_function are specified as the second and third elements of the tuple that specifies the actions, namely, if the second argument is a list, the elements of the list are passed as arguments to the function, and if the third argument is a dictionary, the values are passed as keyword arguments (based on the keys) to the function.

There are a few other important keywords in the doit task dictionary. These include:

  • basename: The base name for the task, will be appended with the name keyword to generate a unique name for the task
  • name: A unique name for the task, see also basename
  • uptodate: A list of True, False, None, or function calls (that must return one of True, False, or None) to determine whether the task should be executed. Any elements of the list that are False will result in the task being executed. Note that even if all the elements are True, the task may still be executed because doit also considers other kinds of dependencies, such as output files, to determine whether the task is out-of-date. See also the doit documentation.

    • config_changed: This is a class from the Nikola utils module (and is an overload of the class of the same name from doit) that computes a hash to determine if a “configuration” value has changed. The configuration value is passed to the function and can be a string or a dictionary. If the value is a dictionary, then each key is hashed to determine any changes; if anything comes out different, a False element is put into the uptodate list. Usage is

      config_dict = {"keyword": "something that can be hashed"}
      yield {
          # actions, etc.
          "uptodate": [utils.config_changed(config_dict)],
      }
      

A very useful place to find out what plugins can do is to look at the plugins directory in the Nikola source tree. Hopefully, this description will help you understand what the code is doing!