Repository: pdm-project/pdm

Issue:
pdm check: check if the lockfile is up to date and related pre-commit hook
## Is your feature request related to a problem? Please describe.

I'd like to be able to guarentee that the lockfile is up to date when I update pyproject.toml. Because pdm stores a hash of specific keys found in pyproject.toml within pdm.lock, I want a command that simply exposes that information.

## Describe the solution you'd like
Simply put, `pdm check` which errors with a nonzero error code if the hash stored in pdm.lock doesn't match pyproject.toml or pdm.lock does not exist.

Furthermore, this could be exposed as a pre-commit hook which would check if the hash matches when pyproject.toml is edited.


Discussion:
onerandomusername: seems like pre-commit is failing for code that wasn't changed in this pull

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fa74165e..2af380c7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -33,7 +33,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python-version: [3.7, 3.8, 3.9, "3.10", "3.11.0-alpha - 3.11.0"]
+        python-version: [3.7, 3.8, 3.9, "3.10", "3.11"]
         os: [ubuntu-latest, windows-latest, macos-latest]
         install-via: [pip]
         arch: [x64]
@@ -46,7 +46,6 @@ jobs:
             os: windows-latest
             install-via: pip
             arch: x86
-    continue-on-error: ${{ startsWith(matrix.python-version, '3.11') }}
     steps:
       - uses: actions/checkout@v3
 
diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml
index 8a51505c..9ac55552 100644
--- a/.pre-commit-hooks.yaml
+++ b/.pre-commit-hooks.yaml
@@ -1,3 +1,11 @@
+- id: pdm-lock-check
+  name: pdm-lock-check
+  description: run pdm lock --check to validate config
+  entry: pdm lock --check
+  language: python
+  language_version: python3
+  pass_filenames: false
+  files: ^pyproject.toml$
 - id: pdm-export
   name: pdm-export-lock
   description: export locked packages to requirements.txt or setup.py
