blob: c07836c69086ebb0f526f1a7d45ed449923fe51a [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.
Allen Webb81a58ba2021-05-03 10:53:17 -050033EBUILD_TEMPLATE = (
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -070034"""# 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}
Allen Webb81a58ba2021-05-03 10:53:17 -050052""")
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -070053
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.
Allen Webb81a58ba2021-05-03 10:53:17 -050058EMPTY_CRATE = (
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -070059"""# 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}
Allen Webb81a58ba2021-05-03 10:53:17 -050075""")
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -070076
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
Allen Webb81a58ba2021-05-03 10:53:17 -050094class VersionParseError(Exception):
95 """Error that is returned when parsing a version fails."""
96
97
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -070098def prepare_staging(args):
99 """Prepare staging directory."""
100 sdir = args.staging_dir
101 dirs = [
102 os.path.join(sdir, 'ebuild', 'dev-rust'),
103 os.path.join(sdir, 'crates')
104 ]
105 for d in dirs:
106 os.makedirs(d, exist_ok=True)
107
108
109def load_metadata(manifest_path):
110 """Run cargo metadata and get metadata for build."""
111 cwd = os.path.dirname(manifest_path)
112 cmd = [
113 'cargo', 'metadata', '--format-version', '1', '--manifest-path',
114 manifest_path
115 ]
116 output = subprocess.check_output(cmd, cwd=cwd)
117
118 return json.loads(output)
119
120
121def get_crate_path(package, staging_dir):
122 """Get path to crate in staging directory."""
123 return os.path.join(
124 staging_dir, 'crates', '{}-{}.crate'.format(package['name'],
125 package['version']))
126
127
128def get_clean_crate_name(package):
129 """Clean up crate name to {name}-{major}.{minor}.{patch}."""
130 return '{}-{}.crate'.format(package['name'],
131 get_clean_package_version(package))
132
133
134def version_to_tuple(name, version, missing=-1):
135 """Extract dependency type and semver from a given version string."""
136 def version_to_int(num):
137 if not num or num == '*':
138 return missing
139 return int(num)
140
141 m = re.match(VERSION_RE, version)
142 if not m:
143 print('{} failed to parse dep version: {}'.format(name, version))
Allen Webb81a58ba2021-05-03 10:53:17 -0500144 raise VersionParseError
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700145
146 dep = m.group('dep')
147 major = m.group('major')
148 minor = m.group('minor')
149 patch = m.group('patch')
150
151 has_star = any([x == '*' for x in [major, minor, patch]])
152
153 major = version_to_int(major)
154 minor = version_to_int(minor)
155 patch = version_to_int(patch)
156
157 if has_star:
158 dep = '~'
159 elif not dep:
160 dep = '^'
161
162 return (dep, major, minor, patch)
163
164
165def get_clean_package_version(package):
166 """Get package version in the format {major}.{minor}.{patch}."""
167 (_, major, minor, patch) = version_to_tuple(package['name'],
168 package['version'],
169 missing=0)
170 return '{}.{}.{}'.format(major, minor, patch)
171
172
173def get_ebuild_path(package, staging_dir, make_dir=False):
174 """Get path to ebuild in given directory."""
175 ebuild_path = os.path.join(
176 staging_dir, 'dev-rust', package['name'],
177 '{}-{}.ebuild'.format(package['name'],
178 get_clean_package_version(package)))
179
180 if make_dir:
181 os.makedirs(os.path.dirname(ebuild_path), exist_ok=True)
182
183 return ebuild_path
184
185
186def download_package(package, staging_dir):
187 """Download the crate from crates.io."""
188 dl_uri = CRATE_DL_URI.format(PN=package['name'], PV=package['version'])
189 crate_path = get_crate_path(package, staging_dir)
190
191 # Already downloaded previously
192 if os.path.isfile(crate_path):
193 return
194
195 ret = subprocess.run(['curl', '-L', dl_uri, '-o', crate_path],
196 stdout=subprocess.DEVNULL,
197 stderr=subprocess.DEVNULL).returncode
198
199 if ret:
200 print('{} failed to download: {}'.format(dl_uri, ret))
201
202
203def get_description(package):
204 """Get a description of the crate from metadata."""
205 # pylint: disable=invalid-string-quote
206 if package.get('description', None):
207 desc = package['description'].replace('`', '\'').replace('"', '\'')
208 return desc.rstrip('\n')
209
210 return ''
211
212
213def get_homepage(package):
214 """Get the homepage of the crate from metadata or use crates.io."""
215 if package.get('homepage', None):
216 return package['homepage']
217
218 return 'https://crates.io/crates/{}'.format(package['name'])
219
220
221def convert_license(cargo_license, package):
222 """Convert licenses from cargo to a format usable in ebuilds."""
223 cargo_license = '' if not cargo_license else cargo_license
224 has_or = ' OR ' in cargo_license
225 delim = ' OR ' if has_or else '/'
226
227 found = cargo_license.split(delim)
228 licenses_or = []
229 for f in found:
230 if f in LICENSES:
231 licenses_or.append(LICENSES[f])
232
233 if not licenses_or:
234 print('{} is missing an appropriate license: {}'.format(
235 package['name'], license))
236 return "$(die 'Please replace with appropriate license')"
237
238 if len(licenses_or) > 1:
239 lstr = '|| ( {} )'.format(' '.join(licenses_or))
240 else:
241 lstr = licenses_or[0]
242
243 return lstr
244
245
246def convert_dependencies(dependencies, package, optional_packages):
247 """Convert dependencies from metadata into the ebuild format."""
248 def caret_to_ebuild(info):
249 prefix = '>=dev-rust/{name}-{major}.{minor}.{patch}:='.format(**info)
250 suffix = '<dev-rust/{name}-{major_1}'.format(**info)
251
252 if info['minor'] == -1:
253 prefix = '>=dev-rust/{name}-{major}:='.format(**info)
254 elif info['patch'] == -1:
255 prefix = '>=dev-rust/{name}-{major}.{minor}:='.format(**info)
256
257 if info['major'] == 0:
258 suffix = '<dev-rust/{name}-{major}.{minor_1}'.format(**info)
259
260 return '{} {}'.format(prefix, suffix)
261
262 def tilde_to_ebuild(info):
263 prefix = '>=dev-rust/{name}-{major}.{minor}.{patch}:='.format(**info)
264 suffix = '<dev-rust/{name}-{major}.{minor_1}'.format(**info)
265 ebuild = '{} {}'.format(prefix, suffix)
266
267 if info['minor'] == -1:
268 ebuild = '=dev-rust/{name}-{major}*:='.format(**info)
269 elif info['patch'] == -1:
270 ebuild = '=dev-rust/{name}-{major}.{minor}*:='.format(**info)
271
272 return ebuild
273
274 def dep_to_ebuild(name, dep_type, major, minor, patch):
275 info = {
276 'name': name,
277 'major': major,
278 'minor': minor,
279 'patch': patch,
280 'major_1': major + 1,
281 'minor_1': minor + 1,
282 'patch_1': patch + 1
283 }
284
285 if dep_type == '^':
286 return caret_to_ebuild(info)
287 if dep_type == '~':
288 return tilde_to_ebuild(info)
289
290 # Remaining dep type is =
291 return '=dev-rust/{name}-{major}.{minor}.{patch}:='.format(**info)
292
293 deps = []
294 for dep in dependencies:
295 # Skip all dev dependencies. We will still handle normal and build deps.
296 if dep.get('kind', None) == 'dev':
297 continue
298
299 # Optional dependencies get empty packages created
300 if dep.get('optional', None):
301 optional_packages[dep['name']] = {
302 'name': dep['name'],
303 'version': dep['req'],
304 'features': dep['features'],
305 }
306
Allen Webb81a58ba2021-05-03 10:53:17 -0500307 # Convert version requirement to ebuild DEPEND.
308 try:
309 # Convert requirement to version tuple
310 (deptype, major, minor, patch) = version_to_tuple(dep['name'], dep['req'])
311 ebuild = dep_to_ebuild(dep['name'], deptype, major, minor, patch)
312 deps.append('\t{}'.format(ebuild))
313 except VersionParseError:
314 # Rarely dependencies look something like ">=0.6, <0.8"
315 deps.append("\t$(die 'Please replace with proper DEPEND: {} = {}')".format(dep['name'], dep['req']))
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700316
317 if not deps:
318 return ''
319
320 # Add DEPEND= into template with all dependencies
321 # RDEPEND="${DEPEND}" required for race in cros-rust
322 fmtstring = 'DEPEND="\n{}\n"\nRDEPEND="${{DEPEND}}"\n'
323 return fmtstring.format('\n'.join(deps))
324
325
326def package_ebuild(package, ebuild_dir, optional_packages):
327 """Create ebuild from metadata and write to ebuild directory."""
328 ebuild_path = get_ebuild_path(package, ebuild_dir, make_dir=True)
329
330 autogen_notice = AUTOGEN_NOTICE
331
332 # Check if version matches clean version or modify the autogen notice
333 if package['version'] != get_clean_package_version(package):
334 autogen_notice = '\n'.join([
335 autogen_notice,
336 '# ${{PV}} was changed from the original {}'.format(
337 package['version'])
338 ])
339
340 dependencies = convert_dependencies(package['dependencies'], package,
341 optional_packages)
342 template_info = {
343 'copyright_year': datetime.now().year,
344 'description': get_description(package),
345 'homepage': get_homepage(package),
346 'license': convert_license(package['license'], package),
347 'dependencies': dependencies,
348 'autogen_notice': autogen_notice,
349 }
350
351 with open(ebuild_path, 'w') as ebuild:
352 ebuild.write(EBUILD_TEMPLATE.format(**template_info))
353
354
355def upload_gsutil(package, staging_dir, no_upload=False):
356 """Upload crate to distfiles."""
357 if no_upload:
358 return
359
360 crate_path = get_crate_path(package, staging_dir)
361 crate_name = get_clean_crate_name(package)
362 ret = subprocess.run([
363 'gsutil', 'cp', '-a', 'public-read', crate_path,
364 'gs://chromeos-localmirror/distfiles/{}'.format(crate_name)
365 ]).returncode
366
367 if ret:
368 print('{} failed to upload to chromeos-localmirror: {}'.format(
369 crate_name, ret))
370
371
Allen Webb81a58ba2021-05-03 10:53:17 -0500372def update_ebuild(package, args, ebuild_dir, target_dir):
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700373 """Update ebuild with generated one and generate MANIFEST."""
374 ebuild_src = get_ebuild_path(package, ebuild_dir)
375 ebuild_dest = get_ebuild_path(package, target_dir, make_dir=True)
376
Allen Webb81a58ba2021-05-03 10:53:17 -0500377 # Do not overwrite existing ebuilds unless explicity asked to.
378 if args.overwrite_existing_ebuilds or not os.path.exists(ebuild_dest):
379 shutil.copy(ebuild_src, ebuild_dest)
380 else:
381 print('ebuild {} already exists, skipping.'.format(ebuild_dest))
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700382
383 # Generate manifest w/ ebuild digest
384 ret = subprocess.run(['ebuild', ebuild_dest, 'digest']).returncode
385 if ret:
386 print('ebuild {} digest failed: {}'.format(ebuild_dest, ret))
387
388
389def process_package(package, args, optional_packages):
390 """Process each package listed in the metadata."""
391 staging_dir = args.staging_dir
392 ebuild_dir = os.path.join(staging_dir, 'ebuild')
393 target_dir = args.target_dir
394
395 download_package(package, staging_dir)
396 package_ebuild(package, ebuild_dir, optional_packages)
397
398 if not args.dry_run and package['name'] not in args.skip:
399 upload_gsutil(package, staging_dir, no_upload=args.no_upload)
Allen Webb81a58ba2021-05-03 10:53:17 -0500400 update_ebuild(package, args, ebuild_dir, target_dir)
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700401
402def process_empty_package(empty_package, args):
403 """Process packages that should generate empty ebuilds."""
404 staging_dir = args.staging_dir
405 ebuild_dir = os.path.join(staging_dir, 'ebuild')
406 target_dir = args.target_dir
407
408 ebuild_src = get_ebuild_path(empty_package, ebuild_dir, make_dir=True)
409 ebuild_dest = get_ebuild_path(empty_package, target_dir, make_dir=True)
410
411 crate_features = ''
412 if empty_package.get('features', None):
413 crate_features = 'CROS_RUST_EMPTY_CRATE_FEATURES=( {} )'.format(
414 ' '.join(['"{}"'.format(x) for x in empty_package['features']]))
415
416 template_info = {
417 'copyright_year': datetime.now().year,
418 'crate_features': crate_features,
419 'autogen_notice': AUTOGEN_NOTICE,
420 }
421
422 with open(ebuild_src, 'w') as ebuild:
423 ebuild.write(EMPTY_CRATE.format(**template_info))
424
425 if not args.dry_run and empty_package['name'] not in args.skip:
426 shutil.copy(ebuild_src, ebuild_dest)
427
428def check_if_package_is_required(empty_package, args):
429 """Check if an empty package already has a non-empty ebuild."""
430 ebuild_dest = get_ebuild_path(empty_package, args.target_dir, make_dir=False)
431 ebuild_target_dir = os.path.dirname(ebuild_dest)
432
433 # Find all ebuilds in ebuild_target dir and confirm they have
434 # CROS_RUST_EMPTY_CRATE in them. If they do not, they need to be manually
435 # handled.
436 for root, _, files in os.walk(ebuild_target_dir):
437 for efile in files:
438 if not efile.endswith('.ebuild'):
439 continue
440
441 with open(os.path.join(root, efile), 'r') as ebuild:
442 if 'CROS_RUST_EMPTY_CRATE' not in ebuild.read():
443 print('{} was not an empty crate.'.format(efile))
444 return True
445
446 return False
447
448def main(argv):
449 """Convert dependencies from Cargo.toml into ebuilds."""
450 args = parse_args(argv)
451
452 prepare_staging(args)
453
454 processed_packages = {}
455 optional_packages = {}
456 metadata = load_metadata(args.manifest_path)
457 for p in metadata['packages']:
458 process_package(p, args, optional_packages)
459 processed_packages[p['name']] = True
460
461 for key in optional_packages:
Allen Webb81a58ba2021-05-03 10:53:17 -0500462 try:
463 if key not in processed_packages and not check_if_package_is_required(
464 optional_packages[key], args):
465 process_empty_package(optional_packages[key], args)
466 except VersionParseError:
467 print('{} has a malformed version'.format(key))
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700468
469
470def parse_args(argv):
471 """Parse the command-line arguments."""
472 parser = argparse.ArgumentParser(description=__doc__)
473 parser.add_argument(
474 '-s',
475 '--staging-dir',
476 required=True,
477 help='Staging directory for temporary and generated files')
478
479 parser.add_argument(
480 '-d',
481 '--dry-run',
482 action='store_true',
483 help='Generate dependency tree but do not upload anything')
484
485 parser.add_argument('-t',
486 '--target-dir',
487 help='Path to chromiumos-overlay')
488
489 parser.add_argument('-k',
490 '--skip',
491 action='append',
492 help='Skip these packages when updating ebuilds')
493 parser.add_argument('-n',
494 '--no-upload',
495 action='store_true',
496 help='Skip uploading crates to distfiles')
Allen Webb81a58ba2021-05-03 10:53:17 -0500497 parser.add_argument('-x',
498 '--overwrite-existing-ebuilds',
499 action='store_true',
500 help='Skip uploading crates to distfiles')
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700501
502 parser.add_argument('manifest_path',
503 help='Cargo.toml used to generate ebuilds.')
504
505 args = parser.parse_args(argv)
506
507 # Require target directory if not dry run
508 if not args.target_dir and not args.dry_run:
509 raise Exception('Target directory must be set unless dry-run is True.')
510
511 if not args.skip:
512 args.skip = []
513
514 return args
515
516
517if __name__ == '__main__':
518 sys.exit(main(sys.argv[1:]))