Developing CLI plugins

New in version 0.18.0.

Warning

This is an incubating feature. API adjustments are still possible.

Since version 0.18.0, pyHanko’s CLI can load Signer implementations from external sources with minimal configuration.

If you develop an integration for a remote signing service or hardware device that isn’t already supported by the pyHanko CLI out of the box, you can make your implementation available to CLI users as a separate package. If you set things up the right way, all your users have to do is install it, and pyHanko will automagically detect the plugin.

This page aims to provide you with some pointers to upgrade your Signer implementation into a CLI-integrated plugin.

Note

Plugins are only supported on Python 3.8 and up.

General principles

Throughout, we assume that you have a Signer implementation that you want to hook into the CLI. This could be an integration that you developed yourself, or simply a wrapper around an existing Signer to facilitate integration with some third-party service or a particular hardware device. Anything goes.

In order to help you write the necessary glue code to patch things into the CLI, we’ll go over the following:

  • how to provide the mapping between CLI arguments and instances of your Signer;

  • how to get access to other parts of the CLI context (e.g. configuration settings);

  • how to ensure that the pyhanko executable picks up your plugin.

The plugin API

Implementation-wise, all you have to do is implement the SigningCommandPlugin interface. This will provide the link between pyHanko’s click-based CLI and your custom Signer.

This is what the basic skeleton looks like.

class MySigningCommand(SigningCommandPlugin):
    subcommand_name = 'mysigner'
    help_summary = 'a short line about the plugin'

    def click_options(self) -> List[click.Option]:
        ...

    def create_signer(
        self, context: CLIContext, **kwargs
    ) -> ContextManager[Signer]:
        ...

The subcommand_name and help_summary attributes are self-explanatory: they respectively provide the name and help text for the subcommand to addsig that’s being added by your plugin.

The click_options() method provides the click options to your plugin’s subcommand. For more details on how to define options see the Click documentation.

As an example, the options for a simplified version of the pkcs11 subcommand in pyHanko’s CLI could’ve been defined as follows.

def click_options(self) -> List[click.Option]:
    return [
        click.Option(
            ('--lib',),
            help='path to PKCS#11 module',
            type=readable_file,
            required=False,
        ),
        click.Option(
            ('--token-label',),
            help='PKCS#11 token label',
            type=str,
            required=False,
        ),
        click.Option(
            ('--cert-label',),
            help='certificate label',
            type=str,
            required=False,
        ),
        click.Option(
            ('--key-label',), help='key label', type=str, required=False
        ),
    ]

The core plumbing for your plugin will be supplied in the create_signer() method.

Here’s a brief rundown of what the arguments mean.

  • The context parameter supplies the current CLIContext, which in particular exposes access to the contents of the config file (if any).

  • The remaining keyword arguments are wired through directly from click, and will correspond to the options you defined in click_options().

Note that the return type of create_signer() is not just a Signer, but a context manager wrapping a Signer. This allows pyHanko to easily return control to the plugin after signing or when errors are thrown, so that the plugin code can run its own teardown logic.

Warning

The plugin class must have a no-arguments __init__ method.

Plugin discovery and registration

Using a package entry points

The easiest way to make your plugin discoverable is to package it with a package entry point for pyHanko CLI plugins. The entry point group ID is pyhanko.cli_plugin.signing. If you manage your plugin’s packaging metadata with pyproject.toml, this is all you have to add:

[project.entry-points."pyhanko.cli_plugin.signing"]
your_plugin = "some_package.path.to.module:SomePluginClass"

With entry points set up, pyHanko will automatically discover your plugin if it’s installed (i.e. if importlib can find it).

From the configuration file

If you don’t want to use packages or can’t for some reason, you also have the option to reference them from pyHanko’s configuration file, like so:

plugins:
    - some_package.path.to.module:SomePluginClass