Skip to content

Commit

Permalink
feat: New command pdm outdated
Browse files Browse the repository at this point in the history
Close #358

Signed-off-by: Frost Ming <me@frostming.com>
  • Loading branch information
frostming committed Mar 25, 2024
1 parent bd92b07 commit 9dbcb6f
Show file tree
Hide file tree
Showing 17 changed files with 228 additions and 41 deletions.
16 changes: 16 additions & 0 deletions docs/docs/usage/dependency.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,22 @@ pdm remove -G web h11
pdm remove -dG test pytest-cov
```

## List outdated packages and the latest versions

+++ 2.13.0

To list outdated packages and the latest versions:

```bash
pdm outdated
```

You can pass glob patterns to filter the packages to show:

```bash
pdm outdated requests* flask*
```

## Install the packages pinned in lock file

There are a few similar commands to do this job with slight differences:
Expand Down
7 changes: 3 additions & 4 deletions src/pdm/cli/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,20 +344,19 @@ def get_latest_version(project: Project) -> str | None:

def check_update(project: Project) -> None: # pragma: no cover
"""Check if there is a new version of PDM available"""
from packaging.version import Version

from pdm.cli.utils import is_homebrew_installation, is_pipx_installation, is_scoop_installation
from pdm.utils import parse_version

if project.core.ui.verbosity < termui.Verbosity.NORMAL:
return

this_version = project.core.version
latest_version = get_latest_version(project)
if latest_version is None or Version(this_version) >= Version(latest_version):
if latest_version is None or parse_version(this_version) >= parse_version(latest_version):
return
disable_command = "pdm config check_update false"

is_prerelease = Version(latest_version).is_prerelease
is_prerelease = parse_version(latest_version).is_prerelease

if is_pipx_installation():
install_command = f"pipx upgrade {'--pip-args=--pre ' if is_prerelease else ''}pdm"
Expand Down
6 changes: 1 addition & 5 deletions src/pdm/cli/commands/list.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import csv
import io
import json
import re
from collections import defaultdict
from fnmatch import fnmatch
from typing import Iterable, Mapping, Sequence
Expand All @@ -18,6 +17,7 @@
build_dependency_graph,
check_project_file,
get_dist_location,
normalize_pattern,
show_dependency_graph,
)
from pdm.compat import importlib_metadata as im
Expand All @@ -29,10 +29,6 @@
SUBDEP_GROUP_LABEL = ":sub"


def normalize_pattern(pattern: str) -> str:
return re.sub(r"[^A-Za-z0-9*?]+", "-", pattern).lower()


class Command(BaseCommand):
"""List packages installed in the current working set"""

Expand Down
130 changes: 130 additions & 0 deletions src/pdm/cli/commands/outdated.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
from __future__ import annotations

import functools
import json
from concurrent.futures import ThreadPoolExecutor
from dataclasses import asdict, dataclass
from fnmatch import fnmatch
from itertools import zip_longest
from typing import TYPE_CHECKING

from pdm.cli.commands.base import BaseCommand
from pdm.cli.utils import normalize_pattern
from pdm.models.requirements import strip_extras

if TYPE_CHECKING:
from argparse import ArgumentParser, Namespace

from unearth import PackageFinder

from pdm.project.core import Project


@dataclass
class ListPackage:
package: str
installed_version: str
pinned_version: str
latest_version: str = ""


class Command(BaseCommand):
"""Check for outdated packages and list the latest versions."""

def add_arguments(self, parser: ArgumentParser) -> None:
parser.add_argument(
"--json", action="store_const", const="json", dest="format", default="table", help="Output in JSON format"
)
parser.add_argument("patterns", nargs="*", help="The packages to check", type=normalize_pattern)

@functools.lru_cache
@staticmethod
def _find_first_diff(a: str, b: str) -> int:
a_parts = a.split(".")
b_parts = b.split(".")
for i, (x, y) in enumerate(zip_longest(a_parts, b_parts)):
if x != y:
return (len(".".join(a_parts[:i])) + 1) if i > 0 else 0
return 0

@staticmethod
def _match_pattern(name: str, patterns: list[str]) -> bool:
return not patterns or any(fnmatch(name, p) for p in patterns)

@staticmethod
def _populate_latest_version(finder: PackageFinder, package: ListPackage) -> None:
best = finder.find_best_match(package.package).best
if best:
package.latest_version = best.version or ""

@staticmethod
def _format_json(packages: list[ListPackage]) -> str:
return json.dumps([asdict(package) for package in packages], indent=2)

@staticmethod
def _render_version(version: str, base_version: str) -> str:
from packaging.version import InvalidVersion

from pdm.utils import parse_version

if not version or version == base_version:
return version
if not base_version:
return f"[bold red]{version}[/]"

try:
parsed_version = parse_version(version)
parsed_base_version = parse_version(base_version)
except InvalidVersion:
return version
first_diff = Command._find_first_diff(version, base_version)
head, tail = version[:first_diff], version[first_diff:]
if parsed_version.major != parsed_base_version.major:
return f"{head}[bold red]{tail}[/]"
if parsed_version.minor != parsed_base_version.minor:
return f"{head}[bold yellow]{tail}[/]"
return f"{head}[bold green]{tail}[/]"

def handle(self, project: Project, options: Namespace) -> None:
environment = project.environment
installed = environment.get_working_set()
resolved = {strip_extras(k)[0]: v for k, v in project.locked_repository.all_candidates.items()}

collected: list[ListPackage] = []

for name, distribution in installed.items():
if not self._match_pattern(name, options.patterns):
continue
if name == project.name:
continue
constrained_version = resolved.pop(name).version or "" if name in resolved else ""
collected.append(ListPackage(name, distribution.version or "", constrained_version))

for name, candidate in resolved.items():
if not self._match_pattern(name, options.patterns):
continue
if candidate.req.marker and not candidate.req.marker.evaluate(environment.marker_environment):
continue
collected.append(ListPackage(name, "", candidate.version or ""))

with environment.get_finder() as finder, ThreadPoolExecutor() as executor:
for package in collected:
executor.submit(self._populate_latest_version, finder, package)

collected = sorted(
[p for p in collected if p.latest_version and p.latest_version != p.installed_version],
key=lambda p: p.package,
)
if options.format == "json":
print(self._format_json(collected))
else:
rows = [
(
package.package,
package.installed_version,
self._render_version(package.pinned_version, package.installed_version),
self._render_version(package.latest_version, package.installed_version),
)
for package in collected
]
project.core.ui.display_columns(rows, header=["Package", "Installed", "Pinned", "Latest"])
6 changes: 2 additions & 4 deletions src/pdm/cli/commands/self_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
import sys
from typing import Any

from packaging.version import parse

from pdm import termui
from pdm.cli.actions import get_latest_pdm_version_from_pypi
from pdm.cli.commands.base import BaseCommand
Expand All @@ -17,7 +15,7 @@
from pdm.environments import BareEnvironment
from pdm.models.working_set import WorkingSet
from pdm.project import Project
from pdm.utils import is_in_zipapp, normalize_name
from pdm.utils import is_in_zipapp, normalize_name, parse_version

PDM_REPO = "https://github.com/pdm-project/pdm"

Expand Down Expand Up @@ -233,7 +231,7 @@ def handle(self, project: Project, options: argparse.Namespace) -> None:
else:
version = get_latest_pdm_version_from_pypi(project, options.pre)
assert version is not None, "No version found"
if parse(__version__) >= parse(version):
if parse_version(__version__) >= parse_version(version):
project.core.ui.echo(f"Already up-to-date: [primary]{__version__}[/]")
return
package = f"pdm=={version}"
Expand Down
6 changes: 2 additions & 4 deletions src/pdm/cli/commands/show.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,22 @@
import argparse
from typing import TYPE_CHECKING

from packaging.version import Version

from pdm.cli.commands.base import BaseCommand
from pdm.cli.options import venv_option
from pdm.exceptions import PdmUsageError
from pdm.models.candidates import Candidate
from pdm.models.project_info import ProjectInfo
from pdm.models.requirements import parse_requirement
from pdm.project import Project
from pdm.utils import normalize_name
from pdm.utils import normalize_name, parse_version

if TYPE_CHECKING:
from unearth import Package


def filter_stable(package: Package) -> bool:
assert package.version
return not Version(package.version).is_prerelease
return not parse_version(package.version).is_prerelease


class Command(BaseCommand):
Expand Down
5 changes: 5 additions & 0 deletions src/pdm/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -792,3 +792,8 @@ def populate_requirement_names(req_mapping: dict[str, Requirement]) -> None:
if key and key.startswith(":empty:"):
req_mapping[req.identify()] = req
del req_mapping[key]


def normalize_pattern(pattern: str) -> str:
"""Normalize a pattern to a valid name for a package."""
return re.sub(r"[^A-Za-z0-9*?]+", "-", pattern).lower()
4 changes: 2 additions & 2 deletions src/pdm/models/specifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@
from_specifierset,
)
from packaging.specifiers import SpecifierSet
from packaging.version import Version as ParsedVersion

from pdm.exceptions import InvalidPyVersion
from pdm.models.versions import Version
from pdm.utils import parse_version


def _read_max_versions() -> dict[Version, int]:
Expand Down Expand Up @@ -257,6 +257,6 @@ def _fix_py4k(spec: VersionSpecifier) -> VersionSpecifier:
if isinstance(spec, UnionSpecifier):
*pre, last = spec.ranges
return UnionSpecifier([*pre, _fix_py4k(last)])
if isinstance(spec, RangeSpecifier) and spec.max == ParsedVersion("4.0"):
if isinstance(spec, RangeSpecifier) and spec.max == parse_version("4.0"):
return dataclasses.replace(spec, max=None, include_max=False)
return spec
6 changes: 3 additions & 3 deletions src/pdm/project/lockfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
from typing import Any, Iterable, Mapping

import tomlkit
from packaging.version import Version

from pdm import termui
from pdm.exceptions import PdmUsageError
from pdm.project.toml_file import TOMLBase
from pdm.utils import parse_version

GENERATED_COMMENTS = [
"This file is @generated by PDM.",
Expand All @@ -32,7 +32,7 @@ class Compatibility(enum.IntEnum):


class Lockfile(TOMLBase):
spec_version = Version("4.4.1")
spec_version = parse_version("4.4.1")

@cached_property
def default_strategies(self) -> set[str]:
Expand Down Expand Up @@ -104,7 +104,7 @@ def compatibility(self) -> Compatibility:
return Compatibility.SAME
if not self.file_version:
return Compatibility.NONE
lockfile_version = Version(self.file_version)
lockfile_version = parse_version(self.file_version)
if lockfile_version == self.spec_version:
return Compatibility.SAME
if lockfile_version.major != self.spec_version.major or lockfile_version.minor > self.spec_version.minor:
Expand Down
4 changes: 2 additions & 2 deletions src/pdm/pytest.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@

import httpx
import pytest
from packaging.version import parse as parse_version
from pytest_mock import MockerFixture
from unearth import Link

Expand All @@ -63,7 +62,7 @@
from pdm.models.session import PDMPyPIClient
from pdm.project.config import Config
from pdm.project.core import Project
from pdm.utils import find_python_in_path, normalize_name, path_to_url
from pdm.utils import find_python_in_path, normalize_name, parse_version, path_to_url

if TYPE_CHECKING:
from typing import Protocol
Expand Down Expand Up @@ -569,6 +568,7 @@ def __call__(
input: str | None = None,
obj: Project | None = None,
env: Mapping[str, str] | None = None,
cleanup: bool = True,
**kwargs: Any,
) -> RunResult:
"""
Expand Down
6 changes: 3 additions & 3 deletions src/pdm/resolver/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from typing import TYPE_CHECKING, Callable

from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
from packaging.version import InvalidVersion
from resolvelib import AbstractProvider, RequirementsConflicted
from resolvelib.resolvers import Criterion

Expand All @@ -15,7 +15,7 @@
from pdm.models.requirements import FileRequirement, parse_requirement, strip_extras
from pdm.resolver.python import PythonCandidate, PythonRequirement, find_python_matches, is_python_satisfied_by
from pdm.termui import logger
from pdm.utils import deprecation_warning, is_url, normalize_name, url_without_fragments
from pdm.utils import deprecation_warning, is_url, normalize_name, parse_version, url_without_fragments

if TYPE_CHECKING:
from typing import Any, Iterable, Iterator, Mapping, Sequence
Expand Down Expand Up @@ -187,7 +187,7 @@ def _find_candidates(self, requirement: Requirement) -> Iterable[Candidate]:
candidate = self.locked_candidates[key]
if candidate.version is not None:
try:
parsed_version = Version(candidate.version)
parsed_version = parse_version(candidate.version)
except InvalidVersion: # pragma: no cover
pass
else:
Expand Down
Loading

0 comments on commit 9dbcb6f

Please sign in to comment.