|
1 | 1 | import json |
2 | 2 | import logging |
| 3 | +import re |
3 | 4 | import time |
4 | 5 | from pathlib import Path |
5 | 6 | from subprocess import CompletedProcess |
6 | | -from typing import Dict, Generator, List, NoReturn, Set |
| 7 | +from typing import Dict, Generator, List, NoReturn, Optional, Set |
| 8 | + |
| 9 | +try: |
| 10 | + from importlib.metadata import Distribution, EntryPoint |
| 11 | +except ImportError: |
| 12 | + from importlib_metadata import Distribution, EntryPoint # type: ignore |
7 | 13 |
|
8 | 14 | from packaging.utils import canonicalize_name |
9 | 15 |
|
|
35 | 41 |
|
36 | 42 | logger = logging.getLogger(__name__) |
37 | 43 |
|
| 44 | +_entry_point_value_pattern = re.compile( |
| 45 | + r""" |
| 46 | + ^(?P<module>[\w.]+)\s* |
| 47 | + (:\s*(?P<attr>[\w.]+))?\s* |
| 48 | + (?P<extras>\[.*\])?\s*$ |
| 49 | + """, |
| 50 | + re.VERBOSE, |
| 51 | +) |
| 52 | + |
38 | 53 |
|
39 | 54 | class VenvContainer: |
40 | 55 | """A collection of venvs managed by pipx.""" |
@@ -336,8 +351,43 @@ def list_installed_packages(self) -> Set[str]: |
336 | 351 | pip_list = json.loads(cmd_run.stdout.strip()) |
337 | 352 | return set([x["name"] for x in pip_list]) |
338 | 353 |
|
| 354 | + def _find_entry_point(self, app: str) -> Optional[EntryPoint]: |
| 355 | + if not self.python_path.exists(): |
| 356 | + return None |
| 357 | + dists = Distribution.discover( |
| 358 | + name=self.main_package_name, |
| 359 | + path=[str(get_site_packages(self.python_path))], |
| 360 | + ) |
| 361 | + for dist in dists: |
| 362 | + for ep in dist.entry_points: |
| 363 | + if ep.group == "pipx.run" and ep.name == app: |
| 364 | + return ep |
| 365 | + return None |
| 366 | + |
339 | 367 | def run_app(self, app: str, filename: str, app_args: List[str]) -> NoReturn: |
340 | | - exec_app([str(self.bin_path / filename)] + app_args) |
| 368 | + entry_point = self._find_entry_point(app) |
| 369 | + |
| 370 | + # No [pipx.run] entry point; default to run console script. |
| 371 | + if entry_point is None: |
| 372 | + exec_app([str(self.bin_path / filename)] + app_args) |
| 373 | + |
| 374 | + # Evaluate and execute the entry point. |
| 375 | + # TODO: After dropping support for Python < 3.9, use |
| 376 | + # "entry_point.module" and "entry_point.attr" instead. |
| 377 | + match = _entry_point_value_pattern.match(entry_point.value) |
| 378 | + assert match is not None, "invalid entry point" |
| 379 | + module, attr = match.group("module", "attr") |
| 380 | + code = ( |
| 381 | + f"import sys, {module}\n" |
| 382 | + f"sys.argv[0] = {entry_point.name!r}\n" |
| 383 | + f"sys.exit({module}.{attr}())\n" |
| 384 | + ) |
| 385 | + exec_app([str(self.python_path), "-c", code] + app_args) |
| 386 | + |
| 387 | + def has_app(self, app: str, filename: str) -> bool: |
| 388 | + if self._find_entry_point(app) is not None: |
| 389 | + return True |
| 390 | + return (self.bin_path / filename).is_file() |
341 | 391 |
|
342 | 392 | def _upgrade_package_no_metadata(self, package: str, pip_args: List[str]) -> None: |
343 | 393 | with animate( |
|
0 commit comments