diff --git a/docs/docs/usage/advanced.md b/docs/docs/usage/advanced.md
index daeca0cb..0c908d67 100644
--- a/docs/docs/usage/advanced.md
+++ b/docs/docs/usage/advanced.md
@@ -240,3 +240,14 @@ This hook wraps the command `pdm export` along with any valid argument. It can b
       args: ['-o', 'requirements.txt', '--without-hashes']
       files: ^pdm.lock$
 ```
+
+### Check `pdm.lock` is up to date with pyproject.toml
+
+This hook wraps the command `pdm lock --check` along with any valid argument. It can be used as a hook (e.g., for CI) to ensure that whenever `pyproject.toml` has a dependency added/changed/removed, that `.pdm.lock` is also up to date.
+
+```yaml
+- repo: https://github.com/pdm-project/pdm
+  rev: 2.x.y # a PDM release exposing the hook
+  hooks:
+    - id: pdm-lock-check
+```
diff --git a/docs/docs/usage/configuration.md b/docs/docs/usage/configuration.md
index a63aac2b..cd7f67cc 100644
--- a/docs/docs/usage/configuration.md
+++ b/docs/docs/usage/configuration.md
@@ -53,7 +53,7 @@ The following configuration items can be retrieved and modified by [`pdm config`
 | `pypi.json_api`                   | Consult PyPI's JSON API for package metadata                              | `False`                                                               | Yes                  | `PDM_PYPI_JSON_API`      |
 | `strategy.save`                   | Specify how to save versions when a package is added                      | `compatible`(can be: `exact`, `wildcard`, `minimum`)                  | Yes                  |                          |
 | `strategy.update`                 | The default strategy for updating packages                                | `reuse`(can be : `eager`)                                             | Yes                  |                          |
-| `strategy.resolve_max_rounds`     | Specify the max rounds of resolution process                              | 1000                                                                  | Yes                  | `PDM_RESOLVE_MAX_ROUNDS` |
+| `strategy.resolve_max_rounds`     | Specify the max rounds of resolution process                              | 10000                                                                  | Yes                  | `PDM_RESOLVE_MAX_ROUNDS` |
 | `venv.location`                   | Parent directory for virtualenvs                                          | `<default data location on OS>/venvs`                                 | No                   |                          |
 | `venv.backend`                    | Default backend to create virtualenv                                      | `virtualenv`                                                          | Yes                  | `PDM_VENV_BACKEND`       |
 | `venv.prompt`                     | Formatted string to be displayed in the prompt when virtualenv is active  | `{project_name}-{python_version}`                                     | Yes                  | `PDM_VENV_PROMPT`        |
diff --git a/news/1471.feature.md b/news/1471.feature.md
new file mode 100644
index 00000000..bdc45782
--- /dev/null
+++ b/news/1471.feature.md
@@ -0,0 +1,1 @@
+A new pre-commit hook to run `pdm lock --check` in pre-commit.
diff --git a/news/1472.doc.md b/news/1472.doc.md
new file mode 100644
index 00000000..513e1f54
--- /dev/null
+++ b/news/1472.doc.md
@@ -0,0 +1,1 @@
+Doumented default value for `strategy.resolve_max_rounds` updated to match what is used in the code.
diff --git a/src/pdm/cli/actions.py b/src/pdm/cli/actions.py
index 09083454..d590e00b 100644
--- a/src/pdm/cli/actions.py
+++ b/src/pdm/cli/actions.py
@@ -26,7 +26,6 @@ from pdm.cli.utils import (
     find_importable_files,
     format_lockfile,
     format_resolution_impossible,
-    get_dist_location,
     merge_dictionary,
     save_version_specifiers,
     set_env_in_reg,
@@ -470,52 +469,6 @@ def do_remove(
         )
 
 
-def do_list(
-    project: Project,
-    graph: bool = False,
-    reverse: bool = False,
-    freeze: bool = False,
-    json: bool = False,
-) -> None:
-    """Display a list of packages installed in the local packages directory."""
-    from pdm.cli.utils import build_dependency_graph, show_dependency_graph
-
-    check_project_file(project)
-    working_set = project.environment.get_working_set()
-    if graph:
-        dep_graph = build_dependency_graph(
-            working_set, project.environment.marker_environment
-        )
-        show_dependency_graph(project, dep_graph, reverse=reverse, json=json)
-    else:
-        if reverse:
-            raise PdmUsageError("--reverse must be used with --graph")
-        if json:
-            raise PdmUsageError("--json must be used with --graph")
-        if freeze:
-            reqs = sorted(
-                (
-                    Requirement.from_dist(dist)
-                    .as_line()
-                    .replace(
-                        "${PROJECT_ROOT}",
-                        project.root.absolute().as_posix().lstrip("/"),
-                    )
-                    for dist in sorted(
-                        working_set.values(), key=lambda d: d.metadata["Name"]
-                    )
-                ),
-                key=lambda x: x.lower(),
-            )
-            project.core.ui.echo("\n".join(reqs))
-            return
-        rows = [
-            (f"[req]{k}[/]", f"[warning]{v.version}[/]", get_dist_location(v))
-            for k, v in sorted(working_set.items())
-        ]
-        project.core.ui.display_columns(rows, ["Package", "Version", "Location"])
-
-
 def do_build(
     project: Project,
     sdist: bool = True,
diff --git a/src/pdm/cli/commands/list.py b/src/pdm/cli/commands/list.py
index 328847c8..642b4169 100644
--- a/src/pdm/cli/commands/list.py
+++ b/src/pdm/cli/commands/list.py
@@ -1,37 +1,445 @@
+from __future__ import annotations
+
 import argparse
+import csv
+import io
+import json
+from collections import defaultdict
+from typing import Iterable, Mapping, Sequence
 
 from pdm.cli import actions
 from pdm.cli.commands.base import BaseCommand
+from pdm.cli.utils import (
+    DirectedGraph,
+    Package,
+    build_dependency_graph,
+    check_project_file,
+    get_dist_location,
+    show_dependency_graph,
+)
+from pdm.compat import importlib_metadata as im
+from pdm.exceptions import PdmUsageError
+from pdm.models.requirements import Requirement
 from pdm.project import Project
 
+# Group label for subdependencies
+SUBDEP_GROUP_LABEL = ":sub"
+
 
 class Command(BaseCommand):
     """List packages installed in the current working set"""
 
+    DEFAULT_FIELDS = "name,version,location"
+
     def add_arguments(self, parser: argparse.ArgumentParser) -> None:
-        group = parser.add_mutually_exclusive_group()
-        group.add_argument(
-            "--graph", action="store_true", help="Display a graph of dependencies"
-        )
-        group.add_argument(
+        graph = parser.add_mutually_exclusive_group()
+
+        parser.add_argument(
             "--freeze",
             action="store_true",
             help="Show the installed dependencies in pip's requirements.txt format",
         )
+
+        graph.add_argument(
+            "--graph", action="store_true", help="Display a graph of dependencies"
+        )
+
+        parser.add_argument(
+            "-r", "--reverse", action="store_true", help="Reverse the dependency graph"
+        )
+
+        parser.add_argument(
+            "--resolve",
+            action="store_true",
+            default=False,
+            help="Resolve all requirements to output licenses "
+            "(instead of just showing those currently installed)",
+        )
+
+        parser.add_argument(
+            "--fields",
+            default=Command.DEFAULT_FIELDS,
+            help="Select information to output as a comma separated string. "
+            "For example: name,version,homepage,licenses,groups.",
+        )
+
         parser.add_argument(
+            "--sort",
+            default=None,
+            help="Sort the output using a given field name. If nothing is "
+            "set, no sort is applied. Multiple fields can be combined with ','.",
+        )
+
+        list_formats = parser.add_mutually_exclusive_group()
+
+        list_formats.add_argument(
+            "--csv",
+            action="store_true",
+            help="Output dependencies in CSV document format",
+        )
+
+        list_formats.add_argument(
             "--json",
             action="store_true",
-            help="Show the installed dependencies in JSON document format",
+            help="Output dependencies in JSON document format",
         )
+
+        list_formats.add_argument(
+            "--markdown",
+            action="store_true",
+            help="Output dependencies and legal notices in markdown "
+            "document format - best effort basis",
+        )
+
         parser.add_argument(
-            "-r", "--reverse", action="store_true", help="Reverse the dependency graph"
+            "--include",
+            default="",
+            help="Dependency groups to include in the output. By default "
+            "all are included",
+        )
+
+        parser.add_argument(
+            "--exclude",
+            default="",
+            help="Exclude dependency groups from the output",
         )
 
     def handle(self, project: Project, options: argparse.Namespace) -> None:
-        actions.do_list(
-            project,
-            graph=options.graph,
-            reverse=options.reverse,
-            freeze=options.freeze,
-            json=options.json,
+
+        # Raise an error if the project is not defined.
+        check_project_file(project)
+
+        # Freeze.
+        if options.freeze:
+            self.hande_freeze(project, options)
+            return
+
+        # Map dependency groups to requirements.
+        name_to_groups: Mapping[str, set[str]] = defaultdict(set)
+        for g in project.iter_groups():
+            for k in project.get_dependencies(g):
+                if "[" in k:
+                    k = k.split("[")[0]
+                name_to_groups[k].add(g)
+
+        # Set up `--include` and `--exclude` dep groups.
+        # Include everything by default (*) then exclude after.
+        # Check to make sure that only valid dep group names are given.
+        valid_groups = set([g for g in project.iter_groups()] + [SUBDEP_GROUP_LABEL])
+        include = set(
+            parse_comma_separated_string(
+                options.include, lowercase=False, asterisk_values=valid_groups
+            )
+        )
+        if not all(g in valid_groups for g in include):
+            raise PdmUsageError(
+                f"--include groups must be selected from: {valid_groups}"
+            )
+        exclude = set(
+            parse_comma_separated_string(
+                options.exclude, lowercase=False, asterisk_values=valid_groups
+            )
+        )
+        if exclude and not all(g in valid_groups for g in exclude):
+            raise PdmUsageError(
+                f"--exclude groups must be selected from: {valid_groups}"
+            )
+
+        # Include selects only certain groups when set, but always selects :sub
+        # unless it is explicitly unset.
+        selected_groups = include if include else valid_groups
+        selected_groups = selected_groups | {SUBDEP_GROUP_LABEL}
+        selected_groups = selected_groups - (exclude - include)
+
+        # Requirements as importtools distributions (eg packages).
+        # Resolve all the requirements. Map the candidates to distributions.
+        requirements = [
+            r
+            for g in selected_groups
+            if g != SUBDEP_GROUP_LABEL
+            for r in project.get_dependencies(g).values()
+        ]
+        if options.resolve:
+            candidates = actions.resolve_candidates_from_lockfile(project, requirements)
+            packages: Mapping[str, im.Distribution] = {
+                k: c.prepare(project.environment).metadata
+                for k, c in candidates.items()
+            }
+
+        # Use requirements from the working set (currently installed).
+        else:
+            packages = project.environment.get_working_set()
+
+        selected_keys = {r.identify() for r in requirements}
+        dep_graph = build_dependency_graph(
+            packages,
+            project.environment.marker_environment,
+            None if not (include or exclude) else selected_keys,
+            include_sub=SUBDEP_GROUP_LABEL in selected_groups,
+        )
+
+        # Process as a graph or list.
+        if options.graph:
+            self.handle_graph(dep_graph, project, options)
+        else:
+            selected_packages = [k.name.split("[")[0] for k in dep_graph if k]
+            packages = {k: v for k, v in packages.items() if k in selected_packages}
+            self.handle_list(packages, name_to_groups, project, options)
+
+    def hande_freeze(self, project: Project, options: argparse.Namespace) -> None:
+        if options.graph:
+            raise PdmUsageError("--graph cannot be used with --freeze")
+        if options.reverse:
+            raise PdmUsageError("--reverse cannot be used without --graph")
+        if options.fields != Command.DEFAULT_FIELDS:
+            raise PdmUsageError("--fields cannot be used with --freeze")
+        if options.resolve:
+            raise PdmUsageError("--resolve cannot be used with --freeze")
+        if options.sort:
+            raise PdmUsageError("--sort cannot be used with --freeze")
+        if options.csv:
+            raise PdmUsageError("--csv cannot be used with --freeze")
+        if options.json:
+            raise PdmUsageError("--json cannot be used with --freeze")
+        if options.markdown:
+            raise PdmUsageError("--markdown cannot be used with --freeze")
+        if options.include or options.exclude:
+            raise PdmUsageError("--include/--exclude cannot be used with --freeze")
+
+        root = project.root.absolute().as_posix().lstrip("/")
+        working_set = project.environment.get_working_set()
+        requirements = sorted(
+            (
+                Requirement.from_dist(dist).as_line().replace("${PROJECT_ROOT}", root)
+                for dist in working_set.values()
+            ),
+            key=lambda x: x.lower(),
+        )
+        project.core.ui.echo("\n".join(requirements))
+
+    def handle_graph(
+        self,
+        dep_graph: DirectedGraph[Package | None],
+        project: Project,
+        options: argparse.Namespace,
+    ) -> None:
+        if options.csv:
+            raise PdmUsageError("--csv cannot be used with --graph")
+        if options.markdown:
+            raise PdmUsageError("--markdown cannot be used with --graph")
+        if options.sort:
+            raise PdmUsageError("--sort cannot be used with --graph")
+
+        show_dependency_graph(
+            project, dep_graph, reverse=options.reverse, json=options.json
+        )
+
+    def handle_list(
+        self,
+        packages: Mapping[str, im.Distribution],
+        name_to_groups: Mapping[str, set[str]],
+        project: Project,
+        options: argparse.Namespace,
+    ) -> None:
+        if options.reverse:
+            raise PdmUsageError("--reverse cannot be used without --graph")
+
+        # Check the fields are specified OK.
+        fields = parse_comma_separated_string(
+            options.fields, asterisk_values=Listable.KEYS
         )
+        if not all(field in Listable.KEYS for field in fields):
+            raise PdmUsageError(
+                f"--fields must specify one or more of: {Listable.KEYS}"
+            )
+
+        # Wrap each distribution with a Listable (and a groups pairing)
+        # to make it easier to filter on later.
+        def _group_of(name: str) -> set[str]:
+            return name_to_groups.get(name, {SUBDEP_GROUP_LABEL})
+
+        records = [Listable(d, _group_of(k)) for k, d in packages.items()]
+        ui = project.core.ui
+
+        # Order based on a field key.
+        if options.sort:
+            keys = parse_comma_separated_string(options.sort)
+            if not all(key in Listable.KEYS for key in keys):
+                raise PdmUsageError(
+                    f"--sort key must be one of: {','.join(Listable.KEYS)}"
+                )
+            records.sort(key=lambda d: tuple(d[key] for key in keys))
+
+        # Write CSV
+        if options.csv:
+            buffer = io.StringIO()
+            writer = csv.DictWriter(buffer, fieldnames=fields)
+            writer.writeheader()
+            for row in records:
+                writer.writerow(row.json(fields))
+            ui.echo(buffer.getvalue(), highlight=True, end="")
+
+        # Write JSON
+        elif options.json:
+            json_row = [row.json(fields) for row in records]
+            ui.echo(json.dumps(json_row, indent=4), highlight=True)
+
+        # Write Markdown
+        elif options.markdown:
+            body = [f"# {project.name} licenses"]
+            body.extend(row.markdown(fields) for row in records)
+            text_body = "\n".join(body)
+            try:
+                ui.echo(text_body, highlight=True)
+            except UnicodeEncodeError:
+                ui.echo(
+                    "Markdown output contains non-ASCII characters. "
+                    "Setting env var PYTHONIOENCODING to 'utf8' may fix this.",
+                    err=True,
+                    style="error",
+                )
+                ui.echo(
+                    text_body.encode().decode("ascii", errors="ignore"), highlight=True
+                )
+                ui.echo(
+                    "**Problem decoding file as UTF-8.  Some characters may be omit.**"
+                )
+
+        # Write nice table format.
+        else:
+            formatted = [row.rich(fields) for row in records]
+            ui.display_columns(formatted, fields)
+
+
+def parse_comma_separated_string(
+    comma_string: str,
+    lowercase: bool = True,
+    asterisk_values: Iterable[str] | None = None,
+) -> list[str]:
+    """Parse a CLI comma separated string.
+    Apply optional lowercase transformation and if the value given is "*" then
+    return a list of pre-defined values (`asterisk_values`).
+    """
+    if asterisk_values is not None and comma_string.strip() == "*":
+        return list(asterisk_values)
+    items = f"{comma_string}".split(",")
+    items = [el.strip() for el in items if el]
+    if lowercase:
+        items = [el.lower() for el in items]
+    return items
+
+
+class Listable:
+    """Wrapper makes sorting and exporting information about a Distribution
+    easier.  It also retrieves license information from dist-info metadata.
+
+    https://packaging.python.org/en/latest/specifications/core-metadata
+    """
+
+    # Fields that users are allowed to sort on.
+    KEYS = frozenset(["name", "groups", "version", "homepage", "licenses", "location"])
+
+    def __init__(self, dist: im.Distribution, groups: set[str]):
+        self.dist = dist
+
+        self.name: str | None = dist.metadata["Name"]
+        self.groups = "|".join(groups)
+
+        self.version: str | None = dist.metadata["Version"]
+        self.version = None if self.version == "UNKNOWN" else self.version
+
+        self.homepage: str | None = dist.metadata["Home-Page"]
+        self.homepage = None if self.homepage == "UNKNOWN" else self.homepage
+
+        # If the License metadata field is empty or UNKNOWN then try to
+        # find the license in the Trove classifiers.  There may be more than one
+        # so generate a pipe separated list (to avoid complexity with CSV export).
+        self.licenses: str | None = dist.metadata["License"]
+        self.licenses = None if self.licenses == "UNKNOWN" else self.licenses
+        if not self.licenses:
+            classifier_licenses = [
+                v
+                for v in dist.metadata.get_all("Classifier", [])  # type: ignore
+                if v.startswith("License")
+            ]
+            alternatives = [parts.split("::") for parts in classifier_licenses]
+            alternatives = [part[-1].strip() for part in alternatives if part]
+            self.licenses = "|".join(alternatives)
+
+    @property
+    def location(self) -> str:
+        return get_dist_location(self.dist)
+
+    def license_files(self) -> list[im.PackagePath]:
+        """Path to files inside the package that may contain license information
+        or other legal notices.
+
+        The implementation is a "best effort" and may contain errors, select
+        incorrect information, or otherwise be error-prone. It is not a
+        substitute for a lawyer.
+        """
+        if not self.dist.files:
+            return []
+
+        # Inconsistency between packages means that we check in several locations
+        # for license files.  There may be 0 or more of these.  There may be false
+        # positives & negatives.
+        locations = ("**/LICENSE*", "**/LICENCE*", "**/COPYING*", "**/NOTICE*")
+
+        # Compile a list of all file paths in the distribution that look like
+        # they might contain a license file.
+        paths = []
+        for path in self.dist.files:
+            paths += [path for loc in locations if path.match(loc)]
+        return paths
+
+    def __getitem__(self, field: str) -> str:
+        if field not in Listable.KEYS:
+            raise PdmUsageError(f"list field `{field}` not in: {Listable.KEYS}")
+        return getattr(self, field)
+
+    def json(self, fields: Sequence[str]) -> dict:
+        return {f: self[f] for f in fields}
+
+    def rich(self, fields: Sequence[str]) -> Sequence[str]:
+        output = []
+        for field in fields:
+            data = f"{self[field]}"
+            data = data if field != "name" else f"[req]{data}[/]"
+            data = data if field != "version" else f"[warning]{data}[/]"
+            data = data if field != "groups" else f"[error]{data}[/]"
+            output.append(data)
+        return output
+
+    def markdown(self, fields: Sequence[str]) -> str:
+        nl = "\n"
+        section = ""
+
+        # Heading
+        section += f"## {self.name}{nl}"
+        section += f"{nl}"
+
+        # Table
+        section += f"| Name | {self.name} |{nl}"
+        section += f"|----|----|{nl}"
+        for field in fields:
+            if field == "name":
+                continue
+            section += f"| {field.capitalize()} | {self[field]} |{nl}"
+        section += f"{nl}"
+
+        # Files
+        for path in self.license_files():
+            section += f"{path}{nl}"
+            section += f"{nl}{nl}"
+            section += f"````{nl}"
+            try:
+                section += path.read_text("utf-8")
+            except UnicodeDecodeError:
+                section += "Problem decoding file as UTF-8"
+            except Exception as err:
+                section += f"Problem finding license text: {err}"
+            section += f"{nl}"
+            section += f"````{nl}"
+            section += f"{nl}"
+        return section
diff --git a/src/pdm/cli/completions/pdm.bash b/src/pdm/cli/completions/pdm.bash
index 31af1169..2d9c612c 100644
--- a/src/pdm/cli/completions/pdm.bash
+++ b/src/pdm/cli/completions/pdm.bash
@@ -69,7 +69,7 @@ _pdm_25182a7ef85b840e_complete()
             ;;
 
             (list)
-            opts="--freeze --global --graph --help --json --project --reverse --verbose"
+            opts="--csv --exclude --fields --freeze --global --graph --help --include --json --markdown --project --resolve --reverse --sort --verbose"
             ;;
 
             (lock)
diff --git a/src/pdm/cli/completions/pdm.fish b/src/pdm/cli/completions/pdm.fish
index 43c75d91..4bb249db 100644
--- a/src/pdm/cli/completions/pdm.fish
+++ b/src/pdm/cli/completions/pdm.fish
@@ -159,13 +159,20 @@ complete -c pdm -A -n '__fish_seen_subcommand_from install' -l skip -d 'Skip som
 complete -c pdm -A -n '__fish_seen_subcommand_from install' -l verbose -d '-v for detailed output and -vv for more detailed'
 
 # list
+complete -c pdm -A -n '__fish_seen_subcommand_from list' -l csv -d 'Output dependencies in CSV document format'
+complete -c pdm -A -n '__fish_seen_subcommand_from list' -l exclude -d 'Exclude dependency groups from the output'
+complete -c pdm -A -n '__fish_seen_subcommand_from list' -l fields -d 'Select information to output as a comma separated string. For example: name,version,homepage,licenses,groups.'
 complete -c pdm -A -n '__fish_seen_subcommand_from list' -l freeze -d 'Show the installed dependencies in pip\'s requirements.txt format'
 complete -c pdm -A -n '__fish_seen_subcommand_from list' -l global -d 'Use the global project, supply the project root with `-p` option'
 complete -c pdm -A -n '__fish_seen_subcommand_from list' -l graph -d 'Display a graph of dependencies'
 complete -c pdm -A -n '__fish_seen_subcommand_from list' -l help -d 'show this help message and exit'
-complete -c pdm -A -n '__fish_seen_subcommand_from list' -l json -d 'Show the installed dependencies in JSON document format'
+complete -c pdm -A -n '__fish_seen_subcommand_from list' -l include -d 'Dependency groups to include in the output. By default all are included'
+complete -c pdm -A -n '__fish_seen_subcommand_from list' -l json -d 'Output dependencies in JSON document format'
+complete -c pdm -A -n '__fish_seen_subcommand_from list' -l markdown -d 'Output dependencies and legal notices in markdown document format - best effort basis'
 complete -c pdm -A -n '__fish_seen_subcommand_from list' -l project -d 'Specify another path as the project root, which changes the base of pyproject.toml and __pypackages__'
+complete -c pdm -A -n '__fish_seen_subcommand_from list' -l resolve -d 'Resolve all requirements to output licenses (instead of just showing those currently installed)'
 complete -c pdm -A -n '__fish_seen_subcommand_from list' -l reverse -d 'Reverse the dependency graph'
+complete -c pdm -A -n '__fish_seen_subcommand_from list' -l sort -d 'Sort the output using a given field name. If nothing is set, no sort is applied. Multiple fields can be combined with \',\'.'
 complete -c pdm -A -n '__fish_seen_subcommand_from list' -l verbose -d '-v for detailed output and -vv for more detailed'
 
 # lock
diff --git a/src/pdm/cli/completions/pdm.ps1 b/src/pdm/cli/completions/pdm.ps1
index 1ef961b0..138700d9 100644
--- a/src/pdm/cli/completions/pdm.ps1
+++ b/src/pdm/cli/completions/pdm.ps1
@@ -284,7 +284,7 @@ function TabExpansion($line, $lastWord) {
             "list" {
                 $completer.AddOpts(
                     @(
-                        [Option]::new(@("--graph", "--global", "-g", "--reverse", "-r", "--freeze", "--json")),
+                        [Option]::new(@("--graph", "--global", "-g", "--reverse", "-r", "--freeze", "--json", "--csv", "--markdown", "--fields", "--sort", "--include", "--exclude", "--resolve")),
                         $projectOption
                     ))
                 break
diff --git a/src/pdm/cli/completions/pdm.zsh b/src/pdm/cli/completions/pdm.zsh
index 80587040..dee8271f 100644
--- a/src/pdm/cli/completions/pdm.zsh
+++ b/src/pdm/cli/completions/pdm.zsh
@@ -194,9 +194,16 @@ _pdm() {
       arguments+=(
         {-g,--global}'[Use the global project, supply the project root with `-p` option]'
         {-r,--reverse}'[Reverse the dependency graph]'
-        '--json[Show the installed dependencies in JSON document format]'
+        '--fields[Select information to output as a comma separated string.]:fields:'
+        "--sort[Sort the output using a given field name. If nothing is set, no sort is applied. Multiple fields can be combined with ',']:sort:"
+        '--json[Output dependencies in JSON document format]'
+        '--csv[Output dependencies in CSV document format]'
+        '--markdown[Output dependencies and legal notices in markdown document format - best effort basis]'
         '--graph[Display a graph of dependencies]'
         "--freeze[Show the installed dependencies as pip's requirements.txt format]"
+        "--include[Dependency groups to include in the output. By default all are included]:include:"
+        "--exclude[Dependency groups to exclude from the output]:exclude:"
+        "--resolve[Resolve all requirements to output licenses (instead of just showing those currently installed)"
       )
       ;;
     lock)
diff --git a/src/pdm/cli/utils.py b/src/pdm/cli/utils.py
index b20a8e54..3f8ac2dd 100644
--- a/src/pdm/cli/utils.py
+++ b/src/pdm/cli/utils.py
@@ -27,6 +27,7 @@ from resolvelib.structs import DirectedGraph
 from rich.tree import Tree
 
 from pdm import termui
+from pdm.compat import importlib_metadata as im
 from pdm.exceptions import PdmArgumentError, PdmUsageError, ProjectError
 from pdm.formats import FORMATS
 from pdm.formats.base import make_array, make_inline_table
@@ -38,7 +39,6 @@ from pdm.models.requirements import (
     strip_extras,
 )
 from pdm.models.specifiers import get_specifier
-from pdm.models.working_set import WorkingSet
 from pdm.project import Project
 from pdm.utils import is_path_relative_to, url_to_path
 
@@ -156,7 +156,10 @@ class Package:
 
 
 def build_dependency_graph(
-    working_set: WorkingSet, marker_env: dict[str, str] | None = None
+    working_set: Mapping[str, im.Distribution],
+    marker_env: dict[str, str] | None = None,
+    selected: set[str] | None = None,
+    include_sub: bool = True,
 ) -> DirectedGraph:
     """Build a dependency graph from locked result."""
     graph: DirectedGraph[Package | None] = DirectedGraph()
@@ -189,14 +192,25 @@ def build_dependency_graph(
             if extras:
                 node_with_extras.add(name)
             graph.add(node)
-
-            for k in reqs:
-                child = add_package(k, working_set.get(strip_extras(k)[0]))
-                graph.connect(node, child)
+            if include_sub:
+                for k in reqs:
+                    child = add_package(k, working_set.get(strip_extras(k)[0]))
+                    graph.connect(node, child)
 
         return node
 
+    selected_map: dict[str, str] = {}
+    for key in selected or ():
+        name = key.split("[")[0]
+        if len(key) >= len(selected_map.get(name, "")):
+            # Ensure key with extras remains
+            selected_map[name] = key
     for k, dist in working_set.items():
+        if selected is not None:
+            name = k.split("[")[0]
+            if name not in selected_map:
+                continue
+            k = selected_map[name]
         add_package(k, dist)
     for node in list(graph):
         if node is not None and not list(graph.iter_parents(node)):
diff --git a/src/pdm/installers/uninstallers.py b/src/pdm/installers/uninstallers.py
index 67846879..d0cc85e0 100644
--- a/src/pdm/installers/uninstallers.py
+++ b/src/pdm/installers/uninstallers.py
@@ -122,9 +122,11 @@ class BaseRemovePaths(abc.ABC):
     def remove(self) -> None:
         """Remove the files"""
 
+    @abc.abstractmethod
     def commit(self) -> None:
         """Commit the removal"""
 
+    @abc.abstractmethod
     def rollback(self) -> None:
         """Roll back the removal operations"""
 


Test patch:
diff --git a/tests/cli/test_list.py b/tests/cli/test_list.py
index 1e32854f..8e3fef80 100644
--- a/tests/cli/test_list.py
+++ b/tests/cli/test_list.py
@@ -1,40 +1,101 @@
 import json
+import os
+import pathlib
+from unittest import mock
 
 import pytest
+from rich.box import ASCII
 
 from pdm.cli import actions
-from pdm.exceptions import PdmException
+from pdm.cli.commands.list import Command
+from pdm.models.specifiers import PySpecSet
+from tests import FIXTURES
+from tests.conftest import Distribution
 
 
 def test_list_command(project, invoke, mocker):
-    do_list = mocker.patch.object(actions, "do_list")
+    # Calls the correct handler within the Command
+    m = mocker.patch.object(Command, "handle_list")
     invoke(["list"], obj=project)
-    do_list.assert_called_once()
+    m.assert_called_once()
 
 
-@pytest.mark.usefixtures("repository", "working_set")
-def test_list_dependency_graph(project, capsys):
+@pytest.mark.usefixtures("working_set")
+def test_list_graph_command(project, invoke, mocker):
+    # Calls the correct handler within the list command
+    m = mocker.patch.object(Command, "handle_graph")
+    invoke(["list", "--graph"], obj=project)
+    m.assert_called_once()
+
+
+@mock.patch("rich.console.ConsoleOptions.ascii_only", lambda: True)
+@pytest.mark.usefixtures("working_set")
+def test_list_dependency_graph(project, invoke):
+    # Shows a line that contains a sub requirement (any order).
     actions.do_add(project, packages=["requests"])
-    actions.do_list(project, graph=True)
-    content, _ = capsys.readouterr()
-    assert "└── urllib3 1.22 [ required: <1.24,>=1.21.1 ]" in content
+    result = invoke(["list", "--graph"], obj=project)
+    expected = "-- urllib3 1.22 [ required: <1.24,>=1.21.1 ]" in result.outputs
+    assert expected
 
 
+@mock.patch("rich.console.ConsoleOptions.ascii_only", lambda: True)
 @pytest.mark.usefixtures("working_set")
-def test_list_dependency_graph_with_circular_forward(project, capsys, repository):
+def test_list_dependency_graph_include_exclude(project, invoke):
+    # Just include dev packages in the graph
+    project.environment.python_requires = PySpecSet(">=3.6")
+    dep_path = FIXTURES.joinpath("projects/demo").as_posix()
+    actions.do_add(
+        project,
+        dev=True,
+        group="dev",
+        editables=[f"{dep_path}[security]"],
+    )
+
+    # Output full graph
+    result = invoke(["list", "--graph"], obj=project)
+    expects = (
+        "demo 0.0.1 [ Not required ]\n",
+        "+-- chardet 3.0.4 [ required: Any ]\n" if os.name == "nt" else "",
+        "`-- idna 2.7 [ required: Any ]\n",
+        "requests 2.19.1 [ Not required ]\n",
+        "+-- certifi 2018.11.17 [ required: >=2017.4.17 ]\n",
+        "+-- chardet 3.0.4 [ required: <3.1.0,>=3.0.2 ]\n",
+        "+-- idna 2.7 [ required: <2.8,>=2.5 ]\n",
+        "`-- urllib3 1.22 [ required: <1.24,>=1.21.1 ]\n",
+    )
+    expects = "".join(expects)
+    assert expects == result.outputs
+
+    # Now exclude the dev dep.
+    result = invoke(["list", "--graph", "--exclude", "dev"], obj=project)
+    expects = ""
+    assert expects == result.outputs
+
+    # Only include the dev dep
+    result = invoke(
+        ["list", "--graph", "--include", "dev", "--exclude", "*"], obj=project
+    )
+    expects = "demo[security] 0.0.1 [ required: Any ]\n"
+    expects = "".join(expects)
+    assert expects == result.outputs
+
+
+@pytest.mark.usefixtures("working_set")
+def test_list_dependency_graph_with_circular_forward(project, invoke, repository):
+    # shows a circular dependency
     repository.add_candidate("foo", "0.1.0")
     repository.add_candidate("foo-bar", "0.1.0")
     repository.add_dependencies("foo", "0.1.0", ["foo-bar"])
     repository.add_dependencies("foo-bar", "0.1.0", ["foo"])
     actions.do_add(project, packages=["foo"])
