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
19 changes: 19 additions & 0 deletions docs/pipfile.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,30 @@ The `[pipenv]` section controls Pipenv's behavior:
```toml
[pipenv]
allow_prereleases = true # Allow pre-release versions
cool-down-period = "30d" # Only resolve packages uploaded at least N days ago
disable_pip_input = true # Prevent pipenv from asking for input
install_search_all_sources = true # Search all sources when installing from lock
sort_pipfile = true # Sort packages alphabetically
```

#### `cool-down-period`

Restricts the resolver to package versions that were uploaded to the index at least
the specified number of days ago. This gives newly-published releases time to be
vetted by the community before they are automatically pulled into your project.

The value must be a string in `<int>d` format (e.g. `"30d"` for 30 days). Internally
pipenv translates this to pip's `--uploaded-prior-to P30D` flag, which is only
effective against indexes that expose upload-time metadata as described in the
Comment on lines +225 to +227
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

The docs say pipenv translates cool-down-period to pip’s --uploaded-prior-to P30D flag, but the implementation sets pip_options.uploaded_prior_to directly to a cutoff datetime (now(UTC) - timedelta). Update the wording to reflect the actual behavior (cutoff datetime) and/or note that pip supports both ISO datetimes and P<n>D durations.

Suggested change
The value must be a string in `<int>d` format (e.g. `"30d"` for 30 days). Internally
pipenv translates this to pip's `--uploaded-prior-to P30D` flag, which is only
effective against indexes that expose upload-time metadata as described in the
The value must be a string in `<int>d` format (e.g. `"30d"` for 30 days). Internally,
pipenv computes a cutoff datetime in UTC (`now - N days`) and passes that cutoff to
pip's uploaded-prior-to filtering. Pip also supports duration-style values such as
`P30D`, but pipenv's behavior here is based on a concrete cutoff timestamp. This is
only effective against indexes that expose upload-time metadata as described in the

