1- import contextlib
1+ from functools import cache
22from packaging .specifiers import SpecifierSet
3- from typing import Sequence , Dict
3+ from typing import Callable , Sequence , Dict
44import datetime
55import requests
66
1515 read_toml ,
1616 write_toml ,
1717)
18- from packaging .version import Version , InvalidVersion
18+ from packaging .version import Version
19+ from packaging .utils import (
20+ InvalidSdistFilename ,
21+ InvalidWheelFilename ,
22+ canonicalize_name ,
23+ parse_sdist_filename ,
24+ parse_wheel_filename ,
25+ )
1926
2027__all__ = ["read_schedule" , "read_toml" , "write_toml" , "update_pyproject_toml" ]
2128
2229
30+ @cache
2331def _get_oldest_version_in_window (package : str , years : float ) -> Version | None :
2432 """
2533 Query PyPI, return oldest non-pre release version uploaded within the last ``years`` years.
@@ -37,88 +45,137 @@ 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+ first_uploads : dict [Version , datetime . datetime ] = {}
4149 for f in data .get ("files" , []):
42- parts = f .get ("filename" , "" ). split ( "-" )
43- if len ( parts ) < 2 :
50+ ver = _version_from_filename ( f .get ("filename" , "" ))
51+ if ver is None or ver . is_prerelease :
4452 continue
53+
4554 try :
46- ver = Version (parts [1 ])
47- except InvalidVersion :
48- continue
49- if ver .is_prerelease :
50- continue
51- upload_str = f .get ("upload-time" , "" )
52- upload_time = None
53- for fmt in ["%Y-%m-%dT%H:%M:%S.%fZ" , "%Y-%m-%dT%H:%M:%SZ" ]:
54- with contextlib .suppress (ValueError ):
55- upload_time = datetime .datetime .strptime (upload_str , fmt ).replace (
56- tzinfo = datetime .timezone .utc
57- )
58- break
59- if upload_time is None or upload_time < cutoff :
55+ upload_time = datetime .datetime .fromisoformat (f .get ("upload-time" , "" ))
56+ except ValueError :
6057 continue
61- candidates .append (ver )
6258
59+ previous = first_uploads .get (ver )
60+ if previous is None or upload_time < previous :
61+ first_uploads [ver ] = upload_time
62+
63+ candidates = [
64+ ver for ver , first_upload in first_uploads .items () if first_upload >= cutoff
65+ ]
6366 return min (candidates , default = None )
6467
6568
66- def update_pyproject_dependencies (dependencies : dict , schedule : Dict [str , str ]):
67- # Iterate by idx because we want to update it inplace
68- for i in range (len (dependencies )):
69- dep_str = dependencies [i ]
69+ def _version_from_filename (filename : str ) -> Version | None :
70+ try :
71+ _ , version , _ , _ = parse_wheel_filename (filename )
72+ return version
73+ except InvalidWheelFilename :
74+ pass
75+
76+ try :
77+ _ , version = parse_sdist_filename (filename )
78+ return version
79+ except InvalidSdistFilename :
80+ return None
81+
82+
83+ def update_pyproject_dependencies (
84+ dependencies : list ,
85+ resolve_lower_bound : Callable [[str ], Version | None ],
86+ own_name : str | None ,
87+ ):
88+ # Assign by index so the (tomlkit) list is updated in place
89+ for i , dep_str in enumerate (dependencies ):
90+ if not isinstance (dep_str , str ):
91+ continue
7092 pkg , extras , spec , env = parse_pep_dependency (dep_str )
71- if isinstance (spec , Url ) or pkg not in schedule :
93+ package_key = canonicalize_name (pkg )
94+ if isinstance (spec , Url ) or package_key == own_name :
7295 continue
73- new_lower_bound = Version (schedule [pkg ])
74- try :
75- spec = tighten_lower_bound (spec or SpecifierSet (), new_lower_bound )
76- # Will raise a value error if bound is already tighter, in this case we just do nothing and continue
77- except ValueError :
96+ new_lower_bound = resolve_lower_bound (package_key )
97+ if new_lower_bound is None :
7898 continue
79- if not extras :
80- new_dep_str = f"{ pkg } { repr_spec_set (spec )} { env or '' } "
81- else :
82- new_dep_str = f"{ pkg } { extras } { repr_spec_set (spec )} { env or '' } "
83- dependencies [i ] = new_dep_str
99+ new_spec = tighten_lower_bound (spec or SpecifierSet (), new_lower_bound )
100+ if new_spec is None or new_spec == spec :
101+ # Skip no-op updates so unchanged specs keep their original formatting
102+ continue
103+ dependencies [i ] = f"{ pkg } { extras or '' } { repr_spec_set (new_spec )} { env or '' } "
104+
105+
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 ))
84117
85118
86- def update_dependency_table (dep_table : dict , new_versions : dict ):
119+ def update_dependency_table (
120+ dep_table : dict , new_versions : Dict [str , Version ], own_name : str | None
121+ ):
87122 for pkg , pkg_data in dep_table .items ():
88- # Don't do anything for pkgs that aren't in our schedule
89- if pkg not in new_versions :
123+ package_key = canonicalize_name ( pkg )
124+ if package_key == own_name or package_key not in new_versions :
90125 continue
91126 # Like pkg = ">x.y.z,<a"
92127 if isinstance (pkg_data , str ):
93- if not is_url_spec (pkg_data ):
94- spec = parse_version_spec (pkg_data )
95- new_lower_bound = Version (new_versions [pkg ])
96- spec = tighten_lower_bound (spec , new_lower_bound )
97- dep_table [pkg ] = repr_spec_set (spec )
98- else :
99- # We don't do anything with url spec dependencies
128+ if is_url_spec (pkg_data ):
100129 continue
130+ spec_str = pkg_data
131+ elif isinstance (pkg_data , dict ) and "version" in pkg_data :
132+ # Table like pkg = {version = ">x.y.z", ...}
133+ spec_str = pkg_data ["version" ]
101134 else :
102- # Table like in tests = {path = "."}
103- if "path" in pkg_data :
104- # We don't do anything with path dependencies
105- continue
106- spec = SpecifierSet (pkg_data ["version" ])
107- new_lower_bound = Version (new_versions [pkg ])
108- spec = tighten_lower_bound (spec , new_lower_bound )
109- pkg_data ["version" ] = repr_spec_set (spec )
135+ # We don't do anything with path, url, git, or other non-version dependencies
136+ continue
137+ current_spec = parse_version_spec (spec_str )
138+ new_spec = tighten_lower_bound (current_spec , new_versions [package_key ])
139+ if new_spec is None or new_spec == current_spec :
140+ continue
141+ if isinstance (pkg_data , str ):
142+ dep_table [pkg ] = repr_spec_set (new_spec )
143+ else :
144+ pkg_data ["version" ] = repr_spec_set (new_spec )
145+
110146
147+ def update_pixi_dependencies (
148+ pixi_tables : dict , new_versions : Dict [str , Version ], own_name : str | None
149+ ):
150+ for key in ("dependencies" , "pypi-dependencies" ):
151+ dep_table = pixi_tables .get (key )
152+ if isinstance (dep_table , dict ):
153+ update_dependency_table (dep_table , new_versions , own_name )
111154
112- def update_pixi_dependencies (pixi_tables : dict , schedule : Dict [str , str ]):
113- if "pypi-dependencies" in pixi_tables :
114- update_dependency_table (pixi_tables ["pypi-dependencies" ], schedule )
115- if "dependencies" in pixi_tables :
116- update_dependency_table (pixi_tables ["dependencies" ], schedule )
155+ # Recurse into [tool.pixi.feature.X] and platform tables like
156+ # [tool.pixi.target.linux-64], which hold the same dependency keys
157+ for key in ("feature" , "target" ):
158+ subtables = pixi_tables .get (key )
159+ if isinstance (subtables , dict ):
160+ for subtable in subtables .values ():
161+ if isinstance (subtable , dict ):
162+ update_pixi_dependencies (subtable , new_versions , own_name )
117163
118- if "feature" in pixi_tables :
119- for _ , feature_data in pixi_tables ["feature" ].items ():
120- if "dependencies" in feature_data :
121- update_dependency_table (feature_data ["dependencies" ], schedule )
164+
165+ def _update_requires_python (project_data : dict , new_lower_bound : Version ):
166+ current_requires_python = project_data .get ("requires-python" )
167+ if not current_requires_python :
168+ project_data ["requires-python" ] = f">={ new_lower_bound } "
169+ return
170+ try :
171+ current_spec = parse_version_spec (current_requires_python )
172+ except ValueError :
173+ # Leave specs we can't parse (e.g. poetry-style "^3.10") alone
174+ return
175+ new_spec = tighten_lower_bound (current_spec , new_lower_bound )
176+ if new_spec is not None and new_spec != current_spec :
177+ # Only write when the bound actually moved, to avoid cosmetic rewrites
178+ project_data ["requires-python" ] = repr_spec_set (new_spec )
122179
123180
124181def update_pyproject_toml (
@@ -134,36 +191,35 @@ def update_pyproject_toml(
134191 ),
135192 key = lambda s : datetime .datetime .fromisoformat (s ["start_date" ]),
136193 )
137- new_version = {}
194+ new_version : Dict [ str , Version ] = {}
138195 for schedule in applicable :
139196 # Fill in the latest known requirement (schedule is sorted, newer entries overwrite older)
140197 for pkg , version in schedule ["packages" ].items ():
141- new_version [pkg ] = version
198+ new_version [canonicalize_name ( pkg ) ] = Version ( version )
142199 if not new_version :
143200 raise RuntimeError (
144201 "Could not find schedule that applies to current time, perhaps your schedule is outdated."
145202 )
203+ project_data = pyproject_data .get ("project" , {})
204+ if not isinstance (project_data , dict ):
205+ project_data = {}
206+ # Self-references like "pkg[extras]" are used to share extras between
207+ # dependency groups, their version is always the local one so never pin it.
208+ own_name = project_data .get ("name" )
209+ own_name = canonicalize_name (own_name ) if isinstance (own_name , str ) else None
210+
146211 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- )
212+ _update_requires_python (project_data , new_version ["python" ])
213+
214+ def resolve_lower_bound (package_key : str ) -> Version | None :
215+ if package_key in new_version :
216+ return new_version [package_key ]
217+ if update_all is not None :
218+ return _get_oldest_version_in_window (package_key , update_all )
219+ return None
220+
221+ for dependencies in iter_pep_dependency_lists (pyproject_data ):
222+ update_pyproject_dependencies (dependencies , resolve_lower_bound , own_name )
223+
153224 if "tool" in pyproject_data and "pixi" in pyproject_data ["tool" ]:
154- pixi_data = pyproject_data ["tool" ]["pixi" ]
155- update_pixi_dependencies (pixi_data , new_version )
156- if update_all is not None :
157- deps = pyproject_data .get ("project" , {}).get ("dependencies" , [])
158- for i , dep_str in enumerate (deps ):
159- pkg , extras , spec , env = parse_pep_dependency (dep_str )
160- if pkg in new_version or isinstance (spec , Url ) or spec is None :
161- continue
162- min_ver = _get_oldest_version_in_window (pkg , update_all )
163- if min_ver is None :
164- continue
165- try :
166- updated = tighten_lower_bound (spec , min_ver )
167- deps [i ] = f"{ pkg } { extras or '' } { repr_spec_set (updated )} { env or '' } "
168- except ValueError :
169- continue
225+ update_pixi_dependencies (pyproject_data ["tool" ]["pixi" ], new_version , own_name )
0 commit comments