-    capsys.readouterr()
-    actions.do_list(project, graph=True)
-    content, _ = capsys.readouterr()
-    assert "foo [circular]" in content
+    result = invoke(["list", "--graph"], obj=project)
+    circular_found = "foo [circular]" in result.outputs
+    assert circular_found
 
 
+@mock.patch("rich.console.ConsoleOptions.ascii_only", lambda: True)
 @pytest.mark.usefixtures("working_set")
-def test_list_dependency_graph_with_circular_reverse(project, capsys, repository):
+def test_list_dependency_graph_with_circular_reverse(project, invoke, repository):
     repository.add_candidate("foo", "0.1.0")
     repository.add_candidate("foo-bar", "0.1.0")
     repository.add_candidate("baz", "0.1.0")
@@ -42,55 +103,49 @@ def test_list_dependency_graph_with_circular_reverse(project, capsys, repository
     repository.add_dependencies("foo-bar", "0.1.0", ["foo", "baz"])
     repository.add_dependencies("baz", "0.1.0", [])
     actions.do_add(project, packages=["foo"])
-    capsys.readouterr()
-    actions.do_list(project, graph=True, reverse=True)
-    content, _ = capsys.readouterr()
-    expected = """
-    └── foo 0.1.0 [ requires: Any ]
-        ├── foo-bar [circular] [ requires: Any ]
-        └── test-project 0.0.0 [ requires: ~=0.1 ]"""
-    assert expected in content
-
-
-@pytest.mark.usefixtures("repository", "working_set")
-def test_freeze_dependencies_list(project, capsys, mocker):
-    actions.do_add(project, packages=["requests"])
-    capsys.readouterr()
-    mocker.patch(
-        "pdm.models.requirements.Requirement.from_dist",
-        side_effect=lambda d: d.as_req(),
+
+    # --reverse flag shows packages reversed and with [circular]
+    result = invoke(["list", "--graph", "--reverse"], obj=project)
+    expected = (
+        "baz 0.1.0 \n"
+        "`-- foo-bar 0.1.0 [ requires: Any ]\n"
+        "    `-- foo 0.1.0 [ requires: Any ]\n"
+        "        +-- foo-bar [circular] [ requires: Any ]\n"
+        "        `-- test-project 0.0.0 [ requires: ~=0.1 ]\n"
     )
-    actions.do_list(project, freeze=True)
-    content, _ = capsys.readouterr()
-    assert "requests==2.19.1" in content
-    assert "urllib3==1.22" in content
+    assert expected in result.outputs
 
+    # -r flag shows packages reversed and with [circular]
+    result = invoke(["list", "--graph", "-r"], obj=project)
+    assert expected in result.outputs
 
-def test_list_reverse_without_graph_flag(project):
-    with pytest.raises(PdmException):
-        actions.do_list(project, reverse=True)
 
+def test_list_reverse_without_graph_flag(project, invoke):
+    # results in PDMUsageError since --reverse needs --graph
+    result = invoke(["list", "--reverse"], obj=project)
+    assert "[PdmUsageError]" in result.stderr
+    assert "--reverse cannot be used without --graph" in result.stderr
 
-@pytest.mark.usefixtures("repository", "working_set")
-def test_list_reverse_dependency_graph(project, capsys):
-    actions.do_add(project, packages=["requests"])
-    capsys.readouterr()
-    actions.do_list(project, True, True)
-    content, _ = capsys.readouterr()
-    assert "└── requests 2.19.1 [ requires: <1.24,>=1.21.1 ]" in content
+    result = invoke(["list", "-r"], obj=project)
+    assert "[PdmUsageError]" in result.stderr
+    assert "--reverse cannot be used without --graph" in result.stderr
 
 
-def test_list_json_without_graph_flag(project):
-    with pytest.raises(PdmException):
-        actions.do_list(project, json=True)
+@mock.patch("rich.console.ConsoleOptions.ascii_only", lambda: True)
+@pytest.mark.usefixtures("working_set")
+def test_list_reverse_dependency_graph(project, invoke):
+    # requests visible on leaf node
+    actions.do_add(project, packages=["requests"])
+    result = invoke(["list", "--graph", "--reverse"], obj=project)
+    assert "`-- requests 2.19.1 [ requires: <1.24,>=1.21.1 ]" in result.outputs
 
 
-@pytest.mark.usefixtures("repository", "working_set")
-def test_list_json(project, capsys):
+@pytest.mark.usefixtures("working_set")
+def test_list_json(project, invoke):
+    # check json output matches graph output
     actions.do_add(project, packages=["requests"], no_self=True)
-    content, _ = capsys.readouterr()
-    actions.do_list(project, graph=True, json=True)
-    content, _ = capsys.readouterr()
+    result = invoke(["list", "--graph", "--json"], obj=project)
+
     expected = [
         {
             "package": "requests",
@@ -124,15 +179,14 @@ def test_list_json(project, capsys):
             ],
         }
     ]
-    assert expected == json.loads(content)
+    assert expected == json.loads(result.outputs)
 
 
-@pytest.mark.usefixtures("repository", "working_set")
-def test_list_json_reverse(project, capsys):
+@pytest.mark.usefixtures("working_set")
+def test_list_json_reverse(project, invoke):
+    # check json output matches reversed graph
     actions.do_add(project, packages=["requests"], no_self=True)
-    capsys.readouterr()
-    actions.do_list(project, graph=True, reverse=True, json=True)
-    content, _ = capsys.readouterr()
+    result = invoke(["list", "--graph", "--reverse", "--json"], obj=project)
     expected = [
         {
             "package": "certifi",
@@ -188,11 +242,12 @@ def test_list_json_reverse(project, capsys):
         },
     ]
 
-    assert expected == json.loads(content)
+    assert expected == json.loads(result.outputs)
 
 
 @pytest.mark.usefixtures("working_set")
-def test_list_json_with_circular_forward(project, capsys, repository):
+def test_list_json_with_circular_forward(project, invoke, repository):
+    # circulars are handled in json exports
     repository.add_candidate("foo", "0.1.0")
     repository.add_candidate("foo-bar", "0.1.0")
     repository.add_candidate("baz", "0.1.0")
@@ -200,9 +255,7 @@ def test_list_json_with_circular_forward(project, capsys, repository):
     repository.add_dependencies("foo", "0.1.0", ["foo-bar"])
     repository.add_dependencies("foo-bar", "0.1.0", ["foo"])
     actions.do_add(project, packages=["baz"], no_self=True)
-    capsys.readouterr()
-    actions.do_list(project, graph=True, json=True)
-    content, _ = capsys.readouterr()
+    result = invoke(["list", "--graph", "--json"], obj=project)
     expected = [
         {
             "package": "baz",
@@ -232,11 +285,12 @@ def test_list_json_with_circular_forward(project, capsys, repository):
             ],
         },
     ]
