1- import contextlib
2- from collections import defaultdict
1+ from functools import cache
32from packaging .specifiers import SpecifierSet
4- from typing import Sequence , Dict
3+ from typing import Callable , Sequence , Dict
54import datetime
65import requests
76
2827__all__ = ["read_schedule" , "read_toml" , "write_toml" , "update_pyproject_toml" ]
2928
3029
30+ @cache
3131def _get_oldest_version_in_window (package : str , years : float ) -> Version | None :
3232 """
3333 Query PyPI, return oldest non-pre release version uploaded within the last ``years`` years.
@@ -45,28 +45,23 @@ def _get_oldest_version_in_window(package: str, years: float) -> Version | None:
4545 data = resp .json ()
4646 except Exception :
4747 return None
48- release_dates : dict [Version , list [ datetime .datetime ]] = defaultdict ( list )
48+ first_uploads : dict [Version , datetime .datetime ] = {}
4949 for f in data .get ("files" , []):
5050 ver = _version_from_filename (f .get ("filename" , "" ))
5151 if ver is None or ver .is_prerelease :
5252 continue
5353
54- upload_str = f .get ("upload-time" , "" )
55- upload_time = None
56- for fmt in ["%Y-%m-%dT%H:%M:%S.%fZ" , "%Y-%m-%dT%H:%M:%SZ" ]:
57- with contextlib .suppress (ValueError ):
58- upload_time = datetime .datetime .strptime (upload_str , fmt ).replace (
59- tzinfo = datetime .timezone .utc
60- )
61- break
62- if upload_time is None :
54+ try :
55+ upload_time = datetime .datetime .fromisoformat (f .get ("upload-time" , "" ))
56+ except ValueError :
6357 continue
64- release_dates [ver ].append (upload_time )
58+
59+ previous = first_uploads .get (ver )
60+ if previous is None or upload_time < previous :
61+ first_uploads [ver ] = upload_time
6562
6663 candidates = [
67- ver
68- for ver , upload_times in release_dates .items ()
69- if min (upload_times ) >= cutoff
64+ ver for ver , first_upload in first_uploads .items () if first_upload >= cutoff
7065 ]
7166 return min (candidates , default = None )
7267
@@ -86,105 +81,103 @@ def _version_from_filename(filename: str) -> Version | None:
8681
8782
8883def update_pyproject_dependencies (
89- dependencies : list , schedule : Dict [str , str ], own_name : str | None = None
84+ dependencies : list ,
85+ resolve_lower_bound : Callable [[str ], Version | None ],
86+ own_name : str | None ,
9087):
9188 # Iterate by idx because we want to update it inplace
92- for i in range (len (dependencies )):
93- dep_str = dependencies [i ]
89+ for i , dep_str in enumerate (dependencies ):
9490 if not isinstance (dep_str , str ):
9591 continue
9692 pkg , extras , spec , env = parse_pep_dependency (dep_str )
97- schedule_key = canonicalize_name (pkg )
98- if (
99- isinstance (spec , Url )
100- or schedule_key == own_name
101- or schedule_key not in schedule
102- ):
93+ package_key = canonicalize_name (pkg )
94+ if isinstance (spec , Url ) or package_key == own_name :
10395 continue
104- new_lower_bound = Version (schedule [schedule_key ])
105- try :
106- spec = tighten_lower_bound (spec or SpecifierSet (), new_lower_bound )
107- # Will raise a value error if bound is already tighter, in this case we just do nothing and continue
108- except ValueError :
96+ new_lower_bound = resolve_lower_bound (package_key )
97+ if new_lower_bound is None :
10998 continue
110- if not extras :
111- new_dep_str = f"{ pkg } { repr_spec_set (spec )} { env or '' } "
112- else :
113- new_dep_str = f"{ pkg } { extras } { repr_spec_set (spec )} { env or '' } "
114- dependencies [i ] = new_dep_str
115-
116-
117- def iter_pep_dependency_lists (pyproject_data : dict , project_data : dict ):
118- dependencies = project_data .get ("dependencies" )
119- if isinstance (dependencies , list ):
120- yield dependencies
99+ new_spec = tighten_lower_bound (spec or SpecifierSet (), new_lower_bound )
100+ if new_spec is None or new_spec == spec :
101+ # The new bound doesn't fit the existing spec or changes nothing
102+ continue
103+ dependencies [i ] = f"{ pkg } { extras or '' } { repr_spec_set (new_spec )} { env or '' } "
121104
122- optional_dependencies = project_data .get ("optional-dependencies" , {})
123- if isinstance (optional_dependencies , dict ):
124- for dependencies in optional_dependencies .values ():
125- if isinstance (dependencies , list ):
126- yield dependencies
127105
128- dependency_groups = pyproject_data .get ("dependency-groups" , {})
129- if isinstance (dependency_groups , dict ):
130- for dependencies in dependency_groups .values ():
131- if isinstance (dependencies , list ):
132- yield dependencies
106+ def iter_pep_dependency_lists (pyproject_data : dict ):
107+ project_data = pyproject_data .get ("project" )
108+ project_data = project_data if isinstance (project_data , dict ) else {}
109+ groups = [project_data .get ("dependencies" )]
110+ for table in (
111+ project_data .get ("optional-dependencies" ),
112+ pyproject_data .get ("dependency-groups" ),
113+ ):
114+ if isinstance (table , dict ):
115+ groups .extend (table .values ())
116+ yield from (group for group in groups if isinstance (group , list ))
133117
134118
135119def update_dependency_table (
136- dep_table : dict , new_versions : dict , own_name : str | None = None
120+ dep_table : dict , new_versions : Dict [ str , Version ], own_name : str | None
137121):
138122 for pkg , pkg_data in dep_table .items ():
139- schedule_key = canonicalize_name (pkg )
123+ package_key = canonicalize_name (pkg )
140124 # Don't do anything for the package itself or pkgs that aren't in our schedule
141- if schedule_key == own_name or schedule_key not in new_versions :
125+ if package_key == own_name or package_key not in new_versions :
142126 continue
143127 # Like pkg = ">x.y.z,<a"
144128 if isinstance (pkg_data , str ):
145- if not is_url_spec (pkg_data ):
146- spec = parse_version_spec (pkg_data )
147- new_lower_bound = Version (new_versions [schedule_key ])
148- try :
149- spec = tighten_lower_bound (spec , new_lower_bound )
150- except ValueError :
151- continue
152- dep_table [pkg ] = repr_spec_set (spec )
153- else :
129+ if is_url_spec (pkg_data ):
154130 # We don't do anything with url spec dependencies
155131 continue
132+ spec_str = pkg_data
133+ elif isinstance (pkg_data , dict ) and "version" in pkg_data :
134+ # Table like pkg = {version = ">x.y.z", ...}
135+ spec_str = pkg_data ["version" ]
156136 else :
157- # Table like in tests = {path = "."}
158- if not isinstance (pkg_data , dict ) or "version" not in pkg_data :
159- # We don't do anything with path, url, git, or other non-version dependencies
160- continue
161- spec = parse_version_spec (pkg_data ["version" ])
162- new_lower_bound = Version (new_versions [schedule_key ])
163- try :
164- spec = tighten_lower_bound (spec , new_lower_bound )
165- except ValueError :
166- continue
167- pkg_data ["version" ] = repr_spec_set (spec )
137+ # We don't do anything with path, url, git, or other non-version dependencies
138+ continue
139+ current_spec = parse_version_spec (spec_str )
140+ new_spec = tighten_lower_bound (current_spec , new_versions [package_key ])
141+ if new_spec is None or new_spec == current_spec :
142+ continue
143+ if isinstance (pkg_data , str ):
144+ dep_table [pkg ] = repr_spec_set (new_spec )
145+ else :
146+ pkg_data ["version" ] = repr_spec_set (new_spec )
168147
169148
170149def update_pixi_dependencies (
171- pixi_tables : dict , schedule : Dict [str , str ], own_name : str | None = None
150+ pixi_tables : dict , new_versions : Dict [str , Version ], own_name : str | None
172151):
173- if "pypi-dependencies" in pixi_tables :
174- update_dependency_table (pixi_tables ["pypi-dependencies" ], schedule , own_name )
175- if "dependencies" in pixi_tables :
176- update_dependency_table (pixi_tables ["dependencies" ], schedule , own_name )
177-
178- if "feature" in pixi_tables :
179- for _ , feature_data in pixi_tables ["feature" ].items ():
180- if "dependencies" in feature_data :
181- update_dependency_table (
182- feature_data ["dependencies" ], schedule , own_name
183- )
184- if "pypi-dependencies" in feature_data :
185- update_dependency_table (
186- feature_data ["pypi-dependencies" ], schedule , own_name
187- )
152+ for key in ("dependencies" , "pypi-dependencies" ):
153+ dep_table = pixi_tables .get (key )
154+ if isinstance (dep_table , dict ):
155+ update_dependency_table (dep_table , new_versions , own_name )
156+
157+ # Recurse into [tool.pixi.feature.X] and platform tables like
158+ # [tool.pixi.target.linux-64], which hold the same dependency keys
159+ for key in ("feature" , "target" ):
160+ subtables = pixi_tables .get (key )
161+ if isinstance (subtables , dict ):
162+ for subtable in subtables .values ():
163+ if isinstance (subtable , dict ):
164+ update_pixi_dependencies (subtable , new_versions , own_name )
165+
166+
167+ def _update_requires_python (project_data : dict , new_lower_bound : Version ):
168+ current_requires_python = project_data .get ("requires-python" )
169+ if not current_requires_python :
170+ project_data ["requires-python" ] = f">={ new_lower_bound } "
171+ return
172+ try :
173+ current_spec = parse_version_spec (current_requires_python )
174+ except ValueError :
175+ # Leave specs we can't parse (e.g. poetry-style "^3.10") alone
176+ return
177+ new_spec = tighten_lower_bound (current_spec , new_lower_bound )
178+ if new_spec is not None and new_spec != current_spec :
179+ # Only write when the bound actually moved, to avoid cosmetic rewrites
180+ project_data ["requires-python" ] = repr_spec_set (new_spec )
188181
189182
190183def update_pyproject_toml (
@@ -200,11 +193,11 @@ def update_pyproject_toml(
200193 ),
201194 key = lambda s : datetime .datetime .fromisoformat (s ["start_date" ]),
202195 )
203- new_version = {}
196+ new_version : Dict [ str , Version ] = {}
204197 for schedule in applicable :
205198 # Fill in the latest known requirement (schedule is sorted, newer entries overwrite older)
206199 for pkg , version in schedule ["packages" ].items ():
207- new_version [canonicalize_name (pkg )] = version
200+ new_version [canonicalize_name (pkg )] = Version ( version )
208201 if not new_version :
209202 raise RuntimeError (
210203 "Could not find schedule that applies to current time, perhaps your schedule is outdated."
@@ -215,47 +208,20 @@ def update_pyproject_toml(
215208 # Self-references like "pkg[extras]" are used to share extras between
216209 # dependency groups, their version is always the local one so never pin it.
217210 own_name = project_data .get ("name" )
218- if isinstance (own_name , str ):
219- own_name = canonicalize_name (own_name )
220- else :
221- own_name = None
222- if "python" in new_version and isinstance (project_data , dict ):
223- current_requires_python = project_data .get ("requires-python" )
224- if current_requires_python :
225- try :
226- python_spec = tighten_lower_bound (
227- parse_version_spec (current_requires_python ),
228- Version (new_version ["python" ]),
229- )
230- except ValueError :
231- python_spec = parse_version_spec (current_requires_python )
232- else :
233- python_spec = parse_version_spec (new_version ["python" ])
234- project_data ["requires-python" ] = repr_spec_set (python_spec )
211+ own_name = canonicalize_name (own_name ) if isinstance (own_name , str ) else None
212+
213+ if "python" in new_version :
214+ _update_requires_python (project_data , new_version ["python" ])
215+
216+ def resolve_lower_bound (package_key : str ) -> Version | None :
217+ if package_key in new_version :
218+ return new_version [package_key ]
219+ if update_all is not None :
220+ return _get_oldest_version_in_window (package_key , update_all )
221+ return None
235222
236- for dependencies in iter_pep_dependency_lists (pyproject_data , project_data ):
237- update_pyproject_dependencies (dependencies , new_version , own_name )
223+ for dependencies in iter_pep_dependency_lists (pyproject_data ):
224+ update_pyproject_dependencies (dependencies , resolve_lower_bound , own_name )
238225
239226 if "tool" in pyproject_data and "pixi" in pyproject_data ["tool" ]:
240- pixi_data = pyproject_data ["tool" ]["pixi" ]
241- update_pixi_dependencies (pixi_data , new_version , own_name )
242- if update_all is not None :
243- for deps in iter_pep_dependency_lists (pyproject_data , project_data ):
244- for i , dep_str in enumerate (deps ):
245- if not isinstance (dep_str , str ):
246- continue
247- pkg , extras , spec , env = parse_pep_dependency (dep_str )
248- if (
249- canonicalize_name (pkg ) in new_version
250- or canonicalize_name (pkg ) == own_name
251- or isinstance (spec , Url )
252- ):
253- continue
254- min_ver = _get_oldest_version_in_window (pkg , update_all )
255- if min_ver is None :
256- continue
257- try :
258- updated = tighten_lower_bound (spec or SpecifierSet (), min_ver )
259- deps [i ] = f"{ pkg } { extras or '' } { repr_spec_set (updated )} { env or '' } "
260- except ValueError :
261- continue
227+ update_pixi_dependencies (pyproject_data ["tool" ]["pixi" ], new_version , own_name )
0 commit comments