Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for the pex --executable argument #20497

Merged
merged 15 commits into from
Feb 9, 2024
4 changes: 3 additions & 1 deletion src/python/pants/backend/python/goals/package_pex_binary.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
PexEmitWarningsField,
PexEntryPointField,
PexEnvField,
PexExecutableField,
PexExecutionMode,
PexExecutionModeField,
PexIgnoreErrorsField,
Expand Down Expand Up @@ -58,6 +59,7 @@ class PexBinaryFieldSet(PackageFieldSet, RunFieldSet):

entry_point: PexEntryPointField
script: PexScriptField
executable: PexExecutableField
args: PexArgsField
env: PexEnvField

Expand Down Expand Up @@ -141,7 +143,7 @@ async def package_pex_binary(
request = PexFromTargetsRequest(
addresses=[field_set.address],
internal_only=False,
main=resolved_entry_point.val or field_set.script.value,
main=resolved_entry_point.val or field_set.script.value or field_set.executable.value,
Copy link
Sponsor Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re my question on precedence, does this always get an entry point? Can its .val be None? How is this case different than the one below?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, yes, as this is the general purpose case and can be anything.

In the other file, it is a utility function only used for the run goal, so it always has an entry point.

inject_args=field_set.args.value or [],
inject_env=field_set.env.value or FrozenDict[str, str](),
platforms=PexPlatforms.create_from_platforms_field(field_set.platforms),
Expand Down
4 changes: 3 additions & 1 deletion src/python/pants/backend/python/goals/run_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from pants.backend.python.subsystems.debugpy import DebugPy
from pants.backend.python.target_types import (
ConsoleScript,
Executable,
PexEntryPointField,
ResolvedPexEntryPoint,
ResolvePexEntryPointRequest,
Expand Down Expand Up @@ -43,6 +44,7 @@ async def _create_python_source_run_request(
run_in_sandbox: bool,
pex_path: Iterable[Pex] = (),
console_script: Optional[ConsoleScript] = None,
executable: Optional[Executable] = None,
) -> RunRequest:
addresses = [address]
entry_point, transitive_targets = await MultiGet(
Expand All @@ -67,7 +69,7 @@ async def _create_python_source_run_request(
include_source_files=False,
include_local_dists=True,
# `PEX_EXTRA_SYS_PATH` should contain this entry_point's module.
main=console_script or entry_point.val,
main=executable or console_script or entry_point.val,
Copy link
Sponsor Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the different precedence here than above? Only one should be set, so it may not matter in practice, but it's good to be consistent.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function always gets an entry_point, whereas the others can be None. So we check the Nones first.

Oddly enough, nothing in pants actually passes a ConsoleScript to this function. This PR adds a heuristic that might pass an Executable.

So, would you prefer:

Suggested change
main=executable or console_script or entry_point.val,
main=console_script or executable or entry_point.val,

Copy link
Sponsor Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it's fine the way it is, it just scans oddly.

additional_args=(
# N.B.: Since we cobble together the runtime environment via PEX_EXTRA_SYS_PATH
# below, it's important for any app that re-executes itself that these environment
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,52 @@ def test_local_dist() -> None:
assert result.stdout == "LOCAL DIST\n"


def test_local_dist_with_executable_main() -> None:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a copy of test_local_dist, but using an executable main instead of an entry point.

sources = {
"foo/bar.py": "BAR = 'LOCAL DIST'",
"foo/setup.py": dedent(
"""\
from setuptools import setup # pants: no-infer-dep

# Double-brace the package_dir to avoid setup_tmpdir treating it as a format.
setup(name="foo", version="9.8.7", packages=["foo"], package_dir={{"foo": "."}},)
"""
),
"foo/foo-bar-main": "from foo.bar import BAR; print(BAR)",
"foo/BUILD": dedent(
"""\
python_sources(name="lib", sources=["bar.py", "setup.py"])

python_sources(name="main_exe", sources=["foo-bar-main"])

python_distribution(
name="dist",
dependencies=[":lib"],
provides=python_artifact(name="foo", version="9.8.7"),
sdist=False,
generate_setup=False,
)

pex_binary(
name="bin",
executable="foo-bar-main",
# Force-exclude any dep on bar.py, so the only way to consume it is via the dist.
dependencies=[":main_exe", ":dist", "!!:lib"])
"""
),
}
with setup_tmpdir(sources) as tmpdir:
args = [
"--backend-packages=pants.backend.python",
f"--source-root-patterns=['/{tmpdir}']",
"run",
f"{tmpdir}/foo:bin",
]
result = run_pants(args)
result.assert_success()
assert result.stdout == "LOCAL DIST\n"


def test_run_script_from_3rdparty_dist_issue_13747() -> None:
sources = {
"src/BUILD": dedent(
Expand Down
18 changes: 18 additions & 0 deletions src/python/pants/backend/python/goals/run_python_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from dataclasses import dataclass
from pathlib import PurePath
from typing import Optional

from pants.backend.python.goals.run_helper import (
_create_python_source_run_dap_request,
Expand All @@ -10,6 +12,7 @@
from pants.backend.python.subsystems.debugpy import DebugPy
from pants.backend.python.subsystems.setup import PythonSetup
from pants.backend.python.target_types import (
Executable,
InterpreterConstraintsField,
PexEntryPointField,
PythonRunGoalUseSandboxField,
Expand Down Expand Up @@ -48,6 +51,18 @@ def should_use_sandbox(self, python_setup: PythonSetup) -> bool:
return python_setup.default_run_goal_use_sandbox
return self._run_goal_use_sandbox.value

def _executable_main(self) -> Optional[Executable]:
source = PurePath(self.source.value)
source_name = source.stem if source.suffix == ".py" else source.name
if not all(part.isidentifier() for part in source_name.split(".")):
# If the python source is not importable (python modules can't be named with '-'),
# then it must be an executable script.
executable = Executable(self.source.value)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops. self.source.value is relative to the BUILD file, but Executable needs to be relative to the workspace root, similar to the logic in PexExecutableField.

executable = Executable(os.path.join(self.address.spec_path, self.source.value).lstrip(os.path.sep))

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The integration tests are only testing running a pex_binary, not python_source.
So, we need to add another parameter to this test to use app.py or my-app.py so it uses the Executable main:
https://github.com/pantsbuild/pants/blob/main/src/python/pants/backend/python/goals/run_python_source_integration_test.py#L114-L137

else:
# The module is importable, so entry_point will do the heavy lifting instead.
executable = None
return executable


@rule(level=LogLevel.DEBUG)
async def create_python_source_run_request(
Expand All @@ -56,6 +71,7 @@ async def create_python_source_run_request(
return await _create_python_source_run_request(
field_set.address,
entry_point_field=PexEntryPointField(field_set.source.value, field_set.address),
executable=field_set._executable_main(),
pex_env=pex_env,
run_in_sandbox=field_set.should_use_sandbox(python_setup),
)
Expand All @@ -70,6 +86,7 @@ async def create_python_source_run_in_sandbox_request(
run_request = await _create_python_source_run_request(
field_set.address,
entry_point_field=PexEntryPointField(field_set.source.value, field_set.address),
executable=field_set._executable_main(),
pex_env=pex_env,
run_in_sandbox=True,
)
Expand Down Expand Up @@ -101,6 +118,7 @@ async def create_python_source_debug_adapter_request(
run_request = await _create_python_source_run_request(
field_set.address,
entry_point_field=PexEntryPointField(field_set.source.value, field_set.address),
executable=field_set._executable_main(),
pex_env=pex_env,
pex_path=[debugpy_pex],
run_in_sandbox=run_in_sandbox,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,13 @@ class PythonToolBase(PythonToolRequirementsBase):
# Subclasses must set.
default_main: ClassVar[MainSpecification]

# Though possible, we do not recommend setting `default_main` to an Executable
# instead of a ConsoleScript or an EntryPoint. Executable is a niche pex feature
# designed to support poorly named executable python scripts that cannot be imported
# (eg when a file has a character like "-" that is not valid in python identifiers).
# As this should be rare or even non-existant, we do NOT add an `executable` option
cognifloyd marked this conversation as resolved.
Show resolved Hide resolved
# to mirror the other MainSpecification options.

console_script = StrOption(
advanced=True,
default=lambda cls: (
Expand Down
65 changes: 57 additions & 8 deletions src/python/pants/backend/python/target_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
)
from pants.core.util_rules.environments import EnvironmentField
from pants.engine.addresses import Address, Addresses
from pants.engine.rules import Get
from pants.engine.target import (
COMMON_TARGET_FIELDS,
AsyncFieldMixin,
Expand Down Expand Up @@ -296,6 +297,21 @@ def spec(self) -> str:
return self.name


@dataclass(frozen=True)
class Executable(MainSpecification):
executable: str

def iter_pex_args(self) -> Iterator[str]:
yield "--executable"
# We do NOT yield self.executable or self.spec
# as the path needs additional processing in the rule graph.
# see: build_pex in util_rules/pex

@property
def spec(self) -> str:
return self.executable


class EntryPointField(AsyncFieldMixin, Field):
alias = "entry_point"
default = None
Expand All @@ -309,8 +325,8 @@ class EntryPointField(AsyncFieldMixin, Field):
1) `'app.py'`, Pants will convert into the module `path.to.app`;
2) `'app.py:func'`, Pants will convert into `path.to.app:func`.

You may either set this field or the `script` field, but not both. Leave off both fields
to have no entry point.
You may only set one of: this field, or the `script` field, or the `executable` field.
Leave off all three fields to have no entry point.
"""
)
value: EntryPoint | None
Expand Down Expand Up @@ -355,8 +371,8 @@ class PexScriptField(Field):
Set the entry point, i.e. what gets run when executing `./my_app.pex`, to a script or
console_script as defined by any of the distributions in the PEX.

You may either set this field or the `entry_point` field, but not both. Leave off both
fields to have no entry point.
You may only set one of: this field, or the `entry_point` field, or the `executable` field.
Leave off all three fields to have no entry point.
"""
)
value: ConsoleScript | None
Expand All @@ -371,6 +387,33 @@ def compute_value(cls, raw_value: Optional[str], address: Address) -> Optional[C
return ConsoleScript(value)


class PexExecutableField(Field):
alias = "executable"
default = None
help = help_text(
"""
Set the entry point, i.e. what gets run when executing `./my_app.pex`, to an execuatble
local python script. This executable python script is typically something that cannot
be imported so it cannot be used via `script` or `entry_point`.

You may only set one of: this field, or the `entry_point` field, or the `script` field.
Leave off all three fields to have no entry point.
"""
)
value: Executable | None

@classmethod
def compute_value(cls, raw_value: Optional[str], address: Address) -> Optional[Executable]:
value = super().compute_value(raw_value, address)
if value is None:
return None
if not isinstance(value, str):
raise InvalidFieldTypeException(address, cls.alias, value, expected_type="a string")
# spec_path is relative to the workspace. The rule is responsible for
# stripping the source root as needed.
return Executable(f"{address.spec_path}/{value}")


class PexArgsField(StringSequenceField):
alias = "args"
help = help_text(
Expand Down Expand Up @@ -724,6 +767,7 @@ class PexBinary(Target):
*_PEX_BINARY_COMMON_FIELDS,
PexEntryPointField,
PexScriptField,
PexExecutableField,
PexArgsField,
PexEnvField,
OutputPathField,
Expand All @@ -738,13 +782,18 @@ class PexBinary(Target):
)

def validate(self) -> None:
if self[PexEntryPointField].value is not None and self[PexScriptField].value is not None:
got_entry_point = self[PexEntryPointField].value is not None
got_script = self[PexScriptField].value is not None
got_executable = self[PexExecutableField].value is not None

if (got_entry_point + got_script + got_executable) > 1:
raise InvalidTargetException(
softwrap(
f"""
The `{self.alias}` target {self.address} cannot set both the
`{self[PexEntryPointField].alias}` and `{self[PexScriptField].alias}` fields at
the same time. To fix, please remove one.
The `{self.alias}` target {self.address} cannot set more than one of the
`{self[PexEntryPointField].alias}`, `{self[PexScriptField].alias}`, and
`{self[PexExecutableField].alias}` fields at the same time.
To fix, please remove all but one.
"""
)
)
Expand Down
19 changes: 17 additions & 2 deletions src/python/pants/backend/python/target_types_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@
from pants.backend.python.target_types import (
ConsoleScript,
EntryPoint,
Executable,
PexBinariesGeneratorTarget,
PexBinary,
PexEntryPointField,
PexExecutableField,
PexScriptField,
PythonDistribution,
PythonRequirementsField,
Expand Down Expand Up @@ -54,15 +56,28 @@


def test_pex_binary_validation() -> None:
def create_tgt(*, script: str | None = None, entry_point: str | None = None) -> PexBinary:
def create_tgt(
*, script: str | None = None, executable: str | None = None, entry_point: str | None = None
) -> PexBinary:
return PexBinary(
{PexScriptField.alias: script, PexEntryPointField.alias: entry_point},
{
PexScriptField.alias: script,
PexExecutableField.alias: executable,
PexEntryPointField.alias: entry_point,
},
Address("", target_name="t"),
)

with pytest.raises(InvalidTargetException):
create_tgt(script="foo", executable="foo", entry_point="foo")
with pytest.raises(InvalidTargetException):
create_tgt(script="foo", executable="foo")
with pytest.raises(InvalidTargetException):
create_tgt(script="foo", entry_point="foo")
with pytest.raises(InvalidTargetException):
create_tgt(executable="foo", entry_point="foo")
assert create_tgt(script="foo")[PexScriptField].value == ConsoleScript("foo")
assert create_tgt(executable="foo")[PexExecutableField].value == Executable("foo")
assert create_tgt(entry_point="foo")[PexEntryPointField].value == EntryPoint("foo")


Expand Down
15 changes: 14 additions & 1 deletion src/python/pants/backend/python/util_rules/pex.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from pants.backend.python.subsystems.setup import PythonSetup
from pants.backend.python.target_types import (
Executable,
MainSpecification,
PexCompletePlatformsField,
PexLayout,
Expand Down Expand Up @@ -55,6 +56,7 @@
from pants.build_graph.address import Address
from pants.core.target_types import FileSourceField
from pants.core.util_rules.environments import EnvironmentTarget
from pants.core.util_rules.stripped_source_files import StrippedFileName, StrippedFileNameRequest
from pants.core.util_rules.system_binaries import BashBinary
from pants.engine.addresses import Addresses, UnparsedAddressInputs
from pants.engine.collection import Collection, DeduplicatedCollection
Expand Down Expand Up @@ -682,8 +684,20 @@ async def build_pex(
pex_python_setup = await _determine_pex_python_and_platforms(request)
argv.extend(pex_python_setup.argv)

source_dir_name = "source_files"

if request.main is not None:
argv.extend(request.main.iter_pex_args())
if isinstance(request.main, Executable):
# Unlike other MainSpecifiecation types (that can pass spec as-is to pex),
# Executable must be an actual path relative to the sandbox.
# request.main.spec is a python source file including it's spec_path.
cognifloyd marked this conversation as resolved.
Show resolved Hide resolved
# To make it relative to the sandbox, we strip the source root
# and add the source_dir_name (sources get prefixed with that below).
stripped = await Get(
StrippedFileName, StrippedFileNameRequest(request.main.spec)
)
argv.append(f"{source_dir_name}/{stripped.value}")
cognifloyd marked this conversation as resolved.
Show resolved Hide resolved

argv.extend(
f"--inject-args={shlex.quote(injected_arg)}" for injected_arg in request.inject_args
Expand All @@ -696,7 +710,6 @@ async def build_pex(
if request.pex_path:
argv.extend(["--pex-path", ":".join(pex.name for pex in request.pex_path)])

source_dir_name = "source_files"
argv.append(f"--sources-directory={source_dir_name}")
sources_digest_as_subdir = await Get(
Digest, AddPrefix(request.sources or EMPTY_DIGEST, source_dir_name)
Expand Down
Loading