diff --git a/docs/docs/cli.md b/docs/docs/cli.md index 70208501c87..1bf9ce1b134 100644 --- a/docs/docs/cli.md +++ b/docs/docs/cli.md @@ -538,3 +538,46 @@ To only remove a specific package from a cache, you have to specify the cache en ```bash poetry cache clear pypi:requests:2.24.0 ``` + +## plugin + +The `plugin` namespace regroups sub commands to manage Poetry plugins. + +### `plugin add` + +The `plugin add` command installs Poetry plugins and make them available at runtime. + +For example, to install the `poetry-plugin` plugin, you can run: + +```bash +poetry plugin add poetry-plugin +``` + +The package specification formats supported by the `plugin add` command are the same as the ones supported +by the [`add` command](#add). + +If you just want to check what would happen by installing a plugin, you can use the `--dry-run` option + +```bash +poetry plugin add poetry-plugin --dry-run +``` + +#### Options + +* `--dry-run`: Outputs the operations but will not execute anything (implicitly enables --verbose). + +### `plugin show` + +The `plugin show` command lists all the currently installed plugins. + +```bash +poetry plugin show +``` + +### `plugin remove` + +The `plugin remove` command removes installed plugins. + +```bash +poetry plugin remove poetry-plugin +``` diff --git a/docs/docs/plugins.md b/docs/docs/plugins.md new file mode 100644 index 00000000000..637306323a9 --- /dev/null +++ b/docs/docs/plugins.md @@ -0,0 +1,231 @@ +# Plugins + +Poetry supports using and building plugins if you wish to +alter or expand Poetry's functionality with your own. + +For example if your environment poses special requirements +on the behaviour of Poetry which do not apply to the majority of its users +or if you wish to accomplish something with Poetry in a way that is not desired by most users. + +In these cases you could consider creating a plugin to handle your specific logic. + + +## Creating a plugin + +A plugin is a regular Python package which ships its code as part of the package +and may also depend on further packages. + +### Plugin package + +The plugin package must depend on Poetry +and declare a proper [plugin](/docs/pyproject/#plugins) in the `pyproject.toml` file. + +```toml +[tool.poetry] +name = "my-poetry-plugin" +version = "1.0.0" + +# ... +[tool.poetry.dependency] +python = "~2.7 || ^3.7" +poetry = "^1.0" + +[tool.poetry.plugins."poetry.plugin"] +demo = "poetry_demo_plugin.plugin:MyPlugin" +``` + +### Generic plugins + +Every plugin has to supply a class which implements the `poetry.plugins.Plugin` interface. + +The `activate()` method of the plugin is called after the plugin is loaded +and receives an instance of `Poetry` as well as an instance of `cleo.io.IO`. + +Using these two objects all configuration can be read +and all public internal objects and state can be manipulated as desired. + +Example: + +```python +from cleo.io.io import IO + +from poetry.plugins.plugin import Plugin +from poetry.poetry import Poetry + + +class MyPlugin(Plugin): + + def activate(self, poetry: Poetry, io: IO): + version = self.get_custom_version() + io.write_line(f"Setting package version to {version}") + poetry.package.set_version(version) + + def get_custom_version(self) -> str: + ... +``` + +### Application plugins + +If you want to add commands or options to the `poetry` script you need +to create an application plugin which implements the `poetry.plugins.ApplicationPlugin` interface. + +The `activate()` method of the application plugin is called after the plugin is loaded +and receives an instance of `console.Application`. + +```python +from cleo.commands.command import Command +from poetry.plugins.application_plugin import ApplicationPlugin + + +class CustomCommand(Command): + + name = "my-command" + + def handle(self) -> int: + self.line("My command") + + return 0 + + +def factory(): + return CustomCommand() + + +class MyApplicationPlugin(ApplicationPlugin): + def activate(self, application): + application.command_loader.register_factory("my-command", factory) +``` + +!!!note + + It's possible to do the following to register the command: + + ```python + application.add(MyCommand()) + ``` + + However, it is **strongly** recommended to register a new factory + in the command loader to defer the loading of the command when it's actually + called. + + This will help keep the performances of Poetry good. + +The plugin also must be declared in the `pyproject.toml` file of the plugin package +as an `application.plugin` plugin: + +```toml +[tool.poetry.plugins."poetry.application.plugin"] +foo-command = "poetry_demo_plugin.plugin:MyApplicationPlugin" +``` + +!!!warning + + A plugin **must not** remove or modify in any way the core commands of Poetry. + + +### Event handler + +Plugins can also listen to specific events and act on them if necessary. + +These events are fired by [Cleo](https://github.com/sdispater/cleo) +and are accessible from the `cleo.events.console_events` module. + +- `COMMAND`: this event allows attaching listeners before any command is executed. +- `SIGNAL`: this event allows some actions to be performed after the command execution is interrupted. +- `TERMINATE`: this event allows listeners to be attached after the command. +- `ERROR`: this event occurs when an uncaught exception is raised. + +Let's see how to implement an application event handler. For this example +we will see how to load environment variables from a `.env` file before executing +a command. + + +```python +from cleo.events.console_events import COMMAND +from cleo.events.console_command_event import ConsoleCommandEvent +from cleo.events.event_dispatcher import EventDispatcher +from dotenv import load_dotenv +from poetry.console.application import Application +from poetry.console.commands.env_command import EnvCommand +from poetry.plugins.application_plugin import ApplicationPlugin + + +class MyApplicationPlugin(ApplicationPlugin): + def activate(self, application: Application): + application.event_dispatcher.add_listener(COMMAND, self.load_dotenv) + + def load_dotenv( + self, event: ConsoleCommandEvent, event_name: str, dispatcher: EventDispatcher + ) -> None: + command = event.command + if not isinstance(command, EnvCommand): + return + + io = event.io + + if io.is_debug(): + io.write_line("Loading environment variables.") + + load_dotenv() +``` + + +## Using plugins + +Installed plugin packages are automatically loaded when Poetry starts up. + +You have multiple ways to install plugins for Poetry + +### The `plugin add` command + +This is the easiest way and should account for all the ways Poetry can be installed. + +```bash +poetry plugin add poetry-plugin +``` + +The `plugin add` command will ensure that the plugin is compatible with the current version of Poetry +and install the needed packages for the plugin to work. + +The package specification formats supported by the `plugin add` command are the same as the ones supported +by the [`add` command](/docs/cli/#add). + +If you no longer need a plugin and want to uninstall it, you can use the `plugin remove` command. + +```shell +poetry plugin remove poetry-plugin +``` + +You can also list all currently installed plugins by running: + +```shell +poetry plugin show +``` + +### With `pipx inject` + +If you used `pipx` to install Poetry you can add the plugin packages via the `pipx inject` command. + +```shell +pipx inject poetry poetry-plugin +``` + +If you want to uninstall a plugin, you can run: + +```shell +pipx runpip poetry uninstall poetry-plugin +``` + +### With `pip` + +If you used `pip` to install Poetry you can add the plugin packages via the `pip install` command. + +```shell +pip install --user poetry-plugin +``` + +If you want to uninstall a plugin, you can run: + +```shell +pip uninstall poetry-plugin +``` diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index c35ef9579b4..e055a6b6370 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -16,6 +16,7 @@ nav: - Repositories: repositories.md - Managing environments: managing-environments.md - Dependency specification: dependency-specification.md + - Plugins: plugins.md - The pyproject.toml file: pyproject.md - Contributing: contributing.md - FAQ: faq.md diff --git a/poetry.lock b/poetry.lock index fb8897009e2..9b75e396995 100644 --- a/poetry.lock +++ b/poetry.lock @@ -173,6 +173,14 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "entrypoints" +version = "0.3" +description = "Discover and load entry points from installed packages." +category = "main" +optional = false +python-versions = ">=2.7" + [[package]] name = "filelock" version = "3.0.12" @@ -697,7 +705,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pyt [metadata] lock-version = "1.1" python-versions = "^3.6" -content-hash = "6cbc07e5853bcf1280421b77b6fca85f2f7eb5a6ff12049f65ea116b256d94ea" +content-hash = "c72b0807603d4902cff83901d0e65165e243937b5be90b05c17d3c92a06b4fc8" [metadata.files] appdirs = [ @@ -859,6 +867,10 @@ distlib = [ {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"}, {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"}, ] +entrypoints = [ + {file = "entrypoints-0.3-py2.py3-none-any.whl", hash = "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19"}, + {file = "entrypoints-0.3.tar.gz", hash = "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"}, +] filelock = [ {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, diff --git a/poetry/console/application.py b/poetry/console/application.py index e54cbb5d846..d9d9277d274 100644 --- a/poetry/console/application.py +++ b/poetry/console/application.py @@ -19,10 +19,10 @@ from cleo.io.inputs.input import Input from cleo.io.io import IO from cleo.io.outputs.output import Output -from cleo.loaders.factory_command_loader import FactoryCommandLoader from poetry.__version__ import __version__ +from .command_loader import CommandLoader from .commands.command import Command @@ -70,12 +70,19 @@ def _load() -> Type[Command]: "env list", "env remove", "env use", + # Plugin commands + "plugin add", + "plugin remove", + "plugin show", # Self commands "self update", ] if TYPE_CHECKING: + from cleo.io.inputs.definition import Definition + + from poetry.console.commands.installer_command import InstallerCommand from poetry.poetry import Poetry @@ -84,16 +91,17 @@ def __init__(self) -> None: super(Application, self).__init__("poetry", __version__) self._poetry = None + self._io: Optional[IO] = None + self._disable_plugins = False + self._plugins_loaded = False dispatcher = EventDispatcher() dispatcher.add_listener(COMMAND, self.register_command_loggers) - dispatcher.add_listener(COMMAND, self.set_env) - dispatcher.add_listener(COMMAND, self.set_installer) + dispatcher.add_listener(COMMAND, self.configure_env) + dispatcher.add_listener(COMMAND, self.configure_installer) self.set_event_dispatcher(dispatcher) - command_loader = FactoryCommandLoader( - {name: load_command(name) for name in COMMANDS} - ) + command_loader = CommandLoader({name: load_command(name) for name in COMMANDS}) self.set_command_loader(command_loader) @property @@ -105,10 +113,16 @@ def poetry(self) -> "Poetry": if self._poetry is not None: return self._poetry - self._poetry = Factory().create_poetry(Path.cwd()) + self._poetry = Factory().create_poetry( + Path.cwd(), io=self._io, disable_plugins=self._disable_plugins + ) return self._poetry + @property + def command_loader(self) -> CommandLoader: + return self._command_loader + def reset_poetry(self) -> None: self._poetry = None @@ -138,8 +152,17 @@ def create_io( io.output.set_formatter(formatter) io.error_output.set_formatter(formatter) + self._io = io + return io + def _run(self, io: IO) -> int: + self._disable_plugins = io.input.parameter_option("--no-plugins") + + self._load_plugins(io) + + return super()._run(io) + def _configure_io(self, io: IO) -> None: # We need to check if the command being run # is the "run" command. @@ -221,7 +244,9 @@ def register_command_loggers( logger.setLevel(level) - def set_env(self, event: ConsoleCommandEvent, event_name: str, _: Any) -> None: + def configure_env( + self, event: ConsoleCommandEvent, event_name: str, _: Any + ) -> None: from .commands.env_command import EnvCommand command: EnvCommand = cast(EnvCommand, event.command) @@ -244,7 +269,7 @@ def set_env(self, event: ConsoleCommandEvent, event_name: str, _: Any) -> None: command.set_env(env) - def set_installer( + def configure_installer( self, event: ConsoleCommandEvent, event_name: str, _: Any ) -> None: from .commands.installer_command import InstallerCommand @@ -258,11 +283,14 @@ def set_installer( if command.installer is not None: return + self._configure_installer(command, event.io) + + def _configure_installer(self, command: "InstallerCommand", io: "IO") -> None: from poetry.installation.installer import Installer poetry = command.poetry installer = Installer( - event.io, + io, command.env, poetry.package, poetry.locker, @@ -272,6 +300,33 @@ def set_installer( installer.use_executor(poetry.config.get("experimental.new-installer", False)) command.set_installer(installer) + def _load_plugins(self, io: IO) -> None: + if self._plugins_loaded: + return + + self._disable_plugins = io.input.has_parameter_option("--no-plugins") + + if not self._disable_plugins: + from poetry.plugins.plugin_manager import PluginManager + + manager = PluginManager("application.plugin") + manager.load_plugins() + manager.activate(self) + + self._plugins_loaded = True + + @property + def _default_definition(self) -> "Definition": + from cleo.io.inputs.option import Option + + definition = super()._default_definition + + definition.add_option( + Option("--no-plugins", flag=True, description="Disables plugins.") + ) + + return definition + def main() -> int: return Application().run() diff --git a/poetry/console/command_loader.py b/poetry/console/command_loader.py new file mode 100644 index 00000000000..852abe07dc6 --- /dev/null +++ b/poetry/console/command_loader.py @@ -0,0 +1,12 @@ +from typing import Callable + +from cleo.exceptions import LogicException +from cleo.loaders.factory_command_loader import FactoryCommandLoader + + +class CommandLoader(FactoryCommandLoader): + def register_factory(self, command_name: str, factory: Callable) -> None: + if command_name in self._factories: + raise LogicException(f'The command "{command_name}" already exists.') + + self._factories[command_name] = factory diff --git a/poetry/console/commands/command.py b/poetry/console/commands/command.py index be87fe99b7a..a717fa4e666 100644 --- a/poetry/console/commands/command.py +++ b/poetry/console/commands/command.py @@ -1,4 +1,5 @@ from typing import TYPE_CHECKING +from typing import Optional from cleo.commands.command import Command as BaseCommand @@ -11,9 +12,17 @@ class Command(BaseCommand): loggers = [] + _poetry: Optional["Poetry"] = None + @property def poetry(self) -> "Poetry": - return self.get_application().poetry + if self._poetry is None: + return self.get_application().poetry + + return self._poetry + + def set_poetry(self, poetry: "Poetry") -> None: + self._poetry = poetry def get_application(self) -> "Application": return self.application diff --git a/poetry/console/commands/env_command.py b/poetry/console/commands/env_command.py index beb40e1e88e..fd44b415c00 100644 --- a/poetry/console/commands/env_command.py +++ b/poetry/console/commands/env_command.py @@ -4,7 +4,7 @@ if TYPE_CHECKING: - from poetry.utils.env import VirtualEnv + from poetry.utils.env import Env class EnvCommand(Command): @@ -14,8 +14,8 @@ def __init__(self) -> None: super(EnvCommand, self).__init__() @property - def env(self) -> "VirtualEnv": + def env(self) -> "Env": return self._env - def set_env(self, env: "VirtualEnv") -> None: + def set_env(self, env: "Env") -> None: self._env = env diff --git a/poetry/console/commands/init.py b/poetry/console/commands/init.py index 2e10f2ead01..b2e4fd0a315 100644 --- a/poetry/console/commands/init.py +++ b/poetry/console/commands/init.py @@ -430,20 +430,32 @@ def _parse_requirements(self, requirements: List[str]) -> List[Dict[str, str]]: result.append(pair) continue - elif (os.path.sep in requirement or "/" in requirement) and cwd.joinpath( - requirement - ).exists(): - path = cwd.joinpath(requirement) + elif (os.path.sep in requirement or "/" in requirement) and ( + cwd.joinpath(requirement).exists() + or Path(requirement).expanduser().exists() + and Path(requirement).expanduser().is_absolute() + ): + path = Path(requirement).expanduser() + is_absolute = path.is_absolute() + + if not path.is_absolute(): + path = cwd.joinpath(requirement) + if path.is_file(): package = Provider.get_package_from_file(path.resolve()) else: - package = Provider.get_package_from_directory(path) + package = Provider.get_package_from_directory(path.resolve()) result.append( dict( [ ("name", package.name), - ("path", path.relative_to(cwd).as_posix()), + ( + "path", + path.relative_to(cwd).as_posix() + if not is_absolute + else path.as_posix(), + ), ] + ([("extras", extras)] if extras else []) ) diff --git a/.coveragerc b/poetry/console/commands/plugin/__init__.py similarity index 100% rename from .coveragerc rename to poetry/console/commands/plugin/__init__.py diff --git a/poetry/console/commands/plugin/add.py b/poetry/console/commands/plugin/add.py new file mode 100644 index 00000000000..8418aa25c89 --- /dev/null +++ b/poetry/console/commands/plugin/add.py @@ -0,0 +1,203 @@ +import os + +from typing import TYPE_CHECKING +from typing import Dict +from typing import List +from typing import cast + +from cleo.helpers import argument +from cleo.helpers import option + +from ..init import InitCommand + + +if TYPE_CHECKING: + from poetry.console.application import Application # noqa + from poetry.console.commands.update import UpdateCommand # noqa + + +class PluginAddCommand(InitCommand): + + name = "plugin add" + + description = "Adds new plugins." + + arguments = [ + argument("plugins", "The names of the plugins to install.", multiple=True), + ] + + options = [ + option( + "dry-run", + None, + "Output the operations but do not execute anything (implicitly enables --verbose).", + ) + ] + + help = """ +The plugin add command installs Poetry plugins globally. + +It works similarly to the add command: + +If you do not specify a version constraint, poetry will choose a suitable one based on the available package versions. + +You can specify a package in the following forms: + + - A single name (requests) + - A name and a constraint (requests@^2.23.0) + - A git url (git+https://github.com/python-poetry/poetry.git) + - A git url with a revision (git+https://github.com/python-poetry/poetry.git#develop) + - A git SSH url (git+ssh://github.com/python-poetry/poetry.git) + - A git SSH url with a revision (git+ssh://github.com/python-poetry/poetry.git#develop) + - A file path (../my-package/my-package.whl) + - A directory (../my-package/) + - A url (https://example.com/packages/my-package-0.1.0.tar.gz)\ +""" + + def handle(self) -> int: + from pathlib import Path + + import tomlkit + + from cleo.io.inputs.string_input import StringInput + from cleo.io.io import IO + + from poetry.core.pyproject.toml import PyProjectTOML + from poetry.core.semver.helpers import parse_constraint + from poetry.factory import Factory + from poetry.packages.project_package import ProjectPackage + from poetry.repositories.installed_repository import InstalledRepository + from poetry.utils.env import EnvManager + + plugins = self.argument("plugins") + + # Plugins should be installed in the system env to be globally available + system_env = EnvManager.get_system_env() + + env_dir = Path( + os.getenv("POETRY_HOME") if os.getenv("POETRY_HOME") else system_env.path + ) + + # We check for the plugins existence first. + if env_dir.joinpath("pyproject.toml").exists(): + pyproject = tomlkit.loads( + env_dir.joinpath("pyproject.toml").read_text(encoding="utf-8") + ) + poetry_content = pyproject["tool"]["poetry"] + existing_packages = self.get_existing_packages_from_input( + plugins, poetry_content, "dependencies" + ) + + if existing_packages: + self.notify_about_existing_packages(existing_packages) + + plugins = [plugin for plugin in plugins if plugin not in existing_packages] + + if not plugins: + return 0 + + plugins = self._determine_requirements(plugins) + + # We retrieve the packages installed in the system environment. + # We assume that this environment will be a self contained virtual environment + # built by the official installer or by pipx. + # If not, it might lead to side effects since other installed packages + # might not be required by Poetry but still taken into account when resolving dependencies. + installed_repository = InstalledRepository.load( + system_env, with_dependencies=True + ) + + root_package = None + for package in installed_repository.packages: + if package.name == "poetry": + root_package = ProjectPackage(package.name, package.version) + for dependency in package.requires: + root_package.add_dependency(dependency) + + break + + root_package.python_versions = ".".join( + str(v) for v in system_env.version_info[:3] + ) + # We create a `pyproject.toml` file based on all the information + # we have about the current environment. + if not env_dir.joinpath("pyproject.toml").exists(): + Factory.create_pyproject_from_package(root_package, env_dir) + + # We add the plugins to the dependencies section of the previously + # created `pyproject.toml` file + pyproject = PyProjectTOML(env_dir.joinpath("pyproject.toml")) + poetry_content = pyproject.poetry_config + poetry_dependency_section = poetry_content["dependencies"] + plugin_names = [] + for plugin in plugins: + if "version" in plugin: + # Validate version constraint + parse_constraint(plugin["version"]) + + constraint = tomlkit.inline_table() + for name, value in plugin.items(): + if name == "name": + continue + + constraint[name] = value + + if len(constraint) == 1 and "version" in constraint: + constraint = constraint["version"] + + poetry_dependency_section[plugin["name"]] = constraint + plugin_names.append(plugin["name"]) + + pyproject.save() + + # From this point forward, all the logic will be deferred to + # the update command, by using the previously created `pyproject.toml` + # file. + application = cast("Application", self.application) + update_command: "UpdateCommand" = cast( + "UpdateCommand", application.find("update") + ) + # We won't go through the event dispatching done by the application + # so we need to configure the command manually + update_command.set_poetry(Factory().create_poetry(env_dir)) + update_command.set_env(system_env) + application._configure_installer(update_command, self._io) + + argv = ["update"] + plugin_names + if self.option("dry-run"): + argv.append("--dry-run") + + return update_command.run( + IO( + StringInput(" ".join(argv)), + self._io.output, + self._io.error_output, + ) + ) + + def get_existing_packages_from_input( + self, packages: List[str], poetry_content: Dict, target_section: str + ) -> List[str]: + existing_packages = [] + + for name in packages: + for key in poetry_content[target_section]: + if key.lower() == name.lower(): + existing_packages.append(name) + + return existing_packages + + def notify_about_existing_packages(self, existing_packages: List[str]) -> None: + self.line( + "The following plugins are already present in the " + "pyproject.toml file and will be skipped:\n" + ) + for name in existing_packages: + self.line(" • {name}".format(name=name)) + + self.line( + "\nIf you want to update it to the latest compatible version, " + "you can use `poetry plugin update package`.\n" + "If you prefer to upgrade it to the latest available version, " + "you can use `poetry plugin add package@latest`.\n" + ) diff --git a/poetry/console/commands/plugin/remove.py b/poetry/console/commands/plugin/remove.py new file mode 100644 index 00000000000..cb8143175f7 --- /dev/null +++ b/poetry/console/commands/plugin/remove.py @@ -0,0 +1,73 @@ +import os + +from typing import TYPE_CHECKING +from typing import cast + +from cleo.helpers import argument +from cleo.helpers import option + +from poetry.console.commands.command import Command + + +if TYPE_CHECKING: + from poetry.console.application import Application # noqa + from poetry.console.commands.remove import RemoveCommand + + +class PluginRemoveCommand(Command): + + name = "plugin remove" + + description = "Removes installed plugins" + + arguments = [ + argument("plugins", "The names of the plugins to install.", multiple=True), + ] + + options = [ + option( + "dry-run", + None, + "Output the operations but do not execute anything (implicitly enables --verbose).", + ) + ] + + def handle(self) -> int: + from pathlib import Path + + from cleo.io.inputs.string_input import StringInput + from cleo.io.io import IO + + from poetry.factory import Factory + from poetry.utils.env import EnvManager + + plugins = self.argument("plugins") + + system_env = EnvManager.get_system_env() + env_dir = Path( + os.getenv("POETRY_HOME") if os.getenv("POETRY_HOME") else system_env.path + ) + + # From this point forward, all the logic will be deferred to + # the remove command, by using the global `pyproject.toml` file. + application = cast("Application", self.application) + remove_command: "RemoveCommand" = cast( + "RemoveCommand", application.find("remove") + ) + # We won't go through the event dispatching done by the application + # so we need to configure the command manually + remove_command.set_poetry(Factory().create_poetry(env_dir)) + remove_command.set_env(system_env) + application._configure_installer(remove_command, self._io) + + argv = ["remove"] + plugins + if self.option("dry-run"): + argv.append("--dry-run") + + return remove_command.run( + IO( + StringInput(" ".join(argv)), + self._io.output, + self._io.error_output, + ) + ) diff --git a/poetry/console/commands/plugin/show.py b/poetry/console/commands/plugin/show.py new file mode 100644 index 00000000000..ced1a0fd282 --- /dev/null +++ b/poetry/console/commands/plugin/show.py @@ -0,0 +1,95 @@ +from collections import defaultdict +from typing import TYPE_CHECKING +from typing import DefaultDict +from typing import Dict +from typing import List +from typing import Union + +from poetry.console.commands.command import Command + + +if TYPE_CHECKING: + from poetry.core.packages.package import Package + + +class PluginShowCommand(Command): + + name = "plugin show" + + description = "Shows information about the currently installed plugins." + + def handle(self) -> int: + from poetry.plugins.application_plugin import ApplicationPlugin + from poetry.plugins.plugin_manager import PluginManager + from poetry.repositories.installed_repository import InstalledRepository + from poetry.utils.env import EnvManager + from poetry.utils.helpers import canonicalize_name + + plugins: DefaultDict[str, Dict[str, Union["Package", List[str]]]] = defaultdict( + lambda: { + "package": None, + "plugins": [], + "application_plugins": [], + } + ) + + entry_points = ( + PluginManager("application.plugin").get_plugin_entry_points() + + PluginManager("plugin").get_plugin_entry_points() + ) + + system_env = EnvManager.get_system_env() + installed_repository = InstalledRepository.load( + system_env, with_dependencies=True + ) + + packages_by_name = {pkg.name: pkg for pkg in installed_repository.packages} + + for entry_point in entry_points: + plugin = entry_point.load() + category = "plugins" + if issubclass(plugin, ApplicationPlugin): + category = "application_plugins" + + package = packages_by_name[canonicalize_name(entry_point.name)] + plugins[package.pretty_name]["package"] = package + plugins[package.pretty_name][category].append(entry_point) + + for name, info in plugins.items(): + package = info["package"] + self.line("") + self.line( + " • {} ({}){}".format( + name, + package.version, + " " + package.description if package.description else "", + ) + ) + provide_line = " " + if info["plugins"]: + provide_line += " {} plugin{}".format( + len(info["plugins"]), "s" if len(info["plugins"]) > 1 else "" + ) + + if info["application_plugins"]: + if info["plugins"]: + provide_line += " and" + + provide_line += " {} application plugin{}".format( + len(info["application_plugins"]), + "s" if len(info["application_plugins"]) > 1 else "", + ) + + self.line(provide_line) + + if package.requires: + self.line("") + self.line(" Dependencies") + for dependency in package.requires: + self.line( + " - {} ({})".format( + dependency.pretty_name, dependency.pretty_constraint + ) + ) + + return 0 diff --git a/poetry/console/commands/remove.py b/poetry/console/commands/remove.py index a7b2e00a26f..e2b65c46a72 100644 --- a/poetry/console/commands/remove.py +++ b/poetry/console/commands/remove.py @@ -1,6 +1,7 @@ from cleo.helpers import argument from cleo.helpers import option +from ...utils.helpers import canonicalize_name from .installer_command import InstallerCommand @@ -54,12 +55,17 @@ def handle(self) -> int: for key in requirements: del poetry_content[section][key] - # Write the new content back - self.poetry.file.write(content) + dependencies = ( + self.poetry.package.requires + if section == "dependencies" + else self.poetry.package.dev_requires + ) - # Update packages - self.reset_poetry() + for i, dependency in enumerate(reversed(dependencies)): + if dependency.name == canonicalize_name(key): + del dependencies[-i] + # Update packages self._installer.use_executor( self.poetry.config.get("experimental.new-installer", False) ) @@ -76,15 +82,7 @@ def handle(self) -> int: raise - if status != 0 or self.option("dry-run"): - # Revert changes - if not self.option("dry-run"): - self.line_error( - "\n" - "Removal failed, reverting pyproject.toml " - "to its original content." - ) - - self.poetry.file.write(original_content) + if not self.option("dry-run"): + self.poetry.file.write(content) return status diff --git a/poetry/console/events/__init__.py b/poetry/console/events/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/poetry/console/events/console_events.py b/poetry/console/events/console_events.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/poetry/factory.py b/poetry/factory.py index 43555eef6c3..36078bed1e5 100644 --- a/poetry/factory.py +++ b/poetry/factory.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import TYPE_CHECKING from typing import Dict +from typing import List from typing import Optional from cleo.io.io import IO @@ -16,6 +17,8 @@ from .config.file_config_source import FileConfigSource from .locations import CONFIG_DIR from .packages.locker import Locker +from .packages.project_package import ProjectPackage +from .plugins.plugin_manager import PluginManager from .poetry import Poetry from .repositories.pypi_repository import PyPiRepository @@ -30,7 +33,10 @@ class Factory(BaseFactory): """ def create_poetry( - self, cwd: Optional[Path] = None, io: Optional[IO] = None + self, + cwd: Optional[Path] = None, + io: Optional[IO] = None, + disable_plugins: bool = False, ) -> Poetry: if io is None: io = NullIO() @@ -75,35 +81,21 @@ def create_poetry( ) # Configuring sources - sources = poetry.local_config.get("source", []) - for source in sources: - repository = self.create_legacy_repository(source, config) - is_default = source.get("default", False) - is_secondary = source.get("secondary", False) - if io.is_debug(): - message = "Adding repository {} ({})".format( - repository.name, repository.url - ) - if is_default: - message += " and setting it as the default one" - elif is_secondary: - message += " and setting it as secondary" - - io.write_line(message) - - poetry.pool.add_repository(repository, is_default, secondary=is_secondary) + self.configure_sources( + poetry, poetry.local_config.get("source", []), config, io + ) - # Always put PyPI last to prefer private repositories - # but only if we have no other default source - if not poetry.pool.has_default(): - has_sources = bool(sources) - poetry.pool.add_repository(PyPiRepository(), not has_sources, has_sources) - else: - if io.is_debug(): - io.write_line("Deactivating the PyPI repository") + plugin_manager = PluginManager("plugin", disable_plugins=disable_plugins) + plugin_manager.load_plugins() + poetry.set_plugin_manager(plugin_manager) + plugin_manager.activate(poetry, io) return poetry + @classmethod + def get_package(cls, name: str, version: str) -> ProjectPackage: + return ProjectPackage(name, version, version) + @classmethod def create_config(cls, io: Optional[IO] = None) -> Config: if io is None: @@ -140,8 +132,39 @@ def create_config(cls, io: Optional[IO] = None) -> Config: return config + @classmethod + def configure_sources( + cls, poetry: "Poetry", sources: List[Dict[str, str]], config: "Config", io: "IO" + ) -> None: + for source in sources: + repository = cls.create_legacy_repository(source, config) + is_default = source.get("default", False) + is_secondary = source.get("secondary", False) + if io.is_debug(): + message = "Adding repository {} ({})".format( + repository.name, repository.url + ) + if is_default: + message += " and setting it as the default one" + elif is_secondary: + message += " and setting it as secondary" + + io.write_line(message) + + poetry.pool.add_repository(repository, is_default, secondary=is_secondary) + + # Always put PyPI last to prefer private repositories + # but only if we have no other default source + if not poetry.pool.has_default(): + has_sources = bool(sources) + poetry.pool.add_repository(PyPiRepository(), not has_sources, has_sources) + else: + if io.is_debug(): + io.write_line("Deactivating the PyPI repository") + + @classmethod def create_legacy_repository( - self, source: Dict[str, str], auth_config: Config + cls, source: Dict[str, str], auth_config: Config ) -> "LegacyRepository": from .repositories.legacy_repository import LegacyRepository from .utils.helpers import get_cert @@ -164,3 +187,49 @@ def create_legacy_repository( cert=get_cert(auth_config, name), client_cert=get_client_cert(auth_config, name), ) + + @classmethod + def create_pyproject_from_package( + cls, package: "ProjectPackage", path: "Path" + ) -> None: + import tomlkit + + from poetry.layouts.layout import POETRY_DEFAULT + + pyproject = tomlkit.loads(POETRY_DEFAULT) + content = pyproject["tool"]["poetry"] + + content["name"] = package.name + content["version"] = package.version.text + content["description"] = package.description + content["authors"] = package.authors + + dependency_section = content["dependencies"] + dependency_section["python"] = package.python_versions + + for dep in package.requires: + constraint = tomlkit.inline_table() + if dep.is_vcs(): + constraint[dep.vcs] = dep.source_url + + if dep.reference: + constraint["rev"] = dep.reference + elif dep.is_file() or dep.is_directory(): + constraint["path"] = dep.source_url + else: + constraint["version"] = dep.pretty_constraint + + if not dep.marker.is_any(): + constraint["markers"] = str(dep.marker) + + if dep.extras: + constraint["extras"] = list(sorted(dep.extras)) + + if len(constraint) == 1 and "version" in constraint: + constraint = constraint["version"] + + dependency_section[dep.name] = constraint + + path.joinpath("pyproject.toml").write_text( + pyproject.as_string(), encoding="utf-8" + ) diff --git a/poetry/installation/installer.py b/poetry/installation/installer.py index b778711479b..bc254c135e3 100644 --- a/poetry/installation/installer.py +++ b/poetry/installation/installer.py @@ -40,7 +40,7 @@ def __init__( locker: Locker, pool: Pool, config: Config, - installed: Union[InstalledRepository, None] = None, + installed: Union[Repository, None] = None, executor: Optional[Executor] = None, ): self._io = io diff --git a/poetry/locations.py b/poetry/locations.py index 5bd4b7feb17..ff38c9c9e82 100644 --- a/poetry/locations.py +++ b/poetry/locations.py @@ -2,9 +2,11 @@ from .utils.appdirs import user_cache_dir from .utils.appdirs import user_config_dir +from .utils.appdirs import user_data_dir CACHE_DIR = user_cache_dir("pypoetry") +DATA_DIR = user_data_dir("pypoetry") CONFIG_DIR = user_config_dir("pypoetry") REPOSITORY_CACHE_DIR = Path(CACHE_DIR) / "cache" / "repositories" diff --git a/poetry/packages/project_package.py b/poetry/packages/project_package.py new file mode 100644 index 00000000000..22379c2026f --- /dev/null +++ b/poetry/packages/project_package.py @@ -0,0 +1,23 @@ +from typing import TYPE_CHECKING +from typing import Optional +from typing import Union + +from poetry.core.packages.project_package import ProjectPackage as _ProjectPackage + + +if TYPE_CHECKING: + from poetry.core.semver.version import Version # noqa + + +class ProjectPackage(_ProjectPackage): + def set_version( + self, version: Union[str, "Version"], pretty_version: Optional[str] = None + ) -> "ProjectPackage": + from poetry.core.semver.version import Version # noqa + + if not isinstance(version, Version): + self._version = Version.parse(version) + self._pretty_version = pretty_version or version + else: + self._version = version + self._pretty_version = pretty_version or version.text diff --git a/poetry/plugins/__init__.py b/poetry/plugins/__init__.py new file mode 100644 index 00000000000..c81eb48d65f --- /dev/null +++ b/poetry/plugins/__init__.py @@ -0,0 +1,5 @@ +from .application_plugin import ApplicationPlugin +from .plugin import Plugin + + +__all__ = ["ApplicationPlugin", "Plugin"] diff --git a/poetry/plugins/application_plugin.py b/poetry/plugins/application_plugin.py new file mode 100644 index 00000000000..0f896282172 --- /dev/null +++ b/poetry/plugins/application_plugin.py @@ -0,0 +1,12 @@ +from .base_plugin import BasePlugin + + +class ApplicationPlugin(BasePlugin): + """ + Base class for plugins. + """ + + type = "application.plugin" + + def activate(self, application): + raise NotImplementedError() diff --git a/poetry/plugins/base_plugin.py b/poetry/plugins/base_plugin.py new file mode 100644 index 00000000000..9e287c8178c --- /dev/null +++ b/poetry/plugins/base_plugin.py @@ -0,0 +1,6 @@ +class BasePlugin(object): + """ + Base class for all plugin types + """ + + PLUGIN_API_VERSION = "1.0.0" diff --git a/poetry/plugins/plugin.py b/poetry/plugins/plugin.py new file mode 100644 index 00000000000..0c2f0711a29 --- /dev/null +++ b/poetry/plugins/plugin.py @@ -0,0 +1,14 @@ +from .base_plugin import BasePlugin + + +class Plugin(BasePlugin): + """ + Generic plugin not related to the console application. + The activate() method must be implemented and receives + the Poetry instance. + """ + + type = "plugin" + + def activate(self, poetry, io): + raise NotImplementedError() diff --git a/poetry/plugins/plugin_manager.py b/poetry/plugins/plugin_manager.py new file mode 100644 index 00000000000..6f9e8f49ba2 --- /dev/null +++ b/poetry/plugins/plugin_manager.py @@ -0,0 +1,60 @@ +import logging + +from typing import List + +import entrypoints + +from .application_plugin import ApplicationPlugin +from .plugin import Plugin + + +logger = logging.getLogger(__name__) + + +class PluginManager(object): + """ + This class registers and activates plugins. + """ + + def __init__(self, type, disable_plugins=False): # type: (str, bool) -> None + self._type = type + self._disable_plugins = disable_plugins + self._plugins = [] + + def load_plugins(self): # type: () -> None + if self._disable_plugins: + return + + plugin_entrypoints = self.get_plugin_entry_points() + + for entrypoint in plugin_entrypoints: + self._load_plugin_entrypoint(entrypoint) + + def get_plugin_entry_points(self) -> List[entrypoints.EntryPoint]: + return entrypoints.get_group_all("poetry.{}".format(self._type)) + + def add_plugin(self, plugin): # type: (Plugin) -> None + if not isinstance(plugin, (Plugin, ApplicationPlugin)): + raise ValueError( + "The Poetry plugin must be an instance of Plugin or ApplicationPlugin" + ) + + self._plugins.append(plugin) + + def activate(self, *args, **kwargs): + for plugin in self._plugins: + plugin.activate(*args, **kwargs) + + def _load_plugin_entrypoint( + self, entrypoint + ): # type: (entrypoints.EntryPoint) -> None + logger.debug("Loading the {} plugin".format(entrypoint.name)) + + plugin = entrypoint.load() + + if not issubclass(plugin, (Plugin, ApplicationPlugin)): + raise ValueError( + "The Poetry plugin must be an instance of Plugin or ApplicationPlugin" + ) + + self.add_plugin(plugin()) diff --git a/poetry/poetry.py b/poetry/poetry.py index 5c248d46224..54fee74b7fc 100644 --- a/poetry/poetry.py +++ b/poetry/poetry.py @@ -11,6 +11,7 @@ from .config.config import Config from .packages.locker import Locker + from .plugins.plugin_manager import PluginManager from .repositories.pool import Pool @@ -33,6 +34,7 @@ def __init__( self._locker = locker self._config = config self._pool = Pool() + self._plugin_manager = None @property def locker(self) -> "Locker": @@ -60,3 +62,8 @@ def set_config(self, config: "Config") -> "Poetry": self._config = config return self + + def set_plugin_manager(self, plugin_manager: "PluginManager") -> "Poetry": + self._plugin_manager = plugin_manager + + return self diff --git a/poetry/puzzle/provider.py b/poetry/puzzle/provider.py index f18a56e0579..3d384a6697d 100644 --- a/poetry/puzzle/provider.py +++ b/poetry/puzzle/provider.py @@ -431,7 +431,6 @@ def incompatibilities_for( ] def complete_package(self, package: DependencyPackage) -> DependencyPackage: - if package.is_root(): package = package.clone() requires = package.all_requires diff --git a/poetry/repositories/installed_repository.py b/poetry/repositories/installed_repository.py index c3f9e13c764..f33bbd9b99f 100644 --- a/poetry/repositories/installed_repository.py +++ b/poetry/repositories/installed_repository.py @@ -100,10 +100,12 @@ def is_vcs_package(cls, package: Union[Path, Package], env: Env) -> bool: return True @classmethod - def load(cls, env: Env) -> "InstalledRepository": + def load(cls, env: Env, with_dependencies: bool = False) -> "InstalledRepository": """ Load installed packages. """ + from poetry.core.packages.dependency import Dependency + repo = cls() seen = set() @@ -118,6 +120,11 @@ def load(cls, env: Env) -> "InstalledRepository": package = Package(name, version, version) package.description = distribution.metadata.get("summary", "") + if with_dependencies: + for require in distribution.metadata.get_all("requires-dist", []): + dep = Dependency.create_from_pep_508(require) + package.add_dependency(dep) + if package.name in seen: continue diff --git a/poetry/utils/env.py b/poetry/utils/env.py index a4588617d1e..4678cfb5558 100644 --- a/poetry/utils/env.py +++ b/poetry/utils/env.py @@ -803,7 +803,7 @@ def create_venv( p_venv = os.path.normcase(str(venv)) if any(p.startswith(p_venv) for p in paths): # Running properly in the virtualenv, don't need to do anything - return SystemEnv(Path(sys.prefix), self.get_base_prefix()) + return self.get_system_env() return VirtualEnv(venv) @@ -874,7 +874,12 @@ def remove_venv(cls, path: Union[Path, str]) -> None: elif file_path.is_dir(): shutil.rmtree(str(file_path)) - def get_base_prefix(self) -> Path: + @classmethod + def get_system_env(cls) -> "SystemEnv": + return SystemEnv(Path(sys.prefix), cls.get_base_prefix()) + + @classmethod + def get_base_prefix(cls) -> Path: if hasattr(sys, "real_prefix"): return Path(sys.real_prefix) diff --git a/pyproject.toml b/pyproject.toml index a487a789dbf..b68c65adea7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ pexpect = "^4.7.0" packaging = "^20.4" virtualenv = "^20.4.3" keyring = "^21.2.0" +entrypoints = "^0.3" importlib-metadata = {version = "^1.6.0", python = "<3.8"} [tool.poetry.dev-dependencies] @@ -98,3 +99,9 @@ exclude = ''' | tests/.*/setup.py )/ ''' + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:" +] diff --git a/tests/console/commands/plugin/__init__.py b/tests/console/commands/plugin/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/console/commands/plugin/test_add.py b/tests/console/commands/plugin/test_add.py new file mode 100644 index 00000000000..fdcc46ed423 --- /dev/null +++ b/tests/console/commands/plugin/test_add.py @@ -0,0 +1,297 @@ +import pytest + +from poetry.__version__ import __version__ +from poetry.core.packages.package import Package +from poetry.factory import Factory +from poetry.repositories.installed_repository import InstalledRepository +from poetry.repositories.pool import Pool +from poetry.utils.env import EnvManager + + +@pytest.fixture() +def tester(command_tester_factory): + return command_tester_factory("plugin add") + + +@pytest.fixture() +def installed(): + repository = InstalledRepository() + + repository.add_package(Package("poetry", __version__)) + + return repository + + +def configure_sources_factory(repo): + def _configure_sources(poetry, sources, config, io): + pool = Pool() + pool.add_repository(repo) + poetry.set_pool(pool) + + return _configure_sources + + +@pytest.fixture(autouse=True) +def setup_mocks(mocker, env, repo, installed): + mocker.patch.object(EnvManager, "get_system_env", return_value=env) + mocker.patch.object(InstalledRepository, "load", return_value=installed) + mocker.patch.object( + Factory, "configure_sources", side_effect=configure_sources_factory(repo) + ) + + +def test_add_no_constraint(app, repo, tester, env, installed): + repo.add_package(Package("poetry-plugin", "0.1.0")) + + tester.execute("poetry-plugin") + + expected = """\ +Using version ^0.1.0 for poetry-plugin +Updating dependencies +Resolving dependencies... + +Writing lock file + +Package operations: 1 install, 0 updates, 0 removals + + • Installing poetry-plugin (0.1.0) +""" + + assert tester.io.fetch_output() == expected + + update_command = app.find("update") + assert update_command.poetry.file.parent == env.path + assert update_command.poetry.locker.lock.parent == env.path + assert update_command.poetry.locker.lock.exists() + + content = update_command.poetry.file.read()["tool"]["poetry"] + assert "poetry-plugin" in content["dependencies"] + assert content["dependencies"]["poetry-plugin"] == "^0.1.0" + + +def test_add_with_constraint(app, repo, tester, env, installed): + repo.add_package(Package("poetry-plugin", "0.1.0")) + repo.add_package(Package("poetry-plugin", "0.2.0")) + + tester.execute("poetry-plugin@^0.2.0") + + expected = """\ +Updating dependencies +Resolving dependencies... + +Writing lock file + +Package operations: 1 install, 0 updates, 0 removals + + • Installing poetry-plugin (0.2.0) +""" + + assert tester.io.fetch_output() == expected + + update_command = app.find("update") + assert update_command.poetry.file.parent == env.path + assert update_command.poetry.locker.lock.parent == env.path + + content = update_command.poetry.file.read()["tool"]["poetry"] + assert "poetry-plugin" in content["dependencies"] + assert content["dependencies"]["poetry-plugin"] == "^0.2.0" + + +def test_add_with_git_constraint(app, repo, tester, env, installed): + repo.add_package(Package("pendulum", "2.0.5")) + + tester.execute("git+https://github.com/demo/poetry-plugin.git") + + expected = """\ +Updating dependencies +Resolving dependencies... + +Writing lock file + +Package operations: 2 installs, 0 updates, 0 removals + + • Installing pendulum (2.0.5) + • Installing poetry-plugin (0.1.2 9cf87a2) +""" + + assert tester.io.fetch_output() == expected + + update_command = app.find("update") + assert update_command.poetry.file.parent == env.path + assert update_command.poetry.locker.lock.parent == env.path + + content = update_command.poetry.file.read()["tool"]["poetry"] + assert "poetry-plugin" in content["dependencies"] + assert content["dependencies"]["poetry-plugin"] == { + "git": "https://github.com/demo/poetry-plugin.git" + } + + +def test_add_with_git_constraint_with_extras(app, repo, tester, env, installed): + repo.add_package(Package("pendulum", "2.0.5")) + repo.add_package(Package("tomlkit", "0.7.0")) + + tester.execute("git+https://github.com/demo/poetry-plugin.git[foo]") + + expected = """\ +Updating dependencies +Resolving dependencies... + +Writing lock file + +Package operations: 3 installs, 0 updates, 0 removals + + • Installing pendulum (2.0.5) + • Installing tomlkit (0.7.0) + • Installing poetry-plugin (0.1.2 9cf87a2) +""" + + assert tester.io.fetch_output() == expected + + update_command = app.find("update") + assert update_command.poetry.file.parent == env.path + assert update_command.poetry.locker.lock.parent == env.path + + content = update_command.poetry.file.read()["tool"]["poetry"] + assert "poetry-plugin" in content["dependencies"] + assert content["dependencies"]["poetry-plugin"] == { + "git": "https://github.com/demo/poetry-plugin.git", + "extras": ["foo"], + } + + +def test_add_existing_plugin_warns_about_no_operation( + app, repo, tester, env, installed +): + env.path.joinpath("pyproject.toml").write_text( + """\ +[tool.poetry] +name = "poetry" +version = "1.2.0" +description = "Python dependency management and packaging made easy." +authors = [ + "Sébastien Eustace " +] + +[tool.poetry.dependencies] +python = "^3.6" +poetry-plugin = "^1.2.3" +""", + encoding="utf-8", + ) + + installed.add_package(Package("poetry-plugin", "1.2.3")) + + repo.add_package(Package("poetry-plugin", "1.2.3")) + + tester.execute("poetry-plugin") + + expected = """\ +The following plugins are already present in the pyproject.toml file and will be skipped: + + • poetry-plugin + +If you want to update it to the latest compatible version, you can use `poetry plugin update package`. +If you prefer to upgrade it to the latest available version, you can use `poetry plugin add package@latest`. + +""" + + assert tester.io.fetch_output() == expected + + update_command = app.find("update") + # The update command should not have been called + assert update_command.poetry.file.parent != env.path + + +def test_add_existing_plugin_updates_if_requested( + app, repo, tester, env, installed, mocker +): + env.path.joinpath("pyproject.toml").write_text( + """\ +[tool.poetry] +name = "poetry" +version = "1.2.0" +description = "Python dependency management and packaging made easy." +authors = [ + "Sébastien Eustace " +] + +[tool.poetry.dependencies] +python = "^3.6" +poetry-plugin = "^1.2.3" +""", + encoding="utf-8", + ) + + installed.add_package(Package("poetry-plugin", "1.2.3")) + + repo.add_package(Package("poetry-plugin", "1.2.3")) + repo.add_package(Package("poetry-plugin", "2.3.4")) + + tester.execute("poetry-plugin@latest") + + expected = """\ +Using version ^2.3.4 for poetry-plugin +Updating dependencies +Resolving dependencies... + +Writing lock file + +Package operations: 0 installs, 1 update, 0 removals + + • Updating poetry-plugin (1.2.3 -> 2.3.4) +""" + + assert tester.io.fetch_output() == expected + + update_command = app.find("update") + assert update_command.poetry.file.parent == env.path + assert update_command.poetry.locker.lock.parent == env.path + assert update_command.poetry.locker.lock.exists() + + content = update_command.poetry.file.read()["tool"]["poetry"] + assert "poetry-plugin" in content["dependencies"] + assert content["dependencies"]["poetry-plugin"] == "^2.3.4" + + +def test_adding_a_plugin_can_update_poetry_dependencies_if_needed( + app, repo, tester, env, installed +): + poetry_package = Package("poetry", "1.2.0") + poetry_package.add_dependency(Factory.create_dependency("tomlkit", "^0.7.0")) + + plugin_package = Package("poetry-plugin", "1.2.3") + plugin_package.add_dependency(Factory.create_dependency("tomlkit", "^0.7.2")) + + installed.add_package(poetry_package) + installed.add_package(Package("tomlkit", "0.7.1")) + + repo.add_package(plugin_package) + repo.add_package(Package("tomlkit", "0.7.1")) + repo.add_package(Package("tomlkit", "0.7.2")) + + tester.execute("poetry-plugin") + + expected = """\ +Using version ^1.2.3 for poetry-plugin +Updating dependencies +Resolving dependencies... + +Writing lock file + +Package operations: 1 install, 1 update, 0 removals + + • Updating tomlkit (0.7.1 -> 0.7.2) + • Installing poetry-plugin (1.2.3) +""" + + assert tester.io.fetch_output() == expected + + update_command = app.find("update") + assert update_command.poetry.file.parent == env.path + assert update_command.poetry.locker.lock.parent == env.path + assert update_command.poetry.locker.lock.exists() + + content = update_command.poetry.file.read()["tool"]["poetry"] + assert "poetry-plugin" in content["dependencies"] + assert content["dependencies"]["poetry-plugin"] == "^1.2.3" diff --git a/tests/console/commands/plugin/test_remove.py b/tests/console/commands/plugin/test_remove.py new file mode 100644 index 00000000000..0a66d8717cc --- /dev/null +++ b/tests/console/commands/plugin/test_remove.py @@ -0,0 +1,189 @@ +import pytest +import tomlkit + +from poetry.__version__ import __version__ +from poetry.core.packages.package import Package +from poetry.factory import Factory +from poetry.layouts.layout import POETRY_DEFAULT +from poetry.repositories.installed_repository import InstalledRepository +from poetry.repositories.pool import Pool +from poetry.utils.env import EnvManager + + +@pytest.fixture() +def tester(command_tester_factory): + return command_tester_factory("plugin remove") + + +@pytest.fixture() +def installed(): + repository = InstalledRepository() + + repository.add_package(Package("poetry", __version__)) + + return repository + + +def configure_sources_factory(repo): + def _configure_sources(poetry, sources, config, io): + pool = Pool() + pool.add_repository(repo) + poetry.set_pool(pool) + + return _configure_sources + + +@pytest.fixture(autouse=True) +def setup_mocks(mocker, env, repo, installed): + mocker.patch.object(EnvManager, "get_system_env", return_value=env) + mocker.patch.object(InstalledRepository, "load", return_value=installed) + mocker.patch.object( + Factory, "configure_sources", side_effect=configure_sources_factory(repo) + ) + + +@pytest.fixture() +def pyproject(env): + pyproject = tomlkit.loads(POETRY_DEFAULT) + content = pyproject["tool"]["poetry"] + + content["name"] = "poetry" + content["version"] = __version__ + content["description"] = "" + content["authors"] = ["Sébastien Eustace "] + + dependency_section = content["dependencies"] + dependency_section["python"] = "^3.6" + + env.path.joinpath("pyproject.toml").write_text( + tomlkit.dumps(pyproject), encoding="utf-8" + ) + + +def test_remove_installed_package(app, repo, tester, env, installed, pyproject): + lock_content = { + "package": [ + { + "name": "poetry-plugin", + "version": "1.2.3", + "category": "main", + "optional": False, + "platform": "*", + "python-versions": "*", + "checksum": [], + }, + ], + "metadata": { + "python-versions": "^3.6", + "platform": "*", + "content-hash": "123456789", + "hashes": {"poetry-plugin": []}, + }, + } + + env.path.joinpath("poetry.lock").write_text( + tomlkit.dumps(lock_content), encoding="utf-8" + ) + + pyproject = tomlkit.loads( + env.path.joinpath("pyproject.toml").read_text(encoding="utf-8") + ) + content = pyproject["tool"]["poetry"] + + dependency_section = content["dependencies"] + dependency_section["poetry-plugin"] = "^1.2.3" + + env.path.joinpath("pyproject.toml").write_text( + tomlkit.dumps(pyproject), encoding="utf-8" + ) + + installed.add_package(Package("poetry-plugin", "1.2.3")) + + tester.execute("poetry-plugin") + + expected = """\ +Updating dependencies +Resolving dependencies... + +Writing lock file + +Package operations: 0 installs, 0 updates, 1 removal + + • Removing poetry-plugin (1.2.3) +""" + + assert tester.io.fetch_output() == expected + + remove_command = app.find("remove") + assert remove_command.poetry.file.parent == env.path + assert remove_command.poetry.locker.lock.parent == env.path + assert remove_command.poetry.locker.lock.exists() + assert not remove_command.installer.executor._dry_run + + content = remove_command.poetry.file.read()["tool"]["poetry"] + assert "poetry-plugin" not in content["dependencies"] + + +def test_remove_installed_package_dry_run(app, repo, tester, env, installed, pyproject): + lock_content = { + "package": [ + { + "name": "poetry-plugin", + "version": "1.2.3", + "category": "main", + "optional": False, + "platform": "*", + "python-versions": "*", + "checksum": [], + }, + ], + "metadata": { + "python-versions": "^3.6", + "platform": "*", + "content-hash": "123456789", + "hashes": {"poetry-plugin": []}, + }, + } + + env.path.joinpath("poetry.lock").write_text( + tomlkit.dumps(lock_content), encoding="utf-8" + ) + + pyproject = tomlkit.loads( + env.path.joinpath("pyproject.toml").read_text(encoding="utf-8") + ) + content = pyproject["tool"]["poetry"] + + dependency_section = content["dependencies"] + dependency_section["poetry-plugin"] = "^1.2.3" + + env.path.joinpath("pyproject.toml").write_text( + tomlkit.dumps(pyproject), encoding="utf-8" + ) + + installed.add_package(Package("poetry-plugin", "1.2.3")) + + tester.execute("poetry-plugin --dry-run") + + expected = """\ +Updating dependencies +Resolving dependencies... + +Writing lock file + +Package operations: 0 installs, 0 updates, 1 removal + + • Removing poetry-plugin (1.2.3) + • Removing poetry-plugin (1.2.3) +""" + + assert tester.io.fetch_output() == expected + + remove_command = app.find("remove") + assert remove_command.poetry.file.parent == env.path + assert remove_command.poetry.locker.lock.parent == env.path + assert remove_command.poetry.locker.lock.exists() + assert remove_command.installer.executor._dry_run + + content = remove_command.poetry.file.read()["tool"]["poetry"] + assert "poetry-plugin" in content["dependencies"] diff --git a/tests/console/commands/plugin/test_show.py b/tests/console/commands/plugin/test_show.py new file mode 100644 index 00000000000..80c990376da --- /dev/null +++ b/tests/console/commands/plugin/test_show.py @@ -0,0 +1,172 @@ +import pytest + +from entrypoints import EntryPoint as _EntryPoint + +from poetry.__version__ import __version__ +from poetry.core.packages.package import Package +from poetry.factory import Factory +from poetry.plugins.application_plugin import ApplicationPlugin +from poetry.plugins.plugin import Plugin +from poetry.repositories.installed_repository import InstalledRepository +from poetry.repositories.pool import Pool +from poetry.utils.env import EnvManager + + +class EntryPoint(_EntryPoint): + def load(self): + if "ApplicationPlugin" in self.object_name: + return ApplicationPlugin + + return Plugin + + +@pytest.fixture() +def tester(command_tester_factory): + return command_tester_factory("plugin show") + + +@pytest.fixture() +def installed(): + repository = InstalledRepository() + + repository.add_package(Package("poetry", __version__)) + + return repository + + +def configure_sources_factory(repo): + def _configure_sources(poetry, sources, config, io): + pool = Pool() + pool.add_repository(repo) + poetry.set_pool(pool) + + return _configure_sources + + +@pytest.fixture(autouse=True) +def setup_mocks(mocker, env, repo, installed): + mocker.patch.object(EnvManager, "get_system_env", return_value=env) + mocker.patch.object(InstalledRepository, "load", return_value=installed) + mocker.patch.object( + Factory, "configure_sources", side_effect=configure_sources_factory(repo) + ) + + +def test_show_displays_installed_plugins(app, tester, installed, mocker): + mocker.patch( + "entrypoints.get_group_all", + side_effect=[ + [ + EntryPoint( + "poetry-plugin", + "poetry_plugin.plugins:ApplicationPlugin", + "FirstApplicationPlugin", + ) + ], + [ + EntryPoint( + "poetry-plugin", + "poetry_plugin.plugins:Plugin", + "FirstPlugin", + ) + ], + ], + ) + + installed.add_package(Package("poetry-plugin", "1.2.3")) + + tester.execute("") + + expected = """ + • poetry-plugin (1.2.3) + 1 plugin and 1 application plugin +""" + + assert tester.io.fetch_output() == expected + + +def test_show_displays_installed_plugins_with_multiple_plugins( + app, tester, installed, mocker +): + mocker.patch( + "entrypoints.get_group_all", + side_effect=[ + [ + EntryPoint( + "poetry-plugin", + "poetry_plugin.plugins:ApplicationPlugin", + "FirstApplicationPlugin", + ), + EntryPoint( + "poetry-plugin", + "poetry_plugin.plugins:ApplicationPlugin", + "SecondApplicationPlugin", + ), + ], + [ + EntryPoint( + "poetry-plugin", + "poetry_plugin.plugins:Plugin", + "FirstPlugin", + ), + EntryPoint( + "poetry-plugin", + "poetry_plugin.plugins:Plugin", + "SecondPlugin", + ), + ], + ], + ) + + installed.add_package(Package("poetry-plugin", "1.2.3")) + + tester.execute("") + + expected = """ + • poetry-plugin (1.2.3) + 2 plugins and 2 application plugins +""" + + assert tester.io.fetch_output() == expected + + +def test_show_displays_installed_plugins_with_dependencies( + app, tester, installed, mocker +): + mocker.patch( + "entrypoints.get_group_all", + side_effect=[ + [ + EntryPoint( + "poetry-plugin", + "poetry_plugin.plugins:ApplicationPlugin", + "FirstApplicationPlugin", + ) + ], + [ + EntryPoint( + "poetry-plugin", + "poetry_plugin.plugins:Plugin", + "FirstPlugin", + ) + ], + ], + ) + + plugin = Package("poetry-plugin", "1.2.3") + plugin.add_dependency(Factory.create_dependency("foo", ">=1.2.3")) + plugin.add_dependency(Factory.create_dependency("bar", "<4.5.6")) + installed.add_package(plugin) + + tester.execute("") + + expected = """ + • poetry-plugin (1.2.3) + 1 plugin and 1 application plugin + + Dependencies + - foo (>=1.2.3) + - bar (<4.5.6) +""" + + assert tester.io.fetch_output() == expected diff --git a/tests/console/test_application.py b/tests/console/test_application.py new file mode 100644 index 00000000000..6e4e5bab98f --- /dev/null +++ b/tests/console/test_application.py @@ -0,0 +1,101 @@ +import re + +from cleo.testers.application_tester import ApplicationTester +from entrypoints import EntryPoint + +from poetry.console.application import Application +from poetry.console.commands.command import Command +from poetry.plugins.application_plugin import ApplicationPlugin + + +class FooCommand(Command): + name = "foo" + + description = "Foo Command" + + def handle(self): + self.line("foo called") + + return 0 + + +class AddCommandPlugin(ApplicationPlugin): + def activate(self, application: Application): + application.command_loader.register_factory("foo", lambda: FooCommand()) + + +def test_application_with_plugins(mocker): + mocker.patch( + "entrypoints.get_group_all", + return_value=[ + EntryPoint( + "my-plugin", "tests.console.test_application", "AddCommandPlugin" + ) + ], + ) + + app = Application() + + tester = ApplicationTester(app) + tester.execute("") + + assert re.search(r"\s+foo\s+Foo Command", tester.io.fetch_output()) is not None + assert 0 == tester.status_code + + +def test_application_with_plugins_disabled(mocker): + mocker.patch( + "entrypoints.get_group_all", + return_value=[ + EntryPoint( + "my-plugin", "tests.console.test_application", "AddCommandPlugin" + ) + ], + ) + + app = Application() + + tester = ApplicationTester(app) + tester.execute("--no-plugins") + + assert re.search(r"\s+foo\s+Foo Command", tester.io.fetch_output()) is None + assert 0 == tester.status_code + + +def test_application_execute_plugin_command(mocker): + mocker.patch( + "entrypoints.get_group_all", + return_value=[ + EntryPoint( + "my-plugin", "tests.console.test_application", "AddCommandPlugin" + ) + ], + ) + + app = Application() + + tester = ApplicationTester(app) + tester.execute("foo") + + assert "foo called\n" == tester.io.fetch_output() + assert 0 == tester.status_code + + +def test_application_execute_plugin_command_with_plugins_disabled(mocker): + mocker.patch( + "entrypoints.get_group_all", + return_value=[ + EntryPoint( + "my-plugin", "tests.console.test_application", "AddCommandPlugin" + ) + ], + ) + + app = Application() + + tester = ApplicationTester(app) + tester.execute("foo --no-plugins") + + assert "" == tester.io.fetch_output() + assert '\nThe command "foo" does not exist.\n' == tester.io.fetch_error() + assert 1 == tester.status_code diff --git a/tests/fixtures/git/github.com/demo/poetry-plugin/poetry_plugin/__init__.py b/tests/fixtures/git/github.com/demo/poetry-plugin/poetry_plugin/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/git/github.com/demo/poetry-plugin/pyproject.toml b/tests/fixtures/git/github.com/demo/poetry-plugin/pyproject.toml new file mode 100644 index 00000000000..b45d9d976eb --- /dev/null +++ b/tests/fixtures/git/github.com/demo/poetry-plugin/pyproject.toml @@ -0,0 +1,18 @@ +[tool.poetry] +name = "poetry-plugin" +version = "0.1.2" +description = "Some description." +authors = [ + "Sébastien Eustace " +] +license = "MIT" + +[tool.poetry.dependencies] +python = "^3.6" +pendulum = "^2.0" +tomlkit = {version = "^0.7.0", optional = true} + +[tool.poetry.extras] +foo = ["tomlkit"] + +[tool.poetry.dev-dependencies] diff --git a/tests/plugins/test_plugin_manager.py b/tests/plugins/test_plugin_manager.py new file mode 100644 index 00000000000..b8a4c5d5230 --- /dev/null +++ b/tests/plugins/test_plugin_manager.py @@ -0,0 +1,112 @@ +from pathlib import Path + +import pytest + +from cleo.io.buffered_io import BufferedIO +from entrypoints import EntryPoint + +from poetry.packages.locker import Locker +from poetry.packages.project_package import ProjectPackage +from poetry.plugins import ApplicationPlugin +from poetry.plugins import Plugin +from poetry.plugins.plugin_manager import PluginManager +from poetry.poetry import Poetry + + +CWD = Path(__file__).parent.parent / "fixtures" / "simple_project" + + +class MyPlugin(Plugin): + def activate(self, poetry, io): + io.write_line("Updating version") + poetry.package.set_version("9.9.9") + + +class MyCommandPlugin(ApplicationPlugin): + @property + def commands(self): + return [] + + +class InvalidPlugin: + def activate(self, poetry, io): + io.write_line("Updating version") + poetry.package.version = "9.9.9" + + +@pytest.fixture() +def poetry(tmp_dir, config): + poetry = Poetry( + CWD / "pyproject.toml", + {}, + ProjectPackage("simple-project", "1.2.3"), + Locker(CWD / "poetry.lock", {}), + config, + ) + + return poetry + + +@pytest.fixture() +def io(): + return BufferedIO() + + +@pytest.fixture() +def manager_factory(poetry, io): + def _manager(type="plugin"): + return PluginManager(type) + + return _manager + + +@pytest.fixture() +def no_plugin_manager(poetry, io): + return PluginManager("plugin", disable_plugins=True) + + +def test_load_plugins_and_activate(manager_factory, poetry, io, mocker): + manager = manager_factory() + + mocker.patch( + "entrypoints.get_group_all", + return_value=[ + EntryPoint("my-plugin", "tests.plugins.test_plugin_manager", "MyPlugin") + ], + ) + + manager.load_plugins() + manager.activate(poetry, io) + + assert "9.9.9" == poetry.package.version.text + assert "Updating version\n" == io.fetch_output() + + +def test_load_plugins_with_invalid_plugin(manager_factory, poetry, io, mocker): + manager = manager_factory() + + mocker.patch( + "entrypoints.get_group_all", + return_value=[ + EntryPoint( + "my-plugin", "tests.plugins.test_plugin_manager", "InvalidPlugin" + ) + ], + ) + + with pytest.raises(ValueError): + manager.load_plugins() + + +def test_load_plugins_with_plugins_disabled(no_plugin_manager, poetry, io, mocker): + mocker.patch( + "entrypoints.get_group_all", + return_value=[ + EntryPoint("my-plugin", "tests.plugins.test_plugin_manager", "MyPlugin") + ], + ) + + no_plugin_manager.load_plugins() + + assert "1.2.3" == poetry.package.version.text + assert "" == io.fetch_output() diff --git a/tests/test_factory.py b/tests/test_factory.py index 7a4e9d5ac4a..f966a8407fb 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -6,8 +6,11 @@ import pytest +from entrypoints import EntryPoint + from poetry.core.toml.file import TOMLFile from poetry.factory import Factory +from poetry.plugins.plugin import Plugin from poetry.repositories.legacy_repository import LegacyRepository from poetry.repositories.pypi_repository import PyPiRepository @@ -15,6 +18,12 @@ fixtures_dir = Path(__file__).parent / "fixtures" +class MyPlugin(Plugin): + def activate(self, poetry, io): + io.write_line("Updating version") + poetry.package.set_version("9.9.9") + + def test_create_poetry(): poetry = Factory().create_poetry(fixtures_dir / "sample_project") @@ -224,3 +233,14 @@ def test_create_poetry_with_local_config(fixture_dir): assert not poetry.config.get("virtualenvs.create") assert not poetry.config.get("virtualenvs.options.always-copy") assert not poetry.config.get("virtualenvs.options.system-site-packages") + + +def test_create_poetry_with_plugins(mocker): + mocker.patch( + "entrypoints.get_group_all", + return_value=[EntryPoint("my-plugin", "tests.test_factory", "MyPlugin")], + ) + + poetry = Factory().create_poetry(fixtures_dir / "sample_project") + + assert "9.9.9" == poetry.package.version.text