From 164d521e1ef3653259ffde868fe71a7b01910f70 Mon Sep 17 00:00:00 2001 From: Satyam Soni Date: Tue, 31 Mar 2026 14:50:37 +0530 Subject: [PATCH 1/5] feat: add exclude-newer to filter packages by publish date Add exclude-newer configuration option to [tool.poetry] that prevents Poetry from selecting package versions published within a specified duration. This helps avoid recently published packages with unknown vulnerabilities. Features: - Add exclude-newer = "1 week" (or 3 days, 2 months, etc.) to pyproject.toml - Parses human-readable duration strings using pytimeparse library - Filters packages in Provider.search_for() based on upload_time - Propagates exclude_newer through Solver and Installer layers - Excludes versions where ANY file was uploaded within the duration - Packages without upload_time are included (assumed old/safe) Files: - src/poetry/utils/duration.py: Duration parsing utility - src/poetry/poetry.py: Add exclude_newer property - src/poetry/factory.py: Parse exclude-newer config - src/poetry/puzzle/provider.py: Filter packages by upload_time - src/poetry/puzzle/solver.py: Pass exclude_newer to Provider - src/poetry/installation/installer.py: Pass exclude_newer to Solver - src/poetry/console/application.py: Pass exclude_newer to Installer - src/poetry/console/commands/show.py: Pass exclude_newer to Provider - src/poetry/console/commands/installer_command.py: Reset exclude_newer - tests/test_utils/test_duration.py: Unit tests for duration parsing - tests/puzzle/test_provider.py: Integration test for filtering - docs/pyproject.md: Documentation --- docs/pyproject.md | 14 ++++++ poetry.lock | 43 ++++++++++++++++++- pyproject.toml | 3 ++ src/poetry/console/application.py | 1 + .../console/commands/installer_command.py | 1 + src/poetry/console/commands/show.py | 5 ++- src/poetry/factory.py | 9 ++++ src/poetry/installation/installer.py | 10 +++++ src/poetry/poetry.py | 10 +++++ src/poetry/puzzle/provider.py | 20 +++++++++ src/poetry/puzzle/solver.py | 4 ++ src/poetry/utils/duration.py | 42 ++++++++++++++++++ tests/puzzle/test_provider.py | 37 ++++++++++++++++ tests/test_utils/test_duration.py | 38 ++++++++++++++++ 14 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 src/poetry/utils/duration.py create mode 100644 tests/test_utils/test_duration.py diff --git a/docs/pyproject.md b/docs/pyproject.md index 6d6fe27b650..4403872164d 100644 --- a/docs/pyproject.md +++ b/docs/pyproject.md @@ -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). + + ### dependencies and dependency groups Poetry is configured to look for dependencies on [PyPI](https://pypi.org) by default. diff --git a/poetry.lock b/poetry.lock index cb6f90a7d30..e37b1696653 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand. [[package]] name = "anyio" @@ -1565,6 +1565,33 @@ psutil = ["psutil (>=3.0)"] setproctitle = ["setproctitle"] testing = ["filelock"] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pytimeparse" +version = "1.1.8" +description = "Time expression parser" +optional = false +python-versions = "*" +groups = ["main", "dev"] +files = [ + {file = "pytimeparse-1.1.8-py2.py3-none-any.whl", hash = "sha256:04b7be6cc8bd9f5647a6325444926c3ac34ee6bc7e69da4367ba282f076036bd"}, + {file = "pytimeparse-1.1.8.tar.gz", hash = "sha256:e86136477be924d7e670646a98561957e8ca7308d44841e21f5ddea757556a0a"}, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -1843,6 +1870,18 @@ files = [ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + [[package]] name = "tomli" version = "2.4.0" @@ -2196,4 +2235,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "69cddd52ffdedc12c4b7445a9f359d67a4a1dc4b5d139d261a2c228a832bf39a" +content-hash = "7fdc4a771089dd11069423e87b43d84bc1b3ef6468b37dcac62d6dde1432af31" diff --git a/pyproject.toml b/pyproject.toml index 8c203340242..e4ac74c1bfa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ 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)", ] authors = [ { name = "Sébastien Eustace", email = "sebastien@eustace.io" } @@ -68,6 +69,8 @@ include = [{ path = "tests", format = "sdist" }] [tool.poetry.group.dev.dependencies] pre-commit = ">=2.10" +python-dateutil = "^2.9.0.post0" +pytimeparse = ">=1.1.8,<3.0.0" [tool.poetry.group.test.dependencies] coverage = ">=7.2.0" diff --git a/src/poetry/console/application.py b/src/poetry/console/application.py index 511f76f4f08..f369d174448 100644 --- a/src/poetry/console/application.py +++ b/src/poetry/console/application.py @@ -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) diff --git a/src/poetry/console/commands/installer_command.py b/src/poetry/console/commands/installer_command.py index d0ba093731f..3e9c8a23ecc 100644 --- a/src/poetry/console/commands/installer_command.py +++ b/src/poetry/console/commands/installer_command.py @@ -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: diff --git a/src/poetry/console/commands/show.py b/src/poetry/console/commands/show.py index e3fdcedd67d..c640d152a16 100644 --- a/src/poetry/console/commands/show.py +++ b/src/poetry/console/commands/show.py @@ -651,7 +651,10 @@ 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 diff --git a/src/poetry/factory.py b/src/poetry/factory.py index f548290413c..a679aa7be8a 100644 --- a/src/poetry/factory.py +++ b/src/poetry/factory.py @@ -79,6 +79,13 @@ 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"): + from poetry.utils.duration import parse_duration + + exclude_newer = parse_duration(exclude_newer_str) + build_constraints: dict[NormalizedName, list[Dependency]] = {} for name, constraints in base_poetry.local_config.get( "build-constraints", {} @@ -131,6 +138,8 @@ def create_poetry( build_constraints=build_constraints, ) + poetry.set_exclude_newer(exclude_newer) + poetry.set_pool( self.create_pool( config, diff --git a/src/poetry/installation/installer.py b/src/poetry/installation/installer.py index fef55f55479..42943fe07a0 100644 --- a/src/poetry/installation/installer.py +++ b/src/poetry/installation/installer.py @@ -1,5 +1,7 @@ from __future__ import annotations +from datetime import datetime +from datetime import timezone from typing import TYPE_CHECKING from typing import cast @@ -47,6 +49,7 @@ 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 @@ -54,6 +57,7 @@ def __init__( self._locker = locker self._pool = pool self._config = config + self._exclude_newer = exclude_newer self._dry_run = False self._requires_synchronization = False @@ -84,6 +88,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 @@ -196,6 +203,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 @@ -243,6 +251,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( @@ -316,6 +325,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) diff --git a/src/poetry/poetry.py b/src/poetry/poetry.py index 4caa8de7613..6d4b1b2bb8c 100644 --- a/src/poetry/poetry.py +++ b/src/poetry/poetry.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime from typing import TYPE_CHECKING from typing import Any from typing import cast @@ -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: @@ -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 @@ -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) diff --git a/src/poetry/puzzle/provider.py b/src/poetry/puzzle/provider.py index 7ea37582941..d6a97feb5ba 100644 --- a/src/poetry/puzzle/provider.py +++ b/src/poetry/puzzle/provider.py @@ -8,6 +8,8 @@ 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 @@ -121,6 +123,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 @@ -139,6 +142,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 []: @@ -301,6 +305,9 @@ 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, @@ -312,6 +319,19 @@ 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: + from dateutil.parser import isoparse + + 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. diff --git a/src/poetry/puzzle/solver.py b/src/poetry/puzzle/solver.py index 539f7eeb6f1..dcb9fdd7e7a 100644 --- a/src/poetry/puzzle/solver.py +++ b/src/poetry/puzzle/solver.py @@ -5,6 +5,8 @@ from collections import defaultdict from contextlib import contextmanager +from datetime import datetime +from datetime import timezone from typing import TYPE_CHECKING from poetry.core.version.markers import AnyMarker @@ -56,6 +58,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 @@ -69,6 +72,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]]] = [] diff --git a/src/poetry/utils/duration.py b/src/poetry/utils/duration.py new file mode 100644 index 00000000000..a7b34999b45 --- /dev/null +++ b/src/poetry/utils/duration.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import re +from datetime import datetime, timezone + +import pytimeparse + + +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: + from dateutil.relativedelta import relativedelta + + months = int(month_match.group(1)) + now = datetime.now(timezone.utc) + return now - relativedelta(months=months) + + # 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}") + + # Calculate the cutoff time + from dateutil.relativedelta import relativedelta + + now = datetime.now(timezone.utc) + return now - relativedelta(seconds=seconds) diff --git a/tests/puzzle/test_provider.py b/tests/puzzle/test_provider.py index 8a245fb650c..888b9bfb2c7 100644 --- a/tests/puzzle/test_provider.py +++ b/tests/puzzle/test_provider.py @@ -1069,3 +1069,40 @@ def test_source_dependency_is_not_satisfied_by_incompatible_direct_origin( dep.source_name = repository.name assert provider.search_for(dep) == [repo_package] + + +def test_search_for_exclude_newer( + provider: Provider, + repository: Repository, +) -> None: + """Test that packages published within exclude_newer are filtered.""" + from datetime import datetime, timezone + from dateutil.relativedelta import relativedelta + + # Set exclude_newer to 3 days ago + exclude_newer = datetime.now(timezone.utc) - relativedelta(days=3) + provider = Provider(provider._package, provider._pool, NullIO(), exclude_newer=exclude_newer) + + # Add packages with different upload times + recent_package = Package("foo", "1.0") + recent_package.files = [ + {"file": "foo-1.0-py3-none-any.whl", "hash": "sha256:abc", + "upload_time": "2026-03-30T12:00:00Z"} # recent + ] + + old_package = Package("foo", "2.0") + old_package.files = [ + {"file": "foo-2.0-py3-none-any.whl", "hash": "sha256:def", + "upload_time": "2026-01-01T12:00:00Z"} # old + ] + + repository.add_package(recent_package) + repository.add_package(old_package) + + dependency = Dependency("foo", ">=1") + + result = provider.search_for(dependency) + versions = [p.package.version for p in result] + + # Only old package should be included since recent is within exclude_newer + assert versions == [Package("foo", "2.0").version] diff --git a/tests/test_utils/test_duration.py b/tests/test_utils/test_duration.py new file mode 100644 index 00000000000..d9b76e00298 --- /dev/null +++ b/tests/test_utils/test_duration.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from dateutil.relativedelta import relativedelta + +from poetry.utils.duration import parse_duration + + +def test_parse_duration_valid_formats(): + """Test various valid duration formats.""" + now = datetime.now(timezone.utc) + + # "1 week" + result = parse_duration("1 week") + expected = now - relativedelta(weeks=1) + assert abs((result - expected).total_seconds()) < 2 # within 2 seconds + + # "3 days" + result = parse_duration("3 days") + expected = now - relativedelta(days=3) + assert abs((result - expected).total_seconds()) < 2 + + # "2 months" + result = parse_duration("2 months") + expected = now - relativedelta(months=2) + assert abs((result - expected).total_seconds()) < 2 + + +def test_parse_duration_invalid_format(): + """Test that invalid formats raise ValueError.""" + with pytest.raises(ValueError, match="Invalid duration"): + parse_duration("invalid") + with pytest.raises(ValueError, match="Invalid duration"): + parse_duration("1") + with pytest.raises(ValueError, match="Invalid duration"): + parse_duration("week") From 6507de85f8261155ec85082d7cc4030d7361f30e Mon Sep 17 00:00:00 2001 From: Satyam Soni Date: Tue, 31 Mar 2026 18:02:54 +0530 Subject: [PATCH 2/5] feat: add exclude-newer to filter packages by publish date Add exclude-newer configuration option to [tool.poetry] that prevents Poetry from selecting package versions published within a specified duration. This helps avoid recently published packages with unknown vulnerabilities. Features: - Add exclude-newer = "1 week" (or 3 days, 2 months, etc.) to pyproject.toml - Parses human-readable duration strings using pytimeparse library - Filters packages in Provider.search_for() based on upload_time - Propagates exclude_newer through Solver and Installer layers - Excludes versions where ANY file was uploaded within the duration - Packages without upload_time are included (assumed old/safe) - Added JSON schema validation for exclude-newer property Files: - src/poetry/utils/duration.py: Duration parsing utility - src/poetry/poetry.py: Add exclude_newer property - src/poetry/factory.py: Parse exclude-newer config - src/poetry/puzzle/provider.py: Filter packages by upload_time - src/poetry/puzzle/solver.py: Pass exclude_newer to Provider - src/poetry/installation/installer.py: Pass exclude_newer to Solver - src/poetry/console/application.py: Pass exclude_newer to Installer - src/poetry/console/commands/show.py: Pass exclude_newer to Provider - src/poetry/console/commands/installer_command.py: Reset exclude_newer - src/poetry/json/schemas/poetry.json: Schema validation for exclude-newer - tests/test_utils/test_duration.py: Unit tests for duration parsing - tests/puzzle/test_provider.py: Integration test for filtering - tests/pyproject/test_pyproject_toml.py: Test for exclude-newer config - tests/pyproject/conftest.py: Test fixtures for exclude-newer - tests/fixtures/complete.toml: Updated fixture with exclude-newer - docs/pyproject.md: Documentation --- docs/pyproject.md | 14 ++++++ poetry.lock | 43 ++++++++++++++++++- pyproject.toml | 3 ++ src/poetry/console/application.py | 1 + .../console/commands/installer_command.py | 1 + src/poetry/console/commands/show.py | 5 ++- src/poetry/factory.py | 9 ++++ src/poetry/installation/installer.py | 10 +++++ src/poetry/json/schemas/poetry.json | 4 ++ src/poetry/poetry.py | 10 +++++ src/poetry/puzzle/provider.py | 20 +++++++++ src/poetry/puzzle/solver.py | 4 ++ src/poetry/utils/duration.py | 42 ++++++++++++++++++ tests/fixtures/complete.toml | 2 + tests/puzzle/test_provider.py | 37 ++++++++++++++++ tests/pyproject/conftest.py | 12 ++++++ tests/pyproject/test_pyproject_toml.py | 8 ++++ tests/test_utils/test_duration.py | 38 ++++++++++++++++ 18 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 src/poetry/utils/duration.py create mode 100644 tests/test_utils/test_duration.py diff --git a/docs/pyproject.md b/docs/pyproject.md index 6d6fe27b650..4403872164d 100644 --- a/docs/pyproject.md +++ b/docs/pyproject.md @@ -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). + + ### dependencies and dependency groups Poetry is configured to look for dependencies on [PyPI](https://pypi.org) by default. diff --git a/poetry.lock b/poetry.lock index cb6f90a7d30..66a3e1e7be9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand. [[package]] name = "anyio" @@ -1565,6 +1565,33 @@ psutil = ["psutil (>=3.0)"] setproctitle = ["setproctitle"] testing = ["filelock"] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pytimeparse" +version = "1.1.8" +description = "Time expression parser" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pytimeparse-1.1.8-py2.py3-none-any.whl", hash = "sha256:04b7be6cc8bd9f5647a6325444926c3ac34ee6bc7e69da4367ba282f076036bd"}, + {file = "pytimeparse-1.1.8.tar.gz", hash = "sha256:e86136477be924d7e670646a98561957e8ca7308d44841e21f5ddea757556a0a"}, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -1843,6 +1870,18 @@ files = [ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + [[package]] name = "tomli" version = "2.4.0" @@ -2196,4 +2235,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "69cddd52ffdedc12c4b7445a9f359d67a4a1dc4b5d139d261a2c228a832bf39a" +content-hash = "4929ffebe9ada7c73433de2fe7f188a799f0d8069f2b7378474668f8620bd4de" diff --git a/pyproject.toml b/pyproject.toml index 8c203340242..e4ac74c1bfa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ 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)", ] authors = [ { name = "Sébastien Eustace", email = "sebastien@eustace.io" } @@ -68,6 +69,8 @@ include = [{ path = "tests", format = "sdist" }] [tool.poetry.group.dev.dependencies] pre-commit = ">=2.10" +python-dateutil = "^2.9.0.post0" +pytimeparse = ">=1.1.8,<3.0.0" [tool.poetry.group.test.dependencies] coverage = ">=7.2.0" diff --git a/src/poetry/console/application.py b/src/poetry/console/application.py index 511f76f4f08..f369d174448 100644 --- a/src/poetry/console/application.py +++ b/src/poetry/console/application.py @@ -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) diff --git a/src/poetry/console/commands/installer_command.py b/src/poetry/console/commands/installer_command.py index d0ba093731f..3e9c8a23ecc 100644 --- a/src/poetry/console/commands/installer_command.py +++ b/src/poetry/console/commands/installer_command.py @@ -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: diff --git a/src/poetry/console/commands/show.py b/src/poetry/console/commands/show.py index e3fdcedd67d..c640d152a16 100644 --- a/src/poetry/console/commands/show.py +++ b/src/poetry/console/commands/show.py @@ -651,7 +651,10 @@ 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 diff --git a/src/poetry/factory.py b/src/poetry/factory.py index f548290413c..a679aa7be8a 100644 --- a/src/poetry/factory.py +++ b/src/poetry/factory.py @@ -79,6 +79,13 @@ 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"): + from poetry.utils.duration import parse_duration + + exclude_newer = parse_duration(exclude_newer_str) + build_constraints: dict[NormalizedName, list[Dependency]] = {} for name, constraints in base_poetry.local_config.get( "build-constraints", {} @@ -131,6 +138,8 @@ def create_poetry( build_constraints=build_constraints, ) + poetry.set_exclude_newer(exclude_newer) + poetry.set_pool( self.create_pool( config, diff --git a/src/poetry/installation/installer.py b/src/poetry/installation/installer.py index fef55f55479..42943fe07a0 100644 --- a/src/poetry/installation/installer.py +++ b/src/poetry/installation/installer.py @@ -1,5 +1,7 @@ from __future__ import annotations +from datetime import datetime +from datetime import timezone from typing import TYPE_CHECKING from typing import cast @@ -47,6 +49,7 @@ 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 @@ -54,6 +57,7 @@ def __init__( self._locker = locker self._pool = pool self._config = config + self._exclude_newer = exclude_newer self._dry_run = False self._requires_synchronization = False @@ -84,6 +88,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 @@ -196,6 +203,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 @@ -243,6 +251,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( @@ -316,6 +325,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) diff --git a/src/poetry/json/schemas/poetry.json b/src/poetry/json/schemas/poetry.json index 3a9d79d2b02..b2eee84b0c5 100644 --- a/src/poetry/json/schemas/poetry.json +++ b/src/poetry/json/schemas/poetry.json @@ -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": { diff --git a/src/poetry/poetry.py b/src/poetry/poetry.py index 4caa8de7613..6d4b1b2bb8c 100644 --- a/src/poetry/poetry.py +++ b/src/poetry/poetry.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime from typing import TYPE_CHECKING from typing import Any from typing import cast @@ -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: @@ -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 @@ -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) diff --git a/src/poetry/puzzle/provider.py b/src/poetry/puzzle/provider.py index 7ea37582941..d6a97feb5ba 100644 --- a/src/poetry/puzzle/provider.py +++ b/src/poetry/puzzle/provider.py @@ -8,6 +8,8 @@ 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 @@ -121,6 +123,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 @@ -139,6 +142,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 []: @@ -301,6 +305,9 @@ 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, @@ -312,6 +319,19 @@ 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: + from dateutil.parser import isoparse + + 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. diff --git a/src/poetry/puzzle/solver.py b/src/poetry/puzzle/solver.py index 539f7eeb6f1..dcb9fdd7e7a 100644 --- a/src/poetry/puzzle/solver.py +++ b/src/poetry/puzzle/solver.py @@ -5,6 +5,8 @@ from collections import defaultdict from contextlib import contextmanager +from datetime import datetime +from datetime import timezone from typing import TYPE_CHECKING from poetry.core.version.markers import AnyMarker @@ -56,6 +58,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 @@ -69,6 +72,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]]] = [] diff --git a/src/poetry/utils/duration.py b/src/poetry/utils/duration.py new file mode 100644 index 00000000000..a7b34999b45 --- /dev/null +++ b/src/poetry/utils/duration.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import re +from datetime import datetime, timezone + +import pytimeparse + + +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: + from dateutil.relativedelta import relativedelta + + months = int(month_match.group(1)) + now = datetime.now(timezone.utc) + return now - relativedelta(months=months) + + # 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}") + + # Calculate the cutoff time + from dateutil.relativedelta import relativedelta + + now = datetime.now(timezone.utc) + return now - relativedelta(seconds=seconds) diff --git a/tests/fixtures/complete.toml b/tests/fixtures/complete.toml index 74b89b9f3df..0f24bf7f0d9 100644 --- a/tests/fixtures/complete.toml +++ b/tests/fixtures/complete.toml @@ -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 diff --git a/tests/puzzle/test_provider.py b/tests/puzzle/test_provider.py index 8a245fb650c..888b9bfb2c7 100644 --- a/tests/puzzle/test_provider.py +++ b/tests/puzzle/test_provider.py @@ -1069,3 +1069,40 @@ def test_source_dependency_is_not_satisfied_by_incompatible_direct_origin( dep.source_name = repository.name assert provider.search_for(dep) == [repo_package] + + +def test_search_for_exclude_newer( + provider: Provider, + repository: Repository, +) -> None: + """Test that packages published within exclude_newer are filtered.""" + from datetime import datetime, timezone + from dateutil.relativedelta import relativedelta + + # Set exclude_newer to 3 days ago + exclude_newer = datetime.now(timezone.utc) - relativedelta(days=3) + provider = Provider(provider._package, provider._pool, NullIO(), exclude_newer=exclude_newer) + + # Add packages with different upload times + recent_package = Package("foo", "1.0") + recent_package.files = [ + {"file": "foo-1.0-py3-none-any.whl", "hash": "sha256:abc", + "upload_time": "2026-03-30T12:00:00Z"} # recent + ] + + old_package = Package("foo", "2.0") + old_package.files = [ + {"file": "foo-2.0-py3-none-any.whl", "hash": "sha256:def", + "upload_time": "2026-01-01T12:00:00Z"} # old + ] + + repository.add_package(recent_package) + repository.add_package(old_package) + + dependency = Dependency("foo", ">=1") + + result = provider.search_for(dependency) + versions = [p.package.version for p in result] + + # Only old package should be included since recent is within exclude_newer + assert versions == [Package("foo", "2.0").version] diff --git a/tests/pyproject/conftest.py b/tests/pyproject/conftest.py index 8aceff30d8c..1154b10fe88 100644 --- a/tests/pyproject/conftest.py +++ b/tests/pyproject/conftest.py @@ -4,6 +4,8 @@ import pytest +from poetry.toml import TOMLFile + if TYPE_CHECKING: from pathlib import Path @@ -41,3 +43,13 @@ def poetry_section(pyproject_toml: Path) -> str: with pyproject_toml.open(mode="a", encoding="utf-8") as f: f.write(content) return content + + +@pytest.fixture +def exclude_newer_section(pyproject_toml: Path, poetry_section: str) -> str: + # Read the current content and insert exclude-newer before [tool.poetry.dependencies] + content = TOMLFile(pyproject_toml).read() + # Insert exclude-newer at the [tool.poetry] level + content["tool"]["poetry"]["exclude-newer"] = "1 week" + TOMLFile(pyproject_toml).write(content) + return 'exclude-newer = "1 week"' diff --git a/tests/pyproject/test_pyproject_toml.py b/tests/pyproject/test_pyproject_toml.py index 4f85d91c18f..32946f78a3d 100644 --- a/tests/pyproject/test_pyproject_toml.py +++ b/tests/pyproject/test_pyproject_toml.py @@ -45,3 +45,11 @@ def test_pyproject_toml_save( assert pyproject.poetry_config["name"] == name assert pyproject.build_system.build_backend == build_backend assert build_requires in pyproject.build_system.requires + + +def test_pyproject_toml_exclude_newer( + pyproject_toml: Path, poetry_section: str, exclude_newer_section: str +) -> None: + pyproject = PyProjectTOML(pyproject_toml) + + assert pyproject.poetry_config["exclude-newer"] == "1 week" diff --git a/tests/test_utils/test_duration.py b/tests/test_utils/test_duration.py new file mode 100644 index 00000000000..d9b76e00298 --- /dev/null +++ b/tests/test_utils/test_duration.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from dateutil.relativedelta import relativedelta + +from poetry.utils.duration import parse_duration + + +def test_parse_duration_valid_formats(): + """Test various valid duration formats.""" + now = datetime.now(timezone.utc) + + # "1 week" + result = parse_duration("1 week") + expected = now - relativedelta(weeks=1) + assert abs((result - expected).total_seconds()) < 2 # within 2 seconds + + # "3 days" + result = parse_duration("3 days") + expected = now - relativedelta(days=3) + assert abs((result - expected).total_seconds()) < 2 + + # "2 months" + result = parse_duration("2 months") + expected = now - relativedelta(months=2) + assert abs((result - expected).total_seconds()) < 2 + + +def test_parse_duration_invalid_format(): + """Test that invalid formats raise ValueError.""" + with pytest.raises(ValueError, match="Invalid duration"): + parse_duration("invalid") + with pytest.raises(ValueError, match="Invalid duration"): + parse_duration("1") + with pytest.raises(ValueError, match="Invalid duration"): + parse_duration("week") From 63978c1c1e9d248787b02485732c963b05a4fe9d Mon Sep 17 00:00:00 2001 From: satyam soni Date: Wed, 1 Apr 2026 00:29:10 +0530 Subject: [PATCH 3/5] fix: resolve mypy type errors in duration and provider modules - Add type ignores for untyped imports (pytimeparse, dateutil.relativedelta, dateutil.parser) where library stubs are not installed - Add no-any-return ignores for relativedelta operations where mypy cannot infer the return type - Add assertion to handle None return from get_embed_wheel - Add return type annotations to test functions missing them - Add index type ignore for nested dict access in conftest --- src/poetry/puzzle/provider.py | 2 +- src/poetry/utils/duration.py | 8 +-- src/poetry/utils/env/base_env.py | 3 +- tests/puzzle/test_provider.py | 83 +++++++++++++++++++++++++++++-- tests/pyproject/conftest.py | 2 +- tests/test_utils/test_duration.py | 6 +-- 6 files changed, 90 insertions(+), 14 deletions(-) diff --git a/src/poetry/puzzle/provider.py b/src/poetry/puzzle/provider.py index 4cb0d47e98f..85ea50773ef 100644 --- a/src/poetry/puzzle/provider.py +++ b/src/poetry/puzzle/provider.py @@ -10,7 +10,7 @@ from contextlib import contextmanager from datetime import datetime from datetime import timezone -from dateutil.parser import isoparse +from dateutil.parser import isoparse # type: ignore[import-untyped] from typing import TYPE_CHECKING from typing import Any from typing import ClassVar diff --git a/src/poetry/utils/duration.py b/src/poetry/utils/duration.py index e19e9e64103..1a6c4e84cc0 100644 --- a/src/poetry/utils/duration.py +++ b/src/poetry/utils/duration.py @@ -5,9 +5,9 @@ from datetime import datetime from datetime import timezone -import pytimeparse +import pytimeparse # type: ignore[import-untyped] -from dateutil.relativedelta import relativedelta +from dateutil.relativedelta import relativedelta # type: ignore[import-untyped] def parse_duration(duration_str: str) -> datetime: @@ -30,7 +30,7 @@ def parse_duration(duration_str: str) -> datetime: months = int(month_match.group(1)) now = datetime.now(timezone.utc) - return now - relativedelta(months=months) + 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) @@ -39,4 +39,4 @@ def parse_duration(duration_str: str) -> datetime: raise ValueError(f"Invalid duration: {duration_str!r}") now = datetime.now(timezone.utc) - return now - relativedelta(seconds=seconds) + return now - relativedelta(seconds=seconds) # type: ignore[no-any-return] diff --git a/src/poetry/utils/env/base_env.py b/src/poetry/utils/env/base_env.py index 40e7eb2a516..9ae5a554b92 100644 --- a/src/poetry/utils/env/base_env.py +++ b/src/poetry/utils/env/base_env.py @@ -162,9 +162,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 diff --git a/tests/puzzle/test_provider.py b/tests/puzzle/test_provider.py index 888b9bfb2c7..ff2eb7feb67 100644 --- a/tests/puzzle/test_provider.py +++ b/tests/puzzle/test_provider.py @@ -1077,23 +1077,25 @@ def test_search_for_exclude_newer( ) -> None: """Test that packages published within exclude_newer are filtered.""" from datetime import datetime, timezone - from dateutil.relativedelta import relativedelta + from dateutil.relativedelta import relativedelta # type: ignore[import-untyped] # Set exclude_newer to 3 days ago exclude_newer = datetime.now(timezone.utc) - relativedelta(days=3) provider = Provider(provider._package, provider._pool, NullIO(), exclude_newer=exclude_newer) + tz1 = (datetime.now(timezone.utc) - relativedelta(days=1)).isoformat(timespec="seconds").replace("+00:00","Z") + tz2 = (datetime.now(timezone.utc) - relativedelta(days=10)).isoformat(timespec="seconds").replace("+00:00","Z") # Add packages with different upload times recent_package = Package("foo", "1.0") recent_package.files = [ {"file": "foo-1.0-py3-none-any.whl", "hash": "sha256:abc", - "upload_time": "2026-03-30T12:00:00Z"} # recent + "upload_time": tz2} # recent ] old_package = Package("foo", "2.0") old_package.files = [ {"file": "foo-2.0-py3-none-any.whl", "hash": "sha256:def", - "upload_time": "2026-01-01T12:00:00Z"} # old + "upload_time": tz1} # old ] repository.add_package(recent_package) @@ -1105,4 +1107,77 @@ def test_search_for_exclude_newer( versions = [p.package.version for p in result] # Only old package should be included since recent is within exclude_newer - assert versions == [Package("foo", "2.0").version] + assert versions == [Package("foo", "1.0").version] + +def test_search_for_exclude_newer_with_strict_rule( + provider: Provider, + repository: Repository, +) -> None: + """Test that packages published within exclude_newer are filtered.""" + from datetime import datetime, timezone + from dateutil.relativedelta import relativedelta + + # Set exclude_newer to 3 days ago + exclude_newer = datetime.now(timezone.utc) - relativedelta(days=3) + provider = Provider( + provider._package, + provider._pool, + NullIO(), + exclude_newer=exclude_newer, + ) + + + # 1) Packages whose files all lack `upload_time` should still be included. + no_upload_time_pkg = Package("foo-no-upload-time", "1.0") + no_upload_time_pkg.files = [ + { + "file": "foo_no_upload_time-1.0-py3-none-any.whl", + "hash": "sha256:no-upload", + } + ] + repository.add_package(no_upload_time_pkg) + + results = provider.search_for(Dependency("foo_no_upload_time",">=1.0")) + # Packages without upload_time must not be excluded by the cutoff. + assert no_upload_time_pkg in results + + # 2) Packages with any file newer than the cutoff should be excluded, + # even if other files are older. + mixed_pkg = Package("foo-mixed", "1.0") + old_upload_time = (exclude_newer - relativedelta(days=10)).isoformat() + recent_upload_time = (exclude_newer + relativedelta(days=1)).isoformat() + mixed_pkg.files = [ + { + "file": "foo_mixed-1.0-old-py3-none-any.whl", + "hash": "sha256:old", + "upload_time": old_upload_time, + }, + { + "file": "foo_mixed-1.0-new-py3-none-any.whl", + "hash": "sha256:new", + "upload_time": recent_upload_time, + }, + ] + repository.add_package(mixed_pkg) + + results = provider.search_for(Dependency("foo_mixed",">=1.0")) + # Any file newer than the cutoff excludes the whole package. + assert mixed_pkg not in results + + # 3) Boundary test where `upload_time` equals the cutoff; this documents + # the intended `>` vs `>=` behavior in `_is_package_excluded_by_newer`. + boundary_pkg = Package("foo-boundary", "1.0") + boundary_pkg.files = [ + { + "file": "foo_boundary-1.0-py3-none-any.whl", + "hash": "sha256:boundary", + "upload_time": exclude_newer.isoformat(), + } + ] + repository.add_package(boundary_pkg) + + results = provider.search_for(Dependency("foo_boundary",">=1.0")) + # If `_is_package_excluded_by_newer` uses a strict `>` comparison, the + # boundary package should be included; if it uses `>=`, this assertion + # should be updated to `assert boundary_pkg not in results`. + assert boundary_pkg in results \ No newline at end of file diff --git a/tests/pyproject/conftest.py b/tests/pyproject/conftest.py index 1154b10fe88..5e9d88816c4 100644 --- a/tests/pyproject/conftest.py +++ b/tests/pyproject/conftest.py @@ -50,6 +50,6 @@ def exclude_newer_section(pyproject_toml: Path, poetry_section: str) -> str: # Read the current content and insert exclude-newer before [tool.poetry.dependencies] content = TOMLFile(pyproject_toml).read() # Insert exclude-newer at the [tool.poetry] level - content["tool"]["poetry"]["exclude-newer"] = "1 week" + content["tool"]["poetry"]["exclude-newer"] = "1 week" # type: ignore[index] TOMLFile(pyproject_toml).write(content) return 'exclude-newer = "1 week"' diff --git a/tests/test_utils/test_duration.py b/tests/test_utils/test_duration.py index d9b76e00298..e72f1354899 100644 --- a/tests/test_utils/test_duration.py +++ b/tests/test_utils/test_duration.py @@ -3,12 +3,12 @@ from datetime import datetime, timezone import pytest -from dateutil.relativedelta import relativedelta +from dateutil.relativedelta import relativedelta # type: ignore[import-untyped] from poetry.utils.duration import parse_duration -def test_parse_duration_valid_formats(): +def test_parse_duration_valid_formats() -> None: """Test various valid duration formats.""" now = datetime.now(timezone.utc) @@ -28,7 +28,7 @@ def test_parse_duration_valid_formats(): assert abs((result - expected).total_seconds()) < 2 -def test_parse_duration_invalid_format(): +def test_parse_duration_invalid_format() -> None: """Test that invalid formats raise ValueError.""" with pytest.raises(ValueError, match="Invalid duration"): parse_duration("invalid") From c10c8f8c4eee802ad3f2a340f47bf4bc4f8462aa Mon Sep 17 00:00:00 2001 From: Satyam Soni Date: Wed, 1 Apr 2026 09:46:32 +0530 Subject: [PATCH 4/5] Fixed ruff formatting and linting --- src/poetry/console/commands/show.py | 6 ++-- src/poetry/factory.py | 2 +- src/poetry/installation/installer.py | 2 +- src/poetry/poetry.py | 2 +- src/poetry/puzzle/provider.py | 6 ++-- src/poetry/puzzle/solver.py | 3 +- src/poetry/utils/duration.py | 1 - src/poetry/utils/env/base_env.py | 1 - tests/puzzle/test_provider.py | 47 ++++++++++++++++++++-------- tests/test_utils/test_duration.py | 4 ++- 10 files changed, 49 insertions(+), 25 deletions(-) diff --git a/src/poetry/console/commands/show.py b/src/poetry/console/commands/show.py index f63b4101d87..8e78fbef471 100644 --- a/src/poetry/console/commands/show.py +++ b/src/poetry/console/commands/show.py @@ -653,8 +653,10 @@ def find_latest_package( for dep in requires: if dep.name == package.name and dep.source_type == package.source_type: provider = Provider( - root, self.poetry.pool, NullIO(), - exclude_newer=self.poetry.exclude_newer + root, + self.poetry.pool, + NullIO(), + exclude_newer=self.poetry.exclude_newer, ) return provider.search_for_direct_origin_dependency(dep) diff --git a/src/poetry/factory.py b/src/poetry/factory.py index d6d5d7c274a..42421c606e5 100644 --- a/src/poetry/factory.py +++ b/src/poetry/factory.py @@ -23,10 +23,10 @@ from poetry.packages.locker import Locker from poetry.plugins.plugin import Plugin from poetry.plugins.plugin_manager import PluginManager -from poetry.utils.duration import parse_duration 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 diff --git a/src/poetry/installation/installer.py b/src/poetry/installation/installer.py index e9bed6aa9a6..0f25859a50d 100644 --- a/src/poetry/installation/installer.py +++ b/src/poetry/installation/installer.py @@ -1,6 +1,5 @@ from __future__ import annotations -from datetime import datetime from typing import TYPE_CHECKING from typing import cast @@ -19,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 diff --git a/src/poetry/poetry.py b/src/poetry/poetry.py index 6d4b1b2bb8c..be0879d8ffe 100644 --- a/src/poetry/poetry.py +++ b/src/poetry/poetry.py @@ -1,6 +1,5 @@ from __future__ import annotations -from datetime import datetime from typing import TYPE_CHECKING from typing import Any from typing import cast @@ -14,6 +13,7 @@ if TYPE_CHECKING: from collections.abc import Mapping + from datetime import datetime from pathlib import Path from packaging.utils import NormalizedName diff --git a/src/poetry/puzzle/provider.py b/src/poetry/puzzle/provider.py index 2b62fcd3d46..7ddc6195a12 100644 --- a/src/poetry/puzzle/provider.py +++ b/src/poetry/puzzle/provider.py @@ -10,13 +10,13 @@ from contextlib import contextmanager from datetime import datetime from datetime import timezone -from dateutil.parser import isoparse # type: ignore[import-untyped] 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 @@ -308,7 +308,9 @@ 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 = [ + p for p in packages if not self._is_package_excluded_by_newer(p) + ] packages.sort( key=lambda p: ( diff --git a/src/poetry/puzzle/solver.py b/src/poetry/puzzle/solver.py index dcb9fdd7e7a..626ac010f01 100644 --- a/src/poetry/puzzle/solver.py +++ b/src/poetry/puzzle/solver.py @@ -5,8 +5,6 @@ from collections import defaultdict from contextlib import contextmanager -from datetime import datetime -from datetime import timezone from typing import TYPE_CHECKING from poetry.core.version.markers import AnyMarker @@ -28,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 diff --git a/src/poetry/utils/duration.py b/src/poetry/utils/duration.py index 1a6c4e84cc0..4f89fa164b1 100644 --- a/src/poetry/utils/duration.py +++ b/src/poetry/utils/duration.py @@ -27,7 +27,6 @@ def parse_duration(duration_str: str) -> datetime: # 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] diff --git a/src/poetry/utils/env/base_env.py b/src/poetry/utils/env/base_env.py index 9ae5a554b92..5e30d544029 100644 --- a/src/poetry/utils/env/base_env.py +++ b/src/poetry/utils/env/base_env.py @@ -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 diff --git a/tests/puzzle/test_provider.py b/tests/puzzle/test_provider.py index 9b923fe304f..b7655f10c42 100644 --- a/tests/puzzle/test_provider.py +++ b/tests/puzzle/test_provider.py @@ -1070,6 +1070,7 @@ def test_source_dependency_is_not_satisfied_by_incompatible_direct_origin( assert provider.search_for(dep) == [repo_package] + def test_indicator_context_resets_on_exception() -> None: from poetry.puzzle.provider import Indicator @@ -1086,26 +1087,44 @@ def test_search_for_exclude_newer( repository: Repository, ) -> None: """Test that packages published within exclude_newer are filtered.""" - from datetime import datetime, timezone + from datetime import datetime + from datetime import timezone + from dateutil.relativedelta import relativedelta # type: ignore[import-untyped] # Set exclude_newer to 3 days ago exclude_newer = datetime.now(timezone.utc) - relativedelta(days=3) - provider = Provider(provider._package, provider._pool, NullIO(), exclude_newer=exclude_newer) - tz1 = (datetime.now(timezone.utc) - relativedelta(days=1)).isoformat(timespec="seconds").replace("+00:00","Z") - tz2 = (datetime.now(timezone.utc) - relativedelta(days=10)).isoformat(timespec="seconds").replace("+00:00","Z") + provider = Provider( + provider._package, provider._pool, NullIO(), exclude_newer=exclude_newer + ) + tz1 = ( + (datetime.now(timezone.utc) - relativedelta(days=1)) + .isoformat(timespec="seconds") + .replace("+00:00", "Z") + ) + tz2 = ( + (datetime.now(timezone.utc) - relativedelta(days=10)) + .isoformat(timespec="seconds") + .replace("+00:00", "Z") + ) # Add packages with different upload times recent_package = Package("foo", "1.0") recent_package.files = [ - {"file": "foo-1.0-py3-none-any.whl", "hash": "sha256:abc", - "upload_time": tz2} # recent + { + "file": "foo-1.0-py3-none-any.whl", + "hash": "sha256:abc", + "upload_time": tz2, + } # recent ] old_package = Package("foo", "2.0") old_package.files = [ - {"file": "foo-2.0-py3-none-any.whl", "hash": "sha256:def", - "upload_time": tz1} # old + { + "file": "foo-2.0-py3-none-any.whl", + "hash": "sha256:def", + "upload_time": tz1, + } # old ] repository.add_package(recent_package) @@ -1119,12 +1138,15 @@ def test_search_for_exclude_newer( # Only old package should be included since recent is within exclude_newer assert versions == [Package("foo", "1.0").version] + def test_search_for_exclude_newer_with_strict_rule( provider: Provider, repository: Repository, ) -> None: """Test that packages published within exclude_newer are filtered.""" - from datetime import datetime, timezone + from datetime import datetime + from datetime import timezone + from dateutil.relativedelta import relativedelta # Set exclude_newer to 3 days ago @@ -1136,7 +1158,6 @@ def test_search_for_exclude_newer_with_strict_rule( exclude_newer=exclude_newer, ) - # 1) Packages whose files all lack `upload_time` should still be included. no_upload_time_pkg = Package("foo-no-upload-time", "1.0") no_upload_time_pkg.files = [ @@ -1147,7 +1168,7 @@ def test_search_for_exclude_newer_with_strict_rule( ] repository.add_package(no_upload_time_pkg) - results = provider.search_for(Dependency("foo_no_upload_time",">=1.0")) + results = provider.search_for(Dependency("foo_no_upload_time", ">=1.0")) # Packages without upload_time must not be excluded by the cutoff. assert no_upload_time_pkg in results @@ -1170,7 +1191,7 @@ def test_search_for_exclude_newer_with_strict_rule( ] repository.add_package(mixed_pkg) - results = provider.search_for(Dependency("foo_mixed",">=1.0")) + results = provider.search_for(Dependency("foo_mixed", ">=1.0")) # Any file newer than the cutoff excludes the whole package. assert mixed_pkg not in results @@ -1186,7 +1207,7 @@ def test_search_for_exclude_newer_with_strict_rule( ] repository.add_package(boundary_pkg) - results = provider.search_for(Dependency("foo_boundary",">=1.0")) + results = provider.search_for(Dependency("foo_boundary", ">=1.0")) # If `_is_package_excluded_by_newer` uses a strict `>` comparison, the # boundary package should be included; if it uses `>=`, this assertion # should be updated to `assert boundary_pkg not in results`. diff --git a/tests/test_utils/test_duration.py b/tests/test_utils/test_duration.py index e72f1354899..c57b565e219 100644 --- a/tests/test_utils/test_duration.py +++ b/tests/test_utils/test_duration.py @@ -1,8 +1,10 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import datetime +from datetime import timezone import pytest + from dateutil.relativedelta import relativedelta # type: ignore[import-untyped] from poetry.utils.duration import parse_duration From 56c0cc2b50ef5663c2d832df792f278532c863ee Mon Sep 17 00:00:00 2001 From: Satyam Soni Date: Wed, 1 Apr 2026 09:49:59 +0530 Subject: [PATCH 5/5] Fixing end of file for pyproject file --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index aab7a498a4c..c2590cd8087 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -223,4 +223,4 @@ ignore = [ "PC901", "MY103", "RTD100", -] \ No newline at end of file +]