Repository: pdm-project/pdm

Issue:
`packages` parameter for the `pre_install` and `post_install` hooks is given as an exhausted generator
- [x] I have searched the issue tracker and believe that this is not a duplicate.

Ran into a regression while migrating a plugin to 2.19 where the `pre_install` and `post_install` hooks' `packages` parameter is always an exhausted generator. Looks like it's due to the recent changes to the sync internals in cdb025c6b7efc118eef8f7ec12309c041198fcc9. Full details in the "Root Cause" section.

## Steps to reproduce
### Plugin
```python
# repro-plugin/src/repro_plugin/__init__.py

from __future__ import annotations

from typing import TYPE_CHECKING, reveal_type

from pdm.models.repositories import Package
from pdm.signals import post_install, pre_install

if TYPE_CHECKING:
    from pdm.core import Core
    from pdm.project import Project


def _preinstall_hook(project: Project, packages: list[Package], dry_run: bool, **_):
    if dry_run:
        return
    
    print("pre_install:")
    reveal_type(packages)
    print("Package count:", len(list(packages)))


def _postinstall_hook(project: Project, packages: list[Package], dry_run: bool, **_):
    if dry_run:
        return

    print("post_install:")
    reveal_type(packages)
    print("Package count:", len(list(packages)))


def main(_: Core):
    pre_install.connect(_preinstall_hook)
    post_install.connect(_postinstall_hook)
```

```toml
# ./repro-plugin/pyproject.toml
[project]
name = "repro-plugin"
version = "0.0.0"
description = "pre_install and post_install hook reproduction"
dependencies = [
    "pdm==2.19.1",
]
requires-python = "==3.12.*"
readme = "README.md"
license = {text = "MIT"}

[project.entry-points.pdm]
repro = "repro_plugin:main"
```

### Example project
```toml
# ./pyproject.toml
[project]
name = "example-project"
version = "0.0.0"
description = "Example project using the `pdm-repro` plugin"
dependencies = [
    "requests>=2.32.3",
]
requires-python = "==3.12.*"
readme = "README.md"
license = {text = "MIT"}

[tool.pdm]
plugins = [
    "-e file:///${PROJECT_ROOT}/repro-plugin"
]

[tool.pdm.dev-dependencies]
dev = [
    "-e repro-plugin @ file:///${PROJECT_ROOT}/repro-plugin",
]
```

```
$ pdm install
pre_install:
Runtime type is 'generator'
Package count: 0
Synchronizing working set with resolved packages: 0 to add, 1 to update, 0 to remove

  ✔ Update repro-plugin 0.0.0 -> 0.0.0 successful
  ✔ Update example-project 0.0.0 -> 0.0.0 successful

  0:00:01 🎉 All complete! 1/1
post_install:
Runtime type is 'generator'
Package count: 0
```

## Actual behavior
The `packages` parameter is exhausted and will produce no elements when iterated over.
### Root Cause
The `packages` variable, which replaced the previous `candidates` `dict`, is now a generator:
https://github.com/pdm-project/pdm/blob/16933a1aa41c9d1854dfd4309940b73b3ee008cf/src/pdm/cli/actions.py#L284

... which is passed to the `BaseSynchronizer` constructor then iterated over and exhausted.
https://github.com/pdm-project/pdm/blob/16933a1aa41c9d1854dfd4309940b73b3ee008cf/src/pdm/cli/actions.py#L296
https://github.com/pdm-project/pdm/blob/16933a1aa41c9d1854dfd4309940b73b3ee008cf/src/pdm/installers/base.py#L63

The now exhausted generator object is then passed to the hooks:
https://github.com/pdm-project/pdm/blob/16933a1aa41c9d1854dfd4309940b73b3ee008cf/src/pdm/cli/actions.py#L300-L302

... where trying to iterator over it (`packages`) in the hooks will yield zero elements.

Also worth pointing out that `self.packages` is assigned to this same exhausted generator object. Maybe this is intentional in this case? I'm not too familiar with pdm's internals.
https://github.com/pdm-project/pdm/blob/16933a1aa41c9d1854dfd4309940b73b3ee008cf/src/pdm/installers/base.py#L81

