diff --git a/docs/cli.md b/docs/cli.md index b91b637ebab..d8385f00568 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1173,6 +1173,7 @@ required by * `--all (-a)`: Show all packages (even those not compatible with current system). * `--top-level (-T)`: Only show explicitly defined packages. * `--no-truncate`: Do not truncate the output based on the terminal width. +* `--source`: Show the source of each package (`PyPI`, a configured source name, or a direct source URL). * `--format (-f)`: Specify the output format (`json` or `text`). Default is `text`. `json` cannot be combined with the `--tree` option. {{% note %}} diff --git a/src/poetry/console/commands/show.py b/src/poetry/console/commands/show.py index ad79e13eaf5..d6b9136d255 100644 --- a/src/poetry/console/commands/show.py +++ b/src/poetry/console/commands/show.py @@ -40,6 +40,16 @@ def reverse_deps(pkg: Package, repo: Repository) -> dict[str, str]: return required_by +def get_package_source(package: Package) -> str: + if package.source_type is None: + return "PyPI" + + if package.source_type == "legacy": + return package.source_reference or package.source_url or "PyPI" + + return package.source_url or package.source_reference or package.source_type + + class OutputFormats(str, Enum): JSON = "json" TEXT = "text" @@ -79,6 +89,7 @@ class ShowCommand(GroupCommand, EnvCommand): None, "Do not truncate the output based on the terminal width.", ), + option("source", None, "Show the source of each package."), option( "format", "f", @@ -143,6 +154,12 @@ def handle(self) -> int: ) return 1 + if self.option("source") and self.option("tree"): + self.line_error( + "Error: --source cannot be used with --tree." + ) + return 1 + if self.option("outdated"): self.io.input.set_option("latest", True) @@ -229,6 +246,8 @@ def _display_single_package_information( "version": pkg.pretty_version, "description": pkg.description, } + if self.option("source"): + package_info["source"] = get_package_source(pkg) if pkg.requires: package_info["dependencies"] = { dependency.pretty_name: dependency.pretty_constraint @@ -246,6 +265,10 @@ def _display_single_package_information( ["version", f" : {pkg.pretty_version}"], ["description", f" : {pkg.description}"], ] + if self.option("source"): + rows.insert( + 2, ["source", f" : {get_package_source(pkg)}"] + ) self.table(rows=rows, style="compact").render() @@ -304,12 +327,14 @@ def _display_packages_information( show_all = self.option("all") show_top_level = self.option("top-level") show_why = self.option("why") + show_source = self.option("source") width = ( sys.maxsize if self.option("no-truncate") else shutil.get_terminal_size().columns ) - name_length = version_length = latest_length = required_by_length = 0 + name_length = version_length = latest_length = source_length = 0 + required_by_length = 0 latest_packages = {} latest_statuses = {} installed_repo = InstalledRepository.load(self.env) @@ -344,7 +369,9 @@ def _display_packages_information( version_length = max( version_length, len( - get_package_version_display_string( + locked.pretty_version + if show_source and locked.is_direct_origin() + else get_package_version_display_string( locked, root=self.poetry.file.path.parent ) ), @@ -364,12 +391,19 @@ def _display_packages_information( required_by_length, len(" from " + ",".join(required_by.keys())), ) + + if show_source: + source_length = max( + source_length, len(get_package_source(locked)) + ) else: name_length = max(name_length, current_length) version_length = max( version_length, len( - get_package_version_display_string( + locked.pretty_version + if show_source and locked.is_direct_origin() + else get_package_version_display_string( locked, root=self.poetry.file.path.parent ) ), @@ -381,6 +415,9 @@ def _display_packages_information( required_by_length, len(" from " + ",".join(required_by.keys())) ) + if show_source: + source_length = max(source_length, len(get_package_source(locked))) + if self.option("format") == OutputFormats.JSON: packages = [] @@ -413,6 +450,9 @@ def _display_packages_information( latest, root=self.poetry.file.path.parent ) + if show_source: + package["source"] = get_package_source(locked) + if show_why: required_by = reverse_deps(locked, locked_repository) if required_by: @@ -429,8 +469,14 @@ def _display_packages_information( write_version = name_length + version_length + 3 <= width write_latest = name_length + version_length + latest_length + 3 <= width + source_end_column = name_length + version_length + latest_length + source_length + write_source = show_source and (source_end_column + 3) <= width why_end_column = ( - name_length + version_length + latest_length + required_by_length + name_length + + version_length + + latest_length + + (source_length if write_source else 0) + + required_by_length ) write_why = show_why and (why_end_column + 3) <= width write_description = (why_end_column + 24) <= width @@ -471,8 +517,12 @@ def _display_packages_information( f"{name:{name_length - len(install_marker)}}{install_marker}" ) if write_version: - version = get_package_version_display_string( - locked, root=self.poetry.file.path.parent + version = ( + locked.pretty_version + if show_source and locked.is_direct_origin() + else get_package_version_display_string( + locked, root=self.poetry.file.path.parent + ) ) line += f" {version:{version_length}}" if show_latest: @@ -491,6 +541,9 @@ def _display_packages_information( ) line += f" {version:{latest_length}}" + if write_source: + line += f" {get_package_source(locked):{source_length}}" + if write_why: required_by = reverse_deps(locked, locked_repository) if required_by: @@ -509,6 +562,9 @@ def _display_packages_information( if show_latest: remaining -= latest_length + if write_source: + remaining -= source_length + 1 + if len(locked.description) > remaining: description = description[: remaining - 3] + "..." diff --git a/tests/console/commands/test_show.py b/tests/console/commands/test_show.py index 4f5bc496d33..5c4f518de4b 100644 --- a/tests/console/commands/test_show.py +++ b/tests/console/commands/test_show.py @@ -144,6 +144,171 @@ def test_show_basic_with_installed_packages( assert tester.io.fetch_output() == expected +@output_format_parametrize +def test_show_with_source( + output_format: str, + tester: CommandTester, + poetry: Poetry, + installed: Repository, +) -> None: + poetry.package.add_dependency(Factory.create_dependency("cachy", "^0.1.0")) + poetry.package.add_dependency( + Factory.create_dependency( + "internal-package", + {"version": "^1.0.0", "source": "internal"}, + ) + ) + + cachy_010 = get_package("cachy", "0.1.0") + cachy_010.description = "Cachy package" + internal_package_100 = get_package("internal-package", "1.0.0") + internal_package_100.description = "Internal package" + + installed.add_package(cachy_010) + installed.add_package(internal_package_100) + + assert isinstance(poetry.locker, DummyLocker) + poetry.locker.mock_lock_data( + { + "package": [ + { + "name": "cachy", + "version": "0.1.0", + "description": "Cachy package", + "optional": False, + "platform": "*", + "python-versions": "*", + "checksum": [], + }, + { + "name": "internal-package", + "version": "1.0.0", + "description": "Internal package", + "optional": False, + "platform": "*", + "python-versions": "*", + "checksum": [], + "source": { + "type": "legacy", + "url": "https://example.com/simple/", + "reference": "internal", + }, + }, + ], + "metadata": { + "python-versions": "*", + "platform": "*", + "content-hash": "123456789", + "files": {"cachy": [], "internal-package": []}, + }, + } + ) + + tester.execute(f"--source {output_format}") + + expected: str | list[dict[str, str]] = "" + if "json" in output_format: + expected = [ + { + "name": "cachy", + "installed_status": "installed", + "version": "0.1.0", + "source": "PyPI", + "description": "Cachy package", + }, + { + "name": "internal-package", + "installed_status": "installed", + "version": "1.0.0", + "source": "internal", + "description": "Internal package", + }, + ] + assert json.loads(tester.io.fetch_output()) == expected + else: + expected = """\ +cachy 0.1.0 PyPI Cachy package +internal-package 1.0.0 internal Internal package +""" + assert tester.io.fetch_output() == expected + + +def test_show_with_source_long_direct_url_keeps_descriptions( + tester: CommandTester, + poetry: Poetry, + installed: Repository, +) -> None: + direct_url = "https://example.com/aaaaaaaaaaa.whl" + poetry.package.add_dependency( + Factory.create_dependency("regular-package", "^1.0.0") + ) + poetry.package.add_dependency( + Factory.create_dependency("direct-package", {"url": direct_url}) + ) + + regular_package = get_package("regular-package", "1.0.0") + regular_package.description = "Regular package" + direct_package = get_package("direct-package", "1.0.0") + direct_package.description = "Direct package" + direct_package._source_type = "url" + direct_package._source_url = direct_url + + installed.add_package(regular_package) + installed.add_package(direct_package) + + assert isinstance(poetry.locker, DummyLocker) + poetry.locker.mock_lock_data( + { + "package": [ + { + "name": "regular-package", + "version": "1.0.0", + "description": "Regular package", + "optional": False, + "platform": "*", + "python-versions": "*", + "checksum": [], + }, + { + "name": "direct-package", + "version": "1.0.0", + "description": "Direct package", + "optional": False, + "platform": "*", + "python-versions": "*", + "checksum": [], + "source": { + "type": "url", + "url": direct_url, + }, + }, + ], + "metadata": { + "python-versions": "*", + "platform": "*", + "content-hash": "123456789", + "files": {"regular-package": [], "direct-package": []}, + }, + } + ) + + tester.execute("--source --no-truncate") + + source_width = len(direct_url) + expected = ( + f"{'regular-package':<15} {'1.0.0':<5} {'PyPI':<{source_width}} Regular package\n" + f"{'direct-package':<15} {'1.0.0':<5} {direct_url:<{source_width}} Direct package\n" + ) + assert tester.io.fetch_output() == expected + + +def test_show_with_source_rejects_tree(tester: CommandTester) -> None: + tester.execute("--source --tree") + + assert tester.status_code == 1 + assert tester.io.fetch_error() == "Error: --source cannot be used with --tree.\n" + + def _configure_project_with_groups(poetry: Poetry, installed: Repository) -> None: poetry.package.add_dependency(Factory.create_dependency("cachy", "^0.1.0"))