diff --git a/src/python/pants/backend/awslambda/python/lambdex.py b/src/python/pants/backend/awslambda/python/lambdex.py index 731a22df06c..d822e26f354 100644 --- a/src/python/pants/backend/awslambda/python/lambdex.py +++ b/src/python/pants/backend/awslambda/python/lambdex.py @@ -2,6 +2,7 @@ # Licensed under the Apache License, Version 2.0 (see LICENSE). from pants.backend.python.subsystems.python_tool_base import PythonToolBase +from pants.backend.python.target_types import ConsoleScript class Lambdex(PythonToolBase): @@ -14,4 +15,4 @@ class Lambdex(PythonToolBase): default_extra_requirements = ["setuptools>=50.3.0,<50.4"] register_interpreter_constraints = True default_interpreter_constraints = ["CPython>=3.5"] - default_entry_point = "lambdex.bin.lambdex" + default_main = ConsoleScript("lambdex") diff --git a/src/python/pants/backend/awslambda/python/rules.py b/src/python/pants/backend/awslambda/python/rules.py index b6ff2300e9a..21e1f8adf62 100644 --- a/src/python/pants/backend/awslambda/python/rules.py +++ b/src/python/pants/backend/awslambda/python/rules.py @@ -69,7 +69,7 @@ async def package_python_awslambda( PexFromTargetsRequest( addresses=[field_set.address], internal_only=False, - entry_point=None, + main=None, output_filename=output_filename, platforms=PexPlatforms([platform]), additional_args=[ @@ -87,7 +87,7 @@ async def package_python_awslambda( internal_only=True, requirements=PexRequirements(lambdex.all_requirements), interpreter_constraints=PexInterpreterConstraints(lambdex.interpreter_constraints), - entry_point=lambdex.entry_point, + main=lambdex.main, ) lambdex_pex, pex_result, handler = await MultiGet( diff --git a/src/python/pants/backend/python/goals/coverage_py.py b/src/python/pants/backend/python/goals/coverage_py.py index 334aea29187..2b06e169674 100644 --- a/src/python/pants/backend/python/goals/coverage_py.py +++ b/src/python/pants/backend/python/goals/coverage_py.py @@ -11,6 +11,7 @@ from typing import List, Optional, Tuple, cast from pants.backend.python.subsystems.python_tool_base import PythonToolBase +from pants.backend.python.target_types import ConsoleScript from pants.backend.python.util_rules.pex import ( PexInterpreterConstraints, PexRequest, @@ -99,7 +100,7 @@ class CoverageSubsystem(PythonToolBase): help = "Configuration for Python test coverage measurement." default_version = "coverage>=5.0.3,<5.1" - default_entry_point = "coverage" + default_main = ConsoleScript("coverage") register_interpreter_constraints = True default_interpreter_constraints = ["CPython>=3.6"] @@ -228,7 +229,7 @@ async def setup_coverage(coverage: CoverageSubsystem) -> CoverageSetup: internal_only=True, requirements=PexRequirements(coverage.all_requirements), interpreter_constraints=PexInterpreterConstraints(coverage.interpreter_constraints), - entry_point=coverage.entry_point, + main=coverage.main, ), ) return CoverageSetup(pex) diff --git a/src/python/pants/backend/python/goals/package_pex_binary.py b/src/python/pants/backend/python/goals/package_pex_binary.py index 67ce3f9e278..59b4b326898 100644 --- a/src/python/pants/backend/python/goals/package_pex_binary.py +++ b/src/python/pants/backend/python/goals/package_pex_binary.py @@ -113,7 +113,9 @@ async def package_pex_binary( PexFromTargetsRequest( addresses=[field_set.address], internal_only=False, - entry_point=resolved_entry_point.val, + # TODO(John Sirois): Support ConsoleScript in PexBinary targets: + # https://github.com/pantsbuild/pants/issues/11619 + main=resolved_entry_point.val, platforms=PexPlatforms.create_from_platforms_field(field_set.platforms), output_filename=output_filename, additional_args=field_set.generate_additional_args(pex_binary_defaults), diff --git a/src/python/pants/backend/python/goals/pytest_runner.py b/src/python/pants/backend/python/goals/pytest_runner.py index a09c951abb1..4a93d97c3c0 100644 --- a/src/python/pants/backend/python/goals/pytest_runner.py +++ b/src/python/pants/backend/python/goals/pytest_runner.py @@ -14,6 +14,7 @@ ) from pants.backend.python.subsystems.pytest import PyTest from pants.backend.python.target_types import ( + EntryPoint, PythonRuntimePackageDependencies, PythonTestsSources, PythonTestsTimeout, @@ -172,7 +173,9 @@ async def setup_pytest_for_target( PexRequest( output_filename="pytest_runner.pex", interpreter_constraints=interpreter_constraints, - entry_point="pytest", + # TODO(John Sirois): Switch to ConsoleScript once Pex supports discovering console + # scripts via the PEX_PATH: https://github.com/pantsbuild/pex/issues/1257 + main=EntryPoint("pytest"), internal_only=True, pex_path=[pytest_pex, requirements_pex], ), diff --git a/src/python/pants/backend/python/goals/repl.py b/src/python/pants/backend/python/goals/repl.py index 5cfa8949392..e1bb22f256c 100644 --- a/src/python/pants/backend/python/goals/repl.py +++ b/src/python/pants/backend/python/goals/repl.py @@ -78,7 +78,7 @@ async def create_ipython_repl_request( Pex, PexRequest( output_filename="ipython.pex", - entry_point=ipython.entry_point, + main=ipython.main, requirements=PexRequirements(ipython.all_requirements), interpreter_constraints=requirements_pex_request.interpreter_constraints, internal_only=True, diff --git a/src/python/pants/backend/python/goals/run_pex_binary.py b/src/python/pants/backend/python/goals/run_pex_binary.py index dba4a68b37c..c3fa7ccb0fb 100644 --- a/src/python/pants/backend/python/goals/run_pex_binary.py +++ b/src/python/pants/backend/python/goals/run_pex_binary.py @@ -62,7 +62,9 @@ async def create_pex_binary_run_request( internal_only=True, # Note that the entry point file is not in the PEX itself. It's loaded by setting # `PEX_EXTRA_SYS_PATH`. - entry_point=entry_point.val, + # TODO(John Sirois): Support ConsoleScript in PexBinary targets: + # https://github.com/pantsbuild/pants/issues/11619 + main=entry_point.val, ), ) diff --git a/src/python/pants/backend/python/goals/setup_py.py b/src/python/pants/backend/python/goals/setup_py.py index 8af97bcc19d..3a0938f1a8d 100644 --- a/src/python/pants/backend/python/goals/setup_py.py +++ b/src/python/pants/backend/python/goals/setup_py.py @@ -535,7 +535,7 @@ async def generate_chroot(request: SetupPyChrootRequest) -> SetupPyChroot: f"{binary.address} left the field off. Set `entry_point` to either " f"`app.py:func` or the longhand `path.to.app:func`. See {url}." ) - if ":" not in entry_point: + if not entry_point.function: raise InvalidEntryPoint( "Every `pex_binary` used in `with_binaries()` for the `provides()` field for " f"{exported_addr} must end in the format `:my_func` for the `entry_point` field, " diff --git a/src/python/pants/backend/python/lint/bandit/rules.py b/src/python/pants/backend/python/lint/bandit/rules.py index b46d302329e..8b1a83e2cb8 100644 --- a/src/python/pants/backend/python/lint/bandit/rules.py +++ b/src/python/pants/backend/python/lint/bandit/rules.py @@ -69,7 +69,7 @@ async def bandit_lint_partition( internal_only=True, requirements=PexRequirements(bandit.all_requirements), interpreter_constraints=partition.interpreter_constraints, - entry_point=bandit.entry_point, + main=bandit.main, ), ) diff --git a/src/python/pants/backend/python/lint/bandit/subsystem.py b/src/python/pants/backend/python/lint/bandit/subsystem.py index 8d94226b99c..c3c33d6e64a 100644 --- a/src/python/pants/backend/python/lint/bandit/subsystem.py +++ b/src/python/pants/backend/python/lint/bandit/subsystem.py @@ -4,6 +4,7 @@ from typing import Optional, Tuple, cast from pants.backend.python.subsystems.python_tool_base import PythonToolBase +from pants.backend.python.target_types import ConsoleScript from pants.option.custom_types import file_option, shell_str @@ -14,7 +15,7 @@ class Bandit(PythonToolBase): default_version = "bandit>=1.6.2,<1.7" # `setuptools<45` is for Python 2 support. `stevedore` is because the 3.0 release breaks Bandit. default_extra_requirements = ["setuptools<45", "stevedore<3"] - default_entry_point = "bandit" + default_main = ConsoleScript("bandit") @classmethod def register_options(cls, register): diff --git a/src/python/pants/backend/python/lint/black/rules.py b/src/python/pants/backend/python/lint/black/rules.py index 8e77504e44a..95f36db5d41 100644 --- a/src/python/pants/backend/python/lint/black/rules.py +++ b/src/python/pants/backend/python/lint/black/rules.py @@ -101,7 +101,7 @@ async def setup_black( internal_only=True, requirements=PexRequirements(black.all_requirements), interpreter_constraints=tool_interpreter_constraints, - entry_point=black.entry_point, + main=black.main, ), ) diff --git a/src/python/pants/backend/python/lint/black/subsystem.py b/src/python/pants/backend/python/lint/black/subsystem.py index bc34688ef8e..71e6226ca47 100644 --- a/src/python/pants/backend/python/lint/black/subsystem.py +++ b/src/python/pants/backend/python/lint/black/subsystem.py @@ -4,6 +4,7 @@ from typing import Optional, Tuple, cast from pants.backend.python.subsystems.python_tool_base import PythonToolBase +from pants.backend.python.target_types import ConsoleScript from pants.option.custom_types import file_option, shell_str @@ -14,7 +15,7 @@ class Black(PythonToolBase): # TODO: simplify `test_works_with_python39()` to stop using a VCS version. default_version = "black==20.8b1" default_extra_requirements = ["setuptools"] - default_entry_point = "black:patched_main" + default_main = ConsoleScript("black") register_interpreter_constraints = True default_interpreter_constraints = ["CPython>=3.6"] diff --git a/src/python/pants/backend/python/lint/docformatter/rules.py b/src/python/pants/backend/python/lint/docformatter/rules.py index 3ed815de094..76fee697d8e 100644 --- a/src/python/pants/backend/python/lint/docformatter/rules.py +++ b/src/python/pants/backend/python/lint/docformatter/rules.py @@ -66,7 +66,7 @@ async def setup_docformatter(setup_request: SetupRequest, docformatter: Docforma internal_only=True, requirements=PexRequirements(docformatter.all_requirements), interpreter_constraints=PexInterpreterConstraints(docformatter.interpreter_constraints), - entry_point=docformatter.entry_point, + main=docformatter.main, ), ) diff --git a/src/python/pants/backend/python/lint/docformatter/subsystem.py b/src/python/pants/backend/python/lint/docformatter/subsystem.py index d70b93e50c7..2447135eb54 100644 --- a/src/python/pants/backend/python/lint/docformatter/subsystem.py +++ b/src/python/pants/backend/python/lint/docformatter/subsystem.py @@ -4,6 +4,7 @@ from typing import Tuple, cast from pants.backend.python.subsystems.python_tool_base import PythonToolBase +from pants.backend.python.target_types import ConsoleScript from pants.option.custom_types import shell_str @@ -12,7 +13,7 @@ class Docformatter(PythonToolBase): help = "The Python docformatter tool (https://github.com/myint/docformatter)." default_version = "docformatter>=1.3.1,<1.4" - default_entry_point = "docformatter" + default_main = ConsoleScript("docformatter") register_interpreter_constraints = True default_interpreter_constraints = ["CPython==2.7.*", "CPython>=3.4,<3.9"] diff --git a/src/python/pants/backend/python/lint/flake8/rules.py b/src/python/pants/backend/python/lint/flake8/rules.py index 499bc9c7c59..003a0847caf 100644 --- a/src/python/pants/backend/python/lint/flake8/rules.py +++ b/src/python/pants/backend/python/lint/flake8/rules.py @@ -69,7 +69,7 @@ async def flake8_lint_partition( internal_only=True, requirements=PexRequirements(flake8.all_requirements), interpreter_constraints=partition.interpreter_constraints, - entry_point=flake8.entry_point, + main=flake8.main, ), ) diff --git a/src/python/pants/backend/python/lint/flake8/subsystem.py b/src/python/pants/backend/python/lint/flake8/subsystem.py index 1d254eee0b0..e409b833d61 100644 --- a/src/python/pants/backend/python/lint/flake8/subsystem.py +++ b/src/python/pants/backend/python/lint/flake8/subsystem.py @@ -4,6 +4,7 @@ from typing import Optional, Tuple, cast from pants.backend.python.subsystems.python_tool_base import PythonToolBase +from pants.backend.python.target_types import ConsoleScript from pants.option.custom_types import file_option, shell_str @@ -16,7 +17,7 @@ class Flake8(PythonToolBase): "setuptools<45; python_full_version == '2.7.*'", "setuptools; python_version > '2.7'", ] - default_entry_point = "flake8" + default_main = ConsoleScript("flake8") @classmethod def register_options(cls, register): diff --git a/src/python/pants/backend/python/lint/isort/rules.py b/src/python/pants/backend/python/lint/isort/rules.py index 3c3e1984652..48b3a7b9acf 100644 --- a/src/python/pants/backend/python/lint/isort/rules.py +++ b/src/python/pants/backend/python/lint/isort/rules.py @@ -77,7 +77,7 @@ async def setup_isort(setup_request: SetupRequest, isort: Isort) -> Setup: internal_only=True, requirements=PexRequirements(isort.all_requirements), interpreter_constraints=PexInterpreterConstraints(isort.interpreter_constraints), - entry_point=isort.entry_point, + main=isort.main, ), ) diff --git a/src/python/pants/backend/python/lint/isort/subsystem.py b/src/python/pants/backend/python/lint/isort/subsystem.py index 7d9c7351542..9d5ff0454c0 100644 --- a/src/python/pants/backend/python/lint/isort/subsystem.py +++ b/src/python/pants/backend/python/lint/isort/subsystem.py @@ -4,6 +4,7 @@ from typing import Tuple, cast from pants.backend.python.subsystems.python_tool_base import PythonToolBase +from pants.backend.python.target_types import ConsoleScript from pants.option.custom_types import file_option, shell_str @@ -15,7 +16,7 @@ class Isort(PythonToolBase): default_extra_requirements = ["setuptools"] register_interpreter_constraints = True default_interpreter_constraints = ["CPython>=3.6"] - default_entry_point = "isort.main" + default_main = ConsoleScript("isort") @classmethod def register_options(cls, register): diff --git a/src/python/pants/backend/python/lint/pylint/rules.py b/src/python/pants/backend/python/lint/pylint/rules.py index 3efd2ec6e30..b7f8043cc3a 100644 --- a/src/python/pants/backend/python/lint/pylint/rules.py +++ b/src/python/pants/backend/python/lint/pylint/rules.py @@ -179,7 +179,7 @@ async def pylint_lint_partition(partition: PylintPartition, pylint: Pylint) -> L PexRequest( output_filename="pylint_runner.pex", interpreter_constraints=partition.interpreter_constraints, - entry_point=pylint.entry_point, + main=pylint.main, internal_only=True, pex_path=[pylint_pex, requirements_pex], ), diff --git a/src/python/pants/backend/python/lint/pylint/subsystem.py b/src/python/pants/backend/python/lint/pylint/subsystem.py index 93acfda79f0..dda64e476c7 100644 --- a/src/python/pants/backend/python/lint/pylint/subsystem.py +++ b/src/python/pants/backend/python/lint/pylint/subsystem.py @@ -6,6 +6,7 @@ from typing import List, cast from pants.backend.python.subsystems.python_tool_base import PythonToolBase +from pants.backend.python.target_types import EntryPoint from pants.engine.addresses import UnparsedAddressInputs from pants.option.custom_types import file_option, shell_str, target_option from pants.util.docutil import docs_url @@ -16,7 +17,9 @@ class Pylint(PythonToolBase): help = "The Pylint linter for Python code (https://www.pylint.org/)." default_version = "pylint>=2.4.4,<2.5" - default_entry_point = "pylint" + # TODO(John Sirois): Switch to ConsoleScript once Pex supports discovering console + # scripts via the PEX_PATH: https://github.com/pantsbuild/pex/issues/1257 + default_main = EntryPoint("pylint") @classmethod def register_options(cls, register): diff --git a/src/python/pants/backend/python/subsystems/ipython.py b/src/python/pants/backend/python/subsystems/ipython.py index e2079042176..f7ddc3adbca 100644 --- a/src/python/pants/backend/python/subsystems/ipython.py +++ b/src/python/pants/backend/python/subsystems/ipython.py @@ -2,6 +2,7 @@ # Licensed under the Apache License, Version 2.0 (see LICENSE). from pants.backend.python.subsystems.python_tool_base import PythonToolBase +from pants.backend.python.target_types import ConsoleScript class IPython(PythonToolBase): @@ -9,7 +10,7 @@ class IPython(PythonToolBase): help = "The IPython enhanced REPL (https://ipython.org/)." default_version = "ipython==7.16.1" # The last version to support Python 3.6. - default_entry_point = "IPython:start_ipython" + default_main = ConsoleScript("ipython") @classmethod def register_options(cls, register): diff --git a/src/python/pants/backend/python/subsystems/python_tool_base.py b/src/python/pants/backend/python/subsystems/python_tool_base.py index 9ab0299cc19..cd78b055d00 100644 --- a/src/python/pants/backend/python/subsystems/python_tool_base.py +++ b/src/python/pants/backend/python/subsystems/python_tool_base.py @@ -1,8 +1,10 @@ # Copyright 2018 Pants project contributors (see CONTRIBUTORS.md). # Licensed under the Apache License, Version 2.0 (see LICENSE). -from typing import ClassVar, Optional, Sequence, Tuple, cast +from typing import ClassVar, Sequence, Tuple, cast +from pants.backend.python.target_types import ConsoleScript, EntryPoint, MainSpecification +from pants.option.errors import OptionsError from pants.option.subsystem import Subsystem @@ -52,7 +54,7 @@ class PythonToolBase(PythonToolRequirementsBase): """Base class for subsystems that configure a python tool to be invoked out-of-process.""" # Subclasses must set. - default_entry_point: ClassVar[str] + default_main: ClassVar[MainSpecification] # Subclasses do not need to override. default_interpreter_constraints: ClassVar[Sequence[str]] = [] register_interpreter_constraints: ClassVar[bool] = False @@ -60,14 +62,27 @@ class PythonToolBase(PythonToolRequirementsBase): @classmethod def register_options(cls, register): super().register_options(register) + register( + "--console-script", + type=str, + advanced=True, + default=cls.default_main if isinstance(cls.default_main, ConsoleScript) else None, + help=( + "The console script for the tool. Using this option is generally preferable to " + "(and mutually exclusive with) specifying an --entry-point since console script " + "names have a higher expectation of staying stable across releases of the tool. " + "Usually, you will not want to change this from the default." + ), + ) register( "--entry-point", type=str, advanced=True, - default=cls.default_entry_point, + default=cls.default_main if isinstance(cls.default_main, EntryPoint) else None, help=( - "The main module for the tool. Usually, you will not want to change this from the " - "default." + "The entry point for the tool. Generally you only want to use this option if the " + "tool does not offer a --console-script (which this option is mutually exclusive " + "with). Usually, you will not want to change this from the default." ), ) @@ -92,5 +107,17 @@ def interpreter_constraints(self) -> Tuple[str, ...]: return tuple(self.options.interpreter_constraints) @property - def entry_point(self) -> Optional[str]: - return cast(Optional[str], self.options.entry_point) + def main(self) -> MainSpecification: + is_default_console_script = self.options.is_default("console_script") + is_default_entry_point = self.options.is_default("entry_point") + if not is_default_console_script and not is_default_entry_point: + raise OptionsError( + f"Both --console-script={self.options.console_script} and " + f"--entry-point={self.options.entry_point} are configured but these options are " + f"mutually exclusive. Pick one." + ) + if not is_default_console_script: + return ConsoleScript(cast(str, self.options.console_script)) + if not is_default_entry_point: + return EntryPoint.parse(cast(str, self.options.entry_point)) + return self.default_main diff --git a/src/python/pants/backend/python/target_types.py b/src/python/pants/backend/python/target_types.py index c5f0f08b20c..e51382d8789 100644 --- a/src/python/pants/backend/python/target_types.py +++ b/src/python/pants/backend/python/target_types.py @@ -1,9 +1,12 @@ # Copyright 2020 Pants project contributors (see CONTRIBUTORS.md). # Licensed under the Apache License, Version 2.0 (see LICENSE). +from __future__ import annotations + import collections.abc import logging import os.path +from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum from textwrap import dedent @@ -104,7 +107,73 @@ class PexBinaryDependencies(Dependencies): supports_transitive_excludes = True -class PexEntryPointField(StringField, AsyncFieldMixin, SecondaryOwnerMixin): +class MainSpecification(ABC): + @abstractmethod + def iter_pex_args(self) -> Iterator[str]: + ... + + +@dataclass(frozen=True) +class EntryPoint(MainSpecification): + module: str + function: str | None = None + + @classmethod + def parse(cls, value: str, provenance: str | None = None) -> EntryPoint: + given = f"entry point {provenance}" if provenance else "entry point" + entry_point = value.strip() + if not entry_point: + raise ValueError( + f"The {given} cannot be blank. It must indicate a Python module by name or path " + f"and an optional nullary function in that module separated by a colon, i.e.: " + f"module_name_or_path(':'function_name)?" + ) + module_or_path, sep, func = entry_point.partition(":") + if not module_or_path: + raise ValueError(f"The {given} must specify a module; given: {value!r}") + if ":" in func: + raise ValueError( + f"The {given} can only contain one colon separating the entry point's module from " + f"the entry point function in that module; given: {value!r}" + ) + if sep and not func: + logger.warning( + f"Assuming no entry point function and stripping trailing ':' from the {given}: " + f"{value!r}. Consider deleting it to make it clear no entry point function is " + f"intended." + ) + return cls(module=module_or_path, function=func if func else None) + + def __post_init__(self): + if ":" in self.module: + raise ValueError( + "The `:` character is not valid in a module name. Given an entry point module of " + f"{self.module}. Did you mean to use EntryPoint.parse?" + ) + if self.function and ":" in self.function: + raise ValueError( + "The `:` character is not valid in a function name. Given an entry point function" + f" of {self.function}." + ) + + def iter_pex_args(self) -> Iterator[str]: + yield "--entry-point" + yield str(self) + + def __str__(self) -> str: + return self.module if self.function is None else f"{self.module}:{self.function}" + + +@dataclass(frozen=True) +class ConsoleScript(MainSpecification): + name: str + + def iter_pex_args(self) -> Iterator[str]: + yield "--console-script" + yield self.name + + +class PexEntryPointField(AsyncFieldMixin, SecondaryOwnerMixin, Field): alias = "entry_point" help = ( "The entry point for the binary, i.e. what gets run when executing `./my_binary.pex`.\n\n" @@ -114,51 +183,36 @@ class PexEntryPointField(StringField, AsyncFieldMixin, SecondaryOwnerMixin): "will convert into `path.to.app:func`.\n\nYou must use the file name shorthand for file " "arguments to work with this target.\n\nTo leave off an entry point, set to ''." ) + required = True @classmethod - def compute_value(cls, raw_value: Optional[str], *, address: Address) -> Optional[str]: + def compute_value(cls, raw_value: Optional[str], *, address: Address) -> Optional[EntryPoint]: ep = super().compute_value(raw_value, address=address) - entry_point = ep.strip() if ep is not None else None - if not entry_point: + if ep is None: raise InvalidFieldException( - f"The entry point for {address} cannot be blank. It must indicate a Python module " - "by name or path and an optional nullary function in that module separated by a " - "colon, i.e.: module_name_or_path(':'function_name)?" + f"An entry point must be specified for for {address}. It must indicate a Python " + "module by name or path and an optional nullary function in that module separated " + "by a colon, i.e.: module_name_or_path(':'function_name)?" ) - module_or_path, sep, func = entry_point.partition(":") - if not module_or_path: - raise InvalidFieldException( - f"The entry point for {address} must specify a module; given: {ep!r}" - ) - if ":" in module_or_path or ":" in func: - raise InvalidFieldException( - f"The entry point for {address} can only contain one colon separating the entry " - f"point's module from the entry point function in that module; given: {ep!r}" - ) - if sep and not func: - logger.warning( - f"Assuming no entry point function and stripping trailing ':' from the entry point " - f"{ep!r} declared in {address}. Consider deleting it to make it clear no entry " - f"point function is intended." - ) - return module_or_path - return entry_point + try: + return EntryPoint.parse(ep, provenance=f"for {address}") + except ValueError as e: + raise InvalidFieldException(str(e)) @property def filespec(self) -> Filespec: if not self.value: return {"includes": []} - path, _, func = self.value.partition(":") - if not path.endswith(".py"): + if not self.value.module.endswith(".py"): return {"includes": []} - full_glob = os.path.join(self.address.spec_path, path) + full_glob = os.path.join(self.address.spec_path, self.value.module) return {"includes": [full_glob]} # See `target_types_rules.py` for the `ResolvePexEntryPointRequest -> ResolvedPexEntryPoint` rule. @dataclass(frozen=True) class ResolvedPexEntryPoint: - val: Optional[str] + val: Optional[EntryPoint] @dataclass(frozen=True) diff --git a/src/python/pants/backend/python/target_types_rules.py b/src/python/pants/backend/python/target_types_rules.py index b8990e6d2c0..37172a539a0 100644 --- a/src/python/pants/backend/python/target_types_rules.py +++ b/src/python/pants/backend/python/target_types_rules.py @@ -6,7 +6,7 @@ This is a separate module to avoid circular dependencies. Note that all types used by call sites are defined in `target_types.py`. """ - +import dataclasses import os.path from pants.backend.python.dependency_inference.module_mapper import PythonModule, PythonModuleOwners @@ -64,18 +64,16 @@ async def resolve_pex_entry_point(request: ResolvePexEntryPointRequest) -> Resol ) # Case #1. - if ep_val in ("", ""): + if ep_val.module in ("", ""): return ResolvedPexEntryPoint(None) - path, _, func = ep_val.partition(":") - # If it's already a module (cases #2 and #3), simply use that. Otherwise, convert the file name # into a module path (cases #4 and #5). - if not path.endswith(".py"): + if not ep_val.module.endswith(".py"): return ResolvedPexEntryPoint(ep_val) # Use the engine to validate that the file exists and that it resolves to only one file. - full_glob = os.path.join(address.spec_path, path) + full_glob = os.path.join(address.spec_path, ep_val.module) entry_point_paths = await Get( Paths, PathGlobs( @@ -101,7 +99,7 @@ async def resolve_pex_entry_point(request: ResolvePexEntryPointRequest) -> Resol stripped_source_path = os.path.relpath(entry_point_path, source_root.path) module_base, _ = os.path.splitext(stripped_source_path) normalized_path = module_base.replace(os.path.sep, ".") - return ResolvedPexEntryPoint(f"{normalized_path}:{func}" if func else normalized_path) + return ResolvedPexEntryPoint(dataclasses.replace(ep_val, module=normalized_path)) class InjectPexBinaryEntryPointDependency(InjectDependenciesRequest): @@ -121,8 +119,7 @@ async def inject_pex_binary_entry_point_dependency( ) if entry_point.val is None: return InjectedDependencies() - module, _, _func = entry_point.val.partition(":") - owners = await Get(PythonModuleOwners, PythonModule(module)) + owners = await Get(PythonModuleOwners, PythonModule(entry_point.val.module)) return InjectedDependencies(owners) diff --git a/src/python/pants/backend/python/target_types_test.py b/src/python/pants/backend/python/target_types_test.py index cc965d1da41..5b920dac3c7 100644 --- a/src/python/pants/backend/python/target_types_test.py +++ b/src/python/pants/backend/python/target_types_test.py @@ -13,6 +13,7 @@ from pants.backend.python.macros.python_artifact import PythonArtifact from pants.backend.python.subsystems.pytest import PyTest from pants.backend.python.target_types import ( + EntryPoint, PexBinary, PexBinaryDependencies, PexEntryPointField, @@ -105,7 +106,7 @@ def test_entry_point_validation(caplog: LogCaptureFixture) -> None: ep = "custom.entry_point:" with caplog.at_level(logging.WARNING): - assert "custom.entry_point" == PexEntryPointField(ep, address=addr).value + assert EntryPoint("custom.entry_point") == PexEntryPointField(ep, address=addr).value assert len(caplog.record_tuples) == 1 _, levelno, message = caplog.record_tuples[0] @@ -122,7 +123,7 @@ def test_resolve_pex_binary_entry_point() -> None: ] ) - def assert_resolved(*, entry_point: Optional[str], expected: Optional[str]) -> None: + def assert_resolved(*, entry_point: Optional[str], expected: Optional[EntryPoint]) -> None: addr = Address("src/python/project") rule_runner.create_file("src/python/project/app.py") rule_runner.create_file("src/python/project/f2.py") @@ -131,22 +132,26 @@ def assert_resolved(*, entry_point: Optional[str], expected: Optional[str]) -> N assert result.val == expected # Full module provided. - assert_resolved(entry_point="custom.entry_point", expected="custom.entry_point") - assert_resolved(entry_point="custom.entry_point:func", expected="custom.entry_point:func") + assert_resolved(entry_point="custom.entry_point", expected=EntryPoint("custom.entry_point")) + assert_resolved( + entry_point="custom.entry_point:func", expected=EntryPoint.parse("custom.entry_point:func") + ) # File names are expanded into the full module path. - assert_resolved(entry_point="app.py", expected="project.app") - assert_resolved(entry_point="app.py:func", expected="project.app:func") + assert_resolved(entry_point="app.py", expected=EntryPoint(module="project.app")) + assert_resolved( + entry_point="app.py:func", expected=EntryPoint(module="project.app", function="func") + ) # We special case the strings `` and ``. assert_resolved(entry_point="", expected=None) assert_resolved(entry_point="", expected=None) with pytest.raises(ExecutionError): - assert_resolved(entry_point="doesnt_exist.py", expected="doesnt matter") + assert_resolved(entry_point="doesnt_exist.py", expected=EntryPoint("doesnt matter")) # Resolving >1 file is an error. with pytest.raises(ExecutionError): - assert_resolved(entry_point="*.py", expected="doesnt matter") + assert_resolved(entry_point="*.py", expected=EntryPoint("doesnt matter")) def test_inject_pex_binary_entry_point_dependency() -> None: diff --git a/src/python/pants/backend/python/typecheck/mypy/rules.py b/src/python/pants/backend/python/typecheck/mypy/rules.py index 0eccd978862..ab35f714cb4 100644 --- a/src/python/pants/backend/python/typecheck/mypy/rules.py +++ b/src/python/pants/backend/python/typecheck/mypy/rules.py @@ -205,7 +205,7 @@ async def mypy_typecheck_partition(partition: MyPyPartition, mypy: MyPy) -> Type PexRequest( output_filename="mypy.pex", internal_only=True, - entry_point=mypy.entry_point, + main=mypy.main, requirements=PexRequirements((*mypy.all_requirements, *plugin_requirements)), interpreter_constraints=tool_interpreter_constraints, ), diff --git a/src/python/pants/backend/python/typecheck/mypy/subsystem.py b/src/python/pants/backend/python/typecheck/mypy/subsystem.py index dd5a16a4076..aa69b9c2d14 100644 --- a/src/python/pants/backend/python/typecheck/mypy/subsystem.py +++ b/src/python/pants/backend/python/typecheck/mypy/subsystem.py @@ -6,6 +6,7 @@ from typing import Tuple, cast from pants.backend.python.subsystems.python_tool_base import PythonToolBase +from pants.backend.python.target_types import ConsoleScript from pants.engine.addresses import UnparsedAddressInputs from pants.option.custom_types import file_option, shell_str, target_option @@ -15,7 +16,7 @@ class MyPy(PythonToolBase): help = "The MyPy Python type checker (http://mypy-lang.org/)." default_version = "mypy==0.800" - default_entry_point = "mypy" + default_main = ConsoleScript("mypy") # See `mypy/rules.py`. We only use these default constraints in some situations. Technically, # MyPy only requires 3.5+, but some popular plugins like `django-stubs` require 3.6+. Because # 3.5 is EOL, and users can tweak this back, this seems like a more sensible default. diff --git a/src/python/pants/backend/python/util_rules/pex.py b/src/python/pants/backend/python/util_rules/pex.py index 0d52c9e5d73..4bb9c580e64 100644 --- a/src/python/pants/backend/python/util_rules/pex.py +++ b/src/python/pants/backend/python/util_rules/pex.py @@ -17,7 +17,7 @@ from pkg_resources import Requirement from typing_extensions import Protocol -from pants.backend.python.target_types import InterpreterConstraintsField +from pants.backend.python.target_types import InterpreterConstraintsField, MainSpecification from pants.backend.python.target_types import PexPlatformsField as PythonPlatformsField from pants.backend.python.target_types import PythonRequirementsField from pants.backend.python.util_rules import pex_cli @@ -318,7 +318,7 @@ class PexRequest(EngineAwareParameter): platforms: PexPlatforms sources: Digest | None additional_inputs: Digest | None - entry_point: str | None + main: MainSpecification | None additional_args: Tuple[str, ...] pex_path: Tuple[Pex, ...] apply_requirement_constraints: bool @@ -334,7 +334,7 @@ def __init__( platforms=PexPlatforms(), sources: Digest | None = None, additional_inputs: Digest | None = None, - entry_point: str | None = None, + main: MainSpecification | None = None, additional_args: Iterable[str] = (), pex_path: Iterable[Pex] = (), apply_requirement_constraints: bool = True, @@ -356,7 +356,7 @@ def __init__( :param sources: Any source files that should be included in the Pex. :param additional_inputs: Any inputs that are not source files and should not be included directly in the Pex, but should be present in the environment when building the Pex. - :param entry_point: The entry-point for the built Pex, equivalent to Pex's `-m` flag. If + :param main: The main for the built Pex, equivalent to Pex's `-e` or '-c' flag. If left off, the Pex will open up as a REPL. :param additional_args: Any additional Pex flags. :param pex_path: Pex files to add to the PEX_PATH. @@ -372,7 +372,7 @@ def __init__( self.platforms = platforms self.sources = sources self.additional_inputs = additional_inputs - self.entry_point = entry_point + self.main = main self.additional_args = tuple(additional_args) self.pex_path = tuple(pex_path) self.apply_requirement_constraints = apply_requirement_constraints @@ -552,8 +552,8 @@ async def build_pex( else: argv.append("--no-manylinux") - if request.entry_point is not None: - argv.extend(["--entry-point", request.entry_point]) + if request.main is not None: + argv.extend(request.main.iter_pex_args()) source_dir_name = "source_files" argv.append(f"--sources-directory={source_dir_name}") diff --git a/src/python/pants/backend/python/util_rules/pex_from_targets.py b/src/python/pants/backend/python/util_rules/pex_from_targets.py index 13c75952812..08f2e26ade5 100644 --- a/src/python/pants/backend/python/util_rules/pex_from_targets.py +++ b/src/python/pants/backend/python/util_rules/pex_from_targets.py @@ -12,7 +12,11 @@ from packaging.utils import canonicalize_name as canonicalize_project_name from pkg_resources import Requirement -from pants.backend.python.target_types import PythonRequirementsField, parse_requirements_file +from pants.backend.python.target_types import ( + MainSpecification, + PythonRequirementsField, + parse_requirements_file, +) from pants.backend.python.util_rules.pex import ( PexInterpreterConstraints, PexPlatforms, @@ -57,7 +61,7 @@ class PexFromTargetsRequest: addresses: Addresses output_filename: str internal_only: bool - entry_point: str | None + main: MainSpecification | None platforms: PexPlatforms additional_args: Tuple[str, ...] additional_requirements: Tuple[str, ...] @@ -76,7 +80,7 @@ def __init__( *, output_filename: str, internal_only: bool, - entry_point: str | None = None, + main: MainSpecification | None = None, platforms: PexPlatforms = PexPlatforms(), additional_args: Iterable[str] = (), additional_requirements: Iterable[str] = (), @@ -97,7 +101,7 @@ def __init__( to end users, such as with the `binary` goal. Typically, instead, the user never directly uses the Pex, e.g. with `lint` and `test`. If True, we will use a Pex setting that results in faster build time but compatibility with fewer interpreters at runtime. - :param entry_point: The entry-point for the built Pex, equivalent to Pex's `-m` flag. If + :param main: The main for the built Pex, equivalent to Pex's `-e` or `-c` flag. If left off, the Pex will open up as a REPL. :param platforms: Which platforms should be supported. Setting this value will cause interpreter constraints to not be used because platforms already constrain the valid @@ -122,7 +126,7 @@ def __init__( self.addresses = Addresses(addresses) self.output_filename = output_filename self.internal_only = internal_only - self.entry_point = entry_point + self.main = main self.platforms = platforms self.additional_args = tuple(additional_args) self.additional_requirements = tuple(additional_requirements) @@ -289,7 +293,7 @@ async def pex_from_targets(request: PexFromTargetsRequest, python_setup: PythonS requirements=requirements, interpreter_constraints=interpreter_constraints, platforms=request.platforms, - entry_point=request.entry_point, + main=request.main, sources=merged_input_digest, additional_inputs=request.additional_inputs, additional_args=request.additional_args, diff --git a/src/python/pants/backend/python/util_rules/pex_test.py b/src/python/pants/backend/python/util_rules/pex_test.py index 1d35a8baa8a..4b58d9340cd 100644 --- a/src/python/pants/backend/python/util_rules/pex_test.py +++ b/src/python/pants/backend/python/util_rules/pex_test.py @@ -13,7 +13,11 @@ import pytest from pkg_resources import Requirement -from pants.backend.python.target_types import InterpreterConstraintsField +from pants.backend.python.target_types import ( + EntryPoint, + InterpreterConstraintsField, + MainSpecification, +) from pants.backend.python.util_rules.pex import ( Pex, PexInterpreterConstraints, @@ -326,7 +330,7 @@ def create_pex_and_get_all_data( *, pex_type: type[Pex | VenvPex] = Pex, requirements: PexRequirements = PexRequirements(), - entry_point: str | None = None, + main: MainSpecification | None = None, interpreter_constraints: PexInterpreterConstraints = PexInterpreterConstraints(), platforms: PexPlatforms = PexPlatforms(), sources: Digest | None = None, @@ -342,7 +346,7 @@ def create_pex_and_get_all_data( requirements=requirements, interpreter_constraints=interpreter_constraints, platforms=platforms, - entry_point=entry_point, + main=main, sources=sources, additional_inputs=additional_inputs, additional_args=additional_pex_args, @@ -376,7 +380,7 @@ def create_pex_and_get_pex_info( *, pex_type: type[Pex | VenvPex] = Pex, requirements: PexRequirements = PexRequirements(), - entry_point: str | None = None, + main: MainSpecification | None = None, interpreter_constraints: PexInterpreterConstraints = PexInterpreterConstraints(), platforms: PexPlatforms = PexPlatforms(), sources: Digest | None = None, @@ -390,7 +394,7 @@ def create_pex_and_get_pex_info( rule_runner, pex_type=pex_type, requirements=requirements, - entry_point=entry_point, + main=main, interpreter_constraints=interpreter_constraints, platforms=platforms, sources=sources, @@ -413,7 +417,7 @@ def test_pex_execution(rule_runner: RuleRunner) -> None: ), ], ) - pex_output = create_pex_and_get_all_data(rule_runner, entry_point="main", sources=sources) + pex_output = create_pex_and_get_all_data(rule_runner, main=EntryPoint("main"), sources=sources) pex_files = pex_output["files"] assert "pex" not in pex_files @@ -458,7 +462,7 @@ def test_pex_environment(rule_runner: RuleRunner, pex_type: type[Pex | VenvPex]) pex_output = create_pex_and_get_all_data( rule_runner, pex_type=pex_type, - entry_point="main", + main=EntryPoint("main"), sources=sources, additional_pants_args=( "--subprocess-environment-env-vars=LANG", # Value should come from environment. @@ -531,7 +535,7 @@ def assert_direct_requirements(pex_info): def test_entry_point(rule_runner: RuleRunner) -> None: entry_point = "pydoc" - pex_info = create_pex_and_get_pex_info(rule_runner, entry_point=entry_point) + pex_info = create_pex_and_get_pex_info(rule_runner, main=EntryPoint(entry_point)) assert pex_info["entry_point"] == entry_point