Rules and the Target API
How to use the Target API in rules.
Start by reading the Concepts of the Target API.
Note that the engine does not have special knowledge about Target
s and Field
s. To the engine, these are like any other types you'd use, and the @rule
s to work with targets are like any other @rule
.
How to read values from a Target
As explained in Concepts, a Target
is a combination of fields, where each field gives some metadata about your code.
To read a particular Field
for a Target
, look it up with the Field
's class in square brackets, like you would look up a normal Python dictionary:
from pants.backend.python.target_types import PythonTestsTimeout
timeout_field = target[PythonTestsTimeout]
print(timeout_field.value)
This will return an instance of the Field
subclass you looked up, which has two properties: alias: str
and value
. The type of value
depends on the particular field; often, the field will be str
, int
, or Tuple[str, ...]
, but it can also be a more complex type like a PythonArtifact
object. In this example, we'd get back a PythonTestsTimeout
object, with value
having an int
type. MyPy knows what the type will be; it can also be helpful to enable type hints with your editor.
Looking up a field with tgt[MyField]
will fail if the field is not registered on the target type.
If the Field
might not be registered, and you're okay with using a default value, you can instead use the method .get()
. When the Field is not registered, this will call the constructor for that Field
with raw_value=None
, which is equivalent to if the user left off the field from their BUILD file.
from pants.backend.python.target_types import PythonTestsTimeout
timeout_field = target.get(PythonTestsTimeout)
print(timeout_field.value)
Often, you may want to see if a target type has a particular Field
registered. This is useful to filter targets. Use the methods .has_field()
and .has_fields()
.
from pants.backend.python.target_types import PythonTestsTimeout, PythonSources
if target.has_field(PythonSources):
print("My plugin can work on this target.")
if target.has_fields([PythonSources, PythonTestsTimeout]):
print("The target has both Python sources and a timeout field")
Field
subclasses
As explained in Concepts, subclassing Field
s is key to how the Target API works.
The Target
methods [MyField]
, .has_field()
and .get()
understand when a Field
is subclassesd, as follows:
>>> json_target.has_field(JsonSources)
True
>>> json_target.has_field(Sources)
True
>>> python_target.has_field(JsonSources)
False
>>> python_target.has_field(Sources)
True
This allows you to express specifically which types of Field
s you need to work. For example, the ./pants filedeps
goal only needs Sources
, and works with any subclasses. Meanwhile, Black and isort need PythonSources
, and work with any subclasses. Finally, the Pytest runner needs PythonTestsSources
(or any subclass).
A Target's Address
Every target is identifed by its Address
, from pants.engine.adddresses
. Many types used in the Plugin API will use Address
objects as fields, and it's also often useful to use the Address
when writing the description for a Process
you run.
A Target
has a field address: Address
, e.g. my_tgt.address
.
You can also create an Address
object directly, which is often useful in tests:
project:tgt
->Address("project", target_name="tgt")
project/
->Address("project")
//:top-level
->Address("", target_name="top_level")
You can use str(address)
or address.spec
to get the normalized string representation. address.spec_path
will give the path to the parent directory of the target's original BUILD file.
How to resolve targets
How do you get Target
s in the first place in your plugin?
As explained in Goal rules, to get all of the targets specified on the command line by a user, you can request the type Targets
as a parameter to your @rule
or @goal_rule
. From there, you can optionally filter out the targets you want, such as by using target.has_field()
.
from pants.engine.target import Targets
@rule
async def example(targets: Targets) -> Foo:
logger.info(f"User specified these targets: {[tgt.address.spec for tgt in targets]}")
...
You can also request Addresses
(from pants.engine.addresses
) as a parameter to your @rule
if you only need the addresses specified on the command line by a user.
For most Common plugin tasks, like adding a linter, Pants will have already filtered out the relevant targets for you and will pass you only the targets you care about.
Given targets, you can find their direct and transitive dependencies. See the below section "The Dependencies field".
You can also find targets by writing your own Spec
s, rather than using what the user provided. (All of these types come from pants.base.specs
.)
await Get(Targets, AddressSpecs([DescendantAddresses("dir")])
->./pants list dir::
await Get(Targets, AddressSpecs([SiblingAddresses("dir")])
->./pants list dir:
await Get(Targets, AddressSpecs([AscendantAddresess("dir")])
-> will find all targets in this directory and aboveawait Get(Targets, AddressSpecs([AddressLiteral("dir", "tgt")])
->./pants list dir:tgt
await Get(Targets, FilesystemSpecs([FilesystemLiteralSpec("dir/f.ext")])
->./pants list dir/f.ext
await Get(Targets, FilesystemSpecs([FilesystemGlobSpec("dir/*.ext")])
->./pants list 'dir/*.ext'
Finally, you can look up an Address
given a raw address string. This is often useful to allow a user to refer to targets in Options and in Field
s in your Target
. For example, this mechanism is how the dependencies
field works. This will error if the address does not exist.
from pants.engine.addresses import AddressInput, Address
from pants.engine.rules import Get, rule
@rule
async def example(...) -> Foo:
address = await Get(Address, AddressInput, AddressInput.parse("project/util:tgt"))
Given an Address
, there are two ways to find its corresponding Target
:
from pants.engine.addresses import AddressInput, Address, Addresses
from pants.engine.rules import Get, rule
from pants.engine.target import Targets, WrappedTarget
@rule
async def example(...) -> Foo:
address = Address("project/util", target_name="tgt")
# Approach #1
wrapped_target = await Get(WrappedTarget, Address, address)
target = wrapped_target.target
# Approach #2
targets = await Get(Targets, Addresses([address])
target = targets[0]
The Dependencies
field
The Dependencies
field is an AsyncField
, which means that you must use the engine to hydrate its values, rather than using Dependencies.value
like normal.
from pants.engine.target import Dependencies, DependenciesRequest, Targets
from pants.engine.rules import Get, rule
@rule
async def demo(...) -> Foo:
...
direct_deps = await Get(Targets, DependenciesRequest(target.get(Dependencies))
DependenciesRequest
takes a single argument: field: Dependencies
. The return type Targets
is a Collection
of individual Target
objects corresponding to each direct dependency of the original target.
If you only need the addresses of a target's direct dependencies, you can use Get(Addresses, DependenciesRequest(target.get(Dependencies))
instead. (Addresses
is defined in pants.engine.addresses
.)
Transitive dependencies with TransitiveTargets
If you need the transitive dependencies of a target—meaning both the direct dependencies and those dependencies' dependencies—use Get(TransitiveDependencies, TransitiveTargetsRequest)
.
from pants.engine.target import TransitiveTargets, TransitiveTargetsRequest
from pants.engine.rules import Get, rule
@rule
async def demo(...) -> Foo:
...
transitive_targets = await Get(TransitiveTargets, TransitiveTargetsRequest([target.address])
TransitiveTargetsRequest
takes an iterable of Address
es.
TransitiveTargets
has two fields: roots: Tuple[Target, ...]
and dependencies: Tuple[Target, ...]
. roots
stores the original input targets, and dependencies
stores the transitive dependencies of those roots. TransitiveTargets
also has a property closure: FrozenOrderedSet[Target]
which merges the roots and dependencies.
Dependencies-like fields
You may want to have a field on your target that's like the normal dependencies
field, but you do something special with it. For example, Pants's archive target type has the fields files
and packages
, rather than dependencies
, and it has special logic on those fields like running the equivalent of ./pants package
on the packages
field.
Instead of subclassing Dependencies
, you can subclass SpecialCasedDependencies
from pants.engine.target
. You must set the alias
class property to the field's name.
from pants.engine.target import SpecialCasedDependencies, Target
class PackagesField(SpecialCasedDependencies):
alias = "packages"
class MyTarget(Target):
alias = "my_tgt"
core_fields = (..., PackagesField)
Then, to resolve the addresses, you can use:
from pants.engine.addresses import Address, Addresses, UnparsedAddressedInputs
from pants.engine.target import Targets
from pants.engine.rules import Get, rule
@rule
async def demo(...) -> Foo:
addresses = await Get(
Addresses,
UnparsedAddressedInputs,
my_tgt[MyField]].to_unparsed_address_inputs()
)
# Or, use this:
targets = await Get(
Targets,
UnparsedAddressedInputs,
my_tgt[MyField]].to_unparsed_address_inputs()
)
Pants will include your special-cased dependencies with ./pants dependencies
, ./pants dependees
, and ./pants --changed-since
, but the dependencies will not show up when using await Get(Addresses, DependenciesRequest)
.
The Sources
field
The Sources
field is an AsyncField
, which means that you must use the engine to hydrate its values, rather than using Sources.value
like normal.
from pants.engine.target import HydratedSources, HydrateSourcesRequest
from pants.engine.rules import Get, rule
@rule
async def demo(...) -> Foo:
...
sources = await Get(HydratedSources, HydrateSourcesRequest(target.get(Sources))
HydrateSourcesRequest
expects a Sources
object. This can be a subclass, such as PythonSources
or PythonBinarySources
.
HydratedSources
has a field called snapshot: Snapshot
, which allows you to see what files were resolved by calling hydrated_sources.snapshot.files
and to use the resulting Digest
in your plugin with hydrated_sources.snapshot.digest
.
Typically, you will want to use the higher-level Get(SourceFiles, SourceFilesRequest)
utility instead of Get(HydrateSources, HydrateSourcesRequest)
. This allows you to ergonomically hydrate multiple Sources
objects in the same call, resulting in a single merged snapshot of all the input source fields.
from pants.core.util_rules.source_files import SourceFiles, SourceFilesRequest
from pants.engine.rules import Get, rule
@rule
async def demo(...) -> Foo:
...
sources = await Get(SourceFiles, SourceFilesRequest([target.get(Sources)]))
SourceFilesRequest
expects an iterable of Sources
objects. SourceFiles
has a field snapshot: Snapshot
with the merged snapshot of all resolved input sources fields.
Enabling codegen
If you want your plugin to work with code generation, you must set the argument enable_codegen=True
, along with for_sources_types
with the types of Sources
you're expecting.
from pants.backend.python.target_types import PythonSources
from pants.core.target_types import ResourcesSources
from pants.engine.target import HydratedSources, HydrateSourcesRequest
from pants.engine.rules import Get, rule
@rule
async def demo(...) -> Foo:
...
sources = await Get(
HydratedSources,
HydrateSourcesRequest(
target.get(Sources),
enable_codegen=True,
for_sources_types=(PythonSources, ResourcesSources)
)
)
If the provided Sources
field object is already a subclass of one of the for_sources_types
—or it can be generated into one of those types—then the sources will be hydrated; otherwise, you'll get back a HydratedSources
object with an empty snapshot and the field sources_type=None
.
SourceFilesRequest
also accepts the enable_codegen
and for_source_types
arguments. This will filter out any inputted Sources
field that are not compatible with for_sources_type
.
from pants.backend.python.target_types import PythonSources
from pants.core.target_types import ResourcesSources
from pants.core.util_rules.source_files import SourceFiles, SourceFilesRequest
from pants.engine.rules import Get, rule
@rule
async def demo(...) -> Foo:
...
sources = await Get(
SourceFiles,
SourceFilesRequest(
[target.get(Sources)],
enable_codegen=True,
for_sources_types=(PythonSources, ResourcesSources)
)
)
Stripping source roots
You may sometimes want to remove source roots from files, i.e. go from src/python/f.py
to f.py
. This can make it easier to work with tools that would otherwise be confused by the source root.
To strip source roots, use Get(StrippedSourceFiles, SourceFiles)
.
from pants.core.util_rules.source_files import SourceFiles, SourceFilesRequest
from pants.core.util_rules.stripped_source_files import StrippedSourceFiles
from pants.engine.rules import Get, rule
@rule
async demo(...) -> Foo:
...
unstripped_sources = await Get(SourceFiles, SourceFilesRequest([target.get(Sources)]))
stripped_sources = await Get(StrippedSourceFiles, SourceFiles, unstripped_sources)
StrippedSourceFiles
has a single field snapshot: Snapshot
.
You can also use Get(StrippedSourceFiles, SourceFilesRequest)
, and the engine will automatically go from SourceFilesRequest -> SourceFiles -> StrippedSourceFiles)
.
FieldSet
s
A FieldSet
is a way to specify which Fields your rule needs to use in a typed way that is understood by the engine.
Normally, your rule should simply use tgt.get()
and tgt.has_field()
instead of a FieldSet
. However, for several of the Common plugin tasks, you will instead need to create a FieldSet
so that the combination of fields you use can be represented by a type understood by the engine.
To create a FieldSet
, create a new dataclass with @dataclass(frozen=True)
. You will sometimes directly subclass FieldSet
, but will often subclass something like BinaryFieldSet
or TestFieldSet
. Refer to the instructions in Common plugin tasks.
List every Field
that your plugin will use as a field of your dataclass. The types hints you specify will be used by Pants to identify what Field
s to use, e.g. PythonSources
or Dependencies
.
Finally, set the class property required_fields
as a tuple of the Field
s that your plugin requires. Pants will use this to filter out irrelevant targets that your plugin does not know how to operate on. Often, this will be the same as the Field
s that you listed as dataclass fields, but it does not need to be. If a target type does not have registered one of the Field
s that are in the dataclass fields, and it isn't a required Field
, then Pants will use a default value as if the user left it off from their BUILD file.
from dataclasses import dataclass
from pants.engine.target import Dependencies, FieldSet
@dataclass(frozen=True)
class ShellcheckFieldSet(FieldSet):
required_fields = (BashSources,)
sources: BashSources
# Because this is not in `required_fields`, this `FieldSet` will still match target types
# that don't have a `Dependencies` field registered. If it's not registered, then a
# default value for `Dependencies` will be used as if the user left off the field from
# their BUILD file.
dependencies: Dependencies
In your rule, you can access your FieldSet
like a normal dataclass, e.g. field_set.sources
or field_set.dependencies
. The object also has a field called address: Address
.