Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@

.venv
/poetry.toml
/docs/superpowers/plans
/docs/superpowers/specs
14 changes: 14 additions & 0 deletions docs/pyproject.md
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,20 @@ If a VCS is being used for a package, the exclude field will be seeded with the
VCS ignore settings can be negated by adding entries in `include`; be sure to explicitly set the `format` as above.
{{% /note %}}

### exclude-newer

The `exclude-newer` field in `[tool.poetry]` allows you to exclude package versions that were published within a specified duration. This is useful for avoiding recently published packages with unknown vulnerabilities.

```toml
[tool.poetry]
exclude-newer = "1 week"
```

Supported duration formats include: `seconds`, `minutes`, `hours`, `days`, `weeks`, `months`, `years` (e.g., "3 days", "2 weeks", "1 month").

When set, Poetry will not consider any package version where any file was uploaded within the specified duration. If a package version has multiple files (e.g., wheel and source tarball), it will be excluded if **any** file was uploaded within the duration. Packages without upload time information are included by default (assumed to be older).
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (typo): Clarify the parenthetical phrase to make the sentence grammatically complete.

The parenthetical "(assumed to be older)" reads as a fragment. Consider making it a complete clause, e.g. "…are included by default; they are assumed to be older" or "…, and are assumed to be older."

Suggested implementation:

Supported duration formats include: `seconds`, `minutes`, `hours`, `days`, `weeks`, `months`, `years` (e.g., "3 days", "2 weeks", "1 month").

When set, Poetry will not consider any package version where any file was uploaded within the specified duration. If a package version has multiple files (e.g., wheel and source tarball), it will be excluded if **any** file was uploaded within the duration. Packages without upload time information are included by default and are assumed to be older.

Supported duration formats include: `seconds`, `minutes`, `hours`, `days`, `weeks`, `months`, `years` (e.g., "3 days", "2 weeks", "1 month").

When set, Poetry will not consider any package version where any file was uploaded within the specified duration. If a package version has multiple files (e.g., wheel and source tarball), it will be excluded if **any** file was uploaded within the duration. Packages without upload time information are included by default and are assumed to be older.



### dependencies and dependency groups

