blob: 6a76cee6c755bf596a20acc94c3847981f7b027a [file] [log] [blame]
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -07001#!/usr/bin/env python3
2# pylint: disable=line-too-long, subprocess-run-check, unused-argument
3# Copyright 2021 The Chromium OS Authors. All rights reserved.
4# Use of this source code is governed by a BSD-style license that can be
5# found in the LICENSE file.
6"""Convert Cargo metadata to ebuilds using cros-rust.
7
8Used to add new Rust projects and building up ebuilds for the dependency tree.
9"""
10
11import argparse
12from datetime import datetime
13import json
14import os
15import re
16import shutil
17import subprocess
18import sys
19
20SCRIPT_NAME = 'cargo2ebuild.py'
21AUTOGEN_NOTICE = '\n# This file was automatically generated by {}'.format(
22 SCRIPT_NAME)
23
24CRATE_DL_URI = 'https://crates.io/api/v1/crates/{PN}/{PV}/download'
25
26# Required parameters
27# copyright_year: Current year for copyright assignment.
28# description: Description of the crates.
29# homepage: Homepage of the crates.
30# license: Ebuild compatible license string.
31# dependencies: Ebuild compatible dependency string.
32# autogen_notice: Autogenerated notification string.
33EBUILD_TEMPLATE = \
34"""# Copyright {copyright_year} The Chromium OS Authors. All rights reserved.
35# Distributed under the terms of the GNU General Public License v2
36
37EAPI="7"
38
39CROS_RUST_REMOVE_DEV_DEPS=1
40
41inherit cros-rust
42
43DESCRIPTION="{description}"
44HOMEPAGE="{homepage}"
45SRC_URI="https://crates.io/api/v1/crates/${{PN}}/${{PV}}/download -> ${{P}}.crate"
46
47LICENSE="{license}"
48SLOT="${{PV}}/${{PR}}"
49KEYWORDS="*"
50
51{dependencies}{autogen_notice}
52"""
53
54# Required parameters:
55# copyright_year: Current year for copyright assignment.
56# crate_features: Features to add to this empty crate.
57# autogen_notice: Autogenerated notification string.
58EMPTY_CRATE = \
59"""# Copyright {copyright_year} The Chromium OS Authors. All rights reserved.
60# Distributed under the terms of the GNU General Public License v2
61
62EAPI="7"
63
64CROS_RUST_EMPTY_CRATE=1
65{crate_features}
66inherit cros-rust
67
68DESCRIPTION="Empty crate"
69HOMEPAGE=""
70
71LICENSE="BSD-Google"
72SLOT="${{PV}}/${{PR}}"
73KEYWORDS="*"
74{autogen_notice}
75"""
76
77LICENSES = {
78 'Apache-2.0': 'Apache-2.0',
79 'MIT': 'MIT',
80 'BSD-3-Clause': 'BSD',
81 '0BSD': '0BSD',
82 'ISC': 'ISC',
83}
84
85VERSION_RE = (
86 '^(?P<dep>[\\^~=])?' # Dependency type: ^, ~, =
87 '(?P<major>[0-9]+|[*])' # Major version (can be *)
88 '(.(?P<minor>[0-9]+|[*]))?' # Minor version
89 '(.(?P<patch>[0-9]+|[*]))?' # Patch version
90 '([+\\-].*)?$' # Any semver values beyond patch version
91)
92
93
94def prepare_staging(args):
95 """Prepare staging directory."""
96 sdir = args.staging_dir
97 dirs = [
98 os.path.join(sdir, 'ebuild', 'dev-rust'),
99 os.path.join(sdir, 'crates')
100 ]
101 for d in dirs:
102 os.makedirs(d, exist_ok=True)
103
104
105def load_metadata(manifest_path):
106 """Run cargo metadata and get metadata for build."""
107 cwd = os.path.dirname(manifest_path)
108 cmd = [
109 'cargo', 'metadata', '--format-version', '1', '--manifest-path',
110 manifest_path
111 ]
112 output = subprocess.check_output(cmd, cwd=cwd)
113
114 return json.loads(output)
115
116
117def get_crate_path(package, staging_dir):
118 """Get path to crate in staging directory."""
119 return os.path.join(
120 staging_dir, 'crates', '{}-{}.crate'.format(package['name'],
121 package['version']))
122
123
124def get_clean_crate_name(package):
125 """Clean up crate name to {name}-{major}.{minor}.{patch}."""
126 return '{}-{}.crate'.format(package['name'],
127 get_clean_package_version(package))
128
129
130def version_to_tuple(name, version, missing=-1):
131 """Extract dependency type and semver from a given version string."""
132 def version_to_int(num):
133 if not num or num == '*':
134 return missing
135 return int(num)
136
137 m = re.match(VERSION_RE, version)
138 if not m:
139 print('{} failed to parse dep version: {}'.format(name, version))
140
141 dep = m.group('dep')
142 major = m.group('major')
143 minor = m.group('minor')
144 patch = m.group('patch')
145
146 has_star = any([x == '*' for x in [major, minor, patch]])
147
148 major = version_to_int(major)
149 minor = version_to_int(minor)
150 patch = version_to_int(patch)
151
152 if has_star:
153 dep = '~'
154 elif not dep:
155 dep = '^'
156
157 return (dep, major, minor, patch)
158
159
160def get_clean_package_version(package):
161 """Get package version in the format {major}.{minor}.{patch}."""
162 (_, major, minor, patch) = version_to_tuple(package['name'],
163 package['version'],
164 missing=0)
165 return '{}.{}.{}'.format(major, minor, patch)
166
167
168def get_ebuild_path(package, staging_dir, make_dir=False):
169 """Get path to ebuild in given directory."""
170 ebuild_path = os.path.join(
171 staging_dir, 'dev-rust', package['name'],
172 '{}-{}.ebuild'.format(package['name'],
173 get_clean_package_version(package)))
174
175 if make_dir:
176 os.makedirs(os.path.dirname(ebuild_path), exist_ok=True)
177
178 return ebuild_path
179
180
181def download_package(package, staging_dir):
182 """Download the crate from crates.io."""
183 dl_uri = CRATE_DL_URI.format(PN=package['name'], PV=package['version'])
184 crate_path = get_crate_path(package, staging_dir)
185
186 # Already downloaded previously
187 if os.path.isfile(crate_path):
188 return
189
190 ret = subprocess.run(['curl', '-L', dl_uri, '-o', crate_path],
191 stdout=subprocess.DEVNULL,
192 stderr=subprocess.DEVNULL).returncode
193
194 if ret:
195 print('{} failed to download: {}'.format(dl_uri, ret))
196
197
198def get_description(package):
199 """Get a description of the crate from metadata."""
200 # pylint: disable=invalid-string-quote
201 if package.get('description', None):
202 desc = package['description'].replace('`', '\'').replace('"', '\'')
203 return desc.rstrip('\n')
204
205 return ''
206
207
208def get_homepage(package):
209 """Get the homepage of the crate from metadata or use crates.io."""
210 if package.get('homepage', None):
211 return package['homepage']
212
213 return 'https://crates.io/crates/{}'.format(package['name'])
214
215
216def convert_license(cargo_license, package):
217 """Convert licenses from cargo to a format usable in ebuilds."""
218 cargo_license = '' if not cargo_license else cargo_license
219 has_or = ' OR ' in cargo_license
220 delim = ' OR ' if has_or else '/'
221
222 found = cargo_license.split(delim)
223 licenses_or = []
224 for f in found:
225 if f in LICENSES:
226 licenses_or.append(LICENSES[f])
227
228 if not licenses_or:
229 print('{} is missing an appropriate license: {}'.format(
230 package['name'], license))
231 return "$(die 'Please replace with appropriate license')"
232
233 if len(licenses_or) > 1:
234 lstr = '|| ( {} )'.format(' '.join(licenses_or))
235 else:
236 lstr = licenses_or[0]
237
238 return lstr
239
240
241def convert_dependencies(dependencies, package, optional_packages):
242 """Convert dependencies from metadata into the ebuild format."""
243 def caret_to_ebuild(info):
244 prefix = '>=dev-rust/{name}-{major}.{minor}.{patch}:='.format(**info)
245 suffix = '<dev-rust/{name}-{major_1}'.format(**info)
246
247 if info['minor'] == -1:
248 prefix = '>=dev-rust/{name}-{major}:='.format(**info)
249 elif info['patch'] == -1:
250 prefix = '>=dev-rust/{name}-{major}.{minor}:='.format(**info)
251
252 if info['major'] == 0:
253 suffix = '<dev-rust/{name}-{major}.{minor_1}'.format(**info)
254
255 return '{} {}'.format(prefix, suffix)
256
257 def tilde_to_ebuild(info):
258 prefix = '>=dev-rust/{name}-{major}.{minor}.{patch}:='.format(**info)
259 suffix = '<dev-rust/{name}-{major}.{minor_1}'.format(**info)
260 ebuild = '{} {}'.format(prefix, suffix)
261
262 if info['minor'] == -1:
263 ebuild = '=dev-rust/{name}-{major}*:='.format(**info)
264 elif info['patch'] == -1:
265 ebuild = '=dev-rust/{name}-{major}.{minor}*:='.format(**info)
266
267 return ebuild
268
269 def dep_to_ebuild(name, dep_type, major, minor, patch):
270 info = {
271 'name': name,
272 'major': major,
273 'minor': minor,
274 'patch': patch,
275 'major_1': major + 1,
276 'minor_1': minor + 1,
277 'patch_1': patch + 1
278 }
279
280 if dep_type == '^':
281 return caret_to_ebuild(info)
282 if dep_type == '~':
283 return tilde_to_ebuild(info)
284
285 # Remaining dep type is =
286 return '=dev-rust/{name}-{major}.{minor}.{patch}:='.format(**info)
287
288 deps = []
289 for dep in dependencies:
290 # Skip all dev dependencies. We will still handle normal and build deps.
291 if dep.get('kind', None) == 'dev':
292 continue
293
294 # Optional dependencies get empty packages created
295 if dep.get('optional', None):
296 optional_packages[dep['name']] = {
297 'name': dep['name'],
298 'version': dep['req'],
299 'features': dep['features'],
300 }
301
302 # Convert requirement to version tuple
303 (deptype, major, minor, patch) = version_to_tuple(dep['name'], dep['req'])
304 ebuild = dep_to_ebuild(dep['name'], deptype, major, minor, patch)
305 deps.append('\t{}'.format(ebuild))
306
307 if not deps:
308 return ''
309
310 # Add DEPEND= into template with all dependencies
311 # RDEPEND="${DEPEND}" required for race in cros-rust
312 fmtstring = 'DEPEND="\n{}\n"\nRDEPEND="${{DEPEND}}"\n'
313 return fmtstring.format('\n'.join(deps))
314
315
316def package_ebuild(package, ebuild_dir, optional_packages):
317 """Create ebuild from metadata and write to ebuild directory."""
318 ebuild_path = get_ebuild_path(package, ebuild_dir, make_dir=True)
319
320 autogen_notice = AUTOGEN_NOTICE
321
322 # Check if version matches clean version or modify the autogen notice
323 if package['version'] != get_clean_package_version(package):
324 autogen_notice = '\n'.join([
325 autogen_notice,
326 '# ${{PV}} was changed from the original {}'.format(
327 package['version'])
328 ])
329
330 dependencies = convert_dependencies(package['dependencies'], package,
331 optional_packages)
332 template_info = {
333 'copyright_year': datetime.now().year,
334 'description': get_description(package),
335 'homepage': get_homepage(package),
336 'license': convert_license(package['license'], package),
337 'dependencies': dependencies,
338 'autogen_notice': autogen_notice,
339 }
340
341 with open(ebuild_path, 'w') as ebuild:
342 ebuild.write(EBUILD_TEMPLATE.format(**template_info))
343
344
345def upload_gsutil(package, staging_dir, no_upload=False):
346 """Upload crate to distfiles."""
347 if no_upload:
348 return
349
350 crate_path = get_crate_path(package, staging_dir)
351 crate_name = get_clean_crate_name(package)
352 ret = subprocess.run([
353 'gsutil', 'cp', '-a', 'public-read', crate_path,
354 'gs://chromeos-localmirror/distfiles/{}'.format(crate_name)
355 ]).returncode
356
357 if ret:
358 print('{} failed to upload to chromeos-localmirror: {}'.format(
359 crate_name, ret))
360
361
362def update_ebuild(package, ebuild_dir, target_dir):
363 """Update ebuild with generated one and generate MANIFEST."""
364 ebuild_src = get_ebuild_path(package, ebuild_dir)
365 ebuild_dest = get_ebuild_path(package, target_dir, make_dir=True)
366
367 shutil.copy(ebuild_src, ebuild_dest)
368
369 # Generate manifest w/ ebuild digest
370 ret = subprocess.run(['ebuild', ebuild_dest, 'digest']).returncode
371 if ret:
372 print('ebuild {} digest failed: {}'.format(ebuild_dest, ret))
373
374
375def process_package(package, args, optional_packages):
376 """Process each package listed in the metadata."""
377 staging_dir = args.staging_dir
378 ebuild_dir = os.path.join(staging_dir, 'ebuild')
379 target_dir = args.target_dir
380
381 download_package(package, staging_dir)
382 package_ebuild(package, ebuild_dir, optional_packages)
383
384 if not args.dry_run and package['name'] not in args.skip:
385 upload_gsutil(package, staging_dir, no_upload=args.no_upload)
386 update_ebuild(package, ebuild_dir, target_dir)
387
388def process_empty_package(empty_package, args):
389 """Process packages that should generate empty ebuilds."""
390 staging_dir = args.staging_dir
391 ebuild_dir = os.path.join(staging_dir, 'ebuild')
392 target_dir = args.target_dir
393
394 ebuild_src = get_ebuild_path(empty_package, ebuild_dir, make_dir=True)
395 ebuild_dest = get_ebuild_path(empty_package, target_dir, make_dir=True)
396
397 crate_features = ''
398 if empty_package.get('features', None):
399 crate_features = 'CROS_RUST_EMPTY_CRATE_FEATURES=( {} )'.format(
400 ' '.join(['"{}"'.format(x) for x in empty_package['features']]))
401
402 template_info = {
403 'copyright_year': datetime.now().year,
404 'crate_features': crate_features,
405 'autogen_notice': AUTOGEN_NOTICE,
406 }
407
408 with open(ebuild_src, 'w') as ebuild:
409 ebuild.write(EMPTY_CRATE.format(**template_info))
410
411 if not args.dry_run and empty_package['name'] not in args.skip:
412 shutil.copy(ebuild_src, ebuild_dest)
413
414def check_if_package_is_required(empty_package, args):
415 """Check if an empty package already has a non-empty ebuild."""
416 ebuild_dest = get_ebuild_path(empty_package, args.target_dir, make_dir=False)
417 ebuild_target_dir = os.path.dirname(ebuild_dest)
418
419 # Find all ebuilds in ebuild_target dir and confirm they have
420 # CROS_RUST_EMPTY_CRATE in them. If they do not, they need to be manually
421 # handled.
422 for root, _, files in os.walk(ebuild_target_dir):
423 for efile in files:
424 if not efile.endswith('.ebuild'):
425 continue
426
427 with open(os.path.join(root, efile), 'r') as ebuild:
428 if 'CROS_RUST_EMPTY_CRATE' not in ebuild.read():
429 print('{} was not an empty crate.'.format(efile))
430 return True
431
432 return False
433
434def main(argv):
435 """Convert dependencies from Cargo.toml into ebuilds."""
436 args = parse_args(argv)
437
438 prepare_staging(args)
439
440 processed_packages = {}
441 optional_packages = {}
442 metadata = load_metadata(args.manifest_path)
443 for p in metadata['packages']:
444 process_package(p, args, optional_packages)
445 processed_packages[p['name']] = True
446
447 for key in optional_packages:
448 if key not in processed_packages and not check_if_package_is_required(
449 optional_packages[key], args):
450 process_empty_package(optional_packages[key], args)
451
452
453def parse_args(argv):
454 """Parse the command-line arguments."""
455 parser = argparse.ArgumentParser(description=__doc__)
456 parser.add_argument(
457 '-s',
458 '--staging-dir',
459 required=True,
460 help='Staging directory for temporary and generated files')
461
462 parser.add_argument(
463 '-d',
464 '--dry-run',
465 action='store_true',
466 help='Generate dependency tree but do not upload anything')
467
468 parser.add_argument('-t',
469 '--target-dir',
470 help='Path to chromiumos-overlay')
471
472 parser.add_argument('-k',
473 '--skip',
474 action='append',
475 help='Skip these packages when updating ebuilds')
476 parser.add_argument('-n',
477 '--no-upload',
478 action='store_true',
479 help='Skip uploading crates to distfiles')
480
481 parser.add_argument('manifest_path',
482 help='Cargo.toml used to generate ebuilds.')
483
484 args = parser.parse_args(argv)
485
486 # Require target directory if not dry run
487 if not args.target_dir and not args.dry_run:
488 raise Exception('Target directory must be set unless dry-run is True.')
489
490 if not args.skip:
491 args.skip = []
492
493 return args
494
495
496if __name__ == '__main__':
497 sys.exit(main(sys.argv[1:]))