Goal rules

How to create new goals.

For many plugin tasks, you will be extending existing goals, such as adding a new linter to the lint goal. However, you may instead want to create a new goal, such as a publish goal. This page explains how to create a new goal.

As explained in Concepts, @goal_rules are the entry-point into the rule graph. When a user runs pants my-goal, the Pants engine will look for the respective @goal_rule. That @goal_rule will usually request other types, either as parameters in the @goal_rule signature or through await Get. But unlike a @rule, a @goal_rule may also trigger side effects (such as running interactive processes, writing to the filesystem, etc) via await Effect.

Often, you can keep all of your logic inline in the @goal_rule. As your @goal_rule gets more complex, you may end up factoring out helper @rules, but you do not need to start with writing helper @rules.

How to register a new goal

There are four steps to creating a new goal with Pants:

  1. Define a subclass of GoalSubsystem. This is the API to your goal.
    1. Set the class property name to the name of your goal.
    2. Set the class property help, which is used by pants help.
    3. You may register options through attributes of pants.option.option_types types. See Options and subsystems.
  2. Define a subclass of Goal. When a user runs pants my-goal, the engine will request your subclass, which is what causes the @goal_rule to run.
    1. Set the class property subsystem_cls to the GoalSubsystem from the previous step.
    2. A Goal takes a single argument in its constructor, exit_code: int. Pants will use this to determine what its own exit code should be.
  3. Define an @goal_rule, which must return the Goal from the previous step and set its exit_code.
    1. For most goals, simply return MyGoal(exit_code=0). Some goals like lint and test will instead propagate the error code from the tools they run.
  4. Register the @goal_rule in a register.py file.
from pants.engine.goal import Goal, GoalSubsystem
from pants.engine.rules import collect_rules, goal_rule

class HelloWorldSubsystem(GoalSubsystem):
    name = "hello-world"
    help = "An example goal."

class HelloWorld(Goal):
    subsystem_cls = HelloWorldSubsystem
    environment_behavior = Goal.EnvironmentBehavior.LOCAL_ONLY

async def hello_world() -> HelloWorld:
    return HelloWorld(exit_code=1)

def rules():
    return collect_rules()
from example import hello_world

def rules():
    return [*hello_world.rules()]

You may now run pants hello-world, which should cause Pants to return with an error code of 1 (run echo $? to verify). Precisely, this causes the engine to request the type HelloWorld, which results in running the @goal_rule hello_world.

Console: output to stdout/stderr

To output to the user, request the type Console as a parameter in your @goal_rule. This is a special type that may only be requested in @goal_rules and allows you to output to stdout and stderr.

from pants.engine.console import Console

async def hello_world(console: Console) -> HelloWorld:
    console.print_stderr("Uh oh, an error.")
    return HelloWorld(exit_code=1)

Using colors

You may output in color by using the methods .blue(), .cyan(), .green(), .magenta(), .red(), and .yellow(). The colors will only be used if the global option --colors is True.

console.print_stderr(f"{console.red('𐄂')} Error encountered.")

Outputting mixin (optional)

If your goal's purpose is to emit output, it may be helpful to use the mixin Outputting. This mixin will register the output --output-file, which allows the user to redirect the goal's stdout.

from pants.engine.goal import Goal, GoalSubsystem, Outputting
from pants.engine.rules import goal_rule

class HelloWorldSubsystem(Outputting, GoalSubsystem):
    name = "hello-world"
    help = "An example goal."


async def hello_world(
    console: Console, hello_world_subsystem: HelloWorldSubsystem
) -> HelloWorld:
    with hello_world_subsystem.output(console) as write_stdout:
        write_stdout("Hello world!")
    return HelloWorld(exit_code=0)

LineOriented mixin (optional)

If your goal's purpose is to emit output -- and that output is naturally split by new lines -- it may be helpful to use the mixin LineOriented. This subclasses Outputting, so will register both the options --output-file and --sep, which allows the user to change the separator to not be \n.

from pants.engine.goal import Goal, GoalSubsystem, LineOriented
from pants.engine.rules import goal_rule

class HelloWorldSubsystem(LineOriented, GoalSubsystem):
    name = "hello-world"
    help = "An example goal."""


async def hello_world(
    console: Console, hello_world_subsystem: HelloWorldSubsystem
) -> HelloWorld:
    with hello_world_subsystem.line_oriented(console) as print_stdout:
    return HelloWorld(exit_code=0)

How to operate on Targets

Most goals will want to operate on targets. To do this, specify Targets as a parameter of your goal rule.

from pants.engine.target import Targets

async def hello_world(console: Console, targets: Targets) -> HelloWorld:
    for target in targets:
    return HelloWorld(exit_code=0)

This example will print the address of any targets specified by the user, just as the list goal behaves.

$ pants hello-world helloworld/util::

See Rules and the Target API for detailed information on how to use these targets in your rules, including accessing the metadata specified in BUILD files.


Common mistake: requesting the type of target you want in the @goal_rule signature

For example, if you are writing a publish goal, and you expect to operate on python_distribution targets, you might think to request PythonDistribution in your @goal_rule signature:

def publish(distribution: PythonDistributionTarget, console: Console) -> Publish:

This will not work because the engine has no path in the rule graph to resolve a PythonDistribution type given the initial input types to the rule graph (the "roots").

Instead, request Targets, which will give you all the targets that the user specified on the command line. The engine knows how to resolve this type because it can go from Specs -> Addresses -> Targets.

From here, filter out the relevant targets you want using the Target API (see Rules and the Target API).

from pants.engine.target import Targets

def publish(targets: Targets, console: Console) -> Publish:
   relevant_targets = [
       tgt for tgt in targets
       if tgt.has_field(PythonPublishDestination)

Only care about source files?

If you only care about files, and you don't need any metadata from BUILD files, then you can request SpecsPaths instead of Targets.

from pants.engine.fs import SpecsPaths

async def hello_world(console: Console, specs_paths: SpecsPaths) -> HelloWorld:
    for f in specs_paths.files:
    return HelloWorld(exit_code=0)

SpecsPaths.files will list all files matched by the specs, e.g. :: will match every file in the project (regardless of if targets own the files).

To convert SpecsPaths into a Digest, use await Get(Digest, PathGlobs(globs=specs_paths.files)).


Name clashing

It is very unlikely, but is still possible that adding a custom goal with an unfortunate name may cause issues when certain existing Pants options are passed in the command line. For instance, executing a goal named local with a particular option (in this case, the global local_cache option), e.g. pants --no-local-cache local ... would fail since there's no --no-cache flag defined for the local goal.