-    assert expected == json.loads(content)
+    assert expected == json.loads(result.outputs)
 
 
 @pytest.mark.usefixtures("working_set")
-def test_list_json_with_circular_reverse(project, capsys, repository):
+def test_list_json_with_circular_reverse(project, invoke, repository):
+    # circulars are handled in reversed json exports
     repository.add_candidate("foo", "0.1.0")
     repository.add_candidate("foo-bar", "0.1.0")
     repository.add_candidate("baz", "0.1.0")
@@ -244,9 +298,7 @@ def test_list_json_with_circular_reverse(project, capsys, repository):
     repository.add_dependencies("foo-bar", "0.1.0", ["foo", "baz"])
     repository.add_dependencies("baz", "0.1.0", [])
     actions.do_add(project, packages=["foo"], no_self=True)
-    capsys.readouterr()
-    actions.do_list(project, graph=True, json=True, reverse=True)
-    content, _ = capsys.readouterr()
+    result = invoke(["list", "--graph", "--reverse", "--json"], obj=project)
     expected = [
         {
             "package": "baz",
@@ -276,4 +328,499 @@ def test_list_json_with_circular_reverse(project, capsys, repository):
             ],
         },
     ]
-    assert expected == json.loads(content)
+    assert expected == json.loads(result.outputs)
+
+
+def test_list_field_unknown(project, invoke):
+    # unknown list fields flagged to user
+    result = invoke(["list", "--fields", "notvalid"], obj=project)
+    assert "[PdmUsageError]" in result.stderr
+    assert "--fields must specify one or more of:" in result.stderr
+
+
+def test_list_sort_unknown(project, invoke):
+    # unknown sort fields flagged to user
+    result = invoke(["list", "--sort", "notvalid"], obj=project)
+    assert "[PdmUsageError]" in result.stderr
+    assert "--sort key must be one of:" in result.stderr
+
+
+def test_list_freeze_banned_options(project, invoke):
+    # other flags cannot be used with --freeze
+    result = invoke(["list", "--freeze", "--graph"], obj=project)
+    expected = "--graph cannot be used with --freeze"
+    assert expected in result.outputs
+
+    result = invoke(["list", "--freeze", "--reverse"], obj=project)
+    expected = "--reverse cannot be used without --graph"
+    assert expected in result.outputs
+
+    result = invoke(["list", "--freeze", "-r"], obj=project)
+    expected = "--reverse cannot be used without --graph"
+    assert expected in result.outputs
+
+    result = invoke(["list", "--freeze", "--fields", "name"], obj=project)
+    expected = "--fields cannot be used with --freeze"
+    assert expected in result.outputs
+
+    result = invoke(["list", "--freeze", "--resolve"], obj=project)
+    expected = "--resolve cannot be used with --freeze"
+    assert expected in result.outputs
+
+    result = invoke(["list", "--freeze", "--sort", "name"], obj=project)
+    expected = "--sort cannot be used with --freeze"
+    assert expected in result.outputs
+
+    result = invoke(["list", "--freeze", "--csv"], obj=project)
+    expected = "--csv cannot be used with --freeze"
+    assert expected in result.outputs
+
+    result = invoke(["list", "--freeze", "--json"], obj=project)
+    expected = "--json cannot be used with --freeze"
+    assert expected in result.outputs
+
+    result = invoke(["list", "--freeze", "--markdown"], obj=project)
+    expected = "--markdown cannot be used with --freeze"
+    assert expected in result.outputs
+
+    result = invoke(["list", "--freeze", "--include", "dev"], obj=project)
+    expected = "--include/--exclude cannot be used with --freeze"
+    assert expected in result.outputs
+
+    result = invoke(["list", "--freeze", "--exclude", "dev"], obj=project)
+    expected = "--include/--exclude cannot be used with --freeze"
+    assert expected in result.outputs
+
+
+def test_list_multiple_export_formats(project, invoke):
+    # export formats cannot be used with each other
+    result = invoke(["list", "--csv", "--markdown"], obj=project)
+    expected = "--markdown: not allowed with argument --csv"
+    assert expected in result.outputs
+
+    result = invoke(["list", "--csv", "--json"], obj=project)
+    expected = "--json: not allowed with argument --csv"
+    assert expected in result.outputs
+
+    result = invoke(["list", "--markdown", "--csv"], obj=project)
+    expected = "--csv: not allowed with argument --markdown"
+    assert expected in result.outputs
+
+    result = invoke(["list", "--markdown", "--json"], obj=project)
+    expected = "--json: not allowed with argument --markdown"
+    assert expected in result.outputs
+
+    result = invoke(["list", "--json", "--markdown"], obj=project)
+    expected = "--markdown: not allowed with argument --json"
+    assert expected in result.outputs
+
+    result = invoke(["list", "--json", "--csv"], obj=project)
+    expected = "--csv: not allowed with argument --json"
+    assert expected in result.outputs
+
+
+@mock.patch("pdm.termui.ROUNDED", ASCII)
+@pytest.mark.usefixtures("working_set")
+def test_list_bare(project, invoke):
+    actions.do_add(project, packages=["requests"])
+    result = invoke(["list"], obj=project)
+    # Ordering can be different on different platforms
+    # and python versions.
+    assert "| name         | version    | location |\n" in result.output
+    assert "| certifi      | 2018.11.17 |          |\n" in result.output
+    assert "| chardet      | 3.0.4      |          |\n" in result.output
+    assert "| idna         | 2.7        |          |\n" in result.output
+    assert "| requests     | 2.19.1     |          |\n" in result.output
+    assert "| urllib3      | 1.22       |          |\n" in result.output
+    assert "| test-project | 0.0.0      |          |\n" in result.output
+
+
+@mock.patch("pdm.termui.ROUNDED", ASCII)
+@pytest.mark.usefixtures("working_set")
+def test_list_bare_sorted_name(project, invoke):
+    actions.do_add(project, packages=["requests"])
+    result = invoke(["list", "--sort", "name"], obj=project)
+    expected = (
+        "+--------------------------------------+\n"
+        "| name         | version    | location |\n"
+        "|--------------+------------+----------|\n"
+        "| certifi      | 2018.11.17 |          |\n"
+        "| chardet      | 3.0.4      |          |\n"
+        "| idna         | 2.7        |          |\n"
+        "| requests     | 2.19.1     |          |\n"
+        "| test-project | 0.0.0      |          |\n"
+        "| urllib3      | 1.22       |          |\n"
+        "+--------------------------------------+\n"
+    )
+    assert expected == result.output
+
+
+@pytest.fixture()
+def fake_working_set(working_set):
+    """Create fake packages with license data
+    for testing.
+    """
+
+    class _MockPackagePath(pathlib.PurePosixPath):
+        def read_text(self, *args, **kwargs):
+            return self.license_text
+
+    # Foo package.
+    foo = Distribution("foo", "0.1.0", metadata={"License": "A License"})
+    foo_l = _MockPackagePath("foo-0.1.0.dist-info", "LICENSE")
+    foo_l.license_text = "license text for foo here"
+    foo.files = [foo_l]
+
+    # Bar package.
+    bar = Distribution("bar", "3.0.1", metadata={"License": "B License"})
+    bar_l = _MockPackagePath("bar-3.0.1.dist-info", "LICENSE")
+    bar_l.license_text = "license text for bar here"
+    bar.files = [bar_l]
+
+    # Baz package.
+    baz = Distribution("baz", "2.7", metadata={"License": "C License"})
+    baz_l = _MockPackagePath("bar-2.7.dist-info", "LICENSE")
+    baz_l.license_text = "license text for baz here"
+    baz.files = [baz_l]
+
+    # missing package- License is set to UNKNOWN, text saved in COPYING
+    unknown = Distribution(
+        "unknown",
+        "1.0",
+        metadata={
+            "License": "UNKNOWN",
+            "Classifier": "License :: OSI Approved :: Apache Software License",
+        },
+    )
+    unknown_l = _MockPackagePath("unknown-1.0.dist-info", "COPYING")
+    unknown_l.license_text = "license text for UNKNOWN here"
+    unknown.files = [unknown_l]
+
+    # missing package- License is set to UNKNOWN, text saved in LICENCE
+    # using UK spelling
+    classifier = Distribution(
+        "classifier", "1.0", metadata={"Classifier": "License :: PDM TEST D"}
+    )
+    classifier_l = _MockPackagePath("classifier-1.0.dist-info", "LICENCE")
+    classifier_l.license_text = "license text for CLASSIFIER here"
+    classifier_l.read_text = lambda *a, **kw: 1 / 0  # make it throw an error
+    classifier.files = [classifier_l]
+
+    # Place our fake packages in the working set.
+    for candidate in [foo, bar, baz, unknown, classifier]:
+        working_set.add_distribution(candidate)
+    return working_set
+
+
+@pytest.fixture()
+def fake_metadata(mocker, repository):
+    def prepare_metadata(self):
+        can = self.candidate
+        dist = Distribution(can.name, can.version, can.req.editable)
+        dist.dependencies = repository.get_raw_dependencies(can)
+        return dist
+
+    return mocker.patch(
+        "pdm.models.candidates.PreparedCandidate.prepare_metadata",
+        prepare_metadata,
+    )
+
+
+@mock.patch("pdm.termui.ROUNDED", ASCII)
+@pytest.mark.usefixtures("working_set")
+def test_list_freeze(project, invoke):
+    actions.do_add(project, packages=["requests"])
+    result = invoke(["list", "--freeze"], obj=project)
+    expected = (
+        "certifi==2018.11.17\n"
+        "chardet==3.0.4\n"
+        "idna==2.7\n"
+        "requests==2.19.1\n"
+        "test-project==0.0.0\n"
+        "urllib3==1.22\n"
+    )
+    assert expected == result.output
+
+
+@mock.patch("pdm.termui.ROUNDED", ASCII)
+@pytest.mark.usefixtures("working_set")
+def test_list_bare_sorted_version(project, invoke):
+    actions.do_add(project, packages=["requests"])
+    result = invoke(["list", "--sort", "version"], obj=project)
+    expected = (
+        "+--------------------------------------+\n"
+        "| name         | version    | location |\n"
+        "|--------------+------------+----------|\n"
+        "| test-project | 0.0.0      |          |\n"
+        "| urllib3      | 1.22       |          |\n"
+        "| requests     | 2.19.1     |          |\n"
+        "| idna         | 2.7        |          |\n"
+        "| certifi      | 2018.11.17 |          |\n"
+        "| chardet      | 3.0.4      |          |\n"
+        "+--------------------------------------+\n"
+    )
+    assert expected == result.output
+
+
+@mock.patch("pdm.termui.ROUNDED", ASCII)
+@pytest.mark.usefixtures("fake_metadata")
+def test_list_bare_sorted_version_resolve(project, invoke, working_set):
+    project.environment.python_requires = PySpecSet(">=3.6")
+    actions.do_add(project, packages=["requests"], sync=False)
+
+    result = invoke(
+        ["list", "--sort", "version", "--resolve", "-v"], obj=project, strict=True
+    )
+    assert "requests" not in working_set
+    expected = (
+        "+----------------------------------+\n"
+        "| name     | version    | location |\n"
+        "|----------+------------+----------|\n"
+        "| urllib3  | 1.22       |          |\n"
+        "| requests | 2.19.1     |          |\n"
+        "| idna     | 2.7        |          |\n"
+        "| certifi  | 2018.11.17 |          |\n"
+        "| chardet  | 3.0.4      |          |\n"
+        "+----------------------------------+\n"
+    )
+    assert expected == result.outputs, result.outputs
+
+
+@mock.patch("pdm.termui.ROUNDED", ASCII)
+@pytest.mark.usefixtures("fake_working_set")
+def test_list_bare_fields_licences(project, invoke):
+    result = invoke(["list", "--fields", "name,version,groups,licenses"], obj=project)
+    expected = (
+        "+---------------------------------------------------------+\n"
+        "| name       | version | groups | licenses                |\n"
+        "|------------+---------+--------+-------------------------|\n"
+        "| foo        | 0.1.0   | :sub   | A License               |\n"
+        "| bar        | 3.0.1   | :sub   | B License               |\n"
+        "| baz        | 2.7     | :sub   | C License               |\n"
+        "| unknown    | 1.0     | :sub   | Apache Software License |\n"
+        "| classifier | 1.0     | :sub   | PDM TEST D              |\n"
+        "+---------------------------------------------------------+\n"
+    )
+    assert expected == result.output
+
+
+@pytest.mark.usefixtures("fake_working_set")
+def test_list_csv_fields_licences(project, invoke):
+    result = invoke(["list", "--csv", "--fields", "name,version,licenses"], obj=project)
+    expected = (
+        "name,version,licenses\n"
+        "foo,0.1.0,A License\n"
+        "bar,3.0.1,B License\n"
+        "baz,2.7,C License\n"
+        "unknown,1.0,Apache Software License\n"
+        "classifier,1.0,PDM TEST D\n"
+    )
+    assert expected == result.output
+
+
+@pytest.mark.usefixtures("fake_working_set")
+def test_list_json_fields_licences(project, invoke):
+    result = invoke(
+        ["list", "--json", "--fields", "name,version,licenses"], obj=project
+    )
+    expected = [
+        {"name": "foo", "version": "0.1.0", "licenses": "A License"},
+        {"name": "bar", "version": "3.0.1", "licenses": "B License"},
+        {"name": "baz", "version": "2.7", "licenses": "C License"},
+        {"name": "unknown", "version": "1.0", "licenses": "Apache Software License"},
+        {"name": "classifier", "version": "1.0", "licenses": "PDM TEST D"},
+    ]
+
+    assert expected == json.loads(result.outputs)
+
+
+@pytest.mark.usefixtures("fake_working_set")
+def test_list_markdown_fields_licences(project, invoke):
+    result = invoke(
+        ["list", "--markdown", "--fields", "name,version,licenses"], obj=project
+    )
+    expected = (
+        "# test_project licenses\n"
+        "## foo\n\n"
+        "| Name | foo |\n"
+        "|----|----|\n"
+        "| Version | 0.1.0 |\n"
+        "| Licenses | A License |\n\n"
+        "foo-0.1.0.dist-info/LICENSE\n\n\n"
+        "````\n"
+        "license text for foo here\n"
+        "````\n\n\n"
+        "## bar\n\n"
+        "| Name | bar |\n"
+        "|----|----|\n"
+        "| Version | 3.0.1 |\n"
+        "| Licenses | B License |\n\n"
+        "bar-3.0.1.dist-info/LICENSE\n\n\n"
+        "````\n"
+        "license text for bar here\n"
+        "````\n\n\n"
+        "## baz\n\n"
+        "| Name | baz |\n"
+        "|----|----|\n"
+        "| Version | 2.7 |\n"
+        "| Licenses | C License |\n\n"
+        "bar-2.7.dist-info/LICENSE\n\n\n"
+        "````\n"
+        "license text for baz here\n"
+        "````\n\n\n"
+        "## unknown\n\n"
+        "| Name | unknown |\n"
+        "|----|----|\n"
+        "| Version | 1.0 |\n"
+        "| Licenses | Apache Software License |\n\n"
+        "unknown-1.0.dist-info/COPYING\n\n\n"
+        "````\n"
+        "license text for UNKNOWN here\n"
+        "````\n\n\n"
+        "## classifier\n\n"
+        "| Name | classifier |\n"
+        "|----|----|\n"
+        "| Version | 1.0 |\n"
+        "| Licenses | PDM TEST D |\n\n"
+        "classifier-1.0.dist-info/LICENCE\n\n\n"
+        "````\n"
+        "Problem finding license text: division by zero\n"
+        "````\n\n\n"
+    )
+    assert expected == result.output
+
+
+@pytest.mark.usefixtures("working_set", "repository")
+def test_list_csv_include_exclude_valid(project, invoke):
+    project.environment.python_requires = PySpecSet(">=3.6")
+    dep_path = FIXTURES.joinpath("projects/demo").as_posix()
+    actions.do_add(
+        project,
+        dev=True,
+        group="dev",
+        editables=[f"{dep_path}[security]"],
+    )
+    result = invoke(
+        [
+            "list",
+            "--csv",
+            "--fields",
+            "name,version,groups",
+            "--sort",
+            "name",
+            "--include",
+            "notexisting",
+        ],
+        obj=project,
+    )
+    assert "[PdmUsageError]" in result.outputs
+    assert "--include groups must be selected from" in result.outputs
+    assert "dev" in result.outputs
+    assert "default" in result.outputs
+    assert ":sub" in result.outputs
+
+
+@pytest.mark.usefixtures("working_set", "repository")
+def test_list_csv_include_exclude(project, invoke):
+    project.environment.python_requires = PySpecSet(">=3.6")
+    dep_path = FIXTURES.joinpath("projects/demo").as_posix()
+    actions.do_add(
+        project,
+        dev=True,
+        group="dev",
+        editables=[f"{dep_path}[security]"],
+    )
+
+    # Show all groups.
+    result = invoke(
+        ["list", "--csv", "--fields", "name,version,groups", "--sort", "name"],
+        obj=project,
+    )
+    expected = (
+        "name,version,groups\n"
+        "certifi,2018.11.17,:sub\n"
+        "chardet,3.0.4,:sub\n"
+        "demo,0.0.1,dev\n"
+        "idna,2.7,:sub\n"
+        "requests,2.19.1,:sub\n"
+        "urllib3,1.22,:sub\n"
+    )
+    assert expected == result.output
+
+    # Sub always included.
+    result = invoke(
+        [
+            "list",
+            "--csv",
+            "--fields",
+            "name,groups",
+            "--sort",
+            "name",
+            "--include",
+            "dev",
+        ],
+        obj=project,
+    )
+    expected = (
+        "name,groups\n"
+        "certifi,:sub\n"
+        "chardet,:sub\n"
+        "demo,dev\n"
+        "idna,:sub\n"
+        "requests,:sub\n"
+        "urllib3,:sub\n"
+    )
+    assert expected == result.output
+
+    # Include all (default) except sub
+    result = invoke(
+        [
+            "list",
+            "--csv",
+            "--fields",
+            "name,groups",
+            "--sort",
+            "name",
+            "--exclude",
+            ":sub",
+        ],
+        obj=project,
+    )
+    expected = "name,groups\n" "demo,dev\n"
+    assert expected == result.output
+
+    # Show just the dev group
+    result = invoke(
+        [
+            "list",
+            "--csv",
+            "--fields",
+            "name,version,groups",
+            "--sort",
+            "name",
+            "--include",
+            "dev",
+            "--exclude",
+            "*",
+        ],
+        obj=project,
+    )
+    expected = "name,version,groups\n" "demo,0.0.1,dev\n"
+    assert expected == result.output
+
+    # Exclude the dev group.
+    result = invoke(
+        [
+            "list",
+            "--csv",
+            "--fields",
+            "name,version,groups",
+            "--sort",
+            "name",
+            "--exclude",
+            "dev",
+        ],
+        obj=project,
+    )
+    expected = "name,version,groups\n"
+    assert expected == result.output
diff --git a/tests/cli/test_others.py b/tests/cli/test_others.py
index 72ef3abe..4b37be3b 100644
--- a/tests/cli/test_others.py
+++ b/tests/cli/test_others.py
@@ -21,7 +21,6 @@ def test_project_no_init_error(project_no_init):
 
     for handler in (
         actions.do_add,
-        actions.do_list,
         actions.do_lock,
         actions.do_update,
     ):
