Skip to content
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
25 changes: 24 additions & 1 deletion docs/usage/lockfile.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,10 +229,33 @@ pdm config strategy.exclude-newer 7d
exclude-newer = "7d"
```

Package-specific values can override the global cutoff:

```toml
[tool.pdm.resolution]
exclude-newer = "7d"

[tool.pdm.resolution.exclude-newer-override]
mypackage = false
anotherpackage = "3d"
```

An override accepts the same date, timestamp, and duration formats as `exclude-newer`. Set it to `false` to disable the
cutoff for that package. Package names are normalized before matching, and package-specific values take precedence
over the global cutoff. Overrides may also be supplied on the command line:

```bash
pdm lock --exclude-newer 7d --exclude-newer-override mypackage=false anotherpackage=3d
```

Command-line overrides take precedence over entries for the same package in `exclude-newer-override`.

The precedence is: the `--exclude-newer` command line option, then `[tool.pdm.resolution]` in `pyproject.toml`, then `strategy.exclude-newer` in PDM config.

!!! note
The package index must support the `upload-time` field as specified in [PEP 700]. If the field is not present for a given distribution, the distribution will be treated as unavailable.
The package index must support the `upload-time` field as specified in [PEP 700]. If the field is not present for
a given distribution, the distribution will be treated as unavailable unless its cutoff is disabled with an
`exclude-newer-override` value of `false`.

[PEP 700]: https://peps.python.org/pep-0700/

Expand Down
1 change: 1 addition & 0 deletions news/3809.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allow package-specific overrides for `exclude-newer` resolution limits.
31 changes: 28 additions & 3 deletions src/pdm/cli/commands/lock.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import argparse
import re
import sys
from datetime import datetime
from typing import cast

from pdm import termui
Expand All @@ -19,7 +20,19 @@
from pdm.models.markers import EnvSpec
from pdm.models.specifiers import PySpecSet
from pdm.project import Project
from pdm.utils import convert_to_datetime
from pdm.utils import convert_to_datetime, normalize_name


def _parse_exclude_newer_override(value: str) -> tuple[str, datetime | None]:
package, separator, cutoff = value.partition("=")
if not separator or not package or not cutoff:
raise argparse.ArgumentTypeError("exclude-newer overrides must be in the form PACKAGE=DATE")
if cutoff == "false":
return normalize_name(package), None
try:
return normalize_name(package), convert_to_datetime(cutoff)
except ValueError as e:
raise argparse.ArgumentTypeError(f"Invalid exclude-newer override for {package!r}: {cutoff!r}") from e


class Command(BaseCommand):
Expand Down Expand Up @@ -69,6 +82,16 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
"or relative time `N{d|h|w}`",
type=convert_to_datetime,
)
parser.add_argument(
"--exclude-newer-override",
dest="exclude_newer_overrides",
metavar="PACKAGE=DATE",
nargs="+",
action="extend",
default=[],
type=_parse_exclude_newer_override,
help="Override exclude-newer for specific packages; use PACKAGE=false to disable the cutoff",
)

target_group = parser.add_argument_group("Lock Target")
target_group.add_argument("--python", help="The Python range to lock for. E.g. `>=3.9`, `==3.12.*`")
Expand Down Expand Up @@ -107,10 +130,12 @@ def handle(self, project: Project, options: argparse.Namespace) -> None:
if options.exclude_newer:
# cli args override pyproject config if present
project.core.state.exclude_newer = options.exclude_newer
if project.core.state.exclude_newer:
if options.exclude_newer_overrides:
project.core.state.exclude_newer_overrides.update(options.exclude_newer_overrides)
if project.core.state.exclude_newer or project.core.state.exclude_newer_overrides:
strategy = "all"
if strategy != options.update_strategy:
project.core.ui.info("--exclude-newer is set, forcing --update-all")
project.core.ui.info("exclude-newer is set, forcing --update-all")
env_spec: EnvSpec | None = None
if any([options.python, options.platform, options.implementation]):
replace_dict = {}
Expand Down
12 changes: 11 additions & 1 deletion src/pdm/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from pdm.models.repositories import BaseRepository, PyPIRepository
from pdm.project import Project
from pdm.project.config import Config
from pdm.utils import convert_to_datetime, is_in_zipapp
from pdm.utils import convert_to_datetime, is_in_zipapp, normalize_name

if TYPE_CHECKING:
from collections.abc import Iterable
Expand All @@ -55,6 +55,8 @@ class State:
"""The config settings map shared by all packages"""
exclude_newer: datetime | None = None
"""The exclude newer than datetime for the lockfile"""
exclude_newer_overrides: dict[str, datetime | None] = dc.field(default_factory=dict)
"""Package-specific overrides for the exclude-newer cutoff"""
build_isolation: bool = True
"""Whether to make an isolated environment and install requirements for build"""
enable_cache: bool = True
Expand Down Expand Up @@ -188,6 +190,14 @@ def handle(self, project: Project, options: argparse.Namespace) -> None:
self.state.exclude_newer = exclude_newer
if exclude_newer := project.pyproject.resolution.get("exclude-newer"):
self.state.exclude_newer = convert_to_datetime(exclude_newer)
overrides = project.pyproject.resolution.get("exclude-newer-override", {})
try:
self.state.exclude_newer_overrides = {
normalize_name(package): None if value is False else convert_to_datetime(value)
for package, value in overrides.items()
}
except (AttributeError, TypeError, ValueError) as e:
raise PdmUsageError("Invalid `resolution.exclude-newer-override` configuration") from e

for callback in getattr(options, "callbacks", []):
callback(project, options)
Expand Down
1 change: 1 addition & 0 deletions src/pdm/environments/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ def get_finder(
verbosity=self.project.core.ui.verbosity,
minimal_version=minimal_version,
exclude_newer_than=self.project.core.state.exclude_newer,
exclude_newer_overrides=self.project.core.state.exclude_newer_overrides,
)
finder.sources.clear()
for source in sources:
Expand Down
8 changes: 6 additions & 2 deletions src/pdm/models/finder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import logging
from collections.abc import Callable
from datetime import datetime
from typing import TYPE_CHECKING, Any

import unearth
Expand All @@ -10,7 +11,7 @@
from unearth.evaluator import Evaluator, FormatControl, LinkMismatchError, Package

from pdm.models.markers import EnvSpec
from pdm.utils import parse_version
from pdm.utils import normalize_name, parse_version

logger = logging.getLogger("unearth")

Expand Down Expand Up @@ -67,20 +68,23 @@ def __init__(
*,
env_spec: EnvSpec,
minimal_version: bool = False,
exclude_newer_overrides: dict[str, datetime | None] | None = None,
**kwargs: Any,
) -> None:
super().__init__(session, **kwargs)
self.minimal_version = minimal_version
self.env_spec = env_spec
self.exclude_newer_overrides = exclude_newer_overrides or {}

def build_evaluator(self, package_name: str, allow_yanked: bool = False) -> Evaluator:
format_control = FormatControl(no_binary=self.no_binary, only_binary=self.only_binary)
exclude_newer_than = self.exclude_newer_overrides.get(normalize_name(package_name), self.exclude_newer_than)
return PDMEvaluator(
package_name=package_name,
target_python=self.target_python,
allow_yanked=allow_yanked,
format_control=format_control,
exclude_newer_than=self.exclude_newer_than,
exclude_newer_than=exclude_newer_than,
env_spec=self.env_spec,
)

Expand Down
3 changes: 3 additions & 0 deletions src/pdm/resolver/uv.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ def _build_lock_command(self) -> list[str | HiddenText]:

if dt := self.project.core.state.exclude_newer:
cmd.extend(["--exclude-newer", dt.isoformat()])
for package, dt in self.project.core.state.exclude_newer_overrides.items():
value = dt.isoformat() if dt is not None else "false"
cmd.extend(["--exclude-newer-package", f"{package}={value}"])
Comment thread
frostming marked this conversation as resolved.

return cmd

Expand Down
61 changes: 61 additions & 0 deletions tests/cli/test_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -1077,6 +1077,67 @@ def test_pyproject_exclude_newer_overrides_config(project, pdm):
assert project.get_locked_repository().candidates["zipp"].version == "3.7.0"


@pytest.mark.parametrize(
("resolution", "expected_version"),
[
({"exclude-newer": "2024-01-01", "exclude-newer-override": {"zipp": False}}, "3.7.0"),
({"exclude-newer": "2024-01-01", "exclude-newer-override": {"other-package": False}}, "3.6.0"),
({"exclude-newer": "2025-01-01", "exclude-newer-override": {"zipp": "2024-01-01"}}, "3.6.0"),
({"exclude-newer-override": {"Zipp": "2024-01-01"}}, "3.6.0"),
],
)
def test_lock_exclude_newer_overrides(project, pdm, resolution, expected_version):
project.pyproject.metadata["requires-python"] = ">=3.9"
project.project_config["pypi.url"] = "https://my.pypi.org/json"
project.pyproject.settings["resolution"] = resolution
project.add_dependencies(["zipp"])

pdm(["lock"], obj=project, strict=True, cleanup=False)

assert project.get_locked_repository().candidates["zipp"].version == expected_version


def test_cli_exclude_newer_overrides(project, pdm):
project.pyproject.metadata["requires-python"] = ">=3.9"
project.project_config["pypi.url"] = "https://my.pypi.org/json"
project.pyproject.settings["resolution"] = {"exclude-newer-override": {"zipp": "2024-01-01"}}
project.add_dependencies(["zipp"])

pdm(
[
"lock",
"--exclude-newer",
"2024-01-01",
"--exclude-newer-override",
"zipp=false",
"other-package=3d",
],
obj=project,
strict=True,
cleanup=False,
)

assert project.get_locked_repository().candidates["zipp"].version == "3.7.0"


@pytest.mark.parametrize("overrides", [[], {"foo": 0}, {"foo": 1}, {"foo": True}, {"foo": "invalid"}])
def test_invalid_exclude_newer_overrides(project, pdm, overrides):
project.pyproject.settings["resolution"] = {"exclude-newer-override": overrides}

result = pdm(["lock"], obj=project)

assert result.exit_code != 0
assert "Invalid `resolution.exclude-newer-override` configuration" in result.stderr


@pytest.mark.parametrize("override", ["foo=0", "foo=invalid"])
def test_invalid_cli_exclude_newer_override(project, pdm, override):
result = pdm(["lock", "--exclude-newer-override", override], obj=project)

assert result.exit_code != 0
assert "Invalid exclude-newer override" in result.stderr


exclusion_cases = [
pytest.param(("-G", ":all", "--without", "tz,ssl"), id="-G :all --without tz,ssl"),
pytest.param(("-G", ":all", "--without", "tz", "--without", "ssl"), id="-G :all --without tz --without ssl"),
Expand Down
30 changes: 30 additions & 0 deletions tests/resolver/test_uv_exclude_newer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from datetime import datetime, timezone

from pdm.resolver.uv import UvResolver


def test_uv_exclude_newer_overrides(project):
project.core.__dict__["uv_cmd"] = ["uv"]
project.core.state.exclude_newer = datetime(2024, 1, 1, tzinfo=timezone.utc)
project.core.state.exclude_newer_overrides = {
"foo": None,
"bar": datetime(2024, 2, 1, tzinfo=timezone.utc),
}
resolver = UvResolver(
project.environment,
requirements=[],
target=project.environment.spec,
update_strategy="all",
strategies=set(),
)

command = resolver._build_lock_command()

assert command[-6:] == [
"--exclude-newer",
"2024-01-01T00:00:00+00:00",
"--exclude-newer-package",
"foo=false",
"--exclude-newer-package",
"bar=2024-02-01T00:00:00+00:00",
]
Loading