diff --git a/src/installer/utils.py b/src/installer/utils.py index d7cc670..4dcc143 100644 --- a/src/installer/utils.py +++ b/src/installer/utils.py @@ -22,6 +22,8 @@ cast, ) +from installer.exceptions import InstallerError + if TYPE_CHECKING: from installer.records import RecordEntry from installer.scripts import LauncherKind, ScriptSection @@ -31,6 +33,7 @@ __all__ = [ "SCHEME_NAMES", + "InvalidEntryPoint", "WheelFilename", "construct_record_file", "copyfileobj_with_hashing", @@ -63,6 +66,21 @@ "WheelFilename", ["distribution", "version", "build_tag", "tag"] ) + +class InvalidEntryPoint(ValueError, InstallerError): + """Raised when an ``entry_points.txt`` entry is invalid.""" + + def __init__(self, *, section: str, name: str, value: str, reason: str) -> None: + """Initialize with context about the invalid entry point.""" + super().__init__( + f"Invalid entry point {name!r} in [{section}]: {value!r}. {reason}" + ) + self.section = section + self.name = name + self.value = value + self.reason = reason + + # Adapted from https://github.com/python/importlib_metadata/blob/v3.4.0/importlib_metadata/__init__.py#L90 _ENTRYPOINT_REGEX = re.compile( r""" @@ -230,6 +248,7 @@ def parse_entrypoints(text: str) -> Iterable[tuple[str, str, str, "ScriptSection :param text: entire contents of the file :return: name of the script, module to use, attribute to call, kind of script (cli / gui) + :raises InvalidEntryPoint: if an entry point cannot be parsed. """ # Borrowed from https://github.com/python/importlib_metadata/blob/v3.4.0/importlib_metadata/__init__.py#L115 config = ConfigParser(delimiters="=") @@ -243,14 +262,25 @@ def parse_entrypoints(text: str) -> Iterable[tuple[str, str, str, "ScriptSection for name, value in config.items(section): assert isinstance(name, str) match = _ENTRYPOINT_REGEX.match(value) - assert match + if not match: + raise InvalidEntryPoint( + section=section, + name=name, + value=value, + reason="Expected a reference in the form module:attribute.", + ) module = match.group("module") assert isinstance(module, str) attrs = match.group("attrs") - # TODO: make this a proper error, which can be caught. - assert attrs is not None + if attrs is None: # pragma: no cover + raise InvalidEntryPoint( + section=section, + name=name, + value=value, + reason="Missing callable attribute.", + ) assert isinstance(attrs, str) script_section = cast("ScriptSection", section[: -len("_scripts")]) diff --git a/tests/test_core.py b/tests/test_core.py index 121b9d3..5aa7ea4 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -9,6 +9,7 @@ from installer.exceptions import InvalidWheelSource from installer.records import RecordEntry from installer.sources import WheelSource +from installer.utils import InvalidEntryPoint # -------------------------------------------------------------------------------------- @@ -351,6 +352,44 @@ def main(): ] ) + def test_invalid_entrypoint_errors_clearly(self, mock_destination): + source = FakeWheelSource( + distribution="fancy", + version="1.0.0", + regular_files={ + "fancy/__init__.py": b"", + }, + dist_info_files={ + "entry_points.txt": b"""\ + [console_scripts] + fancy = fancy-project:main + """, + "WHEEL": b"""\ + Wheel-Version: 1.0 + Generator: magic (1.0.0) + Root-Is-Purelib: true + Tag: py3-none-any + """, + "METADATA": b"""\ + Metadata-Version: 2.1 + Name: fancy + Version: 1.0.0 + """, + }, + ) + + with pytest.raises(InvalidEntryPoint) as ctx: + install( + source=source, + destination=mock_destination, + additional_metadata={}, + ) + + assert ctx.value.section == "console_scripts" + assert ctx.value.name == "fancy" + assert ctx.value.value == "fancy-project:main" + assert "fancy-project:main" in str(ctx.value) + def test_handles_platlib(self, mock_destination): # Create a fake wheel source = FakeWheelSource( diff --git a/tests/test_utils.py b/tests/test_utils.py index 22c262d..e059208 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -10,6 +10,7 @@ from installer.records import RecordEntry from installer.utils import ( + InvalidEntryPoint, WheelFilename, canonicalize_name, construct_record_file, @@ -264,3 +265,36 @@ class TestParseEntryPoints: def test_valid(self, script, expected): iterable = parse_entrypoints(textwrap.dedent(script)) assert list(iterable) == expected, expected + + @pytest.mark.parametrize( + ("script", "expected_name", "expected_value"), + [ + pytest.param( + """ + [console_scripts] + package = package-main:main + """, + "package", + "package-main:main", + id="invalid-module", + ), + pytest.param( + """ + [console_scripts] + package = package.__main__ + """, + "package", + "package.__main__", + id="missing-attribute", + ), + ], + ) + def test_invalid(self, script, expected_name, expected_value): + with pytest.raises(InvalidEntryPoint) as ctx: + list(parse_entrypoints(textwrap.dedent(script))) + + assert ctx.value.section == "console_scripts" + assert ctx.value.name == expected_name + assert ctx.value.value == expected_value + assert expected_name in str(ctx.value) + assert expected_value in str(ctx.value)