Poetry is configured to look for dependencies on [PyPI](https://pypi.org) by default.
Expand Down
1,166 changes: 633 additions & 533 deletions poetry.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ dependencies = [
"findpython (>=0.6.2,<0.8.0)",
# pbs-installer uses calver, so version is unclamped
"pbs-installer[download,install] (>=2025.6.10)",
"pytimeparse (>=1.1.8)",
"python-dateutil (>=2.9.0)"
]
authors = [
{ name = "Sébastien Eustace", email = "sebastien@eustace.io" }
Expand Down Expand Up @@ -196,6 +198,7 @@ markers = [
]
log_cli_level = "INFO"
xfail_strict = true
pythonpath = ["src"]


[tool.coverage.report]
Expand Down
1 change: 1 addition & 0 deletions src/poetry/console/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,7 @@ def configure_installer_for_command(command: InstallerCommand, io: IO) -> None:
poetry.config,
disable_cache=poetry.disable_cache,
build_constraints=poetry.build_constraints,
exclude_newer=poetry.exclude_newer,
)
command.set_installer(installer)

Expand Down
1 change: 1 addition & 0 deletions src/poetry/console/commands/installer_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def reset_poetry(self) -> None:

self.installer.set_package(self.poetry.package)
self.installer.set_locker(self.poetry.locker)
self.installer.set_exclude_newer(self.poetry.exclude_newer)

@property
def installer(self) -> Installer:
Expand Down
7 changes: 6 additions & 1 deletion src/poetry/console/commands/show.py
Original file line number Diff line number Diff line change
Expand Up @@ -652,7 +652,12 @@ def find_latest_package(
if package.is_direct_origin():
for dep in requires:
if dep.name == package.name and dep.source_type == package.source_type:
provider = Provider(root, self.poetry.pool, NullIO())
provider = Provider(
root,
self.poetry.pool,
NullIO(),
exclude_newer=self.poetry.exclude_newer,
)
return provider.search_for_direct_origin_dependency(dep)

allow_prereleases: bool | None = None
Expand Down
8 changes: 8 additions & 0 deletions src/poetry/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from poetry.poetry import Poetry
from poetry.pyproject.toml import PyProjectTOML
from poetry.toml.file import TOMLFile
from poetry.utils.duration import parse_duration
from poetry.utils.isolated_build import CONSTRAINTS_GROUP_NAME


Expand Down Expand Up @@ -79,6 +80,11 @@ def create_poetry(

base_poetry = super().create_poetry(cwd=cwd, with_groups=with_groups)

# Parse exclude-newer duration if specified
exclude_newer = None
if exclude_newer_str := base_poetry.local_config.get("exclude-newer"):
exclude_newer = parse_duration(exclude_newer_str)

build_constraints: dict[NormalizedName, list[Dependency]] = {}
for name, constraints in base_poetry.local_config.get(
"build-constraints", {}
Expand Down Expand Up @@ -131,6 +137,8 @@ def create_poetry(
build_constraints=build_constraints,
)

poetry.set_exclude_newer(exclude_newer)

poetry.set_pool(
self.create_pool(
config,
Expand Down
9 changes: 9 additions & 0 deletions src/poetry/installation/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
if TYPE_CHECKING:
from collections.abc import Iterable
from collections.abc import Mapping
from datetime import datetime

from cleo.io.io import IO
from packaging.utils import NormalizedName
Expand Down Expand Up @@ -47,13 +48,15 @@ def __init__(
disable_cache: bool = False,
*,
build_constraints: Mapping[NormalizedName, list[Dependency]] | None = None,
exclude_newer: datetime | None = None,
) -> None:
self._io = io
self._env = env
self._package = package
self._locker = locker
self._pool = pool
self._config = config
self._exclude_newer = exclude_newer

self._dry_run = False
self._requires_synchronization = False
Expand Down Expand Up @@ -84,6 +87,9 @@ def __init__(

self._installed_repository = installed

def set_exclude_newer(self, exclude_newer: datetime | None) -> None:
self._exclude_newer = exclude_newer

@property
def executor(self) -> Executor:
return self._executor
Expand Down Expand Up @@ -196,6 +202,7 @@ def _do_refresh(self) -> int:
locked_repository.packages,
locked_repository.packages,
self._io,
exclude_newer=self._exclude_newer,
)

# Always re-solve directory dependencies, otherwise we can't determine
Expand Down Expand Up @@ -243,6 +250,7 @@ def _do_install(self) -> int:
self._installed_repository.packages,
locked_repository.packages,
self._io,
exclude_newer=self._exclude_newer,
)

with solver.provider.use_source_root(
Expand Down Expand Up @@ -316,6 +324,7 @@ def _do_install(self) -> int:
locked_repository.packages,
NullIO(),
active_root_extras=self._extras,
exclude_newer=self._exclude_newer,
)
# Everything is resolved at this point, so we no longer need
# to load deferred dependencies (i.e. VCS, URL and path dependencies)
Expand Down
4 changes: 4 additions & 0 deletions src/poetry/json/schemas/poetry.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
"$ref": "#/definitions/dependencies"
}
}
},
"exclude-newer": {
"type": "string",
"description": "Duration string to exclude packages published within the specified duration (e.g., '1 week', '3 days')."
}
},
"definitions": {
Expand Down
10 changes: 10 additions & 0 deletions src/poetry/poetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

if TYPE_CHECKING:
from collections.abc import Mapping
from datetime import datetime
from pathlib import Path

from packaging.utils import NormalizedName
Expand Down Expand Up @@ -50,6 +51,7 @@ def __init__(
self._plugin_manager: PluginManager | None = None
self._disable_cache = disable_cache
self._build_constraints = build_constraints or {}
self._exclude_newer: datetime | None = None

@property
def pyproject(self) -> PyProjectTOML:
Expand Down Expand Up @@ -80,6 +82,10 @@ def disable_cache(self) -> bool:
def build_constraints(self) -> Mapping[NormalizedName, list[Dependency]]:
return self._build_constraints

@property
def exclude_newer(self) -> datetime | None:
return self._exclude_newer

def set_locker(self, locker: Locker) -> Poetry:
self._locker = locker

Expand All @@ -95,6 +101,10 @@ def set_config(self, config: Config) -> Poetry:

return self

def set_exclude_newer(self, exclude_newer: datetime | None) -> Poetry:
self._exclude_newer = exclude_newer
return self

def get_sources(self) -> list[Source]:
return [
Source(**source)
Expand Down
21 changes: 21 additions & 0 deletions src/poetry/puzzle/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@

from collections import defaultdict
from contextlib import contextmanager
from datetime import datetime
from datetime import timezone
from typing import TYPE_CHECKING
from typing import Any
from typing import ClassVar
from typing import cast

from cleo.ui.progress_indicator import ProgressIndicator
from dateutil.parser import isoparse # type: ignore[import-untyped]
from poetry.core.constraints.version import EmptyConstraint
from poetry.core.constraints.version import Version
from poetry.core.constraints.version import VersionRange
Expand Down Expand Up @@ -122,6 +125,7 @@ def __init__(
*,
locked: list[Package] | None = None,
active_root_extras: Collection[NormalizedName] | None = None,
exclude_newer: datetime | None = None,
) -> None:
self._package = package
self._pool = pool
Expand All @@ -140,6 +144,7 @@ def __init__(
self._active_root_extras = (
frozenset(active_root_extras) if active_root_extras is not None else None
)
self._exclude_newer = exclude_newer

self._explicit_sources: dict[str, str] = {}
for package in locked or []:
Expand Down Expand Up @@ -302,6 +307,11 @@ def search_for(self, dependency: Dependency) -> list[DependencyPackage]:

packages = self._pool.find_packages(dependency)

if self._exclude_newer is not None:
packages = [
p for p in packages if not self._is_package_excluded_by_newer(p)
]

packages.sort(
key=lambda p: (
not p.yanked,
Expand All @@ -313,6 +323,17 @@ def search_for(self, dependency: Dependency) -> list[DependencyPackage]:

return PackageCollection(dependency, packages)

def _is_package_excluded_by_newer(self, package: Package) -> bool:
if self._exclude_newer is None:
return False
for file in package.files or []:
upload_time_str = file.get("upload_time")
if upload_time_str:
upload_time = isoparse(upload_time_str).astimezone(timezone.utc)
if upload_time > self._exclude_newer:
return True
return False

def _search_for_vcs(self, dependency: VCSDependency) -> Package:
"""
Search for the specifications that match the given VCS dependency.
Expand Down
3 changes: 3 additions & 0 deletions src/poetry/puzzle/solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from collections.abc import Collection
from collections.abc import Iterator
from collections.abc import Sequence
from datetime import datetime

from cleo.io.io import IO
from packaging.utils import NormalizedName
Expand Down Expand Up @@ -56,6 +57,7 @@ def __init__(
locked: list[Package],
io: IO,
active_root_extras: Collection[NormalizedName] | None = None,
exclude_newer: datetime | None = None,
) -> None:
self._package = package
self._pool = pool
Expand All @@ -69,6 +71,7 @@ def __init__(
self._io,
locked=locked,
active_root_extras=active_root_extras,
exclude_newer=exclude_newer,
)
self._overrides: list[dict[Package, dict[str, Dependency]]] = []

Expand Down
41 changes: 41 additions & 0 deletions src/poetry/utils/duration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from __future__ import annotations

import re

from datetime import datetime
from datetime import timezone

import pytimeparse # type: ignore[import-untyped]

from dateutil.relativedelta import relativedelta # type: ignore[import-untyped]


def parse_duration(duration_str: str) -> datetime:
"""
Parse a human-readable duration string to a datetime.

Converts strings like "1 week", "3 days", "2 months" to a datetime
representing the cutoff point (current time minus the duration).

:param duration_str: A human-readable duration string (e.g., "1 week")
:return: A datetime object representing the cutoff time
:raises ValueError: If the duration string is invalid
"""
if not duration_str:
raise ValueError("Invalid duration: empty string")

# Handle month-based durations explicitly since pytimeparse doesn't support them
month_match = re.match(r"^(\d+)\s*months?$", duration_str.strip(), re.IGNORECASE)
if month_match:
months = int(month_match.group(1))
now = datetime.now(timezone.utc)
return now - relativedelta(months=months) # type: ignore[no-any-return]

# pytimeparse returns seconds as an integer or None on failure
seconds = pytimeparse.parse(duration_str)

if seconds is None:
raise ValueError(f"Invalid duration: {duration_str!r}")

now = datetime.now(timezone.utc)
return now - relativedelta(seconds=seconds) # type: ignore[no-any-return]
4 changes: 2 additions & 2 deletions src/poetry/utils/env/base_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
if TYPE_CHECKING:
from packaging.tags import Tag
from poetry.core.version.markers import BaseMarker
from virtualenv.seed.wheels.util import Wheel

from poetry.utils.env.generic_env import GenericEnv

Expand Down Expand Up @@ -162,9 +161,10 @@ def find_executables(self) -> None:
self._find_pip_executable()

def get_embedded_wheel(self, distribution: str) -> Path:
wheel: Wheel = get_embed_wheel(
wheel = get_embed_wheel(
distribution, f"{self.version_info[0]}.{self.version_info[1]}"
)
assert wheel is not None
path: Path = wheel.path
return path

Expand Down
2 changes: 2 additions & 0 deletions tests/fixtures/complete.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ documentation = "https://python-poetry.org/docs"

keywords = ["packaging", "dependency", "poetry"]

exclude-newer = "1 week"

# Requirements
[tool.poetry.dependencies]
python = "~2.7 || ^3.2" # Compatible python versions must be declared here
Expand Down
Loading
Loading