44
55Usage:
66 cibw_repair_wheel_licenses.py <wheel_file> \
7- --license MIT --license-file licenses/license_foo_MIT.txt \
8- --license BSD-3-Clause --license-file licenses/license_bar_BSD-3-Clause.txt \
7+ --license MIT \
8+ --license-file ./licenses/license_foo_MIT.txt:foo/LICENSE.txt \
9+ --license-file ./licenses/license_bar_MIT.txt:bar/LICENSE.txt \
910 ...
1011
11- CWD should be at project root (containing pyproject.toml) and license-file
12- paths must be relative.
13-
1412The wheel_file argument should be the path to the wheel file to be repaired.
1513It will be overwritten in place.
14+
15+ The --license and --license-file arguments are multi-use. The --license
16+ argument should be a SPDX license expression. It will be combined with the
17+ existing License-Expression field in the wheel's METADATA file.
18+
19+ The --license-file argument should be a pair of :-separated paths. The first
20+ path is the path to the license file and the second is the relative path to
21+ put the license file under the wheel's .dist-info/licenses directory. The first
22+ path can be a glob pattern. The second path can also be a glob pattern in which
23+ case the * is replaced in the same way. This makes it possible to do
24+
25+ --license-file ./src/foo-*/LICENSE:libs/foo-*/LICENSE
26+
27+ which would find the LICENSE file src/foo-1.0/LICENSE and copy it to the
28+ matched .dist-info/licenses/libs/foo-1.0/LICENSE path in the wheel.
29+
30+ PEP 639 says:
31+
32+ Inside the root license directory, packaging tools MUST reproduce the
33+ directory structure under which the source license files are located
34+ relative to the project root.
35+
36+ It is not clear what that means though if the licenses are coming from code
37+ that is not vendored in the repo/sdist. If they are vendored then presumably
38+ the argument here should be like:
39+
40+ --license-file ./vendored-foo/LICENSE:vendored-foo/LICENSE
41+
1642"""
1743import argparse
1844from pathlib import Path
@@ -31,13 +57,48 @@ def main(*args: str):
3157 parsed = parser .parse_args (args )
3258 wheel_file = Path (parsed .wheel_file )
3359 licenses = parsed .license
34- license_files = [ Path ( f ) for f in parsed .license_file ]
60+ license_files = dict ( arg_to_paths ( f ) for f in parsed .license_file )
3561
3662 update_licenses_wheel (wheel_file , licenses , license_files )
3763
3864
65+ def arg_to_paths (license_file_arg : str ) -> tuple [Path , Path ]:
66+ """
67+ Convert a --license-file argument to a pair of Paths.
68+ """
69+ paths = license_file_arg .strip ().split (":" )
70+ if len (paths ) != 2 :
71+ raise ValueError ("license-file argument must be in the form of <path>:<path>"
72+ f" but got { license_file_arg } " )
73+ glob1_str , glob2_str = paths
74+ paths1 = list (Path ().glob (glob1_str ))
75+ if len (paths1 ) != 1 :
76+ raise ValueError (f"Expected one path from glob pattern { glob1_str } "
77+ f" but got { paths1 } " )
78+ [path1 ] = paths1
79+
80+ if '*' not in glob2_str :
81+ path2_str = glob2_str
82+ else :
83+ # Replace * in glob2_str with the part of path1 that matches glob1_str:
84+ index1 = glob1_str .index ('*' )
85+ part1_glob = glob1_str [:index1 ]
86+ part2_glob = glob1_str [index1 + 1 :]
87+ path1_str = str (path1 )
88+ if len (part2_glob ) != 0 :
89+ wildcard = path1_str [len (part1_glob ):- len (part2_glob )]
90+ else :
91+ wildcard = path1_str [len (part1_glob ):]
92+ assert path1_str .startswith (part1_glob )
93+ assert path1_str .endswith (part2_glob )
94+ assert path1_str == part1_glob + wildcard + part2_glob
95+ path2_str = glob2_str .replace ('*' , wildcard )
96+
97+ return path1 , Path (path2_str )
98+
99+
39100def update_licenses_wheel (
40- wheel_file : Path , licenses : list [str ], license_files : list [ Path ]
101+ wheel_file : Path , licenses : list [str ], license_files : dict [ Path , Path ]
41102):
42103 if wheel_file .exists ():
43104 print ("Found wheel at" , wheel_file )
@@ -80,33 +141,39 @@ def update_licenses_wheel(
80141
81142
82143def update_license_dist_info (
83- dist_info : Path , licenses : list [str ], license_files : list [ Path ]
144+ dist_info : Path , licenses : list [str ], license_files : dict [ Path , Path ]
84145):
85- for license_file in license_files :
86- wheel_license_path = dist_info / "licenses" / license_file . name
146+ for src , dst in license_files . items () :
147+ wheel_license_path = dist_info / "licenses" / dst
87148 if wheel_license_path .exists ():
88- raise ValueError (f"license file already present: { license_file } " )
149+ raise ValueError (f"license file already present: { wheel_license_path } " )
89150 #
90151 # PEP 639 says:
91152 #
92153 # Inside the root license directory, packaging tools MUST reproduce the
93154 # directory structure under which the source license files are located
94155 # relative to the project root.
95156 #
96- makedirs (dist_info / license_file .parent , exist_ok = True )
97- copyfile (license_file , wheel_license_path )
98- print (f"Added license file { license_file } " )
157+ makedirs (wheel_license_path .parent , exist_ok = True )
158+ copyfile (src , wheel_license_path )
159+ print (f"Copied license file { src } to { wheel_license_path } " )
99160
100161 metadata_file = dist_info / "METADATA"
101162
102163 with open (metadata_file , "r" ) as f :
103164 lines = f .readlines ()
104165
166+ def brackets (s : str ) -> str :
167+ if ' ' not in s :
168+ return s
169+ else :
170+ return f"({ s } )"
171+
105172 for n , line in enumerate (lines ):
106173 if line .startswith ("License-Expression: " ):
107174 base_license = line [len ("License-Expression: " ) :].strip ()
108175 all_licenses = [base_license , * licenses ]
109- expression = ' AND ' .join ([f"( { license } )" for license in all_licenses ])
176+ expression = ' AND ' .join ([brackets ( license ) for license in all_licenses ])
110177 lines [n ] = f"License-Expression: { expression } \n "
111178 break
112179 else :
0 commit comments