Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: New command pdm outdated #2718

Merged
merged 4 commits into from
Mar 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
1 change: 1 addition & 0 deletions news/2718.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add command `pdm outdated` to check the outdated packages and list the latest versions.
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 = ""


@functools.lru_cache
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

Check warning on line 38 in src/pdm/cli/commands/outdated.py

View check run for this annotation

Codecov / codecov/patch

src/pdm/cli/commands/outdated.py#L38

Added line #L38 was not covered by tests


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)

@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}[/]"

Check warning on line 73 in src/pdm/cli/commands/outdated.py

View check run for this annotation

Codecov / codecov/patch

src/pdm/cli/commands/outdated.py#L73

Added line #L73 was not covered by tests

try:
parsed_version = parse_version(version)
parsed_base_version = parse_version(base_version)
except InvalidVersion:
return version

Check warning on line 79 in src/pdm/cli/commands/outdated.py

View check run for this annotation

Codecov / codecov/patch

src/pdm/cli/commands/outdated.py#L78-L79

Added lines #L78 - L79 were not covered by tests
first_diff = _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}[/]"

Check warning on line 83 in src/pdm/cli/commands/outdated.py

View check run for this annotation

Codecov / codecov/patch

src/pdm/cli/commands/outdated.py#L83

Added line #L83 was not covered by tests
if parsed_version.minor != parsed_base_version.minor:
return f"{head}[bold yellow]{tail}[/]"
return f"{head}[bold green]{tail}[/]"

Check warning on line 86 in src/pdm/cli/commands/outdated.py

View check run for this annotation

Codecov / codecov/patch

src/pdm/cli/commands/outdated.py#L86

Added line #L86 was not covered by tests

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 ""))

Check warning on line 108 in src/pdm/cli/commands/outdated.py

View check run for this annotation

Codecov / codecov/patch

src/pdm/cli/commands/outdated.py#L107-L108

Added lines #L107 - L108 were not covered by tests

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
6 changes: 5 additions & 1 deletion src/pdm/cli/completions/pdm.bash
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ _pdm_a919b69078acdf0a_complete()
opts="--check --config-setting --dev --exclude-newer --global --group --help --lockfile --no-cross-platform --no-default --no-isolation --no-static-urls --production --project --quiet --refresh --skip --static-urls --strategy --update-reuse --update-reuse-installed --verbose --without"
;;

(outdated)
opts="--global --help --json --project --quiet --verbose"
;;

(plugin)
opts="--help --quiet --verbose"
;;
Expand Down Expand Up @@ -134,7 +138,7 @@ _pdm_a919b69078acdf0a_complete()

# completing for a command
if [[ $cur == $com ]]; then
coms="add build cache completion config export fix import info init install list lock plugin publish remove run search self show sync update use venv"
coms="add build cache completion config export fix import info init install list lock outdated plugin publish remove run search self show sync update use venv"

COMPREPLY=($(compgen -W "${coms}" -- ${cur}))
__ltrim_colon_completions "$cur"
Expand Down
11 changes: 10 additions & 1 deletion src/pdm/cli/completions/pdm.fish
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

function __fish_pdm_a919b69078acdf0a_complete_no_subcommand
for i in (commandline -opc)
if contains -- $i add build cache completion config export fix import info init install list lock plugin publish remove run search self show sync update use venv
if contains -- $i add build cache completion config export fix import info init install list lock outdated plugin publish remove run search self show sync update use venv
return 1
end
end
Expand Down Expand Up @@ -257,6 +257,15 @@ complete -c pdm -A -n '__fish_seen_subcommand_from lock' -l update-reuse-install
complete -c pdm -A -n '__fish_seen_subcommand_from lock' -l verbose -d 'Use `-v` for detailed output and `-vv` for more detailed'
complete -c pdm -A -n '__fish_seen_subcommand_from lock' -l without -d 'Exclude groups of optional-dependencies or dev-dependencies'

# outdated
complete -c pdm -f -n '__fish_pdm_a919b69078acdf0a_complete_no_subcommand' -a outdated -d 'Check for outdated packages and list the latest versions.'
complete -c pdm -A -n '__fish_seen_subcommand_from outdated' -l global -d 'Use the global project, supply the project root with `-p` option'
complete -c pdm -A -n '__fish_seen_subcommand_from outdated' -l help -d 'Show this help message and exit.'
complete -c pdm -A -n '__fish_seen_subcommand_from outdated' -l json -d 'Output in JSON format'
complete -c pdm -A -n '__fish_seen_subcommand_from outdated' -l project -d 'Specify another path as the project root, which changes the base of pyproject.toml and __pypackages__ [env var: PDM_PROJECT]'
complete -c pdm -A -n '__fish_seen_subcommand_from outdated' -l quiet -d 'Suppress output'
complete -c pdm -A -n '__fish_seen_subcommand_from outdated' -l verbose -d 'Use `-v` for detailed output and `-vv` for more detailed'

# plugin
complete -c pdm -f -n '__fish_pdm_a919b69078acdf0a_complete_no_subcommand' -a plugin -d 'Manage the PDM program itself (previously known as plugin)'
complete -c pdm -A -n '__fish_seen_subcommand_from plugin' -l help -d 'Show this help message and exit.'
Expand Down
10 changes: 9 additions & 1 deletion src/pdm/cli/completions/pdm.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ function TabExpansion($line, $lastWord) {

if ($lastBlock -match "^pdm ") {
[string[]]$words = $lastBlock.Split()[1..$lastBlock.Length]
[string[]]$AllCommands = ("add", "build", "cache", "completion", "config", "export", "fix", "import", "info", "init", "install", "list", "lock", "plugin", "publish", "remove", "run", "search", "show", "sync", "update", "use")
[string[]]$AllCommands = ("add", "build", "cache", "completion", "config", "export", "fix", "import", "info", "init", "install", "list", "lock", "outdated", "plugin", "publish", "remove", "run", "search", "show", "sync", "update", "use")
[string[]]$commands = $words.Where( { $_ -notlike "-*" })
$command = $commands[0]
$completer = [Completer]::new()
Expand Down Expand Up @@ -335,6 +335,14 @@ function TabExpansion($line, $lastWord) {
))
break
}
"outdated" {
$completer.AddOpts(
@(
[Option]::new(@("--json")),
$projectOption
))
break
}
"self" {
$subCommand = $commands[1]
switch ($subCommand) {
Expand Down
8 changes: 8 additions & 0 deletions src/pdm/cli/completions/pdm.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ _pdm() {
'list:List packages installed in the current working set'
'lock:Resolve and lock dependencies'
'self:Manage the PDM program itself (previously known as plugin)'
'outdated:Check for outdated packages and list the latest versions'
'publish:Build and publish the project to PyPI'
'remove:Remove packages from pyproject.toml'
'run:Run commands or scripts with local packages loaded'
Expand Down Expand Up @@ -267,6 +268,12 @@ _pdm() {
{-S,--strategy}'[Specify lock strategy(cross_platform,static_urls,direct_minimal_versions). Add no_ prefix to disable. Support given multiple times or split by comma.]:strategy:'
)
;;
outdated)
arguments+=(
'--json[Output in JSON format]'
'*:patterns:'
)
;;
self)
_arguments -C \
$arguments \
Expand Down Expand Up @@ -300,6 +307,7 @@ _pdm() {
list)
arguments+=(
'--plugins[List plugins only]'
'*:patterns:'
)
;;
update)
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
Loading
Loading