Repository: pdm-project/pdm
Pull request #1983 (2023-06-07 02:31:20)

refactor: move cli actions to the command class
Signed-off-by: Frost Ming <me@frostming.com>

## Pull Request Checklist

- [ ] A news fragment is added in `news/` describing what is new.
- [ ] Test cases added for changed code.

## Describe what you have changed in this PR.


codecov-commenter: ## [Codecov](https://app.codecov.io/gh/pdm-project/pdm/pull/1983?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) Report
Patch coverage: **`86.08`**% and project coverage change: **`-0.58`** :warning:
> Comparison is base [(`4618fc8`)](https://app.codecov.io/gh/pdm-project/pdm/commit/4618fc8445df1a854565d98ddf4e54771c934e9b?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) 85.17% compared to head [(`e060e7f`)](https://app.codecov.io/gh/pdm-project/pdm/pull/1983?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) 84.60%.

:exclamation: Your organization is not using the GitHub App Integration. As a result you may experience degraded service beginning May 15th. Please [install the Github App Integration](https://github.com/apps/codecov) for your organization. [Read more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1983      +/-   ##
==========================================
- Coverage   85.17%   84.60%   -0.58%     
==========================================
  Files          99       99              
  Lines        9310     9339      +29     
  Branches     2034     2037       +3     
==========================================
- Hits         7930     7901      -29     
- Misses        937      985      +48     
- Partials      443      453      +10     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `84.49% <86.08%> (-0.48%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://app.codecov.io/gh/pdm-project/pdm/pull/1983?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | Coverage Δ | |
|---|---|---|
| [src/pdm/cli/utils.py](https://app.codecov.io/gh/pdm-project/pdm/pull/1983?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvdXRpbHMucHk=) | `80.97% <40.00%> (-3.53%)` | :arrow_down: |
| [src/pdm/cli/commands/import\_cmd.py](https://app.codecov.io/gh/pdm-project/pdm/pull/1983?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvY29tbWFuZHMvaW1wb3J0X2NtZC5weQ==) | `70.83% <60.00%> (-29.17%)` | :arrow_down: |
| [src/pdm/cli/actions.py](https://app.codecov.io/gh/pdm-project/pdm/pull/1983?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvYWN0aW9ucy5weQ==) | `73.09% <80.00%> (-6.62%)` | :arrow_down: |
| [src/pdm/cli/commands/add.py](https://app.codecov.io/gh/pdm-project/pdm/pull/1983?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvY29tbWFuZHMvYWRkLnB5) | `87.32% <87.50%> (-1.57%)` | :arrow_down: |
| [src/pdm/cli/commands/update.py](https://app.codecov.io/gh/pdm-project/pdm/pull/1983?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvY29tbWFuZHMvdXBkYXRlLnB5) | `91.04% <88.67%> (-8.96%)` | :arrow_down: |
| [src/pdm/cli/commands/use.py](https://app.codecov.io/gh/pdm-project/pdm/pull/1983?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvY29tbWFuZHMvdXNlLnB5) | `93.42% <92.06%> (-6.58%)` | :arrow_down: |
| [src/pdm/cli/commands/remove.py](https://app.codecov.io/gh/pdm-project/pdm/pull/1983?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvY29tbWFuZHMvcmVtb3ZlLnB5) | `95.91% <94.44%> (-4.09%)` | :arrow_down: |
| [src/pdm/cli/commands/base.py](https://app.codecov.io/gh/pdm-project/pdm/pull/1983?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvY29tbWFuZHMvYmFzZS5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/pdm/cli/commands/cache.py](https://app.codecov.io/gh/pdm-project/pdm/pull/1983?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvY29tbWFuZHMvY2FjaGUucHk=) | `83.33% <100.00%> (ø)` | |
| [src/pdm/cli/commands/init.py](https://app.codecov.io/gh/pdm-project/pdm/pull/1983?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvY29tbWFuZHMvaW5pdC5weQ==) | `92.03% <100.00%> (+0.07%)` | :arrow_up: |
| ... and [3 more](https://app.codecov.io/gh/pdm-project/pdm/pull/1983?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | |

... and [8 files with indirect coverage changes](https://app.codecov.io/gh/pdm-project/pdm/pull/1983/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project)


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/pdm-project/pdm/pull/1983?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).


Patch:
diff --git a/src/pdm/cli/actions.py b/src/pdm/cli/actions.py
index 8735ef39..ab85384f 100644
--- a/src/pdm/cli/actions.py
+++ b/src/pdm/cli/actions.py
@@ -8,15 +8,10 @@ import os
 import sys
 import textwrap
 import warnings
-from argparse import Namespace
-from collections import defaultdict
-from itertools import chain
 from typing import Collection, Iterable, cast
 
-import tomlkit
 from resolvelib.reporters import BaseReporter
 from resolvelib.resolvers import ResolutionImpossible, ResolutionTooDeep, Resolver
-from tomlkit.items import Array
 
 from pdm import termui
 from pdm.cli.filters import GroupSelection
@@ -28,20 +23,14 @@ from pdm.cli.utils import (
     format_lockfile,
     format_resolution_impossible,
     get_pep582_path,
-    merge_dictionary,
-    save_version_specifiers,
     set_env_in_reg,
 )
-from pdm.environments import BareEnvironment, PythonLocalEnvironment
-from pdm.exceptions import NoPythonVersion, PdmUsageError, ProjectError
-from pdm.models.backends import DEFAULT_BACKEND
+from pdm.environments import BareEnvironment
+from pdm.exceptions import PdmUsageError, ProjectError
 from pdm.models.candidates import Candidate
-from pdm.models.python import PythonInfo
-from pdm.models.requirements import Requirement, parse_requirement, strip_extras
-from pdm.models.specifiers import get_specifier
+from pdm.models.requirements import Requirement, parse_requirement
 from pdm.project import Project
 from pdm.resolver import resolve
-from pdm.utils import cd, normalize_name
 
 
 def do_lock(
@@ -215,415 +204,10 @@ def do_sync(
         hooks.try_emit("post_install", candidates=candidates, dry_run=dry_run)
 
 
-def do_add(
-    project: Project,
-    *,
-    selection: GroupSelection,
-    sync: bool = True,
-    save: str = "compatible",
-    strategy: str = "reuse",
-    editables: Collection[str] = (),
-    packages: Collection[str] = (),
-    unconstrained: bool = False,
-    no_editable: bool = False,
-    no_self: bool = False,
-    dry_run: bool = False,
-    prerelease: bool = False,
-    fail_fast: bool = False,
-    hooks: HookManager | None = None,
-) -> None:
-    """Add packages and install"""
-    hooks = hooks or HookManager(project)
-    check_project_file(project)
-    if editables and no_editable:
-        raise PdmUsageError("Cannot use --no-editable with editable packages given.")
-    group = selection.one()
-    tracked_names: set[str] = set()
-    requirements: dict[str, Requirement] = {}
-    lock_groups = [] if project.lockfile.empty() else project.lockfile.groups
-    if lock_groups is not None and group not in lock_groups:
-        project.core.ui.echo(f"Adding group [success]{group}[/] to lockfile", err=True, style="info")
-        lock_groups.append(group)
-    if group == "default" or not selection.dev and group not in project.pyproject.settings.get("dev-dependencies", {}):
-        if editables:
-            raise PdmUsageError("Cannot add editables to the default or optional dependency group")
-    for r in [parse_requirement(line, True) for line in editables] + [parse_requirement(line) for line in packages]:
-        if project.name and normalize_name(project.name) == r.key and not r.extras:
-            project.core.ui.echo(
-                f"Package [req]{project.name}[/] is the project itself.",
-                err=True,
-                style="warning",
-            )
-            continue
-        if r.is_file_or_url:
-            r.relocate(project.backend)  # type: ignore[attr-defined]
-        key = r.identify()
-        r.prerelease = prerelease
-        tracked_names.add(key)
-        requirements[key] = r
-    if requirements:
-        project.core.ui.echo(
-            f"Adding packages to [primary]{group}[/] "
-            f"{'dev-' if selection.dev else ''}dependencies: "
-            + ", ".join(f"[req]{r.as_line()}[/]" for r in requirements.values())
-        )
-    all_dependencies = project.all_dependencies
-    group_deps = all_dependencies.setdefault(group, {})
-    if unconstrained:
-        if not requirements:
-            raise PdmUsageError("--unconstrained requires at least one package")
-        for req in group_deps.values():
-            req.specifier = get_specifier("")
-    group_deps.update(requirements)
-    reqs = [r for g, deps in all_dependencies.items() if lock_groups is None or g in lock_groups for r in deps.values()]
-    with hooks.skipping("post_lock"):
-        resolved = do_lock(
-            project,
-            strategy,
-            tracked_names,
-            reqs,
-            dry_run=True,
-            hooks=hooks,
-            groups=lock_groups,
-        )
-
-    # Update dependency specifiers and lockfile hash.
-    deps_to_update = group_deps if unconstrained else requirements
-    save_version_specifiers({group: deps_to_update}, resolved, save)
-    if not dry_run:
-        project.add_dependencies(deps_to_update, group, selection.dev or False)
-        project.write_lockfile(project.lockfile._data, False, groups=lock_groups)
-        hooks.try_emit("post_lock", resolution=resolved, dry_run=dry_run)
-    _populate_requirement_names(group_deps)
-    if sync:
-        do_sync(
-            project,
-            selection=GroupSelection(project, groups=[group], default=False),
-            no_editable=no_editable and tracked_names,
-            no_self=no_self,
-            requirements=list(group_deps.values()),
-            dry_run=dry_run,
-            fail_fast=fail_fast,
-            hooks=hooks,
-        )
-
-
-def _populate_requirement_names(req_mapping: dict[str, Requirement]) -> None:
-    # Update the requirement key if the name changed.
-    for key, req in list(req_mapping.items()):
-        if key and key.startswith(":empty:"):
-            req_mapping[req.identify()] = req
-            del req_mapping[key]
-
-
-def do_update(
-    project: Project,
-    *,
-    selection: GroupSelection,
-    strategy: str = "reuse",
-    save: str = "compatible",
-    unconstrained: bool = False,
-    top: bool = False,
-    dry_run: bool = False,
-    packages: Collection[str] = (),
-    sync: bool = True,
-    no_editable: bool = False,
-    no_self: bool = False,
-    prerelease: bool = False,
-    fail_fast: bool = False,
-    hooks: HookManager | None = None,
-) -> None:
-    """Update specified packages or all packages"""
-    hooks = hooks or HookManager(project)
-    check_project_file(project)
-    if len(packages) > 0 and (top or len(selection.groups) > 1 or not selection.default):
-        raise PdmUsageError("packages argument can't be used together with multiple -G or " "--no-default or --top.")
-    all_dependencies = project.all_dependencies
-    updated_deps: dict[str, dict[str, Requirement]] = defaultdict(dict)
-    locked_groups = project.lockfile.groups
-    if not packages:
-        if prerelease:
-            raise PdmUsageError("--prerelease must be used with packages given")
-        selection.validate()
-        for group in selection:
-            updated_deps[group] = all_dependencies[group]
-    else:
-        group = selection.one()
-        if locked_groups and group not in locked_groups:
-            raise ProjectError(f"Requested group not in lockfile: {group}")
-        dependencies = all_dependencies[group]
-        for name in packages:
-            matched_name = next(
-                (k for k in dependencies if normalize_name(strip_extras(k)[0]) == normalize_name(name)),
-                None,
-            )
-            if not matched_name:
-                raise ProjectError(
-                    f"[req]{name}[/] does not exist in [primary]{group}[/] "
-                    f"{'dev-' if selection.dev else ''}dependencies."
-                )
-            dependencies[matched_name].prerelease = prerelease
-            updated_deps[group][matched_name] = dependencies[matched_name]
-        project.core.ui.echo(
-            "Updating packages: {}.".format(
-                ", ".join(f"[req]{v}[/]" for v in chain.from_iterable(updated_deps.values()))
-            )
-        )
-    if unconstrained:
-        for deps in updated_deps.values():
-            for dep in deps.values():
-                dep.specifier = get_specifier("")
-    reqs = [r for deps in all_dependencies.values() for r in deps.values()]
-    resolved = do_lock(
-        project,
-        strategy,
-        chain.from_iterable(updated_deps.values()),
-        reqs,
-        dry_run=True,
-        hooks=hooks,
-        groups=locked_groups,
-    )
-    for deps in updated_deps.values():
-        _populate_requirement_names(deps)
-    if unconstrained:
-        # Need to update version constraints
-        save_version_specifiers(updated_deps, resolved, save)
-        for group, deps in updated_deps.items():
-            project.add_dependencies(deps, group, selection.dev or False)
-    if not dry_run:
-        project.write_lockfile(project.lockfile._data, False, groups=locked_groups)
-    if sync or dry_run:
-        do_sync(
-            project,
-            selection=selection,
-            clean=False,
-            dry_run=dry_run,
-            requirements=[r for deps in updated_deps.values() for r in deps.values()],
-            tracked_names=list(chain.from_iterable(updated_deps.values())) if top else None,
-            no_editable=no_editable,
-            no_self=no_self,
-            fail_fast=fail_fast,
-            hooks=hooks,
-        )
-
-
-def do_remove(
-    project: Project,
-    selection: GroupSelection,
-    sync: bool = True,
-    packages: Collection[str] = (),
-    no_editable: bool = False,
-    no_self: bool = False,
-    dry_run: bool = False,
-    fail_fast: bool = False,
-    hooks: HookManager | None = None,
-) -> None:
-    """Remove packages from working set and pyproject.toml"""
-    hooks = hooks or HookManager(project)
-    check_project_file(project)
-    if not packages:
-        raise PdmUsageError("Must specify at least one package to remove.")
-    group = selection.one()
-    lock_groups = project.lockfile.groups
-
-    deps, _ = project.get_pyproject_dependencies(group, selection.dev or False)
-    project.core.ui.echo(
-        f"Removing packages from [primary]{group}[/] "
-        f"{'dev-' if selection.dev else ''}dependencies: " + ", ".join(f"[req]{name}[/]" for name in packages)
-    )
-    with cd(project.root):
-        for name in packages:
-            req = parse_requirement(name)
-            matched_indexes = sorted((i for i, r in enumerate(deps) if req.matches(r)), reverse=True)
-            if not matched_indexes:
-                raise ProjectError(f"[req]{name}[/] does not exist in [primary]{group}[/] dependencies.")
-            for i in matched_indexes:
-                del deps[i]
-    cast(Array, deps).multiline(True)
-
-    if not dry_run:
-        project.pyproject.write()
-    if lock_groups and group not in lock_groups:
-        project.core.ui.echo(f"Group [success]{group}[/] isn't in lockfile, skipping lock.", style="warning", err=True)
-        return
-    do_lock(project, "reuse", dry_run=dry_run, hooks=hooks, groups=lock_groups)
-    if sync:
-        do_sync(
-            project,
-            selection=GroupSelection(project, default=False, groups=[group]),
-            clean=True,
-            no_editable=no_editable,
-            no_self=no_self,
-            dry_run=dry_run,
-            fail_fast=fail_fast,
-            hooks=hooks,
-        )
-
-
-def do_use(
-    project: Project,
-    python: str = "",
-    first: bool = False,
-    ignore_remembered: bool = False,
-    ignore_requires_python: bool = False,
-    save: bool = True,
-    venv: str | None = None,
-    hooks: HookManager | None = None,
-) -> PythonInfo:
-    """Use the specified python version and save in project config.
-    The python can be a version string or interpreter path.
-    """
-    from pdm.cli.commands.venv.utils import get_venv_with_name
-    from pdm.models.caches import JSONFileCache
-
-    def version_matcher(py_version: PythonInfo) -> bool:
-        return py_version.valid and (
-            ignore_requires_python or project.python_requires.contains(str(py_version.version), True)
-        )
-
-    hooks = hooks or HookManager(project)
-
-    selected_python: PythonInfo | None = None
-    if venv:
-        virtualenv = get_venv_with_name(project, venv)
-        selected_python = PythonInfo.from_path(virtualenv.interpreter)
-
-    if not project.cache_dir.exists():
-        project.cache_dir.mkdir(parents=True)
-    use_cache: JSONFileCache[str, str] = JSONFileCache(project.cache_dir / "use_cache.json")
-    if python:
-        python = python.strip()
-    if selected_python is None and python and not ignore_remembered:
-        if python in use_cache:
-            path = use_cache.get(python)
-            cached_python = PythonInfo.from_path(path)
-            if not cached_python.valid:
-                project.core.ui.echo(
-                    f"The last selection is corrupted. {path!r}",
-                    style="error",
-                    err=True,
-                )
-            elif version_matcher(cached_python):
-                project.core.ui.echo(
-                    "Using the last selection, add '-i' to ignore it.",
-                    style="warning",
-                    err=True,
-                )
-                selected_python = cached_python
-
-    if selected_python is None:
-        found_interpreters = list(dict.fromkeys(project.find_interpreters(python)))
-        matching_interpreters = list(filter(version_matcher, found_interpreters))
-        if not found_interpreters:
-            raise NoPythonVersion(f"No Python interpreter matching [success]{python}[/] is found.")
-        if not matching_interpreters:
-            project.core.ui.echo("Interpreters found but not matching:", err=True)
-            for py in found_interpreters:
-                info = py.identifier if py.valid else "Invalid"
-                project.core.ui.echo(f"  - {py.path} ({info})", err=True)
-            raise NoPythonVersion(
-                f"No python is found meeting the requirement [success]python {project.python_requires!s}[/]"
-            )
-        if first or len(matching_interpreters) == 1:
-            selected_python = matching_interpreters[0]
-        else:
-            project.core.ui.echo("Please enter the Python interpreter to use")
-            for i, py_version in enumerate(matching_interpreters):
-                project.core.ui.echo(f"{i}. [success]{py_version.path!s}[/] ({py_version.identifier})")
-            selection = termui.ask(
-                "Please select",
-                default="0",
-                prompt_type=int,
-                choices=[str(i) for i in range(len(matching_interpreters))],
-                show_choices=False,
-            )
-            selected_python = matching_interpreters[int(selection)]
-        if python:
-            use_cache.set(python, selected_python.path.as_posix())
-
-    if not save:
-        return selected_python
-    saved_python = project._saved_python
-    old_python = PythonInfo.from_path(saved_python) if saved_python else None
-    project.core.ui.echo(
-        f"Using Python interpreter: [success]{selected_python.path!s}[/] ({selected_python.identifier})"
-    )
-    project.python = selected_python
-    if project.environment.is_local:
-        project.core.ui.echo(
-            "Using __pypackages__ because non-venv Python is used.",
-            style="primary",
-            err=True,
-        )
-    if (
-        old_python
-        and old_python.executable != selected_python.executable
-        and isinstance(project.environment, PythonLocalEnvironment)
-    ):
-        project.core.ui.echo("Updating executable scripts...", style="primary")
-        project.environment.update_shebangs(selected_python.executable.as_posix())
-    hooks.try_emit("post_use", python=selected_python)
-    return selected_python
-
-
-def do_import(
-    project: Project,
-    filename: str,
-    format: str | None = None,
-    options: Namespace | None = None,
-) -> None:
-    """Import project metadata from given file.
-
-    :param project: the project instance
-    :param filename: the file name
-    :param format: the file format, or guess if not given.
-    :param options: other options parsed to the CLI.
-    """
-    from pdm.formats import FORMATS
-
-    if not format:
-        for key in FORMATS:
-            if FORMATS[key].check_fingerprint(project, filename):
-                break
-        else:
-            raise PdmUsageError(
-                "Can't derive the file format automatically, please specify it via '-f/--format' option."
-            )
-    else:
-        key = format
-    if options is None:
-        options = Namespace(dev=False, group=None)
-    project_data, settings = FORMATS[key].convert(project, filename, options)
-    pyproject = project.pyproject._data
-
-    if "tool" not in pyproject or "pdm" not in pyproject["tool"]:
-        pyproject.setdefault("tool", {})["pdm"] = tomlkit.table()
-    if "build" in pyproject["tool"]["pdm"] and isinstance(pyproject["tool"]["pdm"]["build"], str):
-        pyproject["tool"]["pdm"]["build"] = {
-            "setup-script": pyproject["tool"]["pdm"]["build"],
-            "run-setuptools": True,
-        }
-    if "project" not in pyproject:
-        pyproject.add("project", tomlkit.table())
-        pyproject["project"].add(tomlkit.comment("PEP 621 project metadata"))
-        pyproject["project"].add(tomlkit.comment("See https://www.python.org/dev/peps/pep-0621/"))
-
-    merge_dictionary(pyproject["project"], project_data)
-    merge_dictionary(pyproject["tool"]["pdm"], settings)
-    pyproject.setdefault("build-system", DEFAULT_BACKEND.build_system())
-
-    if "requires-python" not in pyproject["project"]:
-        python_version = f"{project.python.major}.{project.python.minor}"
-        pyproject["project"]["requires-python"] = f">={python_version}"
-        project.core.ui.echo(
-            "The project's [primary]requires-python[/] has been set to [primary]>="
-            f"{python_version}[/]. You can change it later if necessary."
-        )
-    project.pyproject.write()
-
-
 def ask_for_import(project: Project) -> None:
     """Show possible importable files and ask user to decide"""
+    from pdm.cli.commands.import_cmd import Command as ImportCommand
+
     importable_files = list(find_importable_files(project))
     if not importable_files:
         return
@@ -640,7 +224,7 @@ def ask_for_import(project: Project) -> None:
     if int(choice) == len(importable_files):
         return
     key, filepath = importable_files[int(choice)]
-    do_import(project, str(filepath), key)
+    ImportCommand.do_import(project, str(filepath), key)
 
 
 def print_pep582_command(project: Project, shell: str = "AUTO") -> None:
diff --git a/src/pdm/cli/commands/add.py b/src/pdm/cli/commands/add.py
index 14d45b96..27c3586a 100644
--- a/src/pdm/cli/commands/add.py
+++ b/src/pdm/cli/commands/add.py
@@ -1,6 +1,8 @@
+from __future__ import annotations
+
 import argparse
+from typing import TYPE_CHECKING
 
-from pdm.cli import actions
 from pdm.cli.commands.base import BaseCommand
 from pdm.cli.filters import GroupSelection
 from pdm.cli.hooks import HookManager
@@ -17,7 +19,12 @@ from pdm.cli.options import (
     venv_option,
 )
 from pdm.exceptions import PdmUsageError
-from pdm.project import Project
+
+if TYPE_CHECKING:
+    from typing import Collection
+
+    from pdm.models.requirements import Requirement
+    from pdm.project import Project
 
 
 class Command(BaseCommand):
@@ -57,7 +64,7 @@ class Command(BaseCommand):
     def handle(self, project: Project, options: argparse.Namespace) -> None:
         if options.editables and options.no_editable:
             raise PdmUsageError("`--no-editable` cannot be used with `-e/--editable`")
-        actions.do_add(
+        self.do_add(
             project,
             selection=GroupSelection.from_options(project, options),
             sync=options.sync,
@@ -73,3 +80,108 @@ class Command(BaseCommand):
             fail_fast=options.fail_fast,
             hooks=HookManager(project, options.skip),
         )
+
+    @staticmethod
+    def do_add(
+        project: Project,
+        *,
+        selection: GroupSelection,
+        sync: bool = True,
+        save: str = "compatible",
+        strategy: str = "reuse",
+        editables: Collection[str] = (),
+        packages: Collection[str] = (),
+        unconstrained: bool = False,
+        no_editable: bool = False,
+        no_self: bool = False,
+        dry_run: bool = False,
+        prerelease: bool = False,
+        fail_fast: bool = False,
+        hooks: HookManager | None = None,
+    ) -> None:
+        """Add packages and install"""
+        from pdm.cli.actions import do_lock, do_sync
+        from pdm.cli.utils import check_project_file, populate_requirement_names, save_version_specifiers
+        from pdm.models.requirements import parse_requirement
+        from pdm.models.specifiers import get_specifier
+        from pdm.utils import normalize_name
+
+        hooks = hooks or HookManager(project)
+        check_project_file(project)
+        if editables and no_editable:
+            raise PdmUsageError("Cannot use --no-editable with editable packages given.")
+        group = selection.one()
+        tracked_names: set[str] = set()
+        requirements: dict[str, Requirement] = {}
+        lock_groups = [] if project.lockfile.empty() else project.lockfile.groups
+        if lock_groups is not None and group not in lock_groups:
+            project.core.ui.echo(f"Adding group [success]{group}[/] to lockfile", err=True, style="info")
+            lock_groups.append(group)
+        if (
+            group == "default"
+            or not selection.dev
+            and group not in project.pyproject.settings.get("dev-dependencies", {})
+        ):
+            if editables:
+                raise PdmUsageError("Cannot add editables to the default or optional dependency group")
+        for r in [parse_requirement(line, True) for line in editables] + [parse_requirement(line) for line in packages]:
+            if project.name and normalize_name(project.name) == r.key and not r.extras:
+                project.core.ui.echo(
+                    f"Package [req]{project.name}[/] is the project itself.",
+                    err=True,
+                    style="warning",
+                )
+                continue
+            if r.is_file_or_url:
+                r.relocate(project.backend)  # type: ignore[attr-defined]
+            key = r.identify()
+            r.prerelease = prerelease
+            tracked_names.add(key)
+            requirements[key] = r
+        if requirements:
+            project.core.ui.echo(
+                f"Adding packages to [primary]{group}[/] "
+                f"{'dev-' if selection.dev else ''}dependencies: "
+                + ", ".join(f"[req]{r.as_line()}[/]" for r in requirements.values())
+            )
+        all_dependencies = project.all_dependencies
+        group_deps = all_dependencies.setdefault(group, {})
+        if unconstrained:
+            if not requirements:
+                raise PdmUsageError("--unconstrained requires at least one package")
+            for req in group_deps.values():
+                req.specifier = get_specifier("")
+        group_deps.update(requirements)
+        reqs = [
+            r for g, deps in all_dependencies.items() if lock_groups is None or g in lock_groups for r in deps.values()
+        ]
+        with hooks.skipping("post_lock"):
+            resolved = do_lock(
+                project,
+                strategy,
+                tracked_names,
+                reqs,
+                dry_run=True,
+                hooks=hooks,
+                groups=lock_groups,
+            )
+
+        # Update dependency specifiers and lockfile hash.
+        deps_to_update = group_deps if unconstrained else requirements
+        save_version_specifiers({group: deps_to_update}, resolved, save)
+        if not dry_run:
+            project.add_dependencies(deps_to_update, group, selection.dev or False)
+            project.write_lockfile(project.lockfile._data, False, groups=lock_groups)
+            hooks.try_emit("post_lock", resolution=resolved, dry_run=dry_run)
+        populate_requirement_names(group_deps)
+        if sync:
+            do_sync(
+                project,
+                selection=GroupSelection(project, groups=[group], default=False),
+                no_editable=no_editable and tracked_names,
+                no_self=no_self,
+                requirements=list(group_deps.values()),
+                dry_run=dry_run,
+                fail_fast=fail_fast,
+                hooks=hooks,
+            )
diff --git a/src/pdm/cli/commands/base.py b/src/pdm/cli/commands/base.py
index 7618eb58..5c39ef59 100644
--- a/src/pdm/cli/commands/base.py
+++ b/src/pdm/cli/commands/base.py
@@ -2,11 +2,13 @@ from __future__ import annotations
 
 import argparse
 from argparse import _SubParsersAction
-from typing import Any
+from typing import Any, TypeVar
 
 from pdm.cli.options import Option, global_option, project_option, verbose_option
 from pdm.project import Project
 
+C = TypeVar("C", bound="BaseCommand")
+
 
 class BaseCommand:
     """A CLI subcommand"""
@@ -19,10 +21,16 @@ class BaseCommand:
     # Rewrite this if you don't want the default ones
     arguments: list[Option] = [verbose_option, global_option, project_option]
 
-    def __init__(self, parser: argparse.ArgumentParser) -> None:
-        for arg in self.arguments:
+    def __init__(self, parser: argparse.ArgumentParser | None = None) -> None:
+        """For compatibility, the parser is optional and won't be used."""
+
+    @classmethod
+    def init_parser(cls: type[C], parser: argparse.ArgumentParser) -> C:
+        cmd = cls()
+        for arg in cmd.arguments:
             arg.add_to_parser(parser)
-        self.add_arguments(parser)
+        cmd.add_arguments(parser)
+        return cmd
 
     @classmethod
     def register_to(cls, subparsers: _SubParsersAction, name: str | None = None, **kwargs: Any) -> None:
@@ -41,7 +49,7 @@ class BaseCommand:
             help=help_text,
             **kwargs,
         )
-        command = cls(parser)
+        command = cls.init_parser(parser)
         command.name = name
         # Store the command instance in the parsed args. See pdm/core.py for more details
         parser.set_defaults(command=command)
diff --git a/src/pdm/cli/commands/cache.py b/src/pdm/cli/commands/cache.py
index 55c28c18..8b05c3bb 100644
--- a/src/pdm/cli/commands/cache.py
+++ b/src/pdm/cli/commands/cache.py
@@ -16,7 +16,7 @@ class Command(BaseCommand):
     arguments = [verbose_option]
 
     def add_arguments(self, parser: argparse.ArgumentParser) -> None:
-        subparsers = parser.add_subparsers(title="Sub commands")
+        subparsers = parser.add_subparsers(title="commands", metavar="cache")
         ClearCommand.register_to(subparsers, "clear")
         RemoveCommand.register_to(subparsers, "remove")
         ListCommand.register_to(subparsers, "list")
diff --git a/src/pdm/cli/commands/import_cmd.py b/src/pdm/cli/commands/import_cmd.py
index a922f482..7d7b75a0 100644
--- a/src/pdm/cli/commands/import_cmd.py
+++ b/src/pdm/cli/commands/import_cmd.py
@@ -1,7 +1,9 @@
+from __future__ import annotations
+
 import argparse
 
-from pdm.cli.actions import do_import
 from pdm.cli.commands.base import BaseCommand
+from pdm.exceptions import PdmUsageError
 from pdm.formats import FORMATS
 from pdm.project import Project
 
@@ -30,4 +32,64 @@ class Command(BaseCommand):
         parser.set_defaults(search_parent=False)
 
     def handle(self, project: Project, options: argparse.Namespace) -> None:
-        do_import(project, options.filename, options.format, options)
+        self.do_import(project, options.filename, options.format, options)
+
+    @staticmethod
+    def do_import(
+        project: Project,
+        filename: str,
+        format: str | None = None,
+        options: argparse.Namespace | None = None,
+    ) -> None:
+        """Import project metadata from given file.
+
+        :param project: the project instance
+        :param filename: the file name
+        :param format: the file format, or guess if not given.
+        :param options: other options parsed to the CLI.
+        """
+        import tomlkit
+
+        from pdm.cli.utils import merge_dictionary
+        from pdm.formats import FORMATS
+        from pdm.models.backends import DEFAULT_BACKEND
+
+        if not format:
+            for key in FORMATS:
+                if FORMATS[key].check_fingerprint(project, filename):
+                    break
+            else:
+                raise PdmUsageError(
+                    "Can't derive the file format automatically, please specify it via '-f/--format' option."
+                )
+        else:
+            key = format
+        if options is None:
+            options = argparse.Namespace(dev=False, group=None)
+        project_data, settings = FORMATS[key].convert(project, filename, options)
+        pyproject = project.pyproject._data
+
+        if "tool" not in pyproject or "pdm" not in pyproject["tool"]:
+            pyproject.setdefault("tool", {})["pdm"] = tomlkit.table()
+        if "build" in pyproject["tool"]["pdm"] and isinstance(pyproject["tool"]["pdm"]["build"], str):
+            pyproject["tool"]["pdm"]["build"] = {
+                "setup-script": pyproject["tool"]["pdm"]["build"],
+                "run-setuptools": True,
+            }
+        if "project" not in pyproject:
+            pyproject.add("project", tomlkit.table())
+            pyproject["project"].add(tomlkit.comment("PEP 621 project metadata"))
+            pyproject["project"].add(tomlkit.comment("See https://www.python.org/dev/peps/pep-0621/"))
+
+        merge_dictionary(pyproject["project"], project_data)
+        merge_dictionary(pyproject["tool"]["pdm"], settings)
+        pyproject.setdefault("build-system", DEFAULT_BACKEND.build_system())
+
+        if "requires-python" not in pyproject["project"]:
+            python_version = f"{project.python.major}.{project.python.minor}"
+            pyproject["project"]["requires-python"] = f">={python_version}"
+            project.core.ui.echo(
+                "The project's [primary]requires-python[/] has been set to [primary]>="
+                f"{python_version}[/]. You can change it later if necessary."
+            )
+        project.pyproject.write()
diff --git a/src/pdm/cli/commands/init.py b/src/pdm/cli/commands/init.py
index 3d1ca6c2..03d3590b 100644
--- a/src/pdm/cli/commands/init.py
+++ b/src/pdm/cli/commands/init.py
@@ -22,8 +22,7 @@ if TYPE_CHECKING:
 class Command(BaseCommand):
     """Initialize a pyproject.toml for PDM"""
 
-    def __init__(self, parser: argparse.ArgumentParser) -> None:
-        super().__init__(parser)
+    def __init__(self) -> None:
         self.interactive = True
 
     @staticmethod
@@ -112,15 +111,17 @@ class Command(BaseCommand):
         parser.set_defaults(search_parent=False)
 
     def handle(self, project: Project, options: argparse.Namespace) -> None:
+        from pdm.cli.commands.use import Command as UseCommand
+
         hooks = HookManager(project, options.skip)
         if project.pyproject.exists():
             project.core.ui.echo("pyproject.toml already exists, update it now.", style="primary")
         else:
             project.core.ui.echo("Creating a pyproject.toml for PDM...", style="primary")
         self.set_interactive(not options.non_interactive)
-
+        do_use = UseCommand().do_use
         if self.interactive:
-            python = actions.do_use(
+            python = do_use(
                 project,
                 options.python or "",
                 first=bool(options.python),
@@ -129,7 +130,7 @@ class Command(BaseCommand):
                 hooks=hooks,
             )
         else:
-            python = actions.do_use(
+            python = do_use(
                 project,
                 options.python or "3",
                 first=True,
diff --git a/src/pdm/cli/commands/remove.py b/src/pdm/cli/commands/remove.py
index 31db901b..25d4cb66 100644
--- a/src/pdm/cli/commands/remove.py
+++ b/src/pdm/cli/commands/remove.py
@@ -1,11 +1,18 @@
+from __future__ import annotations
+
 import argparse
+from typing import TYPE_CHECKING, cast
 
-from pdm.cli import actions
 from pdm.cli.commands.base import BaseCommand
 from pdm.cli.filters import GroupSelection
 from pdm.cli.hooks import HookManager
 from pdm.cli.options import dry_run_option, install_group, lockfile_option, skip_option, venv_option
-from pdm.project import Project
+from pdm.exceptions import PdmUsageError, ProjectError
+
+if TYPE_CHECKING:
+    from typing import Collection
+
+    from pdm.project import Project
 
 
 class Command(BaseCommand):
@@ -32,7 +39,7 @@ class Command(BaseCommand):
         parser.add_argument("packages", nargs="+", help="Specify the packages to remove")
 
     def handle(self, project: Project, options: argparse.Namespace) -> None:
-        actions.do_remove(
+        self.do_remove(
             project,
             selection=GroupSelection.from_options(project, options),
             sync=options.sync,
@@ -43,3 +50,65 @@ class Command(BaseCommand):
             fail_fast=options.fail_fast,
             hooks=HookManager(project, options.skip),
         )
+
+    @staticmethod
+    def do_remove(
+        project: Project,
+        selection: GroupSelection,
+        sync: bool = True,
+        packages: Collection[str] = (),
+        no_editable: bool = False,
+        no_self: bool = False,
+        dry_run: bool = False,
+        fail_fast: bool = False,
+        hooks: HookManager | None = None,
+    ) -> None:
+        """Remove packages from working set and pyproject.toml"""
+        from tomlkit.items import Array
+
+        from pdm.cli.actions import do_lock, do_sync
+        from pdm.cli.utils import check_project_file
+        from pdm.models.requirements import parse_requirement
+        from pdm.utils import cd
+
+        hooks = hooks or HookManager(project)
+        check_project_file(project)
+        if not packages:
+            raise PdmUsageError("Must specify at least one package to remove.")
+        group = selection.one()
+        lock_groups = project.lockfile.groups
+
+        deps, _ = project.get_pyproject_dependencies(group, selection.dev or False)
+        project.core.ui.echo(
+            f"Removing packages from [primary]{group}[/] "
+            f"{'dev-' if selection.dev else ''}dependencies: " + ", ".join(f"[req]{name}[/]" for name in packages)
+        )
+        with cd(project.root):
+            for name in packages:
+                req = parse_requirement(name)
+                matched_indexes = sorted((i for i, r in enumerate(deps) if req.matches(r)), reverse=True)
+                if not matched_indexes:
+                    raise ProjectError(f"[req]{name}[/] does not exist in [primary]{group}[/] dependencies.")
+                for i in matched_indexes:
+                    del deps[i]
+        cast(Array, deps).multiline(True)
+
+        if not dry_run:
+            project.pyproject.write()
+        if lock_groups and group not in lock_groups:
+            project.core.ui.echo(
+                f"Group [success]{group}[/] isn't in lockfile, skipping lock.", style="warning", err=True
+            )
+            return
+        do_lock(project, "reuse", dry_run=dry_run, hooks=hooks, groups=lock_groups)
+        if sync:
+            do_sync(
+                project,
+                selection=GroupSelection(project, default=False, groups=[group]),
+                clean=True,
+                no_editable=no_editable,
+                no_self=no_self,
+                dry_run=dry_run,
+                fail_fast=fail_fast,
+                hooks=hooks,
+            )
diff --git a/src/pdm/cli/commands/self_cmd.py b/src/pdm/cli/commands/self_cmd.py
index cac8c0c8..4e720268 100644
--- a/src/pdm/cli/commands/self_cmd.py
+++ b/src/pdm/cli/commands/self_cmd.py
@@ -64,7 +64,7 @@ class Command(BaseCommand):
         return super().register_to(subparsers, name, aliases=["plugin"], **kwargs)
 
     def add_arguments(self, parser: argparse.ArgumentParser) -> None:
-        subparsers = parser.add_subparsers(title="Sub commands")
+        subparsers = parser.add_subparsers(title="commands", metavar="self")
         ListCommand.register_to(subparsers)
         if not is_in_zipapp():
             AddCommand.register_to(subparsers)
diff --git a/src/pdm/cli/commands/update.py b/src/pdm/cli/commands/update.py
index 564765dc..87f2fd99 100644
--- a/src/pdm/cli/commands/update.py
+++ b/src/pdm/cli/commands/update.py
@@ -1,6 +1,9 @@
+from __future__ import annotations
+
 import argparse
+from collections import defaultdict
+from typing import TYPE_CHECKING
 
-from pdm.cli import actions
 from pdm.cli.commands.base import BaseCommand
 from pdm.cli.filters import GroupSelection
 from pdm.cli.hooks import HookManager
@@ -15,7 +18,13 @@ from pdm.cli.options import (
     update_strategy_group,
     venv_option,
 )
-from pdm.project import Project
+from pdm.exceptions import PdmUsageError, ProjectError
+
+if TYPE_CHECKING:
+    from typing import Collection
+
+    from pdm.models.requirements import Requirement
+    from pdm.project import Project
 
 
 class Command(BaseCommand):
@@ -59,7 +68,7 @@ class Command(BaseCommand):
         parser.set_defaults(dev=None)
 
     def handle(self, project: Project, options: argparse.Namespace) -> None:
-        actions.do_update(
+        self.do_update(
             project,
             selection=GroupSelection.from_options(project, options),
             save=options.save_strategy or project.config["strategy.save"],
@@ -75,3 +84,104 @@ class Command(BaseCommand):
             fail_fast=options.fail_fast,
             hooks=HookManager(project, options.skip),
         )
+
+    @staticmethod
+    def do_update(
+        project: Project,
+        *,
+        selection: GroupSelection,
+        strategy: str = "reuse",
+        save: str = "compatible",
+        unconstrained: bool = False,
+        top: bool = False,
+        dry_run: bool = False,
+        packages: Collection[str] = (),
+        sync: bool = True,
+        no_editable: bool = False,
+        no_self: bool = False,
+        prerelease: bool = False,
+        fail_fast: bool = False,
+        hooks: HookManager | None = None,
+    ) -> None:
+        """Update specified packages or all packages"""
+        from itertools import chain
+
+        from pdm.cli.actions import do_lock, do_sync
+        from pdm.cli.utils import check_project_file, populate_requirement_names, save_version_specifiers
+        from pdm.models.requirements import strip_extras
+        from pdm.models.specifiers import get_specifier
+        from pdm.utils import normalize_name
+
+        hooks = hooks or HookManager(project)
+        check_project_file(project)
+        if len(packages) > 0 and (top or len(selection.groups) > 1 or not selection.default):
+            raise PdmUsageError(
+                "packages argument can't be used together with multiple -G or " "--no-default or --top."
+            )
+        all_dependencies = project.all_dependencies
+        updated_deps: dict[str, dict[str, Requirement]] = defaultdict(dict)
+        locked_groups = project.lockfile.groups
+        if not packages:
+            if prerelease:
+                raise PdmUsageError("--prerelease must be used with packages given")
+            selection.validate()
+            for group in selection:
+                updated_deps[group] = all_dependencies[group]
+        else:
+            group = selection.one()
+            if locked_groups and group not in locked_groups:
+                raise ProjectError(f"Requested group not in lockfile: {group}")
+            dependencies = all_dependencies[group]
+            for name in packages:
+                matched_name = next(
+                    (k for k in dependencies if normalize_name(strip_extras(k)[0]) == normalize_name(name)),
+                    None,
+                )
+                if not matched_name:
+                    raise ProjectError(
+                        f"[req]{name}[/] does not exist in [primary]{group}[/] "
+                        f"{'dev-' if selection.dev else ''}dependencies."
+                    )
+                dependencies[matched_name].prerelease = prerelease
+                updated_deps[group][matched_name] = dependencies[matched_name]
+            project.core.ui.echo(
+                "Updating packages: {}.".format(
+                    ", ".join(f"[req]{v}[/]" for v in chain.from_iterable(updated_deps.values()))
+                )
+            )
+        if unconstrained:
+            for deps in updated_deps.values():
+                for dep in deps.values():
+                    dep.specifier = get_specifier("")
+        reqs = [r for deps in all_dependencies.values() for r in deps.values()]
+        resolved = do_lock(
+            project,
+            strategy,
+            chain.from_iterable(updated_deps.values()),
+            reqs,
+            dry_run=True,
+            hooks=hooks,
+            groups=locked_groups,
+        )
+        for deps in updated_deps.values():
+            populate_requirement_names(deps)
+        if unconstrained:
+            # Need to update version constraints
+            save_version_specifiers(updated_deps, resolved, save)
+            for group, deps in updated_deps.items():
+                project.add_dependencies(deps, group, selection.dev or False)
+        if not dry_run:
+            project.write_lockfile(project.lockfile._data, False, groups=locked_groups)
+        if sync or dry_run:
+            do_sync(
+                project,
+                selection=selection,
+                clean=False,
+                dry_run=dry_run,
+                requirements=[r for deps in updated_deps.values() for r in deps.values()],
+                tracked_names=list(chain.from_iterable(updated_deps.values())) if top else None,
+                no_editable=no_editable,
+                no_self=no_self,
+                fail_fast=fail_fast,
+                hooks=hooks,
+            )
diff --git a/src/pdm/cli/commands/use.py b/src/pdm/cli/commands/use.py
index f052f173..fb5e781b 100644
--- a/src/pdm/cli/commands/use.py
+++ b/src/pdm/cli/commands/use.py
@@ -1,9 +1,14 @@
+from __future__ import annotations
+
 import argparse
 
-from pdm.cli import actions
+from pdm import termui
 from pdm.cli.commands.base import BaseCommand
 from pdm.cli.hooks import HookManager
 from pdm.cli.options import skip_option
+from pdm.exceptions import NoPythonVersion
+from pdm.models.caches import JSONFileCache
+from pdm.models.python import PythonInfo
 from pdm.project import Project
 
 
@@ -27,8 +32,132 @@ class Command(BaseCommand):
         parser.add_argument("--venv", help="Use the interpreter in the virtual environment with the given name")
         parser.add_argument("python", nargs="?", help="Specify the Python version or path", default="")
 
+    @staticmethod
+    def select_python(
+        project: Project,
+        python: str,
+        *,
+        ignore_remembered: bool,
+        ignore_requires_python: bool,
+        venv: str | None,
+        first: bool,
+    ) -> PythonInfo:
+        from pdm.cli.commands.venv.utils import get_venv_with_name
+
+        def version_matcher(py_version: PythonInfo) -> bool:
+            return py_version.valid and (
+                ignore_requires_python or project.python_requires.contains(str(py_version.version), True)
+            )
+
+        if venv:
+            virtualenv = get_venv_with_name(project, venv)
+            return PythonInfo.from_path(virtualenv.interpreter)
+
+        if not project.cache_dir.exists():
+            project.cache_dir.mkdir(parents=True)
+        use_cache: JSONFileCache[str, str] = JSONFileCache(project.cache_dir / "use_cache.json")
+        python = python.strip()
+        if python and not ignore_remembered and python in use_cache:
+            path = use_cache.get(python)
+            cached_python = PythonInfo.from_path(path)
+            if not cached_python.valid:
+                project.core.ui.echo(
+                    f"The last selection is corrupted. {path!r}",
+                    style="error",
+                    err=True,
+                )
+            elif version_matcher(cached_python):
+                project.core.ui.echo(
+                    "Using the last selection, add '-i' to ignore it.",
+                    style="warning",
+                    err=True,
+                )
+                return cached_python
+
+        found_interpreters = list(dict.fromkeys(project.find_interpreters(python)))
+        matching_interpreters = list(filter(version_matcher, found_interpreters))
+        if not found_interpreters:
+            raise NoPythonVersion(f"No Python interpreter matching [success]{python}[/] is found.")
+        if not matching_interpreters:
+            project.core.ui.echo("Interpreters found but not matching:", err=True)
+            for py in found_interpreters:
+                info = py.identifier if py.valid else "Invalid"
+                project.core.ui.echo(f"  - {py.path} ({info})", err=True)
+            raise NoPythonVersion(
+                f"No python is found meeting the requirement [success]python {project.python_requires!s}[/]"
+            )
+        if first or len(matching_interpreters) == 1:
+            return matching_interpreters[0]
+
+        project.core.ui.echo("Please enter the Python interpreter to use")
+        for i, py_version in enumerate(matching_interpreters):
+            project.core.ui.echo(f"{i}. [success]{py_version.path!s}[/] ({py_version.identifier})")
+        selection = termui.ask(
+            "Please select",
+            default="0",
+            prompt_type=int,
+            choices=[str(i) for i in range(len(matching_interpreters))],
+            show_choices=False,
+        )
+        return matching_interpreters[int(selection)]
+
+    def do_use(
+        self,
+        project: Project,
+        python: str = "",
+        first: bool = False,
+        ignore_remembered: bool = False,
+        ignore_requires_python: bool = False,
+        save: bool = True,
+        venv: str | None = None,
+        hooks: HookManager | None = None,
+    ) -> PythonInfo:
+        """Use the specified python version and save in project config.
+        The python can be a version string or interpreter path.
+        """
+        from pdm.environments.local import PythonLocalEnvironment
+
+        selected_python = self.select_python(
+            project,
+            python,
+            ignore_remembered=ignore_remembered,
+            first=first,
+            venv=venv,
+            ignore_requires_python=ignore_requires_python,
+        )
+        if python:
+            use_cache: JSONFileCache[str, str] = JSONFileCache(project.cache_dir / "use_cache.json")
+            use_cache.set(python, selected_python.path.as_posix())
+
+        if not save:
+            return selected_python
+
+        saved_python = project._saved_python
+        old_python = PythonInfo.from_path(saved_python) if saved_python else None
+        project.core.ui.echo(
+            f"Using Python interpreter: [success]{selected_python.path!s}[/] ({selected_python.identifier})"
+        )
+        project.python = selected_python
+        if project.environment.is_local:
+            project.core.ui.echo(
+                "Using __pypackages__ because non-venv Python is used.",
+                style="primary",
+                err=True,
+            )
+        if (
+            old_python
+            and old_python.executable != selected_python.executable
+            and isinstance(project.environment, PythonLocalEnvironment)
+        ):
+            project.core.ui.echo("Updating executable scripts...", style="primary")
+            project.environment.update_shebangs(selected_python.executable.as_posix())
+
+        hooks = hooks or HookManager(project)
+        hooks.try_emit("post_use", python=selected_python)
+        return selected_python
+
     def handle(self, project: Project, options: argparse.Namespace) -> None:
-        actions.do_use(
+        self.do_use(
             project,
             python=options.python,
             first=options.first,
diff --git a/src/pdm/cli/commands/venv/__init__.py b/src/pdm/cli/commands/venv/__init__.py
index 18bd9062..0e156d2e 100644
--- a/src/pdm/cli/commands/venv/__init__.py
+++ b/src/pdm/cli/commands/venv/__init__.py
@@ -2,7 +2,6 @@ from __future__ import annotations
 
 import argparse
 
-from pdm.project import Project
 from pdm.cli.commands.base import BaseCommand
 from pdm.cli.commands.venv.activate import ActivateCommand
 from pdm.cli.commands.venv.create import CreateCommand
@@ -11,6 +10,7 @@ from pdm.cli.commands.venv.purge import PurgeCommand
 from pdm.cli.commands.venv.remove import RemoveCommand
 from pdm.cli.commands.venv.utils import get_venv_with_name
 from pdm.cli.options import Option
+from pdm.project import Project
 
 
 class Command(BaseCommand):
@@ -23,7 +23,7 @@ class Command(BaseCommand):
         group = parser.add_mutually_exclusive_group()
         group.add_argument("--path", help="Show the path to the given virtualenv")
         group.add_argument("--python", help="Show the python interpreter path for the given virtualenv")
-        subparser = parser.add_subparsers()
+        subparser = parser.add_subparsers(metavar="venv", title="commands")
         CreateCommand.register_to(subparser, "create")
         ListCommand.register_to(subparser, "list")
         RemoveCommand.register_to(subparser, "remove")
diff --git a/src/pdm/cli/utils.py b/src/pdm/cli/utils.py
index 8905a793..547b76d1 100644
--- a/src/pdm/cli/utils.py
+++ b/src/pdm/cli/utils.py
@@ -678,3 +678,11 @@ def use_venv(project: Project, name: str) -> None:
         f"In virtual environment: [success]{venv.root}[/]", err=True, style="info", verbosity=termui.Verbosity.DETAIL
     )
     project.environment = PythonEnvironment(project, python=str(venv.interpreter))
+
+
+def populate_requirement_names(req_mapping: dict[str, Requirement]) -> None:
+    # Update the requirement key if the name changed.
+    for key, req in list(req_mapping.items()):
+        if key and key.startswith(":empty:"):
+            req_mapping[req.identify()] = req
+            del req_mapping[key]
diff --git a/src/pdm/core.py b/src/pdm/core.py
index 33e558de..45b29817 100644
--- a/src/pdm/core.py
+++ b/src/pdm/core.py
@@ -83,7 +83,7 @@ class Core:
         ignore_python_option.add_to_parser(self.parser)
         pep582_option.add_to_parser(self.parser)
 
-        self.subparsers = self.parser.add_subparsers(parser_class=ArgumentParser)
+        self.subparsers = self.parser.add_subparsers(parser_class=ArgumentParser, metavar="__root__")
         for _, name, _ in pkgutil.iter_modules(COMMANDS_MODULE_PATH):
             module = importlib.import_module(f"pdm.cli.commands.{name}", __name__)
             try:


Test patch:
diff --git a/tests/cli/test_add.py b/tests/cli/test_add.py
index 9f61931d..ec942dcb 100644
--- a/tests/cli/test_add.py
+++ b/tests/cli/test_add.py
@@ -3,7 +3,6 @@ import shutil
 import pytest
 from unearth import Link
 
-from pdm.cli import actions
 from pdm.models.requirements import parse_requirement
 from pdm.models.specifiers import PySpecSet
 from pdm.utils import path_to_url
@@ -26,7 +25,7 @@ def test_add_package(project, working_set, dev_option, pdm):
 
 
 def test_add_command(project, pdm, mocker):
-    do_add = mocker.patch.object(actions, "do_add")
+    do_add = mocker.patch("pdm.cli.commands.add.Command.do_add")
     pdm(["add", "requests"], obj=project)
     do_add.assert_called_once()
 
diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py
index 406fee20..a97a1bef 100644
--- a/tests/cli/test_init.py
+++ b/tests/cli/test_init.py
@@ -3,7 +3,6 @@ from unittest.mock import ANY
 
 import pytest
 
-from pdm.cli import actions
 from pdm.cli.commands.init import Command
 from pdm.models.backends import get_backend
 from pdm.models.python import PythonInfo
@@ -71,7 +70,7 @@ def test_init_non_interactive(project_no_init, pdm, mocker):
         return_value=("Testing", "me@example.org"),
     )
     do_init = mocker.patch.object(Command, "do_init")
-    do_use = mocker.patch.object(actions, "do_use", return_value=PythonInfo.from_path(sys.executable))
+    do_use = mocker.patch("pdm.cli.commands.use.Command.do_use", return_value=PythonInfo.from_path(sys.executable))
     result = pdm(["init", "-n"], obj=project_no_init)
     assert result.exit_code == 0
     python_version = f"{project_no_init.python.major}.{project_no_init.python.minor}"
diff --git a/tests/cli/test_remove.py b/tests/cli/test_remove.py
index 6959dbf9..61d5ef96 100644
--- a/tests/cli/test_remove.py
+++ b/tests/cli/test_remove.py
@@ -6,7 +6,7 @@ from pdm.models.specifiers import PySpecSet
 
 
 def test_remove_command(project, pdm, mocker):
-    do_remove = mocker.patch.object(actions, "do_remove")
+    do_remove = mocker.patch("pdm.cli.commands.remove.Command.do_remove")
     pdm(["remove", "demo"], obj=project)
     do_remove.assert_called_once()
 
diff --git a/tests/cli/test_update.py b/tests/cli/test_update.py
index 867ffc6c..0c1cb506 100644
--- a/tests/cli/test_update.py
+++ b/tests/cli/test_update.py
@@ -1,6 +1,5 @@
 import pytest
 
-from pdm.cli import actions
 from pdm.models.requirements import parse_requirement
 
 
@@ -12,7 +11,7 @@ def test_update_packages_with_top(project, pdm):
 
 
 def test_update_command(project, pdm, mocker):
-    do_update = mocker.patch.object(actions, "do_update")
+    do_update = mocker.patch("pdm.cli.commands.update.Command.do_update")
     pdm(["update"], obj=project)
     do_update.assert_called_once()
 
diff --git a/tests/cli/test_use.py b/tests/cli/test_use.py
index f5be49f6..05316b7c 100644
--- a/tests/cli/test_use.py
+++ b/tests/cli/test_use.py
@@ -5,7 +5,7 @@ from pathlib import Path
 
 import pytest
 
-from pdm.cli import actions
+from pdm.cli.commands.use import Command as UseCommand
 from pdm.exceptions import NoPythonVersion
 from pdm.models.caches import JSONFileCache
 
@@ -42,7 +42,7 @@ exec "{}" "$@"
     shim_path.write_text(wrapper_script)
     shim_path.chmod(0o755)
 
-    actions.do_use(project, shim_path.as_posix())
+    UseCommand().do_use(project, shim_path.as_posix())
     assert project.python.executable == Path(sys.executable)
 
 
@@ -55,20 +55,21 @@ echo hello
     shim_path.write_text(wrapper_script)
     shim_path.chmod(0o755)
     with pytest.raises(NoPythonVersion):
-        actions.do_use(project, shim_path.as_posix())
+        UseCommand().do_use(project, shim_path.as_posix())
 
 
 def test_use_remember_last_selection(project, mocker):
     cache = JSONFileCache(project.cache_dir / "use_cache.json")
     cache.clear()
-    actions.do_use(project, first=True)
+    do_use = UseCommand().do_use
+    do_use(project, first=True)
     cache._read_cache()
     assert not cache._cache
-    actions.do_use(project, "3", first=True)
+    do_use(project, "3", first=True)
     cache._read_cache()
     assert "3" in cache
     mocker.patch.object(project, "find_interpreters")
-    actions.do_use(project, "3")
+    do_use(project, "3")
     project.find_interpreters.assert_not_called()
 
 
@@ -77,9 +78,10 @@ def test_use_venv_python(project, pdm):
     pdm(["venv", "create", "--name", "test"], obj=project, strict=True)
     project.global_config["python.use_venv"] = True
     venv_location = project.config["venv.location"]
-    actions.do_use(project, venv="in-project")
+    do_use = UseCommand().do_use
+    do_use(project, venv="in-project")
     assert project.python.executable.parent.parent == project.root.joinpath(".venv")
-    actions.do_use(project, venv="test")
+    do_use(project, venv="test")
     assert project.python.executable.parent.parent.parent == Path(venv_location)
     with pytest.raises(Exception, match="No virtualenv with key 'non-exists' is found"):
-        actions.do_use(project, venv="non-exists")
+        do_use(project, venv="non-exists")