Installing tools
Various methods for Pants to access the tools your plugin needs.
BinaryPaths
: Find already installed binaries
For certain tools that are hard to automatically install—such as Docker or language interpreters—you may want to assume that the user already has the tool installed on their machine.
The simplest approach is to assume that the binary is installed at a fixed absolute path, such as /bin/echo
or /usr/bin/perl
. In the argv
for your Process
, use this absolute path as your first element.
If you instead want to allow the binary to be located anywhere on a user's machine, you can use BinaryPaths
to search certain directories—such as a user's $PATH
—to find the absolute path to the binary.
from pants.engine.process import BinaryPathRequest, BinaryPaths, ProcessResult, Process
@rule
async def demo(...) -> Foo:
docker_paths = await Get(
BinaryPaths,
BinaryPathRequest(
binary_name="docker",
search_paths=["/usr/bin", "/bin"],
)
docker_bin = docker_paths.first_path
if docker_bin is None:
raise OSError("Could not find 'docker'.")
result = await Get(ProcessResult, Process(argv=[docker_bin.path, ...], ...)
BinaryPaths
has a field called paths: Tuple[BinaryPath, ...]
, which stores all the discovered absolute paths to the specified binary. Each BinaryPath
object has the fields path: str
, such as /usr/bin/docker
, and fingerprint: str
, which is used to invalidate the cache if the binary changes. The results will be ordered by the order of search_paths
, meaning that earlier entries in search_paths
will show up earlier in the result.
BinaryPaths
also has a convenience property called first_path: Optional[BinaryPath]
, which will return the first matching path, if any.
In this example, the search_paths
are hardcoded. Instead, you may want to create a subsystem to allow users to override the search path through a dedicated option. See pex_environment.py for an example that allows the user to use the special string <PATH>
to read the user's $PATH
environment variable.
When setting up a BinaryPathsRequest
, you can optionally pass the argument test: BinaryPathTest
. When discovering a binary, Pants will run your test and only use the binary if the return code is 0. Pants will also fingerprint the output and invalidate the cache if the output changes from before, such as because the user upgraded the version of the tool.
Why do this? This is helpful to ensure that all discovered binaries are valid and safe. This is also important for Pants to be able to detect when the user has changed the binary, such as upgrading its version.
BinaryPathTest
takes the argument args: Iterable[str]
, which is the arguments that Pants should run on your binary to ensure that it's a valid program. Usually, you'll set args=["--version"]
.
from pants.engine.process import BinaryPathRequest, BinaryPathTest
BinaryPathRequest(
binary_name="docker",
search_paths=["/usr/bin", "/bin"],
test=BinaryPathTest(args=["--version"]),
)
You can optionally set fingerprint_stdout=False
to the BinaryPathTest
constructor, but usually, you should keep the default of True
.
ExternalTool
: Install pre-compiled binaries
If your tool has a pre-compiled binary available online, Pants can download and use that binary automatically for users. This is often a better user experience than requiring the users to pre-install the tool. This will also make your build more deterministic because everyone will be using the same binary.
First, manually download the file. Typically, the downloaded file will be an archive like a .zip
or .tar.xz
file, but it may also be the actual binary. Then, run shasum -a 256
on the downloaded file to get its digest ID, and wc -c
to get its number of bytes.
If the downloaded file is an archive, you will also need to find the relative path to the binary, such as ./bin/shellcheck
. You may need to use a tool like unzip
to inspect the archive.
With this information, you can define a new ExternalTool
:
from pants.core.util_rules.external_tool import ExternalTool
from pants.engine.platform import Platform
class Shellcheck(ExternalTool):
"""A linter for shell scripts."""
options_scope = "shellcheck"
default_version = "v0.7.1"
default_known_versions = [
"v0.7.1|darwin|b080c3b659f7286e27004aa33759664d91e15ef2498ac709a452445d47e3ac23|1348272",
"v0.7.1|linux|64f17152d96d7ec261ad3086ed42d18232fcb65148b44571b564d688269d36c8|1443836",
]
def generate_url(self, plat: Platform) -> str:
plat_str = "linux" if plat == Platform.linux else "darwin"
return (
f"https://github.com/koalaman/shellcheck/releases/download/{self.version}/"
f"shellcheck-{self.version}.{plat_str}.x86_64.tar.xz"
)
def generate_exe(self, _: Platform) -> str:
return f"./shellcheck-{self.version}/shellcheck"
You must define the class properties default_version
and default_known_version
. default_known_version
is a list of pipe-separated strings in the form version|platform|sha256|length
. Use the values you found earlier by running shasum
and wc
for sha256 and length, respectively. platform
should be either linux
or darwin
.
You must also define the methods generate_url
, which is the URL to make a GET request to download the file, and generate_exe
, which is the relative path to the binary in the downloaded digest. Both methods take plat: Platform
as a parameter, which allows you to handle Platform.linux
differently than Platform.darwin
.
Because an ExternalTool
is a subclass of Subsystem
, you must also define an options_scope
. You may optionally register options by overriding the classmethod register_options
.
In your rules, include the ExternalTool
as a parameter of the rule, then use Get(DownloadedExternalTool, ExternalToolRequest)
to download and extract the tool.
from pants.core.util_rules.external_tool import DownloadedExternalTool, ExternalToolRequest
from pants.engine.platform import Platform
@rule
async def demo(shellcheck: Shellcheck, ...) -> Foo:
shellcheck = await Get(
DownloadedExternalTool,
ExternalToolRequest,
shellcheck.get_request(Platform.current)
)
result = await Get(
ProcessResult,
Process(argv=[shellcheck.exe, ...], input_digest=shellcheck.digest, ...)
)
A DownloadedExternalTool
object has two fields: digest: Digest
and exe: str
. Use the .exe
field as the first value of a Process
's argv
, and use the .digest
in the Process's
input_digest
. If you want to use multiple digests for the input, call Get(Digest, MergeDigests)
with the DownloadedExternalTool.digest
included.
Pex
: Install binaries through pip
If a program can be installed via pip
—such as Pytest or Black—and it has an entry point, you can install and run it through Pex
.
from pants.backend.python.util_rules.pex import (
Pex,
PexInterpreterConstraints,
PexProcess,
PexRequest,
PexRequirements,
)
from pants.engine.process import FallibleProcessResult
@rule
async def demo(...) -> Foo:
pex = await Get(
Pex,
PexRequest(
output_filename="black.pex",
requirements=PexRequirements(["black==19.10b0"]),
interpreter_constraints=PexInterpreterConstraints(["CPython>=3.6"]),
entry_point="black:patched_main",
)
)
result = await Get(
FallibleProcessResult,
PexProcess(pex, argv=["--check", ...], ...),
)
When defining a PexRequest
for a tool, you must give arguments for output_filename
, requirements
, entry_point
, and usually interpreter_constraints
. There are several other optional parameters that may be helpful.
The resulting Pex
object has a digest: Digest
field containing the built .pex
file. This digest should be included in the input_digest
to the Process
you run.
Instead of the normal Get(ProcessResult, Process)
, you should use Get(ProcessResult, PexProcess)
, which will set up the environment properly for your Pex to execute. There is a predefined rule to go from PexProcess -> Process
, so Get(ProcessResult, Process)
will cause the engine to run PexProcess -> Process -> ProcessResult
.
PexProcess
requires arguments for pex: Pex
, argv: Iterable[str]
, and description: str
. It has several optional parameters that mirror the arguments to Process
. If you specify input_digest
, be careful to first use Get(Digest, MergeDigests)
on the pex.digest
and any of the other input digests.
PythonToolBase
when you need a SubsystemOften, you will want to create a Subsystem
for your Python tool to allow users to set options like --black-config
. You can subclass PythonToolBase
—which subclasses Subsystem
—to do this:
from pants.backend.python.subsystems.python_tool_base import PythonToolBase
from pants.option.custom_types import file_option
class Black(PythonToolBase):
"""The Black Python code formatter (https://black.readthedocs.io/)."""
options_scope = "black"
default_version = "black==19.10b0"
default_extra_requirements = ["setuptools"]
default_entry_point = "black:patched_main"
default_interpreter_constraints = ["CPython>=3.6"]
@classmethod
def register_options(cls, register):
super().register_options(register)
register(
"--config",
type=file_option,
default=None,
advanced=True,
help="Path to Black's pyproject.toml config file",
)
You must define the class properties options_scope
, default_version
, and default_entry_point
, and can optionally define default_extra_requirements
and default_interpreter_constraints
.
Then, you can set up your Pex
like this:
@rule
async def demo(black: Black, ...) -> Foo:
pex = await Get(
Pex,
PexRequest(
output_filename="black.pex",
requirements=PexRequirements(black.all_requirements),
interpreter_constraints=PexInterpreterConstraints(black.interpreter_constraints),
entry_point=black.entry_point,
),
)