3333from . import overrides
3434from .candidate import Candidate
3535from .constraints import Constraints
36+ from .context import Cooldown
3637from .extras_provider import ExtrasProvider
3738from .http_retry import RETRYABLE_EXCEPTIONS , retry_on_exception
3839from .request_session import session
4849PYTHON_VERSION = Version (python_version ())
4950DEBUG_RESOLVER = os .environ .get ("DEBUG_RESOLVER" , "" )
5051PYPI_SERVER_URL = "https://pypi.org/simple"
52+
53+ # Sentinel meaning "inherit cooldown from ctx.pypi_cooldown".
54+ _UNSET : typing .Final [object ] = object ()
5155GITHUB_URL = "https://github.com"
5256
5357# all supported tags
@@ -85,8 +89,12 @@ def resolve(
8589 include_wheels : bool = True ,
8690 req_type : RequirementType | None = None ,
8791 ignore_platform : bool = False ,
92+ skip_cooldowns : bool = False ,
8893) -> tuple [str , Version ]:
8994 # Create the (reusable) resolver.
95+ extra_kwargs : dict [str , object ] = {}
96+ if skip_cooldowns :
97+ extra_kwargs ["cooldown" ] = None
9098 provider = overrides .find_and_invoke (
9199 req .name ,
92100 "get_resolver_provider" ,
@@ -98,6 +106,7 @@ def resolve(
98106 sdist_server_url = sdist_server_url ,
99107 req_type = req_type ,
100108 ignore_platform = ignore_platform ,
109+ ** extra_kwargs ,
101110 )
102111 return resolve_from_provider (provider , req )
103112
@@ -110,6 +119,7 @@ def default_resolver_provider(
110119 include_wheels : bool ,
111120 req_type : RequirementType | None = None ,
112121 ignore_platform : bool = False ,
122+ cooldown : Cooldown | None | object = _UNSET ,
113123) -> (
114124 PyPIProvider
115125 | GenericProvider
@@ -118,13 +128,19 @@ def default_resolver_provider(
118128 | VersionMapProvider
119129):
120130 """Lookup resolver provider to resolve package versions"""
131+ effective_cooldown = (
132+ ctx .pypi_cooldown
133+ if cooldown is _UNSET
134+ else typing .cast (Cooldown | None , cooldown )
135+ )
121136 return PyPIProvider (
122137 include_sdists = include_sdists ,
123138 include_wheels = include_wheels ,
124139 sdist_server_url = sdist_server_url ,
125140 constraints = ctx .constraints ,
126141 req_type = req_type ,
127142 ignore_platform = ignore_platform ,
143+ cooldown = effective_cooldown ,
128144 )
129145
130146
@@ -397,11 +413,13 @@ def __init__(
397413 constraints : Constraints | None = None ,
398414 req_type : RequirementType | None = None ,
399415 use_resolver_cache : bool = True ,
416+ cooldown : Cooldown | None = None ,
400417 ):
401418 super ().__init__ ()
402419 self .constraints = constraints or Constraints ()
403420 self .req_type = req_type
404421 self .use_cache_candidates = use_resolver_cache
422+ self .cooldown = cooldown
405423
406424 @property
407425 def cache_key (self ) -> str :
@@ -470,10 +488,38 @@ def validate_candidate(
470488 f"{ identifier } : skipping bad version { candidate .version } from { bad_versions } "
471489 )
472490 return False
473- for r in identifier_reqs :
474- if self .is_satisfied_by (requirement = r , candidate = candidate ):
475- return True
476- return False
491+
492+ if not any (
493+ self .is_satisfied_by (requirement = r , candidate = candidate )
494+ for r in identifier_reqs
495+ ):
496+ return False
497+
498+ # Fail closed: if upload_time is missing we cannot verify the package
499+ # is old enough, so we reject it rather than silently bypassing the policy.
500+ if self .cooldown is not None :
501+ if candidate .upload_time is None :
502+ if DEBUG_RESOLVER :
503+ logger .debug (
504+ "%s: skipping %s — upload_time unknown, required for cooldown" ,
505+ identifier ,
506+ candidate .version ,
507+ )
508+ return False
509+ cutoff = self .cooldown .bootstrap_time - self .cooldown .min_age
510+ if candidate .upload_time > cutoff :
511+ if DEBUG_RESOLVER :
512+ age = self .cooldown .bootstrap_time - candidate .upload_time
513+ logger .debug (
514+ "%s: skipping %s uploaded %s ago (cooldown: %s)" ,
515+ identifier ,
516+ candidate .version ,
517+ age ,
518+ self .cooldown .min_age ,
519+ )
520+ return False
521+
522+ return True
477523
478524 def get_preference (
479525 self ,
@@ -608,11 +654,13 @@ def __init__(
608654 ignore_platform : bool = False ,
609655 * ,
610656 use_resolver_cache : bool = True ,
657+ cooldown : Cooldown | None = None ,
611658 ):
612659 super ().__init__ (
613660 constraints = constraints ,
614661 req_type = req_type ,
615662 use_resolver_cache = use_resolver_cache ,
663+ cooldown = cooldown ,
616664 )
617665 self .include_sdists = include_sdists
618666 self .include_wheels = include_wheels
@@ -683,6 +731,35 @@ def _get_no_match_error_message(
683731 else :
684732 file_type_info = "wheels"
685733
734+ # If a cooldown is active, check whether it's responsible for the
735+ # failure so we can give a more actionable error message.
736+ if self .cooldown is not None :
737+ cutoff = self .cooldown .bootstrap_time - self .cooldown .min_age
738+ all_candidates = list (self ._find_cached_candidates (identifier ))
739+ missing_time = [c for c in all_candidates if c .upload_time is None ]
740+ cooldown_blocked = [
741+ c
742+ for c in all_candidates
743+ if c .upload_time is not None and c .upload_time > cutoff
744+ ]
745+ if missing_time and not cooldown_blocked :
746+ return (
747+ f"found { len (missing_time )} candidate(s) for { r } but none have "
748+ f"upload timestamp metadata; cannot enforce the "
749+ f"{ self .cooldown .min_age .days } -day cooldown"
750+ )
751+ if cooldown_blocked :
752+ oldest_days = min (
753+ (self .cooldown .bootstrap_time - c .upload_time ).days
754+ for c in cooldown_blocked
755+ if c .upload_time is not None
756+ )
757+ return (
758+ f"found { len (cooldown_blocked )} candidate(s) for { r } but all "
759+ f"are within the { self .cooldown .min_age .days } -day cooldown window "
760+ f"(oldest is { oldest_days } day(s) old)"
761+ )
762+
686763 return (
687764 f"found no match for { r } using { self .get_provider_description ()} , "
688765 f"searching for { file_type_info } , { prerelease_info } pre-release versions"
0 commit comments