11import contextlib
2+ from collections import defaultdict
23from packaging .specifiers import SpecifierSet
34from typing import Sequence , Dict
45import datetime
1516 read_toml ,
1617 write_toml ,
1718)
18- from packaging .version import Version , InvalidVersion
19+ from packaging .version import Version
20+ from packaging .utils import (
21+ InvalidSdistFilename ,
22+ InvalidWheelFilename ,
23+ canonicalize_name ,
24+ parse_sdist_filename ,
25+ parse_wheel_filename ,
26+ )
1927
2028__all__ = ["read_schedule" , "read_toml" , "write_toml" , "update_pyproject_toml" ]
2129
@@ -37,17 +45,12 @@ def _get_oldest_version_in_window(package: str, years: float) -> Version | None:
3745 data = resp .json ()
3846 except Exception :
3947 return None
40- candidates : list [Version ] = []
48+ release_dates : dict [Version , list [ datetime . datetime ]] = defaultdict ( list )
4149 for f in data .get ("files" , []):
42- parts = f .get ("filename" , "" ).split ("-" )
43- if len (parts ) < 2 :
44- continue
45- try :
46- ver = Version (parts [1 ])
47- except InvalidVersion :
48- continue
49- if ver .is_prerelease :
50+ ver = _version_from_filename (f .get ("filename" , "" ))
51+ if ver is None or ver .is_prerelease :
5052 continue
53+
5154 upload_str = f .get ("upload-time" , "" )
5255 upload_time = None
5356 for fmt in ["%Y-%m-%dT%H:%M:%S.%fZ" , "%Y-%m-%dT%H:%M:%SZ" ]:
@@ -56,21 +59,43 @@ def _get_oldest_version_in_window(package: str, years: float) -> Version | None:
5659 tzinfo = datetime .timezone .utc
5760 )
5861 break
59- if upload_time is None or upload_time < cutoff :
62+ if upload_time is None :
6063 continue
61- candidates .append (ver )
64+ release_dates [ ver ] .append (upload_time )
6265
66+ candidates = [
67+ ver
68+ for ver , upload_times in release_dates .items ()
69+ if min (upload_times ) >= cutoff
70+ ]
6371 return min (candidates , default = None )
6472
6573
66- def update_pyproject_dependencies (dependencies : dict , schedule : Dict [str , str ]):
74+ def _version_from_filename (filename : str ) -> Version | None :
75+ try :
76+ _ , version , _ , _ = parse_wheel_filename (filename )
77+ return version
78+ except InvalidWheelFilename :
79+ pass
80+
81+ try :
82+ _ , version = parse_sdist_filename (filename )
83+ return version
84+ except InvalidSdistFilename :
85+ return None
86+
87+
88+ def update_pyproject_dependencies (dependencies : list , schedule : Dict [str , str ]):
6789 # Iterate by idx because we want to update it inplace
6890 for i in range (len (dependencies )):
6991 dep_str = dependencies [i ]
92+ if not isinstance (dep_str , str ):
93+ continue
7094 pkg , extras , spec , env = parse_pep_dependency (dep_str )
71- if isinstance (spec , Url ) or pkg not in schedule :
95+ schedule_key = canonicalize_name (pkg )
96+ if isinstance (spec , Url ) or schedule_key not in schedule :
7297 continue
73- new_lower_bound = Version (schedule [pkg ])
98+ new_lower_bound = Version (schedule [schedule_key ])
7499 try :
75100 spec = tighten_lower_bound (spec or SpecifierSet (), new_lower_bound )
76101 # Will raise a value error if bound is already tighter, in this case we just do nothing and continue
@@ -85,26 +110,27 @@ def update_pyproject_dependencies(dependencies: dict, schedule: Dict[str, str]):
85110
86111def update_dependency_table (dep_table : dict , new_versions : dict ):
87112 for pkg , pkg_data in dep_table .items ():
113+ schedule_key = canonicalize_name (pkg )
88114 # Don't do anything for pkgs that aren't in our schedule
89- if pkg not in new_versions :
115+ if schedule_key not in new_versions :
90116 continue
91117 # Like pkg = ">x.y.z,<a"
92118 if isinstance (pkg_data , str ):
93119 if not is_url_spec (pkg_data ):
94120 spec = parse_version_spec (pkg_data )
95- new_lower_bound = Version (new_versions [pkg ])
121+ new_lower_bound = Version (new_versions [schedule_key ])
96122 spec = tighten_lower_bound (spec , new_lower_bound )
97123 dep_table [pkg ] = repr_spec_set (spec )
98124 else :
99125 # We don't do anything with url spec dependencies
100126 continue
101127 else :
102128 # Table like in tests = {path = "."}
103- if "path" in pkg_data :
104- # We don't do anything with path dependencies
129+ if not isinstance ( pkg_data , dict ) or "version" not in pkg_data :
130+ # We don't do anything with path, url, git, or other non-version dependencies
105131 continue
106- spec = SpecifierSet (pkg_data ["version" ])
107- new_lower_bound = Version (new_versions [pkg ])
132+ spec = parse_version_spec (pkg_data ["version" ])
133+ new_lower_bound = Version (new_versions [schedule_key ])
108134 spec = tighten_lower_bound (spec , new_lower_bound )
109135 pkg_data ["version" ] = repr_spec_set (spec )
110136
@@ -119,6 +145,8 @@ def update_pixi_dependencies(pixi_tables: dict, schedule: Dict[str, str]):
119145 for _ , feature_data in pixi_tables ["feature" ].items ():
120146 if "dependencies" in feature_data :
121147 update_dependency_table (feature_data ["dependencies" ], schedule )
148+ if "pypi-dependencies" in feature_data :
149+ update_dependency_table (feature_data ["pypi-dependencies" ], schedule )
122150
123151
124152def update_pyproject_toml (
@@ -138,26 +166,58 @@ def update_pyproject_toml(
138166 for schedule in applicable :
139167 # Fill in the latest known requirement (schedule is sorted, newer entries overwrite older)
140168 for pkg , version in schedule ["packages" ].items ():
141- new_version [pkg ] = version
169+ new_version [canonicalize_name ( pkg ) ] = version
142170 if not new_version :
143171 raise RuntimeError (
144172 "Could not find schedule that applies to current time, perhaps your schedule is outdated."
145173 )
146- if "python" in new_version :
147- pyproject_data ["project" ]["requires-python" ] = repr_spec_set (
148- parse_version_spec (new_version ["python" ])
149- )
150- update_pyproject_dependencies (
151- pyproject_data ["project" ]["dependencies" ], new_version
152- )
174+ project_data = pyproject_data .get ("project" , {})
175+ if not isinstance (project_data , dict ):
176+ project_data = {}
177+ if "python" in new_version and isinstance (project_data , dict ):
178+ current_requires_python = project_data .get ("requires-python" )
179+ if current_requires_python :
180+ try :
181+ python_spec = tighten_lower_bound (
182+ parse_version_spec (current_requires_python ),
183+ Version (new_version ["python" ]),
184+ )
185+ except ValueError :
186+ python_spec = parse_version_spec (current_requires_python )
187+ else :
188+ python_spec = parse_version_spec (new_version ["python" ])
189+ project_data ["requires-python" ] = repr_spec_set (python_spec )
190+
191+ dependencies = project_data .get ("dependencies" )
192+ if isinstance (dependencies , list ):
193+ update_pyproject_dependencies (dependencies , new_version )
194+
195+ optional_dependencies = project_data .get ("optional-dependencies" , {})
196+ if isinstance (optional_dependencies , dict ):
197+ for dependencies in optional_dependencies .values ():
198+ if isinstance (dependencies , list ):
199+ update_pyproject_dependencies (dependencies , new_version )
200+
201+ dependency_groups = pyproject_data .get ("dependency-groups" , {})
202+ if isinstance (dependency_groups , dict ):
203+ for dependencies in dependency_groups .values ():
204+ if isinstance (dependencies , list ):
205+ update_pyproject_dependencies (dependencies , new_version )
206+
153207 if "tool" in pyproject_data and "pixi" in pyproject_data ["tool" ]:
154208 pixi_data = pyproject_data ["tool" ]["pixi" ]
155209 update_pixi_dependencies (pixi_data , new_version )
156210 if update_all is not None :
157- deps = pyproject_data . get ( "project" , {}) .get ("dependencies" , [])
211+ deps = project_data .get ("dependencies" , [])
158212 for i , dep_str in enumerate (deps ):
213+ if not isinstance (dep_str , str ):
214+ continue
159215 pkg , extras , spec , env = parse_pep_dependency (dep_str )
160- if pkg in new_version or isinstance (spec , Url ) or spec is None :
216+ if (
217+ canonicalize_name (pkg ) in new_version
218+ or isinstance (spec , Url )
219+ or spec is None
220+ ):
161221 continue
162222 min_ver = _get_oldest_version_in_window (pkg , update_all )
163223 if min_ver is None :
0 commit comments