## Expected behavior
The `pre_install` and `post_install` hooks should be passed a `list[Package]` [as documented](https://github.com/pdm-project/pdm/blob/16933a1aa41c9d1854dfd4309940b73b3ee008cf/src/pdm/signals.py#L48) or some other form of non-empty `MutableSequence`, such that the previous behaviour of being able to mutate the `candidates` dict inflight is preserved.

A straight forward fix would be to consume the generator into a `list` before passing it to the `BaseSynchronizer` constructor and hooks.
```diff
-packages = resolve_from_lockfile(project, requirements, groups=list(selection))
+packages = list(resolve_from_lockfile(project, requirements, groups=list(selection)))
```

## Environment Information
```bash
# Paste the output of `pdm info && pdm info --env` below:
PDM version:
  2.19.1
Python Interpreter:
  /home/pmk/projects/pdm-repro/.venv/bin/python (3.12)
Project Root:
  /home/pmk/projects/pdm-repro
Local Packages:
  
{
  "implementation_name": "cpython",
  "implementation_version": "3.12.6",
  "os_name": "posix",
  "platform_machine": "x86_64",
  "platform_release": "5.4.0-190-generic",
  "platform_system": "Linux",
  "platform_version": "#210-Ubuntu SMP Fri Jul 5 17:03:38 UTC 2024",
  "python_full_version": "3.12.6",
  "platform_python_implementation": "CPython",
  "python_version": "3.12",
  "sys_platform": "linux"
}
```

Discussion:

Patch:
diff --git a/news/3190.bugfix.md b/news/3190.bugfix.md
new file mode 100644
index 00000000..0ad5fb87
--- /dev/null
+++ b/news/3190.bugfix.md
@@ -0,0 +1,1 @@
+Fix the `pre_install` and `post_install` signals receiving an exhausted generator, instead of a list of packages.
diff --git a/pdm.lock b/pdm.lock
index f5229bd7..601e3be2 100644
--- a/pdm.lock
+++ b/pdm.lock
@@ -714,7 +714,7 @@ files = [
 
 [[package]]
 name = "hishel"
-version = "0.0.25"
+version = "0.0.32"
 requires_python = ">=3.8"
 summary = "Persistent cache implementation for httpx and httpcore"
 groups = ["default"]
@@ -723,8 +723,8 @@ dependencies = [
     "typing-extensions>=4.8.0",
 ]
 files = [
-    {file = "hishel-0.0.25-py3-none-any.whl", hash = "sha256:01365a624995ba100d110b7ba5c404605c3bdb3abcfe919e74e381417d9959fd"},
-    {file = "hishel-0.0.25.tar.gz", hash = "sha256:ddb22ee32c656e3c75cd05efefd1165c5fbd1c3f023edffda684f5125625700b"},
+    {file = "hishel-0.0.32-py3-none-any.whl", hash = "sha256:62807cd16f3e2f69f1d12182fb8e167ba8dd763cf1039479dfd13fc15ce5d684"},
+    {file = "hishel-0.0.32.tar.gz", hash = "sha256:64d674c058d59920995f01db8dea12408c526de9d5d112ac955dbe2485ceae1c"},
 ]
 
 [[package]]
diff --git a/pyproject.toml b/pyproject.toml
index 04d24ba2..c1f6fbdb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -32,7 +32,7 @@ dependencies = [
     "tomli>=1.1.0; python_version < \"3.11\"",
     "importlib-resources>=5; python_version < \"3.9\"",
     "importlib-metadata>=3.6; python_version < \"3.10\"",
-    "hishel>=0.0.24,<0.1.0",
+    "hishel>=0.0.32,<0.1.0",
     "msgpack>=1.0",
     "pbs-installer>=2024.4.18",
     "httpx[socks]<1,>0.20",
diff --git a/src/pdm/cli/actions.py b/src/pdm/cli/actions.py
index 99be8446..be167a6b 100644
--- a/src/pdm/cli/actions.py
+++ b/src/pdm/cli/actions.py
@@ -281,7 +281,7 @@ def do_sync(
         selection.validate()
         for group in selection:
             requirements.extend(project.get_dependencies(group))
-    packages = resolve_from_lockfile(project, requirements, groups=list(selection))
+    packages = list(resolve_from_lockfile(project, requirements, groups=list(selection)))
     if tracked_names and dry_run:
         packages = [p for p in packages if p.candidate.identify() in tracked_names]
     synchronizer = project.get_synchronizer()(


Test patch:
diff --git a/tests/test_signals.py b/tests/test_signals.py
index 2d63c47e..9e2c63ff 100644
--- a/tests/test_signals.py
+++ b/tests/test_signals.py
@@ -1,8 +1,12 @@
+from itertools import chain
 from unittest import mock
 
 import pytest
 
 from pdm import signals
+from pdm.models.candidates import Candidate
+from pdm.models.repositories import Package
+from pdm.models.requirements import Requirement
 
 
 def test_post_init_signal(project_no_init, pdm):
@@ -26,3 +30,82 @@ def test_post_lock_and_install_signals(project, pdm):
     signals.post_install.disconnect(post_install)
     for mocker in (pre_lock, post_lock, pre_install, post_install):
         mocker.assert_called_once()
+
+
+@pytest.mark.usefixtures("working_set")
+def test_lock_and_install_signals_injection_with_add(project, pdm):
+    pre_lock = signals.pre_lock.connect(mock.Mock(), weak=False)
+    post_lock = signals.post_lock.connect(mock.Mock(), weak=False)
+    pre_install = signals.pre_install.connect(mock.Mock(), weak=False)
+    post_install = signals.post_install.connect(mock.Mock(), weak=False)
+    pdm(["add", "requests"], obj=project, strict=True)
+    signals.pre_lock.disconnect(pre_lock)
+    signals.post_lock.disconnect(post_lock)
+    signals.pre_install.disconnect(pre_install)
+    signals.post_install.disconnect(post_install)
+
+    assert isinstance(pre_lock.call_args.kwargs["requirements"], list)
+    assert all(isinstance(e, Requirement) for e in pre_lock.call_args.kwargs["requirements"])
+    assert len(pre_lock.call_args.kwargs["requirements"]) == 1
+
+    assert isinstance(post_lock.call_args.kwargs["resolution"], dict)
+    assert all(isinstance(e, Candidate) for e in chain.from_iterable(post_lock.call_args.kwargs["resolution"].values()))
+    assert len(post_lock.call_args.kwargs["resolution"]) == 5
+
+    assert isinstance(pre_install.call_args.kwargs["packages"], list)
+    assert all(isinstance(e, Package) for e in pre_install.call_args.kwargs["packages"])
+    assert len(pre_install.call_args.kwargs["packages"]) == 5
+
+    assert isinstance(post_install.call_args.kwargs["packages"], list)
+    assert all(isinstance(e, Package) for e in post_install.call_args.kwargs["packages"])
+    assert len(post_install.call_args.kwargs["packages"]) == 5
+
+
+@pytest.mark.usefixtures("working_set")
+def test_lock_and_install_signals_injection_with_install(project, pdm):
+    project.add_dependencies(["requests"])
+
+    pre_lock = signals.pre_lock.connect(mock.Mock(), weak=False)
+    post_lock = signals.post_lock.connect(mock.Mock(), weak=False)
+    pre_install = signals.pre_install.connect(mock.Mock(), weak=False)
+    post_install = signals.post_install.connect(mock.Mock(), weak=False)
+    pdm(["install"], obj=project, strict=True)
+    signals.pre_lock.disconnect(pre_lock)
+    signals.post_lock.disconnect(post_lock)
+    signals.pre_install.disconnect(pre_install)
+    signals.post_install.disconnect(post_install)
+
+    assert isinstance(pre_lock.call_args.kwargs["requirements"], list)
+    assert all(isinstance(e, Requirement) for e in pre_lock.call_args.kwargs["requirements"])
+    assert len(pre_lock.call_args.kwargs["requirements"]) == 1
+
+    assert isinstance(post_lock.call_args.kwargs["resolution"], dict)
+    assert all(isinstance(e, Candidate) for e in chain.from_iterable(post_lock.call_args.kwargs["resolution"].values()))
+    assert len(post_lock.call_args.kwargs["resolution"]) == 5
+
+    assert isinstance(pre_install.call_args.kwargs["packages"], list)
+    assert all(isinstance(e, Package) for e in pre_install.call_args.kwargs["packages"])
+    assert len(pre_install.call_args.kwargs["packages"]) == 5
+
+    assert isinstance(post_install.call_args.kwargs["packages"], list)
+    assert all(isinstance(e, Package) for e in post_install.call_args.kwargs["packages"])
+    assert len(post_install.call_args.kwargs["packages"]) == 5
+
+
+@pytest.mark.usefixtures("working_set")
+def test_lock_signals_injection_with_update(project, pdm):
+    project.add_dependencies(["requests"])
+
+    pre_lock = signals.pre_lock.connect(mock.Mock(), weak=False)
+    post_lock = signals.post_lock.connect(mock.Mock(), weak=False)
+    pdm(["update"], obj=project, strict=True)
+    signals.pre_lock.disconnect(pre_lock)
+    signals.post_lock.disconnect(post_lock)
+
+    assert isinstance(pre_lock.call_args.kwargs["requirements"], list)
+    assert all(isinstance(e, Requirement) for e in pre_lock.call_args.kwargs["requirements"])
+    assert len(pre_lock.call_args.kwargs["requirements"]) == 1
+
+    assert isinstance(post_lock.call_args.kwargs["resolution"], dict)
+    assert all(isinstance(e, Candidate) for e in chain.from_iterable(post_lock.call_args.kwargs["resolution"].values()))
+    assert len(post_lock.call_args.kwargs["resolution"]) == 5