Skip to content

Commit

Permalink
feat: install plugins from project config
Browse files Browse the repository at this point in the history
Signed-off-by: Frost Ming <me@frostming.com>
  • Loading branch information
frostming committed May 7, 2023
1 parent 8bc41a1 commit d6805d9
Show file tree
Hide file tree
Showing 30 changed files with 459 additions and 412 deletions.
25 changes: 24 additions & 1 deletion docs/docs/dev/write.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Write a plugin
# PDM Plugins

PDM is aiming at being a community driven package manager.
It is shipped with a full-featured plug-in system, with which you can:
Expand Down Expand Up @@ -209,3 +209,26 @@ Hello, Jack
```

See more plugin management subcommands by typing `pdm self --help` in the terminal.

## Specify the plugins in project

To specify the required plugins for a project, you can use the `plugins` dev dependency group in the `pyproject.toml` file.
These dependencies can be locked and installed into a project plugin library by running `pdm install`.
The project plugin library will be loaded in subsequent PDM commands.

This is useful when you want to share the same plugin set with the contributors.

```toml
# pyproject.toml
[tool.pdm.dev-dependencies]
plugins = [
"pdm-packer"
]
```

Note that this special group will never be installed into your project environment.

!!! note
To install the project plugins, make sure you include the `plugins` group when running `pdm install`, it also has to be
in the lockfile. You can use `-L/--lockfile <lockfile>` to specify another lockfile which contains the locked versions
of the plugin dependencies.
1 change: 1 addition & 0 deletions news/1461.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Install project-level plugins from project config, using a special `plugins` dependency group.
4 changes: 2 additions & 2 deletions src/pdm/builders/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from pyproject_hooks import BuildBackendHookCaller

from pdm.compat import tomllib
from pdm.environments import PrefixEnvironment
from pdm.environments import PythonEnvironment
from pdm.exceptions import BuildError
from pdm.models.in_process import get_sys_config_paths
from pdm.models.requirements import Requirement, parse_requirement
Expand Down Expand Up @@ -290,7 +290,7 @@ def install(self, requirements: Iterable[str], shared: bool = False) -> None:
if not missing:
return
path = self._prefix.shared if shared else self._prefix.overlay
env = PrefixEnvironment(self._env.project, prefix=path)
env = PythonEnvironment(self._env.project, prefix=path)
install_requirements(missing, env)

if shared:
Expand Down
77 changes: 60 additions & 17 deletions src/pdm/cli/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
save_version_specifiers,
set_env_in_reg,
)
from pdm.environments import BareEnvironment, PythonLocalEnvironment
from pdm.environments import BareEnvironment, PythonEnvironment, PythonLocalEnvironment
from pdm.exceptions import NoPythonVersion, PdmUsageError, ProjectError
from pdm.formats import FORMATS
from pdm.formats.base import array_of_inline_tables, make_array, make_inline_table
Expand Down Expand Up @@ -189,30 +189,73 @@ def do_sync(
) -> None:
"""Synchronize project"""
hooks = hooks or HookManager(project)
plugin_requirements: list[Requirement] = []
# Split the requirements into two parts: plugin requirements and normal requirements
if requirements is None:
requirements = []
selection.validate()
for group in selection:
requirements.extend(project.get_dependencies(group).values())
candidates = resolve_candidates_from_lockfile(project, requirements)
if tracked_names and dry_run:
candidates = {name: c for name, c in candidates.items() if name in tracked_names}
synchronizer = project.core.synchronizer_class(
candidates,
project.environment,
clean=clean,
dry_run=dry_run,
no_editable=no_editable,
install_self=not no_self and "default" in selection and bool(project.name),
use_install_cache=project.config["install.cache"],
reinstall=reinstall,
only_keep=only_keep,
fail_fast=fail_fast,
)
if group == "plugins":
plugin_requirements.extend(project.get_dependencies(group).values())
else:
requirements.extend(project.get_dependencies(group).values())
elif selection.one() == "plugins":
plugin_requirements, requirements = requirements, []
elif "plugins" in selection:
plugin_keys = set(project.get_dependencies("plugins").keys())
non_plugin_keys = set(
chain.from_iterable(project.get_dependencies(group).keys() for group in selection if group != "plugins")
)
plugin_requirements = [r for r in requirements if r.identify() in plugin_keys]
requirements = [r for r in requirements if r.identify() in non_plugin_keys]
with project.core.ui.logging("install"):
candidates = resolve_candidates_from_lockfile(project, requirements)
if tracked_names and dry_run:
candidates = {name: c for name, c in candidates.items() if name in tracked_names}
synchronizer = project.core.synchronizer_class(
candidates,
project.environment,
clean=clean,
dry_run=dry_run,
no_editable=no_editable,
install_self=not no_self and "default" in selection and bool(project.name),
use_install_cache=project.config["install.cache"],
reinstall=reinstall,
only_keep=only_keep,
fail_fast=fail_fast,
)
hooks.try_emit("pre_install", candidates=candidates, dry_run=dry_run)
synchronizer.synchronize()
hooks.try_emit("post_install", candidates=candidates, dry_run=dry_run)
if "plugins" in selection:
import platform

from pdm.models.specifiers import PySpecSet

plugin_root = project.root / ".pdm-plugins"
environment = PythonEnvironment(project, prefix=str(plugin_root), python=sys.executable)
environment.python_requires = PySpecSet(f"=={platform.python_version()}")
project.environment = environment
candidates = resolve_candidates_from_lockfile(project, plugin_requirements)
if tracked_names and dry_run:
candidates = {name: c for name, c in candidates.items() if name in tracked_names}
synchronizer = project.core.synchronizer_class(
candidates,
environment,
clean=clean,
dry_run=dry_run,
no_editable=no_editable,
install_self=False,
use_install_cache=project.config["install.cache"],
reinstall=reinstall,
only_keep=only_keep,
fail_fast=fail_fast,
)
with project.core.ui.open_spinner("[success]Installing plugins...[/]"):
with termui._console.capture():
synchronizer.synchronize()
if not plugin_root.joinpath(".gitignore").exists():
plugin_root.joinpath(".gitignore").write_text("*\n")


def do_add(
Expand Down
17 changes: 5 additions & 12 deletions src/pdm/cli/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import argparse
import dataclasses as dc
import json
import os
import sys
Expand Down Expand Up @@ -149,25 +150,17 @@ def _parse_known_args(
raise PdmArgumentError(e) from e


@dc.dataclass(frozen=True)
class Package:
"""An internal class for the convenience of dependency graph building."""

def __init__(self, name: str, version: str | None, requirements: dict[str, Requirement]) -> None:
self.name = name
self.version = version # if version is None, the dist is not installed.
self.requirements = requirements

def __hash__(self) -> int:
return hash(self.name)
name: str = dc.field(hash=True, compare=True)
version: str | None = dc.field(compare=False)
requirements: dict[str, Requirement] = dc.field(compare=False)

def __repr__(self) -> str:
return f"<Package {self.name}=={self.version}>"

def __eq__(self, value: object) -> bool:
if not isinstance(value, Package):
return False
return self.name == value.name


def build_dependency_graph(
working_set: Mapping[str, im.Distribution],
Expand Down
14 changes: 14 additions & 0 deletions src/pdm/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,19 @@ def add_config(name: str, config_item: ConfigItem) -> None:
"""
Config.add_config(name, config_item)

def _add_project_plugins_library(self) -> None:
project = self.create_project(is_global=False)
if project.is_global or not project.root.joinpath(".pdm-plugins").exists():
return

import site
import sysconfig

base = str(project.root / ".pdm-plugins")
replace_vars = {"base": base, "platbase": base}
scheme = "nt" if os.name == "nt" else "posix_prefix"
site.addsitedir(sysconfig.get_path("purelib", scheme, replace_vars))

def load_plugins(self) -> None:
"""Import and load plugins under `pdm.plugin` namespace
A plugin is a callable that accepts the core object as the only argument.
Expand All @@ -234,6 +247,7 @@ def my_plugin(core: pdm.core.Core) -> None:
...
```
"""
self._add_project_plugins_library()
entry_points: Iterable[importlib_metadata.EntryPoint] = itertools.chain(
importlib_metadata.entry_points(group="pdm"),
importlib_metadata.entry_points(group="pdm.plugin"),
Expand Down
2 changes: 0 additions & 2 deletions src/pdm/environments/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from pdm.environments.base import BareEnvironment, BaseEnvironment
from pdm.environments.local import PythonLocalEnvironment
from pdm.environments.prefix import PrefixEnvironment
from pdm.environments.python import PythonEnvironment

_deprecated = {"Environment": PythonLocalEnvironment, "GlobalEnvironment": PythonEnvironment}
Expand All @@ -26,7 +25,6 @@ def __getattr__(name: str) -> Any:
"BaseEnvironment",
"BareEnvironment",
"PythonEnvironment",
"PrefixEnvironment",
"PythonLocalEnvironment",
]
__all__.extend(_deprecated)
19 changes: 7 additions & 12 deletions src/pdm/environments/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class BaseEnvironment(abc.ABC):

is_local = False

def __init__(self, project: Project) -> None:
def __init__(self, project: Project, *, python: str | None = None) -> None:
"""
:param project: the project instance
"""
Expand All @@ -43,6 +43,10 @@ def __init__(self, project: Project) -> None:
self.project.sources,
self.project.core.ui.verbosity >= termui.Verbosity.DETAIL,
)
if python is None:
self._interpreter = project.python
else:
self._interpreter = PythonInfo.from_path(python)

@property
def is_global(self) -> bool:
Expand All @@ -51,7 +55,7 @@ def is_global(self) -> bool:

@property
def interpreter(self) -> PythonInfo:
return self.project.python
return self._interpreter

@abc.abstractmethod
def get_paths(self) -> dict[str, str]:
Expand Down Expand Up @@ -207,16 +211,7 @@ class BareEnvironment(BaseEnvironment):
"""Bare environment that does not depend on project files."""

def __init__(self, project: Project) -> None:
self.python_requires = project.python_requires
self.project = project
self.auth = PdmBasicAuth(
self.project.sources,
self.project.core.ui.verbosity >= termui.Verbosity.DETAIL,
)

@property
def interpreter(self) -> PythonInfo:
return PythonInfo.from_path(sys.executable)
super().__init__(project, python=sys.executable)

def get_paths(self) -> dict[str, str]:
return {}
Expand Down
27 changes: 0 additions & 27 deletions src/pdm/environments/prefix.py

This file was deleted.

29 changes: 12 additions & 17 deletions src/pdm/environments/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

from pdm.environments.base import BaseEnvironment
from pdm.models.in_process import get_sys_config_paths
from pdm.models.python import PythonInfo

if TYPE_CHECKING:
from pdm.project import Project
Expand All @@ -14,24 +13,20 @@
class PythonEnvironment(BaseEnvironment):
"""A project environment that is directly derived from a Python interpreter"""

def __init__(self, project: Project, *, python: str | None = None) -> None:
super().__init__(project)
if python is None:
self._interpreter = project.python
else:
self._interpreter = PythonInfo.from_path(python)

@property
def interpreter(self) -> PythonInfo:
return self._interpreter
def __init__(self, project: Project, *, python: str | None = None, prefix: str | None = None) -> None:
super().__init__(project, python=python)
self.prefix = prefix

def get_paths(self) -> dict[str, str]:
is_venv = self.interpreter.get_venv() is not None
paths = get_sys_config_paths(
str(self.interpreter.executable),
kind="user" if not is_venv and self.project.global_config["global_project.user_site"] else "default",
)
if is_venv:
if self.prefix is not None:
replace_vars = {"base": self.prefix, "platbase": self.prefix}
kind = "prefix"
else:
replace_vars = None
kind = "user" if not is_venv and self.project.global_config["global_project.user_site"] else "default"
paths = get_sys_config_paths(str(self.interpreter.executable), replace_vars, kind=kind)
if is_venv and self.prefix is None:
python_xy = f"python{self.interpreter.identifier}"
paths["include"] = os.path.join(paths["data"], "include", "site", python_xy)
paths["prefix"] = paths["data"]
Expand All @@ -42,6 +37,6 @@ def get_paths(self) -> dict[str, str]:
def process_env(self) -> dict[str, str]:
env = super().process_env
venv = self.interpreter.get_venv()
if venv is not None:
if venv is not None and self.prefix is None:
env.update(venv.env_vars())
return env
4 changes: 2 additions & 2 deletions src/pdm/pytest.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
from pdm.cli.hooks import HookManager
from pdm.compat import Protocol
from pdm.core import Core
from pdm.environments import BaseEnvironment, PrefixEnvironment
from pdm.environments import BaseEnvironment, PythonEnvironment
from pdm.exceptions import CandidateInfoNotFound
from pdm.installers.installers import install_wheel
from pdm.models.backends import get_backend
Expand Down Expand Up @@ -320,7 +320,7 @@ def build_env(build_env_wheels: Iterable[Path], tmp_path_factory: pytest.TempPat
"""
d = tmp_path_factory.mktemp("pdm-test-env")
p = Core().create_project(d)
env = PrefixEnvironment(p, prefix=str(d))
env = PythonEnvironment(p, prefix=str(d))
for wheel in build_env_wheels:
install_wheel(str(wheel), env)
return d
Expand Down
Loading

0 comments on commit d6805d9

Please sign in to comment.