@@ -63,11 +62,11 @@ def test_global_project_other_location(invoke, project):
 
 
 def test_uncaught_error(invoke, mocker):
-    mocker.patch.object(actions, "do_list", side_effect=RuntimeError("test error"))
-    result = invoke(["list"])
+    mocker.patch.object(actions, "do_lock", side_effect=RuntimeError("test error"))
+    result = invoke(["lock"])
     assert "[RuntimeError]: test error" in result.stderr
 
-    result = invoke(["list", "-v"])
+    result = invoke(["lock", "-v"])
     assert isinstance(result.exception, RuntimeError)
 
 
diff --git a/tests/conftest.py b/tests/conftest.py
index 1f904052..98e2af79 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -119,6 +119,14 @@ class TestRepository(BaseRepository):
         self._pypi_data = {}
         self.load_fixtures()
 
+    def get_raw_dependencies(self, candidate):
+        try:
+            pypi_data = self._pypi_data[candidate.req.key][candidate.version]
+        except KeyError:
+            return candidate.prepare(self.environment).metadata.requires or []
+        else:
+            return pypi_data.get("dependencies", [])
+
     def add_candidate(self, name, version, requires_python=""):
         pypi_data = self._pypi_data.setdefault(normalize_name(name), {}).setdefault(
             version, {}
@@ -188,14 +196,28 @@ def build_env():
         yield d
 
 
+class Metadata(dict):
+    def get_all(self, name, fallback=None):
+        return [self[name]] if name in self else fallback
+
+    def __getitem__(self, __key):
+        return dict.get(self, __key)
+
+
 class Distribution:
-    def __init__(self, key, version, editable=False):
+    def __init__(self, key, version, editable=False, metadata=None):
         self.version = version
         self.link_file = "editable" if editable else None
         self.dependencies = []
-        self.metadata = {"Name": key}
+        self._metadata = {"Name": key, "Version": version}
+        if metadata:
+            self._metadata.update(metadata)
         self.name = key
 
+    @property
+    def metadata(self):
+        return Metadata(self._metadata)
+
     def as_req(self):
         return parse_requirement(f"{self.name}=={self.version}")
 
@@ -237,10 +259,9 @@ def working_set(mocker, repository):
     mocker.patch.object(Environment, "get_working_set", return_value=rv)
 
     def install(candidate):
-        dependencies = repository.get_dependencies(candidate)[0]
         key = normalize_name(candidate.name)
         dist = Distribution(key, candidate.version, candidate.req.editable)
-        dist.dependencies = [dep.as_line() for dep in dependencies]
+        dist.dependencies = repository.get_raw_dependencies(candidate)
         rv.add_distribution(dist)
 
     def uninstall(dist):