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_rule
s 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 @rule
s, but you do not need to start with writing helper @rule
s.
How to register a new goal
There are four steps to creating a new goal with Pants:
- Define a subclass of
GoalSubsystem
. This is the API to your goal.- Set the class property
name
to the name of your goal. - Set the class property
help
, which is used bypants help
. - You may register options through attributes of
pants.option.option_types
types. See Options and subsystems.
- Set the class property
- Define a subclass of
Goal
. When a user runspants my-goal
, the engine will request your subclass, which is what causes the@goal_rule
to run.- Set the class property
subsystem_cls
to theGoalSubsystem
from the previous step. - 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.
- Set the class property
- Define an
@goal_rule
, which must return theGoal
from the previous step and set itsexit_code
.- For most goals, simply return
MyGoal(exit_code=0)
. Some goals likelint
andtest
will instead propagate the error code from the tools they run.
- For most goals, simply return
- Register the
@goal_rule
in aregister.py
file.
- pants-plugins/example/hello_world.py
- pants-plugins/example/register.py
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
@goal_rule
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
...
@goal_rule
async def hello_world(console: Console) -> HelloWorld:
console.print_stdout("Hello!")
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."
...
@goal_rule
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."
...
@goal_rule
async def hello_world(
console: Console, hello_world_subsystem: HelloWorldSubsystem
) -> HelloWorld:
with hello_world_subsystem.line_oriented(console) as print_stdout:
print_stdout("0")
print_stdout("1")
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
...
@goal_rule
async def hello_world(console: Console, targets: Targets) -> HelloWorld:
for target in targets:
console.print_stdout(target.address.spec)
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::
helloworld/util
helloworld/util:tests
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.
@goal_rule
signatureFor 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:
@goal_rule
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
@goal_rule
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
...
@goal_rule
async def hello_world(console: Console, specs_paths: SpecsPaths) -> HelloWorld:
for f in specs_paths.files:
console.print_stdout(f)
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))
.
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.