diff --git a/README.md b/README.md index 000d92e..7ee5a80 100644 --- a/README.md +++ b/README.md @@ -53,3 +53,4 @@ poetry export -f requirements.txt --output requirements.txt * `--all-extras`: Include all sets of extra dependencies. * `--without-hashes`: Exclude hashes from the exported file. * `--with-credentials`: Include credentials for extra indices. +* `--exclude`: The names of dependencies to exclude along with nested dependencies when exporting. diff --git a/docs/_index.md b/docs/_index.md index 0837c86..7a43a41 100644 --- a/docs/_index.md +++ b/docs/_index.md @@ -76,3 +76,4 @@ poetry export --only test,docs * `--all-extras`: Include all sets of extra dependencies. * `--without-hashes`: Exclude hashes from the exported file. * `--with-credentials`: Include credentials for extra indices. +* `--exclude`: The names of dependencies to exclude along with nested dependencies when exporting. diff --git a/src/poetry_plugin_export/command.py b/src/poetry_plugin_export/command.py index 06ca162..ef50cf7 100644 --- a/src/poetry_plugin_export/command.py +++ b/src/poetry_plugin_export/command.py @@ -51,6 +51,14 @@ class ExportCommand(GroupCommand): ), option("all-extras", None, "Include all sets of extra dependencies."), option("with-credentials", None, "Include credentials for extra indices."), + option( + "exclude", + None, + "The names of dependencies to exclude along with nested dependencies when" + " exporting.", + flag=False, + multiple=True, + ), ] @property @@ -116,12 +124,19 @@ def handle(self) -> int: f"Extra [{', '.join(sorted(invalid_extras))}] is not specified." ) + excludes = { + canonicalize_name(exclude) + for exclude_opt in self.option("exclude") + for exclude in exclude_opt.split() + } + exporter = Exporter(self.poetry, self.io) exporter.only_groups(list(self.activated_groups)) exporter.with_extras(list(extras)) exporter.with_hashes(not self.option("without-hashes")) exporter.with_credentials(self.option("with-credentials")) exporter.with_urls(not self.option("without-urls")) + exporter.with_excludes(list(excludes)) exporter.export(fmt, Path.cwd(), output or self.io) return 0 diff --git a/src/poetry_plugin_export/exporter.py b/src/poetry_plugin_export/exporter.py index 167980e..02e0c81 100644 --- a/src/poetry_plugin_export/exporter.py +++ b/src/poetry_plugin_export/exporter.py @@ -44,6 +44,7 @@ def __init__(self, poetry: Poetry, io: IO) -> None: self._with_urls = True self._extras: Collection[NormalizedName] = () self._groups: Iterable[str] = [MAIN_GROUP] + self._excludes: Collection[NormalizedName] = () @classmethod def is_format_supported(cls, fmt: str) -> bool: @@ -74,6 +75,11 @@ def with_credentials(self, with_credentials: bool = True) -> Exporter: return self + def with_excludes(self, excludes: Collection[NormalizedName]) -> Exporter: + self._excludes = excludes + + return self + def export(self, fmt: str, cwd: Path, output: IO | str) -> None: if not self.is_format_supported(fmt): raise ValueError(f"Invalid export format: {fmt}") @@ -99,6 +105,7 @@ def _export_generic_txt( root_package_name=root.name, project_python_marker=root.python_marker, extras=self._extras, + excludes=self._excludes, ): line = "" diff --git a/src/poetry_plugin_export/walker.py b/src/poetry_plugin_export/walker.py index 028fbb6..45623aa 100644 --- a/src/poetry_plugin_export/walker.py +++ b/src/poetry_plugin_export/walker.py @@ -55,6 +55,7 @@ def get_project_dependency_packages( root_package_name: NormalizedName, project_python_marker: BaseMarker | None = None, extras: Collection[NormalizedName] = (), + excludes: Collection[NormalizedName] = (), ) -> Iterator[DependencyPackage]: # Apply the project python marker to all requirements. if project_python_marker is not None: @@ -92,6 +93,9 @@ def get_project_dependency_packages( # a package is locked as optional, but is not activated via extras continue + if excludes and package.name in excludes: + continue + selected.append(dependency) for package, dependency in get_project_dependencies( diff --git a/tests/command/test_command_export.py b/tests/command/test_command_export.py index 5951d66..d660b75 100644 --- a/tests/command/test_command_export.py +++ b/tests/command/test_command_export.py @@ -289,3 +289,31 @@ def test_export_exports_constraints_txt_with_warnings( assert develop_warning in tester.io.fetch_error() assert tester.io.fetch_output() == expected + + +@pytest.mark.parametrize( + "excludes, expected", + [ + ( + ["foo"], + f"""\ +bar==1.1.0 ; {MARKER_PY} +baz==2.0.0 ; {MARKER_PY} +qux==1.2.0 ; {MARKER_PY} +""", + ), + ( + ["foo", "bar"], + f"""\ +baz==2.0.0 ; {MARKER_PY} +qux==1.2.0 ; {MARKER_PY} +""", + ), + ], +) +def test_export_excludes( + tester: CommandTester, do_lock: None, excludes: list[str], expected: str +) -> None: + excludes_opts = " ".join([f"--exclude={exclude}" for exclude in excludes]) + tester.execute(f"--format requirements.txt --with=dev --all-extras {excludes_opts}") + assert tester.io.fetch_output() == expected diff --git a/tests/test_exporter.py b/tests/test_exporter.py index f516029..d7c7ca5 100644 --- a/tests/test_exporter.py +++ b/tests/test_exporter.py @@ -831,6 +831,106 @@ def test_exporter_exports_requirements_txt_without_optional_packages( assert content == expected +@pytest.mark.parametrize( + ["packages_data", "skip", "excludes", "lines"], + [ + # two package without deps, exclude foo + ( + [ + {"name": "foo", "version": "1.2.3"}, + {"name": "bar", "version": "4.5.6"}, + ], + None, + ["foo"], + [f"bar==4.5.6 ; {MARKER_PY}"], + ), + # foo has baz dep, exclude foo with baz dep + ( + [ + { + "name": "foo", + "version": "1.2.3", + "dependencies": {"baz": ">=7.8.9"}, + }, + {"name": "bar", "version": "4.5.6"}, + {"name": "baz", "version": "7.8.9", "optional": True}, + ], + {"baz"}, + ["foo"], + [f"bar==4.5.6 ; {MARKER_PY}"], + ), + # foo has baz dep, baz also declared in main group, exclude only foo + ( + [ + { + "name": "foo", + "version": "1.2.3", + "dependencies": {"baz": ">=7.8.9"}, + }, + {"name": "bar", "version": "4.5.6"}, + {"name": "baz", "version": "7.8.9"}, + ], + None, + ["foo"], + [f"bar==4.5.6 ; {MARKER_PY}", f"baz==7.8.9 ; {MARKER_PY}"], + ), + # foo has baz dep, bar also has baz dep, exclude only foo + ( + [ + { + "name": "foo", + "version": "1.2.3", + "dependencies": {"baz": ">=7.8.9"}, + }, + { + "name": "bar", + "version": "4.5.6", + "dependencies": {"baz": ">=7.8.9"}, + }, + {"name": "baz", "version": "7.8.9", "optional": True}, + ], + {"baz"}, + ["foo"], + [f"bar==4.5.6 ; {MARKER_PY}", f"baz==7.8.9 ; {MARKER_PY}"], + ), + ], +) +def test_exporter_exports_requirements_txt_with_excludes( + tmp_path: Path, + poetry: Poetry, + packages_data: list[dict[str, Any]], + excludes: Collection[NormalizedName], + skip: set[str] | None, + lines: list[str], +) -> None: + for package_data in packages_data: + if "optional" not in package_data: + package_data["optional"] = False + if "python-versions" not in package_data: + package_data["python-versions"] = "*" + + poetry.locker.mock_lock_data({ # type: ignore[attr-defined] + "package": packages_data, + "metadata": { + "content-hash": "123456789", + "files": {package["name"]: [] for package in packages_data}, + }, + }) + set_package_requires(poetry, skip=skip) + + exporter = Exporter(poetry, NullIO()) + exporter.with_hashes(False) + exporter.with_excludes(excludes) + exporter.export("requirements.txt", tmp_path, "requirements.txt") + + with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: + content = f.read() + + expected = "\n".join(lines) + "\n" + + assert content == expected + + @pytest.mark.parametrize( ["extras", "lines"], [