From 26df28da536a124e3c4d87df8662de83c0ed49c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Sun, 7 Jul 2024 18:53:41 +0200 Subject: [PATCH] introduce `tool.poetry.requires-plugins` section to define Poetry plugins that are required for the project (#9547) * when running `poetry install` and required plugins are not available in Poetry's own environment they are installed only for the project into .poetry/plugins inside the project folder * when creating the application .poetry/plugins is added to the front of sys.path Co-authored-by: Arun Babu Neelicattu --- docs/plugins.md | 22 + docs/pyproject.md | 12 + src/poetry/console/application.py | 18 +- src/poetry/console/commands/install.py | 3 + .../console/commands/self/show/plugins.py | 4 +- src/poetry/json/schemas/poetry.json | 338 ++++++++++++ src/poetry/plugins/plugin_manager.py | 276 +++++++++- .../repositories/installed_repository.py | 2 +- src/poetry/repositories/repository.py | 4 +- src/poetry/utils/env/base_env.py | 27 +- src/poetry/utils/env/null_env.py | 17 +- tests/console/commands/test_install.py | 14 + tests/console/test_application.py | 44 ++ .../METADATA | 7 + .../entry_points.txt | 2 + .../METADATA | 7 + .../entry_points.txt | 2 + .../my_other_plugin-1.0.dist-info/METADATA | 7 + .../entry_points.txt | 2 + tests/fixtures/project_plugins/pyproject.toml | 6 + .../some_lib-1.0.dist-info/METADATA | 5 + .../some_lib-2.0.dist-info/METADATA | 5 + tests/json/fixtures/self_invalid_plugin.toml | 8 + tests/json/fixtures/self_valid.toml | 8 + tests/json/test_schema.py | 66 +++ tests/json/test_schema_sources.py | 28 - tests/plugins/test_plugin_manager.py | 485 +++++++++++++++++- .../repositories/test_installed_repository.py | 27 +- 28 files changed, 1349 insertions(+), 97 deletions(-) create mode 100644 tests/fixtures/project_plugins/my_application_plugin-1.0.dist-info/METADATA create mode 100644 tests/fixtures/project_plugins/my_application_plugin-1.0.dist-info/entry_points.txt create mode 100644 tests/fixtures/project_plugins/my_application_plugin-2.0.dist-info/METADATA create mode 100644 tests/fixtures/project_plugins/my_application_plugin-2.0.dist-info/entry_points.txt create mode 100644 tests/fixtures/project_plugins/my_other_plugin-1.0.dist-info/METADATA create mode 100644 tests/fixtures/project_plugins/my_other_plugin-1.0.dist-info/entry_points.txt create mode 100644 tests/fixtures/project_plugins/pyproject.toml create mode 100644 tests/fixtures/project_plugins/some_lib-1.0.dist-info/METADATA create mode 100644 tests/fixtures/project_plugins/some_lib-2.0.dist-info/METADATA create mode 100644 tests/json/fixtures/self_invalid_plugin.toml create mode 100644 tests/json/fixtures/self_valid.toml create mode 100644 tests/json/test_schema.py diff --git a/docs/plugins.md b/docs/plugins.md index adcda0ba076..68a028acfe2 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -255,6 +255,28 @@ You can also list all currently installed plugins by running: poetry self show plugins ``` +### Project plugins + +You can also specify that a plugin is required for your project +in the `tool.poetry.requires-plugins` section of the pyproject.toml file: + +```toml +[tool.poetry.requires-plugins] +my-application-plugin = ">1.0" +``` + +If the plugin is not installed in Poetry's own environment when running `poetry install`, +it will be installed only for the current project under `.poetry/plugins` +in the project's directory. + +The syntax to specify `plugins` is the same as for [dependencies]({{< relref "managing-dependencies" >}}). + +{{% warning %}} +You can even overwrite a plugin in Poetry's own environment with another version. +However, if a plugin's dependencies are not compatible with packages in Poetry's own +environment, installation will fail. +{{% /warning %}} + ## Maintaining a plugin diff --git a/docs/pyproject.md b/docs/pyproject.md index 1d0f3a7c271..de68fe7b9a1 100644 --- a/docs/pyproject.md +++ b/docs/pyproject.md @@ -843,6 +843,18 @@ any custom url in the `urls` section. If you publish your package on PyPI, they will appear in the `Project Links` section. +## `requires-plugins` + +In this section, you can specify that certain plugins are required for your project: + +```toml +[tool.poetry.requires-plugins] +my-application-plugin = ">=1.0" +my-plugin = ">=1.0,<2.0" +``` + +See [Project plugins]({{< relref "plugins#project-plugins" >}}) for more information. + ## Poetry and PEP-517 [PEP-517](https://www.python.org/dev/peps/pep-0517/) introduces a standard way diff --git a/src/poetry/console/application.py b/src/poetry/console/application.py index d144c427d4f..c81103f10a9 100644 --- a/src/poetry/console/application.py +++ b/src/poetry/console/application.py @@ -4,7 +4,9 @@ import re from contextlib import suppress +from functools import cached_property from importlib import import_module +from pathlib import Path from typing import TYPE_CHECKING from typing import cast @@ -111,20 +113,13 @@ def __init__(self) -> None: @property def poetry(self) -> Poetry: - from pathlib import Path - from poetry.factory import Factory if self._poetry is not None: return self._poetry - project_path = Path.cwd() - - if self._io and self._io.input.option("directory"): - project_path = Path(self._io.input.option("directory")).absolute() - self._poetry = Factory().create_poetry( - cwd=project_path, + cwd=self._directory, io=self._io, disable_plugins=self._disable_plugins, disable_cache=self._disable_cache, @@ -340,6 +335,7 @@ def _load_plugins(self, io: IO | None = None) -> None: from poetry.plugins.application_plugin import ApplicationPlugin from poetry.plugins.plugin_manager import PluginManager + PluginManager.add_project_plugin_path(self._directory) manager = PluginManager(ApplicationPlugin.group) manager.load_plugins() manager.activate(self) @@ -382,6 +378,12 @@ def _default_definition(self) -> Definition: return definition + @cached_property + def _directory(self) -> Path: + if self._io and self._io.input.option("directory"): + return Path(self._io.input.option("directory")).absolute() + return Path.cwd() + def main() -> int: exit_code: int = Application().run() diff --git a/src/poetry/console/commands/install.py b/src/poetry/console/commands/install.py index ed1076a0a50..d40e6f079b7 100644 --- a/src/poetry/console/commands/install.py +++ b/src/poetry/console/commands/install.py @@ -6,6 +6,7 @@ from cleo.helpers import option from poetry.console.commands.installer_command import InstallerCommand +from poetry.plugins.plugin_manager import PluginManager if TYPE_CHECKING: @@ -104,6 +105,8 @@ def handle(self) -> int: from poetry.masonry.builders.editable import EditableBuilder + PluginManager.ensure_project_plugins(self.poetry, self.io) + if self.option("extras") and self.option("all-extras"): self.line_error( "You cannot specify explicit" diff --git a/src/poetry/console/commands/self/show/plugins.py b/src/poetry/console/commands/self/show/plugins.py index 9f8299a35fa..3c59a28a265 100644 --- a/src/poetry/console/commands/self/show/plugins.py +++ b/src/poetry/console/commands/self/show/plugins.py @@ -70,9 +70,7 @@ def _system_project_handle(self) -> int: } for group in [ApplicationPlugin.group, Plugin.group]: - for entry_point in PluginManager(group).get_plugin_entry_points( - env=system_env - ): + for entry_point in PluginManager(group).get_plugin_entry_points(): assert entry_point.dist is not None package = packages_by_name[canonicalize_name(entry_point.dist.name)] diff --git a/src/poetry/json/schemas/poetry.json b/src/poetry/json/schemas/poetry.json index 279f3290f4b..b5767ecd268 100644 --- a/src/poetry/json/schemas/poetry.json +++ b/src/poetry/json/schemas/poetry.json @@ -4,6 +4,12 @@ "type": "object", "required": [], "properties": { + "requires-plugins": { + "type": "object", + "description": "Poetry plugins that are required for this project.", + "$ref": "#/definitions/dependencies", + "additionalProperties": false + }, "source": { "type": "array", "description": "A set of additional repositories where packages can be found.", @@ -49,6 +55,338 @@ "description": "For PEP 503 simple API repositories, pre-fetch and index the available packages. (experimental)" } } + }, + "dependencies": { + "type": "object", + "patternProperties": { + "^[a-zA-Z-_.0-9]+$": { + "oneOf": [ + { + "$ref": "#/definitions/dependency" + }, + { + "$ref": "#/definitions/long-dependency" + }, + { + "$ref": "#/definitions/git-dependency" + }, + { + "$ref": "#/definitions/file-dependency" + }, + { + "$ref": "#/definitions/path-dependency" + }, + { + "$ref": "#/definitions/url-dependency" + }, + { + "$ref": "#/definitions/multiple-constraints-dependency" + }, + { + "$ref": "#/definitions/dependency-options" + } + ] + } + } + }, + "dependency": { + "type": "string", + "description": "The constraint of the dependency." + }, + "long-dependency": { + "type": "object", + "required": [ + "version" + ], + "additionalProperties": false, + "properties": { + "version": { + "type": "string", + "description": "The constraint of the dependency." + }, + "python": { + "type": "string", + "description": "The python versions for which the dependency should be installed." + }, + "platform": { + "type": "string", + "description": "The platform(s) for which the dependency should be installed." + }, + "markers": { + "type": "string", + "description": "The PEP 508 compliant environment markers for which the dependency should be installed." + }, + "allow-prereleases": { + "type": "boolean", + "description": "Whether the dependency allows prereleases or not." + }, + "allows-prereleases": { + "type": "boolean", + "description": "Whether the dependency allows prereleases or not." + }, + "optional": { + "type": "boolean", + "description": "Whether the dependency is optional or not." + }, + "extras": { + "type": "array", + "description": "The required extras for this dependency.", + "items": { + "type": "string" + } + }, + "source": { + "type": "string", + "description": "The exclusive source used to search for this dependency." + } + } + }, + "git-dependency": { + "type": "object", + "required": [ + "git" + ], + "additionalProperties": false, + "properties": { + "git": { + "type": "string", + "description": "The url of the git repository." + }, + "branch": { + "type": "string", + "description": "The branch to checkout." + }, + "tag": { + "type": "string", + "description": "The tag to checkout." + }, + "rev": { + "type": "string", + "description": "The revision to checkout." + }, + "subdirectory": { + "type": "string", + "description": "The relative path to the directory where the package is located." + }, + "python": { + "type": "string", + "description": "The python versions for which the dependency should be installed." + }, + "platform": { + "type": "string", + "description": "The platform(s) for which the dependency should be installed." + }, + "markers": { + "type": "string", + "description": "The PEP 508 compliant environment markers for which the dependency should be installed." + }, + "allow-prereleases": { + "type": "boolean", + "description": "Whether the dependency allows prereleases or not." + }, + "allows-prereleases": { + "type": "boolean", + "description": "Whether the dependency allows prereleases or not." + }, + "optional": { + "type": "boolean", + "description": "Whether the dependency is optional or not." + }, + "extras": { + "type": "array", + "description": "The required extras for this dependency.", + "items": { + "type": "string" + } + }, + "develop": { + "type": "boolean", + "description": "Whether to install the dependency in development mode." + } + } + }, + "file-dependency": { + "type": "object", + "required": [ + "file" + ], + "additionalProperties": false, + "properties": { + "file": { + "type": "string", + "description": "The path to the file." + }, + "subdirectory": { + "type": "string", + "description": "The relative path to the directory where the package is located." + }, + "python": { + "type": "string", + "description": "The python versions for which the dependency should be installed." + }, + "platform": { + "type": "string", + "description": "The platform(s) for which the dependency should be installed." + }, + "markers": { + "type": "string", + "description": "The PEP 508 compliant environment markers for which the dependency should be installed." + }, + "optional": { + "type": "boolean", + "description": "Whether the dependency is optional or not." + }, + "extras": { + "type": "array", + "description": "The required extras for this dependency.", + "items": { + "type": "string" + } + } + } + }, + "path-dependency": { + "type": "object", + "required": [ + "path" + ], + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "description": "The path to the dependency." + }, + "subdirectory": { + "type": "string", + "description": "The relative path to the directory where the package is located." + }, + "python": { + "type": "string", + "description": "The python versions for which the dependency should be installed." + }, + "platform": { + "type": "string", + "description": "The platform(s) for which the dependency should be installed." + }, + "markers": { + "type": "string", + "description": "The PEP 508 compliant environment markers for which the dependency should be installed." + }, + "optional": { + "type": "boolean", + "description": "Whether the dependency is optional or not." + }, + "extras": { + "type": "array", + "description": "The required extras for this dependency.", + "items": { + "type": "string" + } + }, + "develop": { + "type": "boolean", + "description": "Whether to install the dependency in development mode." + } + } + }, + "url-dependency": { + "type": "object", + "required": [ + "url" + ], + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "description": "The url to the file." + }, + "subdirectory": { + "type": "string", + "description": "The relative path to the directory where the package is located." + }, + "python": { + "type": "string", + "description": "The python versions for which the dependency should be installed." + }, + "platform": { + "type": "string", + "description": "The platform(s) for which the dependency should be installed." + }, + "markers": { + "type": "string", + "description": "The PEP 508 compliant environment markers for which the dependency should be installed." + }, + "optional": { + "type": "boolean", + "description": "Whether the dependency is optional or not." + }, + "extras": { + "type": "array", + "description": "The required extras for this dependency.", + "items": { + "type": "string" + } + } + } + }, + "dependency-options": { + "type": "object", + "additionalProperties": false, + "properties": { + "python": { + "type": "string", + "description": "The python versions for which the dependency should be installed." + }, + "platform": { + "type": "string", + "description": "The platform(s) for which the dependency should be installed." + }, + "markers": { + "type": "string", + "description": "The PEP 508 compliant environment markers for which the dependency should be installed." + }, + "allow-prereleases": { + "type": "boolean", + "description": "Whether the dependency allows prereleases or not." + }, + "source": { + "type": "string", + "description": "The exclusive source used to search for this dependency." + }, + "develop": { + "type": "boolean", + "description": "Whether to install the dependency in development mode." + } + } + }, + "multiple-constraints-dependency": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { + "$ref": "#/definitions/dependency" + }, + { + "$ref": "#/definitions/long-dependency" + }, + { + "$ref": "#/definitions/git-dependency" + }, + { + "$ref": "#/definitions/file-dependency" + }, + { + "$ref": "#/definitions/path-dependency" + }, + { + "$ref": "#/definitions/url-dependency" + }, + { + "$ref": "#/definitions/dependency-options" + } + ] + } } } } diff --git a/src/poetry/plugins/plugin_manager.py b/src/poetry/plugins/plugin_manager.py index bc1861411f9..95ac8b0341d 100644 --- a/src/poetry/plugins/plugin_manager.py +++ b/src/poetry/plugins/plugin_manager.py @@ -1,18 +1,43 @@ from __future__ import annotations +import hashlib +import json import logging +import shutil +import sys +from functools import cached_property +from pathlib import Path from typing import TYPE_CHECKING +from typing import Sequence +import tomlkit + +from poetry.core.packages.project_package import ProjectPackage + +from poetry.__version__ import __version__ +from poetry.installation import Installer +from poetry.packages import Locker from poetry.plugins.application_plugin import ApplicationPlugin from poetry.plugins.plugin import Plugin +from poetry.repositories import Repository +from poetry.repositories.installed_repository import InstalledRepository +from poetry.toml import TOMLFile from poetry.utils._compat import metadata +from poetry.utils._compat import tomllib from poetry.utils.env import Env +from poetry.utils.env import EnvManager if TYPE_CHECKING: from typing import Any + from cleo.io.io import IO + from poetry.core.packages.dependency import Dependency + from poetry.core.packages.package import Package + + from poetry.poetry import Poetry + logger = logging.getLogger(__name__) @@ -26,32 +51,32 @@ def __init__(self, group: str) -> None: self._group = group self._plugins: list[Plugin] = [] + @staticmethod + def add_project_plugin_path(directory: Path) -> None: + from poetry.factory import Factory + + try: + pyproject_toml = Factory.locate(directory) + except RuntimeError: + # no pyproject.toml -> no project plugins + return + + plugin_path = pyproject_toml.parent / ProjectPluginCache.PATH + if plugin_path.exists(): + EnvManager.get_system_env(naive=True).sys_path.insert(0, str(plugin_path)) + + @classmethod + def ensure_project_plugins(cls, poetry: Poetry, io: IO) -> None: + ProjectPluginCache(poetry, io).ensure_plugins() + def load_plugins(self) -> None: plugin_entrypoints = self.get_plugin_entry_points() for ep in plugin_entrypoints: self._load_plugin_entry_point(ep) - @staticmethod - def _is_plugin_candidate(ep: metadata.EntryPoint, env: Env | None = None) -> bool: - """ - Helper method to check if given entry point is a valid as a plugin candidate. - When an environment is specified, the entry point's associated distribution - should be installed, and discoverable in the given environment. - """ - return env is None or ( - ep.dist is not None - and env.site_packages.find_distribution(ep.dist.name) is not None - ) - - def get_plugin_entry_points( - self, env: Env | None = None - ) -> list[metadata.EntryPoint]: - return [ - ep - for ep in metadata.entry_points(group=self._group) - if self._is_plugin_candidate(ep, env) - ] + def get_plugin_entry_points(self) -> list[metadata.EntryPoint]: + return list(metadata.entry_points(group=self._group)) def activate(self, *args: Any, **kwargs: Any) -> None: for plugin in self._plugins: @@ -76,3 +101,214 @@ def _load_plugin_entry_point(self, ep: metadata.EntryPoint) -> None: ) self._add_plugin(plugin()) + + +class ProjectPluginCache: + PATH = Path(".poetry") / "plugins" + + def __init__(self, poetry: Poetry, io: IO) -> None: + self._poetry = poetry + self._io = io + self._path = poetry.pyproject_path.parent / self.PATH + self._config_file = self._path / "config.toml" + self._gitignore_file = self._path.parent / ".gitignore" + + @property + def _plugin_section(self) -> dict[str, Any]: + plugins = self._poetry.local_config.get("requires-plugins", {}) + assert isinstance(plugins, dict) + return plugins + + @cached_property + def _config(self) -> dict[str, Any]: + return { + "python": sys.version, + "poetry": __version__, + "plugins-hash": hashlib.sha256( + json.dumps(self._plugin_section, sort_keys=True).encode() + ).hexdigest(), + } + + def ensure_plugins(self) -> None: + from poetry.factory import Factory + + # parse project plugins + plugins = [] + for name, constraints in self._plugin_section.items(): + _constraints = ( + constraints if isinstance(constraints, list) else [constraints] + ) + for _constraint in _constraints: + plugins.append(Factory.create_dependency(name, _constraint)) + + if not plugins: + if self._path.exists(): + self._io.write_line( + "No project plugins defined." + " Removing the project's plugin cache" + ) + self._io.write_line("") + shutil.rmtree(self._path) + return + + if self._is_fresh(): + if self._io.is_debug(): + self._io.write_line("The project's plugin cache is up to date.") + self._io.write_line("") + return + elif self._path.exists(): + self._io.write_line( + "Removing the project's plugin cache because it is outdated" + ) + # Just remove the cache for two reasons: + # 1. Since the path of the cache has already been added to sys.path + # at this point, we had to distinguish between packages installed + # directly into Poetry's env and packages installed in the project cache. + # 2. Updating packages in the cache does not work out of the box, + # probably, because we use pip to uninstall and pip does not know + # about the cache so that we end up with just overwriting installed + # packages and multiple dist-info folders per package. + # In sum, we keep it simple by always starting from an empty cache + # if something has changed. + shutil.rmtree(self._path) + + # determine plugins relevant for Poetry's environment + poetry_env = EnvManager.get_system_env(naive=True) + relevant_plugins = { + plugin.name: plugin + for plugin in plugins + if plugin.marker.validate(poetry_env.marker_env) + } + if not relevant_plugins: + if self._io.is_debug(): + self._io.write_line( + "No relevant project plugins for Poetry's environment defined." + ) + self._io.write_line("") + self._write_config() + return + + self._io.write_line( + "Ensuring that the Poetry plugins required" + " by the project are available..." + ) + + # check if required plugins are already available + missing_plugin_count = len(relevant_plugins) + satisfied_plugins = set() + insufficient_plugins = [] + installed_packages = [] + installed_repo = InstalledRepository.load(poetry_env) + for package in installed_repo.packages: + if required_plugin := relevant_plugins.get(package.name): + if package.satisfies(required_plugin): + satisfied_plugins.add(package.name) + installed_packages.append(package) + else: + insufficient_plugins.append((package, required_plugin)) + # Do not add the package to installed_packages so that + # the solver does not consider it. + missing_plugin_count -= 1 + if missing_plugin_count == 0: + break + else: + installed_packages.append(package) + + if missing_plugin_count == 0 and not insufficient_plugins: + # all required plugins are installed and satisfy the requirements + self._write_config() + self._io.write_line( + "All required plugins have already been installed" + " in Poetry's environment." + ) + self._io.write_line("") + return + + if insufficient_plugins and self._io.is_debug(): + plugins_str = "\n".join( + f" - {req}\n installed: {p}" for p, req in insufficient_plugins + ) + self._io.write_line( + "The following Poetry plugins are required by the project" + f" but are not satisfied by the installed versions:\n{plugins_str}" + ) + + # install missing plugins + missing_plugins = [ + plugin + for name, plugin in relevant_plugins.items() + if name not in satisfied_plugins + ] + plugins_str = "\n".join(f" - {p}" for p in missing_plugins) + self._io.write_line( + "The following Poetry plugins are required by the project" + f" but are not installed in Poetry's environment:\n{plugins_str}\n" + f"Installing Poetry plugins only for the current project..." + ) + self._install(missing_plugins, poetry_env, installed_packages) + self._io.write_line("") + self._write_config() + + def _is_fresh(self) -> bool: + if not self._config_file.exists(): + return False + + with self._config_file.open("rb") as f: + stored_config = tomllib.load(f) + + return stored_config == self._config + + def _install( + self, + plugins: Sequence[Dependency], + poetry_env: Env, + locked_packages: Sequence[Package], + ) -> None: + project = ProjectPackage(name="poetry-project-instance", version="0") + project.python_versions = ".".join(str(v) for v in poetry_env.version_info[:3]) + # consider all packages in Poetry's environment pinned + for package in locked_packages: + project.add_dependency(package.to_dependency()) + # add missing plugin dependencies + for dependency in plugins: + project.add_dependency(dependency) + + # force new package to be installed in the project cache instead of Poetry's env + poetry_env.paths["platlib"] = str(self._path) + poetry_env.paths["purelib"] = str(self._path) + + self._ensure_cache_directory() + + installer = Installer( + self._io, + poetry_env, + project, + Locker(self._path / "poetry.lock", {}), + self._poetry.pool, + self._poetry.config, + # Build installed repository from locked packages so that plugins + # that may be overwritten are not included. + Repository("poetry-repo", locked_packages), + ) + installer.update(True) + + if installer.run() != 0: + raise RuntimeError("Failed to install required Poetry plugins") + + def _write_config(self) -> None: + self._ensure_cache_directory() + + document = tomlkit.document() + + for key, value in self._config.items(): + document[key] = value + + TOMLFile(self._config_file).write(data=document) + + def _ensure_cache_directory(self) -> None: + if self._path.exists(): + return + + self._path.mkdir(parents=True, exist_ok=True) + # only write .gitignore if path did not exist before + self._gitignore_file.write_text("*", encoding="utf-8") diff --git a/src/poetry/repositories/installed_repository.py b/src/poetry/repositories/installed_repository.py index 07a92156a19..73231d9d9bf 100644 --- a/src/poetry/repositories/installed_repository.py +++ b/src/poetry/repositories/installed_repository.py @@ -239,7 +239,7 @@ def load(cls, env: Env, with_dependencies: bool = False) -> InstalledRepository: seen = set() skipped = set() - for entry in reversed(env.sys_path): + for entry in env.sys_path: if not entry.strip(): logger.debug( "Project environment contains an empty path in sys_path," diff --git a/src/poetry/repositories/repository.py b/src/poetry/repositories/repository.py index 1f15721f7bd..f1586090b14 100644 --- a/src/poetry/repositories/repository.py +++ b/src/poetry/repositories/repository.py @@ -12,6 +12,8 @@ if TYPE_CHECKING: + from collections.abc import Sequence + from packaging.utils import NormalizedName from poetry.core.constraints.version import VersionConstraint from poetry.core.packages.dependency import Dependency @@ -20,7 +22,7 @@ class Repository(AbstractRepository): - def __init__(self, name: str, packages: list[Package] | None = None) -> None: + def __init__(self, name: str, packages: Sequence[Package] | None = None) -> None: super().__init__(name) self._packages: list[Package] = [] diff --git a/src/poetry/utils/env/base_env.py b/src/poetry/utils/env/base_env.py index 79cfec600a0..7c5574c19e3 100644 --- a/src/poetry/utils/env/base_env.py +++ b/src/poetry/utils/env/base_env.py @@ -60,7 +60,6 @@ def __init__(self, path: Path, base: Path | None = None) -> None: self._base = base or path self._site_packages: SitePackages | None = None - self._paths: dict[str, str] | None = None self._supported_tags: list[Tag] | None = None self._purelib: Path | None = None self._platlib: Path | None = None @@ -228,22 +227,20 @@ def is_path_relative_to_lib(self, path: Path) -> bool: @abstractmethod def sys_path(self) -> list[str]: ... - @property + @cached_property def paths(self) -> dict[str, str]: - if self._paths is None: - self._paths = self.get_paths() - - if self.is_venv(): - # We copy pip's logic here for the `include` path - self._paths["include"] = str( - self.path.joinpath( - "include", - "site", - f"python{self.version_info[0]}.{self.version_info[1]}", - ) + paths = self.get_paths() + + if self.is_venv(): + # We copy pip's logic here for the `include` path + paths["include"] = str( + self.path.joinpath( + "include", + "site", + f"python{self.version_info[0]}.{self.version_info[1]}", ) - - return self._paths + ) + return paths @property def supported_tags(self) -> list[Tag]: diff --git a/src/poetry/utils/env/null_env.py b/src/poetry/utils/env/null_env.py index 7bd0a9e1c79..95de157862d 100644 --- a/src/poetry/utils/env/null_env.py +++ b/src/poetry/utils/env/null_env.py @@ -2,6 +2,7 @@ import sys +from functools import cached_property from pathlib import Path from typing import Any @@ -20,16 +21,14 @@ def __init__( self._execute = execute self.executed: list[list[str]] = [] - @property + @cached_property def paths(self) -> dict[str, str]: - if self._paths is None: - self._paths = self.get_paths() - self._paths["platlib"] = str(self._path / "platlib") - self._paths["purelib"] = str(self._path / "purelib") - self._paths["scripts"] = str(self._path / "scripts") - self._paths["data"] = str(self._path / "data") - - return self._paths + paths = self.get_paths() + paths["platlib"] = str(self._path / "platlib") + paths["purelib"] = str(self._path / "purelib") + paths["scripts"] = str(self._path / "scripts") + paths["data"] = str(self._path / "data") + return paths def _run(self, cmd: list[str], **kwargs: Any) -> str: self.executed.append(cmd) diff --git a/tests/console/commands/test_install.py b/tests/console/commands/test_install.py index c94f3c84bd7..df7557749f8 100644 --- a/tests/console/commands/test_install.py +++ b/tests/console/commands/test_install.py @@ -247,6 +247,20 @@ def test_extras_are_parsed_and_populate_installer( assert tester.command.installer._extras == ["first", "second", "third"] +def test_install_ensures_project_plugins( + tester: CommandTester, mocker: MockerFixture +) -> None: + assert isinstance(tester.command, InstallerCommand) + mocker.patch.object(tester.command.installer, "run", return_value=1) + ensure_project_plugins = mocker.patch( + "poetry.plugins.plugin_manager.PluginManager.ensure_project_plugins" + ) + + tester.execute("") + + ensure_project_plugins.assert_called_once() + + def test_extras_conflicts_all_extras( tester: CommandTester, mocker: MockerFixture ) -> None: diff --git a/tests/console/test_application.py b/tests/console/test_application.py index 4629d87267f..8fbc6d7dc67 100644 --- a/tests/console/test_application.py +++ b/tests/console/test_application.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +import shutil from typing import TYPE_CHECKING from typing import ClassVar @@ -12,14 +13,20 @@ from poetry.console.application import Application from poetry.console.commands.command import Command from poetry.plugins.application_plugin import ApplicationPlugin +from poetry.plugins.plugin_manager import ProjectPluginCache from poetry.repositories.cached_repository import CachedRepository from poetry.utils.authenticator import Authenticator +from poetry.utils.env import EnvManager +from poetry.utils.env import MockEnv from tests.helpers import mock_metadata_entry_points if TYPE_CHECKING: + from pathlib import Path + from pytest_mock import MockerFixture + from tests.types import FixtureDirGetter from tests.types import SetProjectContext @@ -86,6 +93,43 @@ def test_application_execute_plugin_command_with_plugins_disabled( assert tester.status_code == 1 +@pytest.mark.parametrize("with_project_plugins", [False, True]) +@pytest.mark.parametrize("no_plugins", [False, True]) +def test_application_project_plugins( + fixture_dir: FixtureDirGetter, + tmp_path: Path, + no_plugins: bool, + with_project_plugins: bool, + mocker: MockerFixture, + set_project_context: SetProjectContext, +) -> None: + env = MockEnv( + path=tmp_path / "env", version_info=(3, 8, 0), sys_path=[str(tmp_path / "env")] + ) + mocker.patch.object(EnvManager, "get_system_env", return_value=env) + + orig_dir = fixture_dir("project_plugins") + project_path = tmp_path / "project" + project_path.mkdir() + shutil.copy(orig_dir / "pyproject.toml", project_path / "pyproject.toml") + project_plugin_path = project_path / ProjectPluginCache.PATH + if with_project_plugins: + project_plugin_path.mkdir(parents=True) + + with set_project_context(project_path, in_place=True): + app = Application() + + tester = ApplicationTester(app) + tester.execute("--no-plugins" if no_plugins else "") + + assert tester.status_code == 0 + sys_path = EnvManager.get_system_env(naive=True).sys_path + if with_project_plugins and not no_plugins: + assert sys_path[0] == str(project_plugin_path) + else: + assert sys_path[0] != str(project_plugin_path) + + @pytest.mark.parametrize("disable_cache", [True, False]) def test_application_verify_source_cache_flag( disable_cache: bool, set_project_context: SetProjectContext diff --git a/tests/fixtures/project_plugins/my_application_plugin-1.0.dist-info/METADATA b/tests/fixtures/project_plugins/my_application_plugin-1.0.dist-info/METADATA new file mode 100644 index 00000000000..2ac98bcc428 --- /dev/null +++ b/tests/fixtures/project_plugins/my_application_plugin-1.0.dist-info/METADATA @@ -0,0 +1,7 @@ +Metadata-Version: 2.1 +Name: my-application-plugin +Version: 1.0 +Summary: description +Requires-Python: >=3.8,<4.0 +Requires-Dist: poetry (>=1.8.0,<3.0.0) +Requires-Dist: some-lib (>=1.7.0,<3.0.0) diff --git a/tests/fixtures/project_plugins/my_application_plugin-1.0.dist-info/entry_points.txt b/tests/fixtures/project_plugins/my_application_plugin-1.0.dist-info/entry_points.txt new file mode 100644 index 00000000000..b0dc8872d01 --- /dev/null +++ b/tests/fixtures/project_plugins/my_application_plugin-1.0.dist-info/entry_points.txt @@ -0,0 +1,2 @@ +[poetry.application.plugin] +my-command=my_application_plugin.plugins:MyApplicationPlugin diff --git a/tests/fixtures/project_plugins/my_application_plugin-2.0.dist-info/METADATA b/tests/fixtures/project_plugins/my_application_plugin-2.0.dist-info/METADATA new file mode 100644 index 00000000000..616f2f02d9f --- /dev/null +++ b/tests/fixtures/project_plugins/my_application_plugin-2.0.dist-info/METADATA @@ -0,0 +1,7 @@ +Metadata-Version: 2.1 +Name: my-application-plugin +Version: 2.0 +Summary: description +Requires-Python: >=3.8,<4.0 +Requires-Dist: poetry (>=1.8.0,<3.0.0) +Requires-Dist: some-lib (>=1.7.0,<3.0.0) diff --git a/tests/fixtures/project_plugins/my_application_plugin-2.0.dist-info/entry_points.txt b/tests/fixtures/project_plugins/my_application_plugin-2.0.dist-info/entry_points.txt new file mode 100644 index 00000000000..b0dc8872d01 --- /dev/null +++ b/tests/fixtures/project_plugins/my_application_plugin-2.0.dist-info/entry_points.txt @@ -0,0 +1,2 @@ +[poetry.application.plugin] +my-command=my_application_plugin.plugins:MyApplicationPlugin diff --git a/tests/fixtures/project_plugins/my_other_plugin-1.0.dist-info/METADATA b/tests/fixtures/project_plugins/my_other_plugin-1.0.dist-info/METADATA new file mode 100644 index 00000000000..20721382dd0 --- /dev/null +++ b/tests/fixtures/project_plugins/my_other_plugin-1.0.dist-info/METADATA @@ -0,0 +1,7 @@ +Metadata-Version: 2.1 +Name: my-other-plugin +Version: 1.0 +Summary: description +Requires-Python: >=3.8,<4.0 +Requires-Dist: poetry (>=1.8.0,<3.0.0) +Requires-Dist: some-lib (>=1.7.0,<3.0.0) diff --git a/tests/fixtures/project_plugins/my_other_plugin-1.0.dist-info/entry_points.txt b/tests/fixtures/project_plugins/my_other_plugin-1.0.dist-info/entry_points.txt new file mode 100644 index 00000000000..a37ff9af31b --- /dev/null +++ b/tests/fixtures/project_plugins/my_other_plugin-1.0.dist-info/entry_points.txt @@ -0,0 +1,2 @@ +[poetry.plugin] +other-plugin=my_application_plugin.plugins:MyOtherPlugin diff --git a/tests/fixtures/project_plugins/pyproject.toml b/tests/fixtures/project_plugins/pyproject.toml new file mode 100644 index 00000000000..9f4453335bf --- /dev/null +++ b/tests/fixtures/project_plugins/pyproject.toml @@ -0,0 +1,6 @@ +[tool.poetry] +package-mode = false + +[tool.poetry.requires-plugins] +my-application-plugin = ">=2.0" +my-other-plugin = ">=1.0" diff --git a/tests/fixtures/project_plugins/some_lib-1.0.dist-info/METADATA b/tests/fixtures/project_plugins/some_lib-1.0.dist-info/METADATA new file mode 100644 index 00000000000..f577113b83e --- /dev/null +++ b/tests/fixtures/project_plugins/some_lib-1.0.dist-info/METADATA @@ -0,0 +1,5 @@ +Metadata-Version: 2.1 +Name: some-lib +Version: 1.0 +Summary: description +Requires-Python: >=3.8,<4.0 diff --git a/tests/fixtures/project_plugins/some_lib-2.0.dist-info/METADATA b/tests/fixtures/project_plugins/some_lib-2.0.dist-info/METADATA new file mode 100644 index 00000000000..a5e948a8bca --- /dev/null +++ b/tests/fixtures/project_plugins/some_lib-2.0.dist-info/METADATA @@ -0,0 +1,5 @@ +Metadata-Version: 2.1 +Name: some-lib +Version: 2.0 +Summary: description +Requires-Python: >=3.8,<4.0 diff --git a/tests/json/fixtures/self_invalid_plugin.toml b/tests/json/fixtures/self_invalid_plugin.toml new file mode 100644 index 00000000000..3310785cac2 --- /dev/null +++ b/tests/json/fixtures/self_invalid_plugin.toml @@ -0,0 +1,8 @@ +[tool.poetry] +name = "foobar" +version = "0.1.0" +description = "" +authors = ["Your Name "] + +[tool.poetry.requires-plugins] +foo = 5 diff --git a/tests/json/fixtures/self_valid.toml b/tests/json/fixtures/self_valid.toml new file mode 100644 index 00000000000..298a50b7831 --- /dev/null +++ b/tests/json/fixtures/self_valid.toml @@ -0,0 +1,8 @@ +[tool.poetry] +name = "foobar" +version = "0.1.0" +description = "" +authors = ["Your Name "] + +[tool.poetry.requires-plugins] +foo = ">=1.0" diff --git a/tests/json/test_schema.py b/tests/json/test_schema.py new file mode 100644 index 00000000000..1e2df28295b --- /dev/null +++ b/tests/json/test_schema.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import json + +from pathlib import Path +from typing import Any + +from poetry.core.json import SCHEMA_DIR as CORE_SCHEMA_DIR + +from poetry.factory import Factory +from poetry.json import SCHEMA_DIR +from poetry.toml import TOMLFile + + +FIXTURE_DIR = Path(__file__).parent / "fixtures" +SOURCE_FIXTURE_DIR = FIXTURE_DIR / "source" + + +def test_pyproject_toml_valid() -> None: + toml: dict[str, Any] = TOMLFile(SOURCE_FIXTURE_DIR / "complete_valid.toml").read() + assert Factory.validate(toml) == {"errors": [], "warnings": []} + + +def test_pyproject_toml_invalid_priority() -> None: + toml: dict[str, Any] = TOMLFile( + SOURCE_FIXTURE_DIR / "complete_invalid_priority.toml" + ).read() + assert Factory.validate(toml) == { + "errors": [ + "data.source[0].priority must be one of ['primary'," + " 'supplemental', 'explicit']" + ], + "warnings": [], + } + + +def test_self_valid() -> None: + toml: dict[str, Any] = TOMLFile(FIXTURE_DIR / "self_valid.toml").read() + assert Factory.validate(toml) == {"errors": [], "warnings": []} + + +def test_self_invalid_plugin() -> None: + toml: dict[str, Any] = TOMLFile(FIXTURE_DIR / "self_invalid_plugin.toml").read() + assert Factory.validate(toml) == { + "errors": [ + "data.requires-plugins.foo must be valid exactly by one definition" + " (0 matches found)" + ], + "warnings": [], + } + + +def test_dependencies_is_consistent_to_poetry_core_schema() -> None: + with (SCHEMA_DIR / "poetry.json").open(encoding="utf-8") as f: + schema = json.load(f) + dependency_definitions = { + key: value for key, value in schema["definitions"].items() if "depend" in key + } + with (CORE_SCHEMA_DIR / "poetry-schema.json").open(encoding="utf-8") as f: + core_schema = json.load(f) + core_dependency_definitions = { + key: value + for key, value in core_schema["definitions"].items() + if "depend" in key + } + assert dependency_definitions == core_dependency_definitions diff --git a/tests/json/test_schema_sources.py b/tests/json/test_schema_sources.py index 307cfba3aa6..e69de29bb2d 100644 --- a/tests/json/test_schema_sources.py +++ b/tests/json/test_schema_sources.py @@ -1,28 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Any - -from poetry.factory import Factory -from poetry.toml import TOMLFile - - -FIXTURE_DIR = Path(__file__).parent / "fixtures" / "source" - - -def test_pyproject_toml_valid() -> None: - toml: dict[str, Any] = TOMLFile(FIXTURE_DIR / "complete_valid.toml").read() - assert Factory.validate(toml) == {"errors": [], "warnings": []} - - -def test_pyproject_toml_invalid_priority() -> None: - toml: dict[str, Any] = TOMLFile( - FIXTURE_DIR / "complete_invalid_priority.toml" - ).read() - assert Factory.validate(toml) == { - "errors": [ - "data.source[0].priority must be one of ['primary'," - " 'supplemental', 'explicit']" - ], - "warnings": [], - } diff --git a/tests/plugins/test_plugin_manager.py b/tests/plugins/test_plugin_manager.py index 67e4b6040b7..5d254b1ee6c 100644 --- a/tests/plugins/test_plugin_manager.py +++ b/tests/plugins/test_plugin_manager.py @@ -1,5 +1,7 @@ from __future__ import annotations +import shutil + from pathlib import Path from typing import TYPE_CHECKING from typing import ClassVar @@ -8,14 +10,28 @@ import pytest from cleo.io.buffered_io import BufferedIO +from cleo.io.outputs.output import Verbosity from poetry.core.constraints.version import Version +from poetry.core.packages.dependency import Dependency +from poetry.core.packages.file_dependency import FileDependency +from poetry.core.packages.package import Package from poetry.core.packages.project_package import ProjectPackage +from poetry.factory import Factory +from poetry.installation.wheel_installer import WheelInstaller from poetry.packages.locker import Locker from poetry.plugins import ApplicationPlugin from poetry.plugins import Plugin from poetry.plugins.plugin_manager import PluginManager +from poetry.plugins.plugin_manager import ProjectPluginCache from poetry.poetry import Poetry +from poetry.puzzle.exceptions import SolverProblemError +from poetry.repositories import Repository +from poetry.repositories import RepositoryPool +from poetry.repositories.installed_repository import InstalledRepository +from poetry.utils.env import Env +from poetry.utils.env import EnvManager +from poetry.utils.env import MockEnv from tests.helpers import mock_metadata_entry_points @@ -48,6 +64,33 @@ def activate(self, poetry: Poetry, io: IO) -> None: poetry.package.version = Version.parse("9.9.9") +@pytest.fixture +def repo() -> Repository: + repo = Repository("repo") + repo.add_package(Package("my-other-plugin", "1.0")) + for version in ("1.0", "2.0"): + package = Package("my-application-plugin", version) + package.add_dependency(Dependency("some-lib", version)) + repo.add_package(package) + repo.add_package(Package("some-lib", version)) + return repo + + +@pytest.fixture +def pool(repo: Repository) -> RepositoryPool: + pool = RepositoryPool() + pool.add_repository(repo) + + return pool + + +@pytest.fixture +def system_env(tmp_path: Path, mocker: MockerFixture) -> Env: + env = MockEnv(path=tmp_path, sys_path=[str(tmp_path / "purelib")]) + mocker.patch.object(EnvManager, "get_system_env", return_value=env) + return env + + @pytest.fixture def poetry(fixture_dir: FixtureDirGetter, config: Config) -> Poetry: project_path = fixture_dir("simple_project") @@ -62,8 +105,21 @@ def poetry(fixture_dir: FixtureDirGetter, config: Config) -> Poetry: return poetry +@pytest.fixture +def poetry_with_plugins( + fixture_dir: FixtureDirGetter, pool: RepositoryPool, tmp_path: Path +) -> Poetry: + orig_path = fixture_dir("project_plugins") + project_path = tmp_path / "project" + project_path.mkdir() + shutil.copy(orig_path / "pyproject.toml", project_path / "pyproject.toml") + poetry = Factory().create_poetry(project_path) + poetry.set_pool(pool) + return poetry + + @pytest.fixture() -def io() -> IO: +def io() -> BufferedIO: return BufferedIO() @@ -75,6 +131,16 @@ def _manager(group: str = Plugin.group) -> PluginManager: return _manager +@pytest.fixture +def with_my_plugin(mocker: MockerFixture) -> None: + mock_metadata_entry_points(mocker, MyPlugin) + + +@pytest.fixture +def with_invalid_plugin(mocker: MockerFixture) -> None: + mock_metadata_entry_points(mocker, InvalidPlugin) + + def test_load_plugins_and_activate( manager_factory: ManagerFactory, poetry: Poetry, @@ -89,16 +155,6 @@ def test_load_plugins_and_activate( assert io.fetch_output() == "Setting readmes\n" -@pytest.fixture -def with_my_plugin(mocker: MockerFixture) -> None: - mock_metadata_entry_points(mocker, MyPlugin) - - -@pytest.fixture -def with_invalid_plugin(mocker: MockerFixture) -> None: - mock_metadata_entry_points(mocker, InvalidPlugin) - - def test_load_plugins_with_invalid_plugin( manager_factory: ManagerFactory, poetry: Poetry, @@ -109,3 +165,410 @@ def test_load_plugins_with_invalid_plugin( with pytest.raises(ValueError): manager.load_plugins() + + +def test_add_project_plugin_path( + poetry_with_plugins: Poetry, + io: BufferedIO, + system_env: Env, + fixture_dir: FixtureDirGetter, +) -> None: + dist_info_1 = "my_application_plugin-1.0.dist-info" + dist_info_2 = "my_application_plugin-2.0.dist-info" + cache = ProjectPluginCache(poetry_with_plugins, io) + shutil.copytree( + fixture_dir("project_plugins") / dist_info_1, cache._path / dist_info_1 + ) + shutil.copytree( + fixture_dir("project_plugins") / dist_info_2, system_env.purelib / dist_info_2 + ) + + assert { + f"{p.name} {p.version}" for p in InstalledRepository.load(system_env).packages + } == {"my-application-plugin 2.0"} + + PluginManager.add_project_plugin_path(poetry_with_plugins.pyproject_path.parent) + + assert { + f"{p.name} {p.version}" for p in InstalledRepository.load(system_env).packages + } == {"my-application-plugin 1.0"} + + +def test_ensure_plugins_no_plugins_no_output(poetry: Poetry, io: BufferedIO) -> None: + PluginManager.ensure_project_plugins(poetry, io) + + assert not (poetry.pyproject_path.parent / ProjectPluginCache.PATH).exists() + assert io.fetch_output() == "" + assert io.fetch_error() == "" + + +def test_ensure_plugins_no_plugins_existing_cache_is_removed( + poetry: Poetry, io: BufferedIO +) -> None: + plugin_path = poetry.pyproject_path.parent / ProjectPluginCache.PATH + plugin_path.mkdir(parents=True) + + PluginManager.ensure_project_plugins(poetry, io) + + assert not plugin_path.exists() + assert io.fetch_output() == ( + "No project plugins defined. Removing the project's plugin cache\n\n" + ) + assert io.fetch_error() == "" + + +@pytest.mark.parametrize("debug_out", [False, True]) +def test_ensure_plugins_no_output_if_fresh( + poetry_with_plugins: Poetry, io: BufferedIO, debug_out: bool +) -> None: + io.set_verbosity(Verbosity.DEBUG if debug_out else Verbosity.NORMAL) + cache = ProjectPluginCache(poetry_with_plugins, io) + cache._write_config() + + cache.ensure_plugins() + + assert cache._config_file.exists() + assert ( + cache._gitignore_file.exists() + and cache._gitignore_file.read_text(encoding="utf-8") == "*" + ) + assert io.fetch_output() == ( + "The project's plugin cache is up to date.\n\n" if debug_out else "" + ) + assert io.fetch_error() == "" + + +@pytest.mark.parametrize("debug_out", [False, True]) +def test_ensure_plugins_ignore_irrelevant_markers( + poetry_with_plugins: Poetry, io: BufferedIO, debug_out: bool +) -> None: + io.set_verbosity(Verbosity.DEBUG if debug_out else Verbosity.NORMAL) + poetry_with_plugins.local_config["requires-plugins"] = { + "irrelevant": {"version": "1.0", "markers": "python_version < '3'"} + } + cache = ProjectPluginCache(poetry_with_plugins, io) + + cache.ensure_plugins() + + assert cache._config_file.exists() + assert ( + cache._gitignore_file.exists() + and cache._gitignore_file.read_text(encoding="utf-8") == "*" + ) + assert io.fetch_output() == ( + "No relevant project plugins for Poetry's environment defined.\n\n" + if debug_out + else "" + ) + assert io.fetch_error() == "" + + +def test_ensure_plugins_remove_outdated( + poetry_with_plugins: Poetry, io: BufferedIO, fixture_dir: FixtureDirGetter +) -> None: + # Test with irrelevant plugins because this is the first return + # where it is relevant that an existing cache is removed. + poetry_with_plugins.local_config["requires-plugins"] = { + "irrelevant": {"version": "1.0", "markers": "python_version < '3'"} + } + fixture_path = fixture_dir("project_plugins") + cache = ProjectPluginCache(poetry_with_plugins, io) + cache._path.mkdir(parents=True) + dist_info = "my_application_plugin-1.0.dist-info" + shutil.copytree(fixture_path / dist_info, cache._path / dist_info) + cache._config_file.touch() + + cache.ensure_plugins() + + assert cache._config_file.exists() + assert not (cache._path / dist_info).exists() + assert io.fetch_output() == ( + "Removing the project's plugin cache because it is outdated\n" + ) + assert io.fetch_error() == "" + + +def test_ensure_plugins_ignore_already_installed_in_system_env( + poetry_with_plugins: Poetry, + io: BufferedIO, + system_env: Env, + fixture_dir: FixtureDirGetter, +) -> None: + fixture_path = fixture_dir("project_plugins") + for dist_info in ( + "my_application_plugin-2.0.dist-info", + "my_other_plugin-1.0.dist-info", + ): + shutil.copytree(fixture_path / dist_info, system_env.purelib / dist_info) + cache = ProjectPluginCache(poetry_with_plugins, io) + + cache.ensure_plugins() + + assert cache._config_file.exists() + assert ( + cache._gitignore_file.exists() + and cache._gitignore_file.read_text(encoding="utf-8") == "*" + ) + assert io.fetch_output() == ( + "Ensuring that the Poetry plugins required by the project are available...\n" + "All required plugins have already been installed in Poetry's environment.\n\n" + ) + assert io.fetch_error() == "" + + +def test_ensure_plugins_install_missing_plugins( + poetry_with_plugins: Poetry, + io: BufferedIO, + system_env: Env, + fixture_dir: FixtureDirGetter, + mocker: MockerFixture, +) -> None: + cache = ProjectPluginCache(poetry_with_plugins, io) + install_spy = mocker.spy(cache, "_install") + execute_mock = mocker.patch( + "poetry.plugins.plugin_manager.Installer._execute", return_value=0 + ) + + cache.ensure_plugins() + + install_spy.assert_called_once_with( + [ + Dependency("my-application-plugin", ">=2.0"), + Dependency("my-other-plugin", ">=1.0"), + ], + system_env, + [], + ) + execute_mock.assert_called_once() + assert [repr(op) for op in execute_mock.call_args.args[0] if not op.skipped] == [ + "", + "", + "", + ] + assert cache._config_file.exists() + assert ( + cache._gitignore_file.exists() + and cache._gitignore_file.read_text(encoding="utf-8") == "*" + ) + assert io.fetch_output() == ( + "Ensuring that the Poetry plugins required by the project are available...\n" + "The following Poetry plugins are required by the project" + " but are not installed in Poetry's environment:\n" + " - my-application-plugin (>=2.0)\n" + " - my-other-plugin (>=1.0)\n" + "Installing Poetry plugins only for the current project...\n" + "Updating dependencies\n" + "Resolving dependencies...\n\n" + "Writing lock file\n\n" + ) + assert io.fetch_error() == "" + + +def test_ensure_plugins_install_only_missing_plugins( + poetry_with_plugins: Poetry, + io: BufferedIO, + system_env: Env, + fixture_dir: FixtureDirGetter, + mocker: MockerFixture, +) -> None: + fixture_path = fixture_dir("project_plugins") + for dist_info in ( + "my_application_plugin-2.0.dist-info", + "some_lib-2.0.dist-info", + ): + shutil.copytree(fixture_path / dist_info, system_env.purelib / dist_info) + cache = ProjectPluginCache(poetry_with_plugins, io) + install_spy = mocker.spy(cache, "_install") + execute_mock = mocker.patch( + "poetry.plugins.plugin_manager.Installer._execute", return_value=0 + ) + + cache.ensure_plugins() + + install_spy.assert_called_once_with( + [Dependency("my-other-plugin", ">=1.0")], + system_env, + [Package("my-application-plugin", "2.0"), Package("some-lib", "2.0")], + ) + execute_mock.assert_called_once() + assert [repr(op) for op in execute_mock.call_args.args[0] if not op.skipped] == [ + "" + ] + assert cache._config_file.exists() + assert ( + cache._gitignore_file.exists() + and cache._gitignore_file.read_text(encoding="utf-8") == "*" + ) + assert io.fetch_output() == ( + "Ensuring that the Poetry plugins required by the project are available...\n" + "The following Poetry plugins are required by the project" + " but are not installed in Poetry's environment:\n" + " - my-other-plugin (>=1.0)\n" + "Installing Poetry plugins only for the current project...\n" + "Updating dependencies\n" + "Resolving dependencies...\n\n" + "Writing lock file\n\n" + ) + assert io.fetch_error() == "" + + +@pytest.mark.parametrize("debug_out", [False, True]) +def test_ensure_plugins_install_overwrite_wrong_version_plugins( + poetry_with_plugins: Poetry, + io: BufferedIO, + system_env: Env, + fixture_dir: FixtureDirGetter, + mocker: MockerFixture, + debug_out: bool, +) -> None: + io.set_verbosity(Verbosity.DEBUG if debug_out else Verbosity.NORMAL) + fixture_path = fixture_dir("project_plugins") + for dist_info in ( + "my_application_plugin-1.0.dist-info", + "some_lib-2.0.dist-info", + ): + shutil.copytree(fixture_path / dist_info, system_env.purelib / dist_info) + cache = ProjectPluginCache(poetry_with_plugins, io) + install_spy = mocker.spy(cache, "_install") + execute_mock = mocker.patch( + "poetry.plugins.plugin_manager.Installer._execute", return_value=0 + ) + + cache.ensure_plugins() + + install_spy.assert_called_once_with( + [ + Dependency("my-application-plugin", ">=2.0"), + Dependency("my-other-plugin", ">=1.0"), + ], + system_env, + [Package("some-lib", "2.0")], + ) + execute_mock.assert_called_once() + assert [repr(op) for op in execute_mock.call_args.args[0] if not op.skipped] == [ + "", + "", + ] + assert cache._config_file.exists() + assert ( + cache._gitignore_file.exists() + and cache._gitignore_file.read_text(encoding="utf-8") == "*" + ) + start = ( + "Ensuring that the Poetry plugins required by the project are available...\n" + ) + opt = ( + "The following Poetry plugins are required by the project" + " but are not satisfied by the installed versions:\n" + " - my-application-plugin (>=2.0)\n" + " installed: my-application-plugin (1.0)\n" + ) + end = ( + "The following Poetry plugins are required by the project" + " but are not installed in Poetry's environment:\n" + " - my-application-plugin (>=2.0)\n" + " - my-other-plugin (>=1.0)\n" + "Installing Poetry plugins only for the current project...\n" + ) + expected = (start + opt + end) if debug_out else (start + end) + assert io.fetch_output().startswith(expected) + assert io.fetch_error() == "" + + +def test_ensure_plugins_pins_other_installed_packages( + poetry_with_plugins: Poetry, + io: BufferedIO, + system_env: Env, + fixture_dir: FixtureDirGetter, + mocker: MockerFixture, +) -> None: + fixture_path = fixture_dir("project_plugins") + for dist_info in ( + "my_application_plugin-1.0.dist-info", + "some_lib-1.0.dist-info", + ): + shutil.copytree(fixture_path / dist_info, system_env.purelib / dist_info) + cache = ProjectPluginCache(poetry_with_plugins, io) + install_spy = mocker.spy(cache, "_install") + execute_mock = mocker.patch( + "poetry.plugins.plugin_manager.Installer._execute", return_value=0 + ) + + with pytest.raises(SolverProblemError): + cache.ensure_plugins() + + install_spy.assert_called_once_with( + [ + Dependency("my-application-plugin", ">=2.0"), + Dependency("my-other-plugin", ">=1.0"), + ], + system_env, + # pinned because it might be a dependency of another plugin or Poetry itself + [Package("some-lib", "1.0")], + ) + execute_mock.assert_not_called() + assert not cache._config_file.exists() + assert ( + cache._gitignore_file.exists() + and cache._gitignore_file.read_text(encoding="utf-8") == "*" + ) + assert io.fetch_output() == ( + "Ensuring that the Poetry plugins required by the project are available...\n" + "The following Poetry plugins are required by the project" + " but are not installed in Poetry's environment:\n" + " - my-application-plugin (>=2.0)\n" + " - my-other-plugin (>=1.0)\n" + "Installing Poetry plugins only for the current project...\n" + "Updating dependencies\n" + "Resolving dependencies...\n" + ) + assert io.fetch_error() == "" + + +@pytest.mark.parametrize("other_version", [False, True]) +def test_project_plugins_are_installed_in_project_folder( + poetry_with_plugins: Poetry, + io: BufferedIO, + system_env: Env, + fixture_dir: FixtureDirGetter, + tmp_path: Path, + other_version: bool, +) -> None: + orig_purelib = system_env.purelib + orig_platlib = system_env.platlib + + # make sure that the path dependency is on the same drive (for Windows tests in CI) + orig_wheel_path = ( + fixture_dir("wheel_with_no_requires_dist") / "demo-0.1.0-py2.py3-none-any.whl" + ) + wheel_path = tmp_path / orig_wheel_path.name + shutil.copy(orig_wheel_path, wheel_path) + + if other_version: + WheelInstaller(system_env).install(wheel_path) + dist_info = orig_purelib / "demo-0.1.0.dist-info" + metadata = dist_info / "METADATA" + metadata.write_text( + metadata.read_text(encoding="utf-8").replace("0.1.0", "0.1.2"), + encoding="utf-8", + ) + dist_info.rename(orig_purelib / "demo-0.1.2.dist-info") + + cache = ProjectPluginCache(poetry_with_plugins, io) + + # just use a file dependency so that we do not have to set up a repository + cache._install([FileDependency("demo", wheel_path)], system_env, []) + + project_site_packages = [p.name for p in cache._path.iterdir()] + assert "demo" in project_site_packages + assert "demo-0.1.0.dist-info" in project_site_packages + + orig_site_packages = [p.name for p in orig_purelib.iterdir()] + if other_version: + assert "demo" in orig_site_packages + assert "demo-0.1.2.dist-info" in orig_site_packages + assert "demo-0.1.0.dist-info" not in orig_site_packages + else: + assert not any(p.startswith("demo") for p in orig_site_packages) + if orig_platlib != orig_purelib: + assert not any(p.name.startswith("demo") for p in orig_platlib.iterdir()) diff --git a/tests/repositories/test_installed_repository.py b/tests/repositories/test_installed_repository.py index 0645f0256bf..aa083eb82a6 100644 --- a/tests/repositories/test_installed_repository.py +++ b/tests/repositories/test_installed_repository.py @@ -4,6 +4,7 @@ import shutil import zipfile +from functools import cached_property from pathlib import Path from typing import TYPE_CHECKING from typing import Iterator @@ -95,7 +96,7 @@ def env( env_dir: Path, site_purelib: Path, site_platlib: Path, src_dir: Path ) -> MockEnv: class _MockEnv(MockEnv): - @property + @cached_property def paths(self) -> dict[str, str]: return { "purelib": site_purelib.as_posix(), @@ -201,6 +202,30 @@ def test_load_successful_with_invalid_distribution( assert str(invalid_dist_info) in message +def test_loads_in_correct_sys_path_order( + tmp_path: Path, current_python: tuple[int, int, int], fixture_dir: FixtureDirGetter +) -> None: + path1 = tmp_path / "path1" + path1.mkdir() + path2 = tmp_path / "path2" + path2.mkdir() + env = MockEnv(path=tmp_path, sys_path=[str(path1), str(path2)]) + fixtures = fixture_dir("project_plugins") + dist_info_1 = "my_application_plugin-1.0.dist-info" + dist_info_2 = "my_application_plugin-2.0.dist-info" + dist_info_other = "my_other_plugin-1.0.dist-info" + shutil.copytree(fixtures / dist_info_1, path1 / dist_info_1) + shutil.copytree(fixtures / dist_info_2, path2 / dist_info_2) + shutil.copytree(fixtures / dist_info_other, path2 / dist_info_other) + + repo = InstalledRepository.load(env) + + assert {f"{p.name} {p.version}" for p in repo.packages} == { + "my-application-plugin 1.0", + "my-other-plugin 1.0", + } + + def test_load_ensure_isolation(repository: InstalledRepository) -> None: package = get_package_from_repository("attrs", repository) assert package is None