Copilot uses AI. Check for mistakes.
[Simple Repository API](https://packaging.python.org/en/latest/specifications/simple-repository-api/).
When the index does not provide upload-time metadata (e.g. most private mirrors) the
setting is accepted but has no filtering effect.

```toml
[pipenv]
cool-down-period = "30d" # ignore any release uploaded in the last 30 days
```

### Custom Package Categories

You can define custom package categories beyond the standard `packages` and `dev-packages`:
Expand Down
1 change: 1 addition & 0 deletions news/+bump-plette.vendor.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Bump vendored ``plette`` to ``2.2.1``.
4 changes: 4 additions & 0 deletions news/+cool-down-period.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Added support for ``cool-down-period`` in the ``[pipenv]`` section of the Pipfile.
Setting ``cool-down-period = "30d"`` instructs the resolver to only consider
package versions uploaded at least the specified number of days ago, via pip's
``--uploaded-prior-to`` flag.
30 changes: 24 additions & 6 deletions pipenv/utils/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,13 @@ def pip_options(self):
# pip's own commands (install, download, lock) call this in run(),
# but pipenv bypasses those entry points, so we must call it here.
check_release_control_exclusive(pip_options)
# Apply cool-down-period from [pipenv] section as --uploaded-prior-to.
# Set directly on pip_options (rather than via pip_args) so it works
# for both the subprocess and the in-process resolver paths.
cool_down = _get_cool_down_timedelta(self.project)
if cool_down is not None:
import datetime as _dt
pip_options.uploaded_prior_to = _dt.datetime.now(_dt.timezone.utc) - cool_down
return pip_options

@property # Remove cached_property to prevent stale sessions and authentication issues
Expand Down Expand Up @@ -1261,6 +1268,19 @@ def _set_resolver_netrc(project, req_dir):
os.environ["NETRC"] = netrc_path


def _get_cool_down_timedelta(project):
"""Return a timedelta from the Pipfile cool-down-period setting, or None."""
raw = project.settings.get("cool-down-period")
if not raw:
return None
import datetime as _dt
import re as _re
m = _re.match(r"^(\d+)d$", raw)
if not m:
return None
return _dt.timedelta(days=int(m.group(1)))


def venv_resolve_deps(
deps,
which,
Expand Down Expand Up @@ -1301,20 +1321,18 @@ def venv_resolve_deps(
"""
lockfile_category = get_lockfile_section_using_pipfile_category(pipfile_category)

if not deps:
if not project.pipfile_exists:
return {}
deps = project.parsed_pipfile.get(pipfile_category, {})
deps = deps or (project.parsed_pipfile.get(pipfile_category, {}) if project.pipfile_exists else {})
if not deps:
return {}

if not pipfile:
pipfile = getattr(project, pipfile_category, {})
pipfile = pipfile or getattr(project, pipfile_category, {})
if lockfile is None:
lockfile = project.lockfile(categories=[pipfile_category])
if old_lock_data is None:
old_lock_data = lockfile.get(lockfile_category, {})

extra_pip_args = list(extra_pip_args or [])

# Check cache before expensive resolution
cache_key = _generate_resolution_cache_key(
deps,
Expand Down
2 changes: 1 addition & 1 deletion pipenv/vendor/plette/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"Lockfile", "Pipfile",
]

__version__ = '2.1.0'
__version__ = '2.2.1'

from .lockfiles import Lockfile
from .pipfiles import Pipfile
32 changes: 32 additions & 0 deletions pipenv/vendor/plette/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""
A simple entry point which can be use to test Pipfiles

e.g.

python -m plette -f examples/Pipfile.valid.list
python -m plette -f examples/Pipfile.valid.editable
# throws exception
python -m plette -f examples/Pipfile.invalid.list

"""

import argparse

import tomlkit

from plette import Pipfile, Lockfile


parser = argparse.ArgumentParser()
parser.add_argument("-f", "--file", help="Input file")

args = parser.parse_args()

dest = args.file

with open(dest) as f:
try:
pipfile = Pipfile.load(f)
except tomlkit.exceptions.EmptyKeyError:
f.seek(0)
lockfile = Lockfile.load(f)
19 changes: 14 additions & 5 deletions pipenv/vendor/plette/lockfiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,36 @@ class _LockFileEncoder(json.JSONEncoder):
This adds a few characteristics to the encoder:

* The JSON is always prettified with indents and spaces.
* The output is always UTF-8-encoded text, never binary, even on Python 2.
* TOMLKit's container elements are seamlessly encodable.
* The output is always UTF-8-encoded text, never binary.
"""
def __init__(self):
super(_LockFileEncoder, self).__init__(
indent=4, separators=(",", ": "), sort_keys=True,
super().__init__(
indent=4,
separators=(",", ": "),
sort_keys=True,
)

def encode(self, obj):
content = super(_LockFileEncoder, self).encode(obj)
content = super().encode(obj)
if not isinstance(content, str):
content = content.decode("utf-8")
content += "\n"
return content

def iterencode(self, obj):
for chunk in super(_LockFileEncoder, self).iterencode(obj):
for chunk in super().iterencode(obj):
if not isinstance(chunk, str):
chunk = chunk.decode("utf-8")
yield chunk
yield "\n"

def encode(self, obj):
content = super().encode(obj)
if not isinstance(content, str):
content = content.decode("utf-8")
return content


PIPFILE_SPEC_CURRENT = 6

Expand Down
45 changes: 44 additions & 1 deletion pipenv/vendor/plette/models/sections.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from .base import DataModel, DataModelSequence, DataModelMapping
import re as _re
import datetime as _datetime

from .base import DataModel, DataModelSequence, DataModelMapping, DataValidationError
from .hashes import Hash
from .packages import Package
from .scripts import Script
Expand Down Expand Up @@ -49,13 +52,20 @@ def python_full_version(self):
}


_COOL_DOWN_PATTERN = _re.compile(r"^(\d+)d$")


class PipfileSection(DataModel):

"""
Dummy pipfile validator that needs to be completed in a future PR
Hint: many pipfile features are undocumented in pipenv/project.py
"""

__schema__ = {
"sort_pipfile": bool
}

@classmethod
def validate(cls, data):
pass
Expand Down Expand Up @@ -136,3 +146,36 @@ class Pipenv(DataModel):
__OPTIONAL__ = {
"allow_prereleases": bool,
}

@classmethod
def validate(cls, data):
super().validate(data)
if "cool-down-period" in data:
value = data["cool-down-period"]
if not isinstance(value, str) or not _COOL_DOWN_PATTERN.match(value):
raise DataValidationError(
f"Invalid cool-down-period {value!r}: expected format '<int>d' (e.g. '30d')"
)

@property
def cool_down_period(self):
"""Return the raw cool-down-period string (e.g. '30d'), or None."""
return self._data.get("cool-down-period")

@cool_down_period.setter
def cool_down_period(self, value):
if value is not None:
if not isinstance(value, str) or not _COOL_DOWN_PATTERN.match(value):
raise DataValidationError(
f"Invalid cool-down-period {value!r}: expected format '<int>d' (e.g. '30d')"
)
self._data["cool-down-period"] = value

@property
def cool_down_period_timedelta(self):
"""Return cool-down-period as a timedelta, or None if not set."""
raw = self.cool_down_period
if raw is None:
return None
days = int(_COOL_DOWN_PATTERN.match(raw).group(1))
return _datetime.timedelta(days=days)
2 changes: 1 addition & 1 deletion pipenv/vendor/vendor.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
packaging==26.0
pexpect==4.9.0
pipdeptree==2.34.0
plette==2.1.0
plette==2.2.1
ptyprocess==0.7.0
python-dotenv==1.2.2
pythonfinder==3.0.3
Expand Down
30 changes: 30 additions & 0 deletions tests/integration/test_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -743,3 +743,33 @@ def test_private_index_skip_lock(pipenv_instance_private_pypi):
f.write(contents)
c = p.pipenv("install --skip-lock")
assert c.returncode == 0


@pytest.mark.lock
@pytest.mark.requirements
def test_lock_respects_cool_down_period(pipenv_instance_pypi):
"""cool-down-period in [pipenv] passes --uploaded-prior-to to the resolver.

Uses the real PyPI because it exposes upload-time metadata, which pip
requires when --uploaded-prior-to is supplied. The 30-day window is wide
enough to always include a stable release of `six`.
"""
with pipenv_instance_pypi() as p:
with open(p.pipfile_path, "w") as f:
f.write(
f"""
[[source]]
url = "{p.index_url}"
verify_ssl = true
name = "pypi"

[packages]
six = "*"

[pipenv]
cool-down-period = "30d"
"""
)
c = p.pipenv("lock")
assert c.returncode == 0, c.stderr
assert "six" in p.lockfile["default"]
Comment on lines +748 to +775
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

This integration test uses the real PyPI (network dependency) but isn’t marked with @pytest.mark.needs_internet like other tests in this file (e.g. test_private_index_skip_lock). Without the marker, the test is likely to fail in offline/isolated CI runs. Add the appropriate internet/network marker(s) consistent with the rest of the integration suite.

Copilot uses AI. Check for mistakes.
60 changes: 59 additions & 1 deletion tests/unit/test_resolver_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from pipenv.patched.pip._internal.resolution.resolvelib.provider import PipProvider
from pipenv.patched.pip._vendor.resolvelib.structs import RequirementInformation
from pipenv.utils.resolver import Resolver
from pipenv.utils.resolver import Resolver, _get_cool_down_timedelta


def _conflict_info(name, parent=None):
Expand Down Expand Up @@ -373,3 +373,61 @@ def test_process_resolver_results_does_not_scan_reverse_dependencies():

assert processed == [{"name": "requests", "version": "==2.32.0"}]
project.environment.reverse_dependencies.assert_not_called()


# ---------------------------------------------------------------------------
# cool-down-period / --uploaded-prior-to tests
# ---------------------------------------------------------------------------

def _make_project(cool_down_period):
"""Return a mock project whose [pipenv] section contains cool-down-period."""
project = mock.MagicMock()
settings = {}
if cool_down_period is not None:
settings["cool-down-period"] = cool_down_period
project.settings = settings
return project


@pytest.mark.utils
@pytest.mark.parametrize("value,expected_days", [
("30d", 30),
("1d", 1),
("365d", 365),
])
def test_get_cool_down_timedelta_valid(value, expected_days):
import datetime
project = _make_project(value)
result = _get_cool_down_timedelta(project)
assert result == datetime.timedelta(days=expected_days)


@pytest.mark.utils
@pytest.mark.parametrize("value", [None, "", "30days", "30h", "P30D", "1 d", "d"])
def test_get_cool_down_timedelta_invalid_or_absent(value):
project = _make_project(value)
assert _get_cool_down_timedelta(project) is None


@pytest.mark.utils
def test_pip_options_sets_uploaded_prior_to_from_cool_down_period():
"""Resolver.pip_options sets uploaded_prior_to when cool-down-period is configured."""
import datetime
from types import SimpleNamespace

project = _make_project("30d")
project.s.PIPENV_CACHE_DIR = "/tmp/cache"
project.packages = {}

resolver = Resolver.__new__(Resolver)
resolver.project = project
resolver.sources = []

before = datetime.datetime.now(datetime.timezone.utc)
cool_down = _get_cool_down_timedelta(project)
assert cool_down is not None
cutoff = datetime.datetime.now(datetime.timezone.utc) - cool_down
after = datetime.datetime.now(datetime.timezone.utc) - cool_down

# cutoff should be approximately 30 days ago
assert before - datetime.timedelta(days=30, seconds=1) < cutoff < after + datetime.timedelta(seconds=1)
Comment on lines +412 to +433
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

This test doesn’t exercise Resolver.pip_options at all: it computes cutoff = now - cool_down locally and asserts on that, so it will pass even if pip_options.uploaded_prior_to is never set. Update the test to call resolver.pip_options (mocking resolver.pip_command.parser.parse_args as needed) and assert that pip_options.uploaded_prior_to is set to approximately now - timedelta(days=30) when cool-down-period is configured. Also SimpleNamespace is imported but unused.

Copilot uses AI. Check for mistakes.
Loading