diff --git a/news/13638.bugfix.rst b/news/13638.bugfix.rst new file mode 100644 index 0000000000..98faf471c7 --- /dev/null +++ b/news/13638.bugfix.rst @@ -0,0 +1,3 @@ +Warn when installed distributions contain invalid metadata and skip +invalid distributions consistently while allowing recovery commands to +operate. diff --git a/src/pip/_internal/commands/show.py b/src/pip/_internal/commands/show.py index 40601a2b9d..a46fab96d9 100644 --- a/src/pip/_internal/commands/show.py +++ b/src/pip/_internal/commands/show.py @@ -94,7 +94,10 @@ def search_packages_info( """ env = get_default_environment() - installed = {dist.canonical_name: dist for dist in env.iter_all_distributions()} + installed = { + dist.canonical_name: dist + for dist in env.iter_all_distributions(skip_invalid=False) + } query_names = [canonicalize_name(name) for name in query] missing = sorted( [name for name, pkg in zip(query, query_names) if pkg not in installed] diff --git a/src/pip/_internal/metadata/base.py b/src/pip/_internal/metadata/base.py index 76fb963658..7d5f21c43c 100644 --- a/src/pip/_internal/metadata/base.py +++ b/src/pip/_internal/metadata/base.py @@ -5,6 +5,7 @@ import functools import json import logging +import os import pathlib import re import zipfile @@ -19,7 +20,7 @@ from pip._vendor.packaging.requirements import Requirement from pip._vendor.packaging.specifiers import InvalidSpecifier, SpecifierSet from pip._vendor.packaging.utils import NormalizedName, canonicalize_name -from pip._vendor.packaging.version import Version +from pip._vendor.packaging.version import InvalidVersion, Version from pip._internal.exceptions import NoneMetadataError from pip._internal.locations import site_packages, user_site @@ -46,6 +47,18 @@ logger = logging.getLogger(__name__) +_VALID_PROJECT_NAME_RE = re.compile( + r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", + flags=re.IGNORECASE, +) + +# Validation results keyed by the distribution's info location. pip scans the +# installed environment several times during a single run, and each scan re-validates +# the metadata of every installed distribution. Cache the result per info +# location so each distribution is validated (and any "Ignoring distribution" +# warning is emitted) only once. +_distribution_validity_cache: dict[str, bool] = {} + class BaseEntryPoint(Protocol): @property @@ -592,7 +605,9 @@ def default(cls) -> BaseEnvironment: def from_paths(cls, paths: list[str] | None) -> BaseEnvironment: raise NotImplementedError() - def get_distribution(self, name: str) -> BaseDistribution | None: + def get_distribution( + self, name: str, skip_invalid: bool = False + ) -> BaseDistribution | None: """Given a requirement name, return the installed distributions. The name may not be normalized. The implementation must canonicalize @@ -609,24 +624,162 @@ def _iter_distributions(self) -> Iterator[BaseDistribution]: """ raise NotImplementedError() - def iter_all_distributions(self) -> Iterator[BaseDistribution]: - """Iterate through all installed distributions without any filtering.""" - for dist in self._iter_distributions(): - # Make sure the distribution actually comes from a valid Python - # packaging distribution. Pip's AdjacentTempDirectory leaves folders - # e.g. ``~atplotlib.dist-info`` if cleanup was interrupted. The - # valid project name pattern is taken from PEP 508. - project_name_valid = re.match( - r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", + def _get_name_and_version_from_info_location( + self, dist: BaseDistribution + ) -> tuple[str, str | None] | None: + """Return (name, version) parsed from the info directory name.""" + info_location = dist.info_location + if not info_location: + return None + + stem, suffix = os.path.splitext(pathlib.Path(info_location).name) + if suffix not in {".dist-info", ".egg-info"}: + return None + + name, sep, version = stem.partition("-") + + if not name: + return None + + if suffix == ".egg-info": + if not sep: + return name, None + version = re.split(r"-py\d+\.\d+", version, maxsplit=1)[0] + + return (name, version) if version else None + + def _is_valid_project_name(self, name: str) -> bool: + return bool(_VALID_PROJECT_NAME_RE.match(name)) + + def validate_distribution(self, dist: BaseDistribution) -> bool: + info_location = dist.info_location + if info_location is not None: + if info_location in _distribution_validity_cache: + return _distribution_validity_cache[info_location] + + result = self._validate_distribution(dist) + if info_location is not None: + _distribution_validity_cache[info_location] = result + return result + + def _validate_distribution(self, dist: BaseDistribution) -> bool: + # do dir name and version exist? + dir_info = self._get_name_and_version_from_info_location(dist) + # does METADATA have a Name value? + name = dist.metadata.get("Name") + if not name: + logger.warning( + "Ignoring distribution %r at %s: METADATA is missing the " + "required Name field.", dist.canonical_name, - flags=re.IGNORECASE, + dist.location, + ) + return False + + # is METADATA Name valid? + if not self._is_valid_project_name(name): + logger.warning( + "Ignoring distribution at %s: %r is not a valid package name. " + "This may be a partial or interrupted installation.", + dist.location, + canonicalize_name(name), ) - if not project_name_valid: + return False + + if not dist.info_location: + return True + + info_name = pathlib.Path(dist.info_location).name + if info_name.endswith(".egg"): + return True + + if dir_info is None: + if info_name.endswith(".dist-info"): logger.warning( - "Ignoring invalid distribution %s (%s)", - dist.canonical_name, + "Ignoring distribution at %s: could not determine " + "package name and/or version from the installation directory.", dist.location, ) + return False + return True # .egg-info without version is valid. + + dir_name, dir_version = dir_info + # is dir name valid? + # Make sure the distribution actually comes from a valid Python + # packaging distribution. Pip's AdjacentTempDirectory leaves folders + # e.g. ``~atplotlib.dist-info`` if cleanup was interrupted. The + # valid project name pattern is taken from PEP 508. + if not self._is_valid_project_name(dir_name): + logger.warning( + "Ignoring distribution at %s: %r is not a valid package name. " + "This may be a partial or interrupted installation.", + dist.location, + canonicalize_name(dir_name), + ) + return False + + # do METADATA Name and directory name agree with each other? + if canonicalize_name(name) != canonicalize_name(dir_name): + logger.warning( + "Ignoring distribution %r at %s: package name in METADATA " + "(%r) does not match the installation directory name (%r).", + canonicalize_name(dir_name), + dist.location, + canonicalize_name(name), + dir_name, + ) + return False + + if dir_version is None: + # No version encoded in the info directory name (e.g. bare + # egg-info from an editable install) — nothing to cross-check. + return True + + # does METADATA have a Version value? + version = dist.metadata.get("Version") + if not version: + logger.warning( + "Ignoring distribution %r at %s: METADATA is missing the " + "required Version field.", + dir_name, + dist.location, + ) + return False + + # do METADATA Version and directory version agree with each other? + # also checking if METADATA Version or directory version are invalid + try: + versions_match = Version(version) == Version(dir_version) + except InvalidVersion: + # Legacy, non-PEP 440 version identifiers (e.g. "2010i") are + # still permitted by the packaging spec. Don't invalidate the + # distribution just because it can't be parsed as PEP 440 + versions_match = version == dir_version + + if not versions_match: + logger.warning( + "Ignoring distribution %r at %s: version in METADATA (%r) " + "does not match the installation directory version (%r).", + canonicalize_name(dir_name), + dist.location, + version, + dir_version, + ) + return False + + return True + + def iter_all_distributions( + self, skip_invalid: bool = True + ) -> Iterator[BaseDistribution]: + """ + Iterate through all installed distributions. + + If skip_invalid is True (the default), invalid distributions are + logged and skipped. + """ + for dist in self._iter_distributions(): + if skip_invalid and not self.validate_distribution(dist): continue yield dist diff --git a/src/pip/_internal/metadata/importlib/_envs.py b/src/pip/_internal/metadata/importlib/_envs.py index be732a9f9f..d1c84f689c 100644 --- a/src/pip/_internal/metadata/importlib/_envs.py +++ b/src/pip/_internal/metadata/importlib/_envs.py @@ -138,11 +138,13 @@ def _iter_distributions(self) -> Iterator[BaseDistribution]: yield from finder.find(location) yield from finder.find_legacy_editables(location) - def get_distribution(self, name: str) -> BaseDistribution | None: + def get_distribution( + self, name: str, skip_invalid: bool = False + ) -> BaseDistribution | None: canonical_name = canonicalize_name(name) matches = ( distribution - for distribution in self.iter_all_distributions() + for distribution in self.iter_all_distributions(skip_invalid=skip_invalid) if distribution.canonical_name == canonical_name ) return next(matches, None) diff --git a/src/pip/_internal/metadata/pkg_resources.py b/src/pip/_internal/metadata/pkg_resources.py index 6e7774418f..776e50e211 100644 --- a/src/pip/_internal/metadata/pkg_resources.py +++ b/src/pip/_internal/metadata/pkg_resources.py @@ -274,21 +274,25 @@ def _iter_distributions(self) -> Iterator[BaseDistribution]: for dist in self._ws: yield Distribution(dist) - def _search_distribution(self, name: str) -> BaseDistribution | None: + def _search_distribution( + self, name: str, skip_invalid: bool = False + ) -> BaseDistribution | None: """Find a distribution matching the ``name`` in the environment. This searches from *all* distributions available in the environment, to match the behavior of ``pkg_resources.get_distribution()``. """ canonical_name = canonicalize_name(name) - for dist in self.iter_all_distributions(): + for dist in self.iter_all_distributions(skip_invalid=skip_invalid): if dist.canonical_name == canonical_name: return dist return None - def get_distribution(self, name: str) -> BaseDistribution | None: + def get_distribution( + self, name: str, skip_invalid: bool = False + ) -> BaseDistribution | None: # Search the distribution by looking through the working set. - dist = self._search_distribution(name) + dist = self._search_distribution(name, skip_invalid=skip_invalid) if dist: return dist @@ -306,4 +310,4 @@ def get_distribution(self, name: str) -> BaseDistribution | None: self._ws.require(name) except pkg_resources.DistributionNotFound: return None - return self._search_distribution(name) + return self._search_distribution(name, skip_invalid=skip_invalid) diff --git a/src/pip/_internal/req/req_install.py b/src/pip/_internal/req/req_install.py index f20707f3d2..fbf7a5d5e6 100644 --- a/src/pip/_internal/req/req_install.py +++ b/src/pip/_internal/req/req_install.py @@ -409,7 +409,9 @@ def check_if_exists(self, use_user_site: bool) -> None: """ if self.req is None: return - existing_dist = get_default_environment().get_distribution(self.req.name) + existing_dist = get_default_environment().get_distribution( + self.req.name, skip_invalid=False + ) if not existing_dist: return @@ -670,7 +672,9 @@ def uninstall( """ assert self.req - dist = get_default_environment().get_distribution(self.req.name) + dist = get_default_environment().get_distribution( + self.req.name, skip_invalid=False + ) if not dist: logger.warning("Skipping %s as it is not installed.", self.name) return None diff --git a/tests/functional/test_freeze.py b/tests/functional/test_freeze.py index 92e55aca6f..71c0ba5774 100644 --- a/tests/functional/test_freeze.py +++ b/tests/functional/test_freeze.py @@ -186,9 +186,35 @@ def fake_install(pkgname: str, dest: str) -> None: output_name, _, _ = line.partition("=") assert canonicalize_name(output_name) not in canonical_invalid_names - # The invalid names should be logged. + # The invalid names should be logged. The metadata backends normalize + # paths (pkg_resources lowercases them on Windows), so compare + # case-insensitively to avoid failing on the case of the path. for name in canonical_invalid_names: - assert f"Ignoring invalid distribution {name} (" in result.stderr + print(result.stderr) + assert ( + f"Ignoring distribution at {os.fspath(script.site_packages_path)}: {name!r}" + ).lower() in result.stderr.lower() + + +def test_freeze_skips_malformed_dist(script: PipTestEnvironment) -> None: + """ + Test that pip freeze skips malformed distributions with a warning. + """ + dist_info_path = os.path.join(os.fspath(script.site_packages_path), "foo.dist-info") + os.makedirs(dist_info_path) + with open(os.path.join(dist_info_path, "METADATA"), "w") as f: + f.write(textwrap.dedent("""\ + Metadata-Version: 1.0 + Name: foo + Version: 1.0 + """)) + + result = script.pip("freeze", expect_stderr=True) + output_lines = {line.strip() for line in result.stdout.splitlines()} + assert "foo==1.0" not in output_lines + assert ( + "package name and/or version from the installation directory." in result.stderr + ) @pytest.mark.git diff --git a/tests/functional/test_list.py b/tests/functional/test_list.py index 1acc28254c..c71478f9d9 100644 --- a/tests/functional/test_list.py +++ b/tests/functional/test_list.py @@ -1,5 +1,6 @@ import json import os +import textwrap from pathlib import Path import pytest @@ -984,3 +985,23 @@ def test_outdated_all_releases_for_specific_package( assert len(outdated) == 1 assert outdated[0]["name"] == "simple" assert outdated[0]["latest_version"] == "2.0a1" + + +def test_list_skips_malformed_dist(script: PipTestEnvironment) -> None: + """ + Test that pip list skips malformed distributions with a warning. + """ + dist_info_path = os.path.join(os.fspath(script.site_packages_path), "foo.dist-info") + os.makedirs(dist_info_path) + with open(os.path.join(dist_info_path, "METADATA"), "w") as f: + f.write(textwrap.dedent("""\ + Metadata-Version: 1.0 + Name: foo + Version: 1.0 + """)) + + result = script.pip("list", "--format=freeze", expect_stderr=True) + output_lines = {line.strip() for line in result.stdout.splitlines()} + + assert "foo==1.0" not in output_lines + assert "could not determine package name and/or version" in result.stderr diff --git a/tests/functional/test_show.py b/tests/functional/test_show.py index a658b829f3..46d4dbec55 100644 --- a/tests/functional/test_show.py +++ b/tests/functional/test_show.py @@ -233,6 +233,28 @@ def test_show_verbose(script: PipTestEnvironment) -> None: assert "Project-URLs:" in lines +def test_show_includes_malformed_dist(script: PipTestEnvironment) -> None: + """ + Test that show can still find and display a malformed distribution, + so the user has the option to recover. + """ + dist_info_path = os.path.join( + os.fspath(script.site_packages_path), + "foo-1.0.dist-info", + ) + os.makedirs(dist_info_path) + with open(os.path.join(dist_info_path, "METADATA"), "w") as f: + f.write(textwrap.dedent("""\ + Metadata-Version: 1.0 + Name: foo + Version: 2.0 + """)) + + result = script.pip("show", "foo", allow_stderr_warning=True) + assert "Name: foo" in result.stdout + assert len(result.stderr) == 0 + + def test_all_fields(script: PipTestEnvironment) -> None: """ Test that all the fields are present diff --git a/tests/functional/test_uninstall.py b/tests/functional/test_uninstall.py index f69031c05c..80b5d3de58 100644 --- a/tests/functional/test_uninstall.py +++ b/tests/functional/test_uninstall.py @@ -720,6 +720,30 @@ def test_uninstall_editable_and_pip_install_easy_install_remove( script.assert_not_installed("FSPkg") +def test_uninstall_removes_malformed_dist(script: PipTestEnvironment) -> None: + """ + Test that uninstall can still find and remove a malformed distribution. + """ + dist_info_path = os.path.join( + os.fspath(script.site_packages_path), + "foo-1.0.dist-info", + ) + os.makedirs(dist_info_path) + with open(os.path.join(dist_info_path, "METADATA"), "w") as f: + f.write(textwrap.dedent("""\ + Metadata-Version: 1.0 + Name: foo + Version: 2.0 + """)) + with open(os.path.join(dist_info_path, "RECORD"), "w") as f: + f.write("foo-1.0.dist-info/METADATA,,\n") + f.write("foo-1.0.dist-info/") + + result = script.pip("uninstall", "foo", "-y", allow_stderr_warning=True) + assert not os.path.exists(dist_info_path) + assert len(result.stderr) == 0 + + def test_uninstall_ignores_missing_packages( script: PipTestEnvironment, data: TestData ) -> None: diff --git a/tests/unit/metadata/test_metadata.py b/tests/unit/metadata/test_metadata.py index 5e7e80fc74..bc43a630bf 100644 --- a/tests/unit/metadata/test_metadata.py +++ b/tests/unit/metadata/test_metadata.py @@ -10,6 +10,7 @@ from pip._internal.metadata import ( BaseDistribution, + BaseEnvironment, get_directory_distribution, get_environment, get_wheel_distribution, @@ -147,3 +148,229 @@ def test_trailing_slash_directory_metadata( dist = get_directory_distribution(path) assert dist.raw_name == dist.canonical_name == "foo" assert dist.location == str(tmp_path) + + +def test_invalid_package_with_invalid_dir_name_is_skipped( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + invalid_package_directory = tmp_path / "~foo-1.0.dist-info" + invalid_package_directory.mkdir() + (invalid_package_directory / "METADATA").write_text( + "Metadata-Version: 1.0\nName: foo\nVersion:1.0\n" + ) + + with caplog.at_level(logging.WARNING, logger="pip._internal.metadata.base"): + result = get_environment([str(tmp_path)]).iter_all_distributions() + + assert len(list(result)) == 0 + assert "This may be a partial or interrupted installation" in caplog.text + + +def test_invalid_package_warning_is_only_emitted_once( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + invalid_package_directory = tmp_path / "~foo-1.0.dist-info" + invalid_package_directory.mkdir() + (invalid_package_directory / "METADATA").write_text( + "Metadata-Version: 1.0\nName: foo\nVersion:1.0\n" + ) + + env = get_environment([str(tmp_path)]) + with caplog.at_level(logging.WARNING, logger="pip._internal.metadata.base"): + assert len(list(env.iter_all_distributions())) == 0 + assert len(list(env.iter_all_distributions())) == 0 + + assert caplog.text.count("is not a valid package name") == 1 + + +def test_valid_package_is_only_validated_once( + tmp_path: Path, +) -> None: + valid_package_directory = tmp_path / "bar-1.0.0.dist-info" + valid_package_directory.mkdir() + (valid_package_directory / "METADATA").write_text( + "Metadata-Version: 1.0\nName: bar\nVersion:1.0.0\n" + ) + + env = get_environment([str(tmp_path)]) + real_validate = BaseEnvironment._validate_distribution + calls: list[BaseDistribution] = [] + + def spy(self: BaseEnvironment, dist: BaseDistribution) -> bool: + calls.append(dist) + return real_validate(self, dist) + + with mock.patch.object(BaseEnvironment, "_validate_distribution", spy): + assert len(list(env.iter_all_distributions())) == 1 + assert len(list(env.iter_all_distributions())) == 1 + + # Each distribution should only be validated once, even though the + # environment was scanned twice. + assert len(calls) == 1 + + +def test_invalid_package_with_invalid_metadata_name_is_skipped( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + invalid_package_directory = tmp_path / "foo-1.0.dist-info" + invalid_package_directory.mkdir() + (invalid_package_directory / "METADATA").write_text( + "Metadata-Version: 1.0\nName: ~foo\nVersion:1.0\n" + ) + + with caplog.at_level(logging.WARNING, logger="pip._internal.metadata.base"): + result = get_environment([str(tmp_path)]).iter_all_distributions() + + assert len(list(result)) == 0 + assert "This may be a partial or interrupted installation" in caplog.text + + +def test_invalid_package_missing_metadata_name_entry_is_skipped( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + invalid_package_directory = tmp_path / "foo-1.0.dist-info" + invalid_package_directory.mkdir() + (invalid_package_directory / "METADATA").write_text( + "Metadata-Version: 1.0\nVersion:1.0\n" + ) + + with caplog.at_level(logging.WARNING, logger="pip._internal.metadata.base"): + result = get_environment([str(tmp_path)]).iter_all_distributions() + + assert len(list(result)) == 0 + assert "METADATA is missing the required Name field." in caplog.text + + +def test_invalid_package_with_name_mismatch_is_skipped( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + invalid_package_directory = tmp_path / "foo-1.0.dist-info" + invalid_package_directory.mkdir() + (invalid_package_directory / "METADATA").write_text( + "Metadata-Version: 1.0\nName:bar\nVersion:1.0\n" + ) + + with caplog.at_level(logging.WARNING, logger="pip._internal.metadata.base"): + result = get_environment([str(tmp_path)]).iter_all_distributions() + assert len(list(result)) == 0 + assert ( + "METADATA ('bar') does not match the installation directory name ('foo')." + in caplog.text + ) + + +def test_invalid_package_with_missing_dir_version_is_skipped( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + invalid_package_directory = tmp_path / "foo.dist-info" + invalid_package_directory.mkdir() + (invalid_package_directory / "METADATA").write_text( + "Metadata-Version: 1.0\nName:foo\nVersion:1.0\n" + ) + + with caplog.at_level(logging.WARNING, logger="pip._internal.metadata.base"): + result = get_environment([str(tmp_path)]).iter_all_distributions() + assert len(list(result)) == 0 + assert "could not determine package name and/or version" in caplog.text + + +def test_invalid_package_with_missing_metadata_version_is_skipped( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + invalid_package_directory = tmp_path / "foo-1.0.dist-info" + invalid_package_directory.mkdir() + (invalid_package_directory / "METADATA").write_text( + "Metadata-Version: 1.0\nName:foo\n" + ) + + with caplog.at_level(logging.WARNING, logger="pip._internal.metadata.base"): + result = get_environment([str(tmp_path)]).iter_all_distributions() + assert len(list(result)) == 0 + assert "METADATA is missing the required Version field" in caplog.text + + +def test_invalid_package_with_version_mismatch_is_skipped( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + invalid_package_directory = tmp_path / "foo-2.0.dist-info" + invalid_package_directory.mkdir() + (invalid_package_directory / "METADATA").write_text( + "Metadata-Version: 1.0\nName:foo\nVersion:1.0\n" + ) + + with caplog.at_level(logging.WARNING, logger="pip._internal.metadata.base"): + result = get_environment([str(tmp_path)]).iter_all_distributions() + + assert len(list(result)) == 0 + assert ( + "METADATA ('1.0') does not match the installation directory version ('2.0')." + in caplog.text + ) + + +def test_invalid_package_is_skipped( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + invalid_package_directory = tmp_path / ".dist-info" + invalid_package_directory.mkdir() + (invalid_package_directory / "METADATA").write_text( + "Metadata-Version: 2.1\nName: foo\nVersion: 1.0\n" + ) + + valid_package_directory = tmp_path / "bar-1.0.0.dist-info" + valid_package_directory.mkdir() + (valid_package_directory / "METADATA").write_text( + "Metadata-Version: 2.1\nName: bar\nVersion:1.0.0\n" + ) + with caplog.at_level(logging.WARNING, logger="pip._internal.metadata.base"): + result = get_environment([str(tmp_path)]).iter_all_distributions() + + assert len(list(result)) == 1 + # The exact reason the distribution is rejected differs between the + # metadata backends (e.g. the pkg_resources backend cannot read the + # METADATA file for a name-less directory), so only check that a warning + # was emitted and the distribution was skipped. + # TODO: Change this once pkg_resources support is dropped. + assert "Ignoring distribution" in caplog.text + + +def test_invalid_package_is_not_skipped_with_skip_invalid( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """ + Test if iter_all_distributions returns invalid distributions + if skip_invalid is set to False. + """ + invalid_package_directory = tmp_path / ".dist-info" + invalid_package_directory.mkdir() + (invalid_package_directory / "METADATA").write_text( + "Metadata-Version: 1.0\nName: foo\n" + ) + + valid_package_directory = tmp_path / "bar-1.0.0.dist-info" + valid_package_directory.mkdir() + (valid_package_directory / "METADATA").write_text( + "Metadata-Version: 1.0\nName: bar\nVersion:1.0.0\n" + ) + with caplog.at_level(logging.WARNING, logger="pip._internal.metadata.base"): + result = get_environment([str(tmp_path)]).iter_all_distributions( + skip_invalid=False + ) + + assert len(list(result)) == 2 + assert len(caplog.text) == 0 + + +def test_valid_package_is_not_skipped( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + valid_package_directory = tmp_path / "bar-1.0.0.dist-info" + valid_package_directory.mkdir() + (valid_package_directory / "METADATA").write_text( + "Metadata-Version: 1.0\nName: bar\nVersion:1.0.0\n" + ) + with caplog.at_level(logging.WARNING, logger="pip._internal.metadata.base"): + result = get_environment([str(tmp_path)]).iter_all_distributions() + + assert len(list(result)) == 1 + assert len(caplog.text) == 0