Skip to content

Pulp CLI Architecture

Pulp Glue

Pulp CLI uses the pulp-glue library as an abstraction layer to perform high-level operations in pulp.

Deferred Api and Entity lookup

In order to be able to access every (sub-)command's help page, it is necessary that no code outside of the final performing command callback accesses the api property of the PulpContext. See pulp-glue section about deferred lookup.

Plugin System

The Pulp CLI is designed with a plugin structure. Plugins can either live in the pulp-cli package or be shipped independently. By convention, all CLI plugins are modules in the open namespace pulpcore.cli. A plugin must register itself with the main app by specifying its main module as a pulp_cli.plugins entrypoint.

[project.entry-points."pulp_cli.plugins"]
myplugin = "pulpcore.cli.myplugin"
entry_points={
    "pulp_cli.plugins": [
        "myplugin=pulpcore.cli.myplugin",
    ],
}

The plugin should then attach subcommands to the pulpcore.cli.common.main command by providing a mount method in the main module.

from pulpcore.cli.common.generic import pulp_command

@pulp_command()
def my_command():
    pass


def mount(main: click.Group, **kwargs: Any) -> None:
    main.add_command(my_command)

Contexts

In click, every subcommand is accompanied by a click.Context, and objects can be attached to them. In this CLI we attach a PulpCLIContext to the main command, which inherits from pulp-glue's PulpContext. This context handles the communication to the pulp server through its api property.

Further we encourage the handling of communication with certain endpoints by subclassing the PulpEntityContext or some of the resource-specific children, such as PulpRepositoryContext. Some examples of this can be found under pulp_glue/{plugin-name}/context.py.

By attaching them to the contexts of certain command groups, they are accessible to commands via the pass_entity_context decorator. Those entity contexts should provide a common interface to the layer of click commands that define the user interaction.

@pulp_group()
@pass_pulp_context
@click.pass_context
def my_command(ctx, pulp_ctx):
    ctx.obj = MyEntityContext(pulp_ctx)


@my_command.command()
@pass_entity_context
def my_sub_command(entity_ctx):
    entity_ctx.entity = {"name": "myentity")
    entity_ctx.destroy()

Generics

For certain often repeated patterns like listing all entities of a particular kind, we provide generic commands that use the underlying context objects. The following example shows the use of the show_command generic.

from pulpcore.cli.common.generic import name_option, show_command,

lookup_params = [name_option]
my_command.add_command(show_command(decorators=lookup_params))

To add options to these subcommands, pass a list of PulpOption objects to the decorators argument. Preferably these are created using the pulp_option factory.

from pulpcore.cli.common.generic import list_command,

filter_params = [
    pulp_option("--name"),
    pulp_option("--name-contains", "name__contains"),
]
my_command.add_command(list_command(decorators=filter_params))

Version dependent code paths

Each Pulp CLI release is designed to support multiple Pulp server versions and the CLI itself is versioned independently of any version of the Pulp server components. It is supposed to be able to communicate with different combinations of server component versions at the same time. Because of this, it might be necessary to guard certain features and workarounds by checking against the available server plugin version.

As a rule of thumb, all necessary workarounds should be implemented in the corresponding Context objects. To facilitate diverting code paths depending on plugin versions, the PulpContext provides the needs_plugin and has_plugin methods, both of which accept a PluginRequirement object to describe dependencies on server components.

While has_plugin will evaluate immediately, needs_plugin can be seen as a deferred assertion. It will raise an error, once the first access to the server is attempted.

# In pulp_glue_my_plugin
class MyEntityContext(PulpEntityContext):
    def show(self, href):
        if self.pulp_ctx.has_plugin(PluginRequirement("my_content", specifier=">=1.2.3", inverted=True)):
            # Versioned workaroud
            # see bug-tracker/12345678
            return lookup_my_content_legacy(href)
        return super().show(href)


# In pulp_cli_my_plugin
@main.command()
@pass_pulp_context
@click.pass_context
def my_command(ctx, pulp_ctx):
    pulp_ctx.needs_plugin(PluginRequirement("my_content", specifier=">=1.0.0"))
    ctx.obj = MyEntityContext(pulp_ctx)

To declare version restrictions on options, the preprocess_entity method can be used to check if a given option is present in the request body and conditionally apply the requirements to the context.

In the following example, a guard is added because my_option was introduced to MyPluginRepository in version 3.24.0 of "my_plugin":

class PulpMyPluginRepositoryContext(PulpRepositoryContext):
    ...

    def preprocess_entity(self, body, partial) -> EntityDefinition:
        body = super().preprocess_entity(body, partial=partial)
        if "my_option" in body:
            self.pulp_ctx.needs_plugin(
                PluginRequirement("my_plugin", specifier=">=3.24.0", feature=_("my feature"))
            )
        return body

Note

The specifier >=x.y.z doesn't include x.y.z.dev according to PEP 440. Therefore, when adapting to an unreleased feature change from a plugin, you need to specify the prerelease part of the version explicitly. However >=x.y.z.dev is never unambiguous in the current Pulp versioning practice. Once that change is released please reset the constraint to the plain x.y.z schema.