2323import hashlib
2424import json
2525import os
26+ import re
2627import subprocess
2728import tempfile
2829from pathlib import Path
@@ -36,6 +37,9 @@ def main() -> None:
3637 print ("Migrating workflows to use ubuntu-slim runner for lightweight jobs..." )
3738 migrate_to_ubuntu_slim ()
3839 print ("=" * 72 )
40+ print ("Migrating pyproject license metadata to SPDX format..." )
41+ migrate_pyproject_license ()
42+ print ("=" * 72 )
3943 print ("Migration script finished. Remember to follow any manual instructions." )
4044 print ("=" * 72 )
4145
@@ -198,6 +202,81 @@ def migrate_to_ubuntu_slim() -> None:
198202 )
199203
200204
205+ def migrate_pyproject_license () -> None : # pylint: disable=too-many-branches
206+ """Migrate pyproject license metadata to SPDX expressions."""
207+ pyproject_path = Path ("pyproject.toml" )
208+ if not pyproject_path .exists ():
209+ print (" Skipping pyproject.toml (file not found)" )
210+ return
211+
212+ content = pyproject_path .read_text (encoding = "utf-8" )
213+ new_content = content
214+ updated = False
215+
216+ license_expression = None
217+ for old_license , new_license in (
218+ ("MIT" , "MIT" ),
219+ ("Proprietary" , "LicenseRef-Proprietary" ),
220+ ("Propietary" , "LicenseRef-Proprietary" ),
221+ ):
222+ old_line = f'license = {{ text = "{ old_license } " }}'
223+ if old_line in new_content :
224+ new_content = new_content .replace (old_line , f'license = "{ new_license } "' , 1 )
225+ license_expression = new_license
226+ updated = True
227+ break
228+
229+ if license_expression is None :
230+ for existing_license in ("MIT" , "LicenseRef-Proprietary" ):
231+ if f'license = "{ existing_license } "' in new_content :
232+ license_expression = existing_license
233+ break
234+
235+ if license_expression is None :
236+ cookiecutter_license = read_cookiecutter_license ()
237+ if cookiecutter_license == "MIT" :
238+ license_expression = "MIT"
239+ elif cookiecutter_license == "Proprietary" :
240+ license_expression = "LicenseRef-Proprietary"
241+
242+ if license_expression is None :
243+ manual_step (
244+ "Unable to detect project license in pyproject.toml. Please set "
245+ "`project.license` to a SPDX expression and add "
246+ '`project.license-files = ["LICENSE"]`.'
247+ )
248+ return
249+
250+ license_line = f'license = "{ license_expression } "'
251+ if "license-files" not in new_content and license_line in new_content :
252+ new_content = new_content .replace (
253+ license_line , f'{ license_line } \n license-files = ["LICENSE"]' , 1
254+ )
255+ updated = True
256+
257+ for classifier in (
258+ "License :: OSI Approved :: MIT License" ,
259+ "License :: Other/Proprietary License" ,
260+ ):
261+ classifier_line = f' "{ classifier } ",\n '
262+ if classifier_line in new_content :
263+ new_content = new_content .replace (classifier_line , "" , 1 )
264+ updated = True
265+
266+ setuptools_version = parse_setuptools_version (new_content )
267+ if setuptools_version is not None and setuptools_version < 77 :
268+ new_content , replaced = replace_setuptools_pin (new_content , "80.9.0" )
269+ if replaced :
270+ updated = True
271+
272+ if not updated or new_content == content :
273+ print (" Skipped pyproject.toml (already up to date)" )
274+ return
275+
276+ replace_file_contents_atomically (pyproject_path , content , new_content , count = 1 )
277+ print (" Updated pyproject.toml: migrated license metadata" )
278+
279+
201280def read_project_type () -> str | None :
202281 """Read the cookiecutter project type from the replay file."""
203282 replay_path = Path (".cookiecutter-replay.json" )
@@ -220,6 +299,47 @@ def read_project_type() -> str | None:
220299 return project_type
221300
222301
302+ def read_cookiecutter_license () -> str | None :
303+ """Read the cookiecutter license from the replay file."""
304+ replay_path = Path (".cookiecutter-replay.json" )
305+ if not replay_path .exists ():
306+ return None
307+
308+ try :
309+ data = json .loads (replay_path .read_text (encoding = "utf-8" ))
310+ except (json .JSONDecodeError , OSError ):
311+ return None
312+
313+ cookiecutter_data = data .get ("cookiecutter" )
314+ if not isinstance (cookiecutter_data , dict ):
315+ return None
316+
317+ license_value = cookiecutter_data .get ("license" )
318+ if not isinstance (license_value , str ):
319+ return None
320+
321+ return license_value
322+
323+
324+ def parse_setuptools_version (content : str ) -> int | None :
325+ """Parse the setuptools major version from pyproject content."""
326+ match = re .search (r'"setuptools\s*==\s*([0-9]+)(?:\.[0-9]+)*"' , content )
327+ if not match :
328+ return None
329+ return int (match .group (1 ))
330+
331+
332+ def replace_setuptools_pin (content : str , new_version : str ) -> tuple [str , bool ]:
333+ """Replace the setuptools pin with a new version."""
334+ new_content , count = re .subn (
335+ r'("setuptools\s*==\s*)[0-9]+(?:\.[0-9]+)*("\s*,?)' ,
336+ rf"\1{ new_version } \2" ,
337+ content ,
338+ count = 1 ,
339+ )
340+ return new_content , count > 0
341+
342+
223343def apply_patch (patch_content : str ) -> None :
224344 """Apply a patch using the patch utility."""
225345 subprocess .run (["patch" , "-p1" ], input = patch_content .encode (), check = True )
0 commit comments