blob: a413e0554d27c4998f2b23ed258915823252596d [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
Allen Webb2e0d5652021-05-03 11:57:57 -050012from collections import defaultdict
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -070013from datetime import datetime
14import json
Allen Webb2e0d5652021-05-03 11:57:57 -050015import logging
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -070016import os
Allen Webb2e0d5652021-05-03 11:57:57 -050017from pprint import pprint
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -070018import re
19import shutil
20import subprocess
21import sys
22
23SCRIPT_NAME = 'cargo2ebuild.py'
24AUTOGEN_NOTICE = '\n# This file was automatically generated by {}'.format(
25 SCRIPT_NAME)
26
27CRATE_DL_URI = 'https://crates.io/api/v1/crates/{PN}/{PV}/download'
28
29# Required parameters
30# copyright_year: Current year for copyright assignment.
31# description: Description of the crates.
32# homepage: Homepage of the crates.
33# license: Ebuild compatible license string.
34# dependencies: Ebuild compatible dependency string.
35# autogen_notice: Autogenerated notification string.
Allen Webb81a58ba2021-05-03 10:53:17 -050036EBUILD_TEMPLATE = (
Allen Webb2e0d5652021-05-03 11:57:57 -050037 """# Copyright {copyright_year} The Chromium OS Authors. All rights reserved.
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -070038# Distributed under the terms of the GNU General Public License v2
39
40EAPI="7"
41
Allen Webb2e0d5652021-05-03 11:57:57 -050042CROS_RUST_REMOVE_DEV_DEPS=1{remove_target_var}
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -070043
44inherit cros-rust
45
Allen Webb2e0d5652021-05-03 11:57:57 -050046DESCRIPTION='{description}'
47HOMEPAGE='{homepage}'
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -070048SRC_URI="https://crates.io/api/v1/crates/${{PN}}/${{PV}}/download -> ${{P}}.crate"
49
50LICENSE="{license}"
51SLOT="${{PV}}/${{PR}}"
52KEYWORDS="*"
53
54{dependencies}{autogen_notice}
Allen Webb81a58ba2021-05-03 10:53:17 -050055""")
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -070056
57# Required parameters:
58# copyright_year: Current year for copyright assignment.
59# crate_features: Features to add to this empty crate.
60# autogen_notice: Autogenerated notification string.
Allen Webb81a58ba2021-05-03 10:53:17 -050061EMPTY_CRATE = (
Allen Webb2e0d5652021-05-03 11:57:57 -050062 """# Copyright {copyright_year} The Chromium OS Authors. All rights reserved.
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -070063# Distributed under the terms of the GNU General Public License v2
64
65EAPI="7"
66
67CROS_RUST_EMPTY_CRATE=1
68{crate_features}
69inherit cros-rust
70
71DESCRIPTION="Empty crate"
72HOMEPAGE=""
73
74LICENSE="BSD-Google"
75SLOT="${{PV}}/${{PR}}"
76KEYWORDS="*"
77{autogen_notice}
Allen Webb81a58ba2021-05-03 10:53:17 -050078""")
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -070079
80LICENSES = {
81 'Apache-2.0': 'Apache-2.0',
82 'MIT': 'MIT',
Allen Webb2e0d5652021-05-03 11:57:57 -050083 'BSD-2-Clause': 'BSD-2',
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -070084 'BSD-3-Clause': 'BSD',
85 '0BSD': '0BSD',
86 'ISC': 'ISC',
87}
88
89VERSION_RE = (
90 '^(?P<dep>[\\^~=])?' # Dependency type: ^, ~, =
91 '(?P<major>[0-9]+|[*])' # Major version (can be *)
92 '(.(?P<minor>[0-9]+|[*]))?' # Minor version
93 '(.(?P<patch>[0-9]+|[*]))?' # Patch version
94 '([+\\-].*)?$' # Any semver values beyond patch version
95)
96
Allen Webb2e0d5652021-05-03 11:57:57 -050097EBUILD_RE = (
98 '^(?P<name>[-a-zA-Z0-9_]+)?' # Ebuild name
99 '-(?P<version>[0-9]+(.[0-9]+)?(.[0-9]+)?)'
100 '([-_].*)?\\.ebuild$' # Any semver values beyond patch version
101)
102
103FEATURES_RE = (
104 '^CROS_RUST_EMPTY_CRATE_FEATURES=\\('
105 '(?P<features>([^)]|$)*)' # Features array
106 '\\)'
107)
108
109DEP_GRAPH_ROOT = '*root*'
110
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700111
Allen Webb81a58ba2021-05-03 10:53:17 -0500112class VersionParseError(Exception):
113 """Error that is returned when parsing a version fails."""
114
115
Allen Webb2e0d5652021-05-03 11:57:57 -0500116class VersionRange:
117 """Represents a range of Rust crate versions."""
118 @staticmethod
119 def from_str(value):
120 """Generate a VersionRange from a crate requirement string."""
121 if value == '*':
122 return VersionRange(v_min=(0, 0, 0))
123
124 v_min = None
125 v_min_inclusive = True
126 v_max = None
127 v_max_inclusive = False
128
129 # Handle a pair of constraints.
130 parts = value.split(', ')
131 if len(parts) == 2:
132 for part in parts:
133 if part.startswith('>='):
134 dep, major, minor, patch = version_to_tuple(part[2:], missing=0)
135 v_min = (major, minor, patch)
136 elif part.startswith('<='):
137 v_max_inclusive = True
138 dep, major, minor, patch = version_to_tuple(part[2:], missing=0)
139 v_max = (major, minor, patch)
140 elif part.startswith('>'):
141 v_min_inclusive = False
142 dep, major, minor, patch = version_to_tuple(part[1:], missing=0)
143 v_min = (major, minor, patch)
144 elif part.startswith('<'):
145 dep, major, minor, patch = version_to_tuple(part[1:], missing=0)
146 v_max = (major, minor, patch)
147 if v_max and v_min and v_max < v_min:
148 raise VersionParseError
149 # We are not going to worry about more than two constraints until we see it.
150 elif len(parts) != 1:
151 raise VersionParseError(
152 'Version constraint has more than two parts: {}'.format(parts))
153 # Handle the typical case of a single constraint operator.
154 else:
155 dep, major, minor, patch = version_to_tuple(value, missing=-1)
156 v_min = (major, minor, patch)
157
158 if dep == '=':
159 v_max = v_min
160 v_max_inclusive = True
161 elif dep == '~':
162 major = max(v_min[0], 0)
163 minor = max(v_min[1], 0)
164 patch = max(v_min[2], 0)
165 if v_min[0] < 0:
166 raise VersionParseError(
167 'The ~ constraint operator requires a major version: {}'.format(value))
168 if v_min[1] < 0:
169 v_max = (major + 1, 0, 0)
170 elif v_min[2] < 0:
171 v_max = (0, minor + 1, 0)
172 else:
173 v_max_inclusive = True
174 v_max = (major, minor, patch)
175 v_min = (major, minor, patch)
176 elif dep and dep != '^':
177 raise VersionParseError('Unrecognized operator: "{}"'.format(dep))
178 else:
179 major = max(v_min[0], 0)
180 minor = max(v_min[1], 0)
181 patch = max(v_min[2], 0)
182
183 v_min = (major, minor, patch)
184 if major > 0:
185 v_max = (major + 1, 0, 0)
186 elif minor > 0:
187 v_max = (0, minor + 1, 0)
188 else:
189 v_max_inclusive = True
190 v_max = v_min
191
192 return VersionRange(v_min=v_min, v_min_inclusive=v_min_inclusive, v_max=v_max,
193 v_max_inclusive=v_max_inclusive)
194
195 def __init__(self, v_min=None, v_min_inclusive=True, v_max=None, v_max_inclusive=False):
196 self.min = v_min
197 self.min_inclusive = v_min_inclusive
198 self.max = v_max
199 self.max_inclusive = v_max_inclusive
200
201 def contains(self, version):
202 """Returns `True` if version is inside the range; `False` otherwise.
203
204 Args:
205 version: Can be either a tuple of integers (major, minor, patch) or a string that will be converted to a
206 tuple of integers. Trailing characters will be removed from the version (e.g. "+openssl-1.0.1").
207 """
208 if isinstance(version, str):
209 try:
210 filtered_version = re.sub(r'([0-9]+\.[0-9]+\.[0-9]+)[^0-9].*$', r'\1', version)
211 if filtered_version != version:
212 logging.warning('Filtered version "%s" to "%s"', version, filtered_version)
213 version = tuple([int(a) for a in filtered_version.split('.')])
214 except Exception as e:
215 print(version)
216 raise e
217
218 if self.min:
219 if self.min_inclusive:
220 if self.min > version:
221 return False
222 elif self.min >= version:
223 return False
224 if self.max:
225 if self.max_inclusive:
226 if self.max < version:
227 return False
228 elif self.max <= version:
229 return False
230 return True
231
232 def to_ebuild_str(self, category_and_name):
233 """Return an ebuild DEPEND entry equivalent to the range.
234
235 Args:
236 category_and_name: A string of the form 'dev-rust/crate-name' where 'dev-rust' is the category and
237 'crate-name' is the name of the ebuild.
238
239 Returns:
240 A string that can be used in an ebuild's DEPEND field.
241 """
242 if not self.min or self.min == (0, 0, 0) and not self.max:
243 return '{}:='.format(category_and_name)
244
245 min_bound = '>=' if self.min_inclusive else '>'
246 max_bound = '<=' if self.max_inclusive else '<'
247 if not self.max:
248 return '{}{}-{}.{}.{}:='.format(min_bound, category_and_name, self.min[0], self.min[1],
249 self.min[2])
250
251 for x in range(len(self.min)):
252 if self.min[x] != self.max[x]:
253 break
254 else:
255 # We want to allow revisions other than -r0, so use `~` instead of `=`.
256 return '~{}-{}.{}.{}:='.format(category_and_name, self.min[0], self.min[1],
257 self.min[2])
258
259 if self.min_inclusive and not self.max_inclusive and self.min[x] + 1 == self.max[x]:
260 if x == 0 and self.min[1] == 0 and self.min[2] == 0:
261 return '={}-{}*:='.format(category_and_name, self.min[0])
262 if x == 1 and self.min[2] == 0:
263 return '={}-{}.{}*:='.format(category_and_name, self.min[0], self.min[1])
264
265 return '{0}{1}-{2}.{3}.{4}:= {5}{1}-{6}.{7}.{8}'.format(
266 min_bound, category_and_name, self.min[0], self.min[1], self.min[2],
267 max_bound, self.max[0], self.max[1], self.max[2])
268
269
270class DepGraphNode:
271 """A node in the dep-graph for a specific version of a package used by DepGraph."""
272 def __init__(self, package):
273 self.package = package
274 self.dependencies = []
275
276 def __repr__(self):
277 return '{}'.format([(a['name'], a['req']) for a in self.dependencies])
278
279 def add_dependency(self, dep):
280 """Adds a dependency to the list."""
281 self.dependencies.append(dep)
282
283
284class DepGraph:
285 """A model of the dependency relationships between crates.
286
287 The functionality this provides over using the Cargo metadata directly is:
288 * Filtering based on cros-rust.eclass features.
289 * Tracking of system ebuilds for detecting edge cases such as:
290 * Already fulfilled dependencies.
291 * Optional crates that would replace or supersede non-empty ebuilds.
292 * Empty crates that are missing definitions for required features.
293 """
294 def __init__(self):
295 # name -> version -> DepGraphNode
296 self.graph = defaultdict(dict)
297
298 # name -> version -> None or [features]
299 self.system_crates = defaultdict(dict)
300
301 # The crate that will be initially checked when flattening the depgraph into a list of required and optional
302 # crates.
303 self.root = None
304
305 def add_package(self, package):
306 """Add a crate to the dependency graph from a parsed metadata.json generated by cargo.
307
308 Args:
309 package: A parsed package from the Cargo metadata.
310
311 Returns:
312 filter_target: True if CROS_RUST_REMOVE_TARGET_CFG=1 should be set in the ebuild.
313 deps: A list of dependencies from the Cargo metadata JSON that still apply after filtering.
314 """
315 dependencies = package['dependencies']
316
317 # Check if there are target specific dependencies and if so if they can be handled by
318 # CROS_RUST_REMOVE_TARGET_CFG. This requires that there not be complicated cfg blocks since the cros-rust eclass
319 # only applies simplistic filtering.
320 has_filterable_target = False
321 has_unfilterable_target = False
322 for dep in dependencies:
323 # Skip all dev dependencies. We will still handle normal and build deps.
324 if dep.get('kind', None) == 'dev':
325 continue
326
327 target = dep.get('target', None)
328 if target is None:
329 continue
330
331 if target_applies_to_chromeos(target):
332 continue
333
334 if not target_is_filterable(target):
335 logging.debug('target "%s" is unfilterable.', target)
336 has_unfilterable_target = True
337 else:
338 has_filterable_target = True
339 if has_unfilterable_target:
340 logging.warning('"%s-%s" might benefit from better target specific dependency filtering',
341 package['name'], package['version'])
342 filter_target = has_filterable_target and not has_unfilterable_target
343
344 graph_node = DepGraphNode(package)
345 self.graph[package['name']][package['version']] = graph_node
346
347 # Check each dependency and add it if it is relevant to Chrome OS.
348 deps = []
349 for dep in dependencies:
350 # Skip all dev dependencies. We will still handle normal and build deps.
351 if dep.get('kind', None) == 'dev':
352 continue
353
354 # Skip filterable configuration dependant dependencies.
355 target = dep.get('target', None)
356 if filter_target and target is not None and not target_applies_to_chromeos(target):
357 continue
358
359 graph_node.add_dependency(dep)
360 deps.append(dep)
361 return filter_target, deps
362
363 def find_system_crates(self, target_dir, names=None):
364 """Take note of crates already present in the target directory.
365
366 Args:
367 target_dir: The overlay directory where the ebuilds exist.
368 names: A list of package names to check. If unspecified all the crates present in the DepGraph are checked.
369 """
370 if names is None:
371 names = self.graph
372
373 for name in names:
374 # Skip if we have already done this crate.
375 if name in self.system_crates:
376 continue
377 available = self.system_crates[name]
378
379 # Check if an empty package already has a non-empty ebuild.
380 ebuild_target_dir = get_ebuild_dir(name, target_dir)
381
382 # Find all ebuilds in ebuild_target dir and if they have
383 # CROS_RUST_EMPTY_CRATE in them, check if features are set.
384 for root, _, files in os.walk(ebuild_target_dir):
385 files.sort()
386 for efile in files:
387 if not efile.endswith('.ebuild'):
388 continue
389 efile_path = os.path.join(root, efile)
390
391 m = re.match(EBUILD_RE, efile)
392 if not m or m.group('name') != name:
393 logging.warning("Found misplaced ebuild: '%s'.", efile_path)
394 continue
395
396 version = m.group('version')
397
398 with open(efile_path, 'r') as ebuild:
399 contents = ebuild.read()
400 if 'CROS_RUST_EMPTY_CRATE' not in contents:
401 features = None
402 else:
403 m = re.search(FEATURES_RE, contents, re.MULTILINE)
404 if m:
405 sanitized = re.sub('[^a-zA-Z0-9_ \t\n-]+', '', m.group('features'))
406 features = set(a.strip() for a in sanitized.split() if a)
407 else:
408 features = set()
409 logging.debug("Found ebuild: '%s'.", efile_path[len(target_dir):])
410 available[version] = features
411
412 def set_root(self, package):
413 """Set the root of the dependency graph used when flattening the graph into a list of dependencies."""
414 self.root = package
415
416 def resolve_version_range(self, name, version_range):
417 """Find a dependency graph entry that fits the constraints.
418
419 Optional dependencies often are not found, so the minimum version in version_range is returned in those cases.
420
421 Args:
422 name: crate name
423 version_range: A VersionRange used to match against crate versions.
424
425 Returns:
426 version: Returns the version of the match or the minimum matching version if no match is found.
427 node: None if no match is found otherwise returns the DepGraphNode of the match.
428 """
429 for version, node in self.graph[name].items():
430 if version_range.contains(version):
431 return version, node
432 if not version_range.min:
433 return '0.0.0', None
434 return '.'.join(map(str, version_range.min)), None
435
436 def check_dependencies(self, args, package, features, optional, features_only=False):
437 """Check the dependencies of a specified package with the specified features enabled.
438
439 Args:
440 args: The command line flags set for cargo2ebuild.
441 package: A parsed package from the Cargo metadata.
442 features: A set containing the enabled features for this package. This will be expanded by the function to
443 include any additional features implied by the original set of features (e.g. if default is set, all the
444 default features will be added).
445 optional: A dictionary of sets to which any new optional dependencies are added. It has the format:
446 (package_name, package_version): set()
447 where the set contains the required features.
448 features_only: If True, this function only checks the optional dependencies for ones that are enabled if
449 the features specified by `features` are enabled.
450
451 Returns:
452 A list of (package_name, package_version) that are required by the specified package with the specified
453 features enabled.
454 """
455 name = package['name']
456 version = package['version']
457 node = self.graph[name][version]
458
459 enabled_optional_deps = get_feature_dependencies(package, features)
460
461 new_required = []
462 for dep in node.dependencies:
463 dep_name = dep['name']
464 if features_only and dep_name not in enabled_optional_deps:
465 continue
466 req = VersionRange.from_str(dep['req'])
467 dep_version, dep_node = self.resolve_version_range(dep_name, req)
468 identifier = (dep_name, dep_version)
469
470 dep_features = set(dep['features'])
471 if dep_node and dep.get('uses_default_features', False):
472 dep_features.add('default')
473
474 is_required = not dep.get('optional', False) or dep_name in enabled_optional_deps
475 if dep_name in enabled_optional_deps:
476 if not features_only:
477 logging.info('Optional dependency required by feature: "%s-%s"', dep_name, dep_version)
478 dep_features.update(enabled_optional_deps[dep_name])
479
480 found = False
481 sys_version = None
482 repl_versions = []
483 if dep_name not in self.system_crates:
484 self.find_system_crates(args.target_dir, names=[dep_name])
485 for sys_version, sys_features in self.system_crates[dep_name].items():
486 sys_crate_is_empty = sys_features is not None
487 missing_features = dep_features.difference(sys_features) if sys_crate_is_empty else set()
488 if not args.overwrite_existing_ebuilds and sys_version == dep_version:
489 if sys_crate_is_empty:
490 if is_required:
491 logging.error('Empty crate "%s-%s" should be replaced with full version.',
492 dep_name, dep_version)
493 elif missing_features:
494 logging.error('Empty crate "%s-%s" has missing features: %s.',
495 dep_name, dep_version, missing_features)
496 found = True
497 break
498 if not sys_crate_is_empty and req.contains(sys_version):
499 if not args.no_substitute or (not is_required and dep_node is None):
500 found = True
501 break
502 if VersionRange.from_str('^{}'.format(sys_version)).contains(dep_version):
503 if sys_crate_is_empty:
504 dep_features.update(sys_features)
505 else:
506 repl_versions.append(sys_version)
507 if found and not features_only:
508 logging.debug('Using system crate: "%s-%s".', dep_name, sys_version)
509 continue
510
511 if is_required:
512 if dep_node is None:
513 logging.error('Required crate "%s" "%s" not in dep-graph. It will be omitted.',
514 dep_name, dep['req'])
515 else:
516 logging.debug('New crate required: "%s-%s".', dep_name, dep_version)
517 new_required.append((dep_node.package, dep_features))
518 else:
519 # If the empty crate would replace a non-empty version of the same crate, treat it as required.
520 if repl_versions:
521 if dep_node is not None:
522 logging.info('Empty crate for "%s-%s" would replace non-empty versions %s. '
523 'Upgrading to non-empty.', dep_name, dep_version, repl_versions)
524 new_required.append((dep_node.package, dep_features))
525 else:
526 logging.error('Required crate "%s-%s" not in metadata. Fix: add it to Cargo.toml',
527 dep_name, dep_version)
528 else:
529 logging.debug('New optional crate required: "%s-%s".', dep_name, dep_version)
530 # Include all features supported by the crate.
531 if dep_node is not None:
532 dep_features = dep_node.package['features'].keys()
533 optional[identifier].update(dep_features if features else ())
534 return new_required
535
536 def flatten(self, args):
537 """Flatten the dependency graph into a list of required and optional crates.
538
539 Returns:
540 Two dictionaries that map tuples of package names and versions to sets of features:
541 (name, version) -> set(features)
542 The first dictionary contains the required crates with their required features, while the second contains
543 the optional crates, versions, and their features.
544 """
545 # (name, version) -> set(features)
546 required = defaultdict(set)
547 optional = defaultdict(set)
548 remaining = [(self.root, {'default'})]
549 while remaining:
550 to_check, features = remaining.pop(0)
551 name = to_check['name']
552 version = to_check['version']
553
554 identifier = (name, version)
555 if identifier in required:
556 features_to_enable = features.difference(required[identifier])
557 if not features_to_enable:
558 continue
559 logging.debug('Extending dependencies for "%s-%s" to include features: "%s"',
560 name, version, features_to_enable)
561 remaining.extend(self.check_dependencies(args, to_check, features_to_enable, optional,
562 features_only=True))
563 else:
564 logging.debug('Resolving dependencies for "%s-%s".', name, version)
565 remaining.extend(self.check_dependencies(args, to_check, features, optional))
566 required[identifier].update(features)
567
568 return dict(required), {a: b for a, b in optional.items() if a not in required}
569
570
571def target_applies_to_chromeos(target):
572 """Return true if the target would be accepted by the cros-rust eclass."""
573 return (
574 target.startswith('cfg(unix') or
575 target.startswith('cfg(linux') or
576 target.startswith('cfg(not(windows)') or
577 '-linux-gnu' in target
578 )
579
580
581def target_is_filterable(target):
582 """Checks to see if the cros-rust eclass would probably handle this target properly.
583
584 Returns:
585 False if the Cargo.toml target configuration block would not be accepted by the cros-rust eclass, but is probably needed.
586 """
587 if target_applies_to_chromeos(target):
588 return True
589 if 'unix' in target or 'linux' in target or 'not(' in target:
590 return False
591 return True
592
593
594def get_feature_dependencies(package, features):
595 """Expand the features set to include implied features and return the enabled optional dependencies.
596
597 Args:
598 package: A parsed package from the Cargo metadata.
599 features: A set() of features that is expanded to include any additional dependencies implied by the original
600 set of features.
601
602 Returns:
603 A dictionary that maps crate names to the required features for the specific crate.
604 Note: that no version is supplied; it must be obtained by finding the named dependency in the package's
605 optional dependency requirements.
606 """
607 feature_list = list(features)
608 enabled_deps = defaultdict(set)
609 lookup = package.get('features', {})
610 for feature in feature_list:
611 if feature not in lookup:
612 if feature != 'default':
613 logging.error('Requested feature "%s" not listed in crate "%s".',
614 feature, package['name'])
615 continue
616 for dep in lookup[feature]:
617 # Check if it is a feature instead of a dependency.
618 if dep in lookup:
619 if dep not in features:
620 feature_list.append(dep)
621 features.add(dep)
622 continue
623 parts = dep.split('/')
624
625 # This creates an empty set if there is not one already.
626 entry = enabled_deps[parts[0]]
627 if len(parts) > 1:
628 entry.add(parts[1])
629 return dict(enabled_deps)
630
631
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700632def prepare_staging(args):
633 """Prepare staging directory."""
634 sdir = args.staging_dir
635 dirs = [
636 os.path.join(sdir, 'ebuild', 'dev-rust'),
637 os.path.join(sdir, 'crates')
638 ]
639 for d in dirs:
640 os.makedirs(d, exist_ok=True)
641
642
643def load_metadata(manifest_path):
644 """Run cargo metadata and get metadata for build."""
645 cwd = os.path.dirname(manifest_path)
646 cmd = [
647 'cargo', 'metadata', '--format-version', '1', '--manifest-path',
648 manifest_path
649 ]
650 output = subprocess.check_output(cmd, cwd=cwd)
651
652 return json.loads(output)
653
654
655def get_crate_path(package, staging_dir):
656 """Get path to crate in staging directory."""
657 return os.path.join(
658 staging_dir, 'crates', '{}-{}.crate'.format(package['name'],
659 package['version']))
660
661
662def get_clean_crate_name(package):
663 """Clean up crate name to {name}-{major}.{minor}.{patch}."""
664 return '{}-{}.crate'.format(package['name'],
Allen Webb2e0d5652021-05-03 11:57:57 -0500665 get_clean_package_version(package['version']))
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700666
667
Allen Webb2e0d5652021-05-03 11:57:57 -0500668def version_to_tuple(version, missing=-1):
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700669 """Extract dependency type and semver from a given version string."""
670 def version_to_int(num):
671 if not num or num == '*':
672 return missing
673 return int(num)
674
675 m = re.match(VERSION_RE, version)
676 if not m:
Allen Webb81a58ba2021-05-03 10:53:17 -0500677 raise VersionParseError
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700678
679 dep = m.group('dep')
680 major = m.group('major')
681 minor = m.group('minor')
682 patch = m.group('patch')
683
684 has_star = any([x == '*' for x in [major, minor, patch]])
685
686 major = version_to_int(major)
687 minor = version_to_int(minor)
688 patch = version_to_int(patch)
689
690 if has_star:
691 dep = '~'
692 elif not dep:
693 dep = '^'
694
Allen Webb2e0d5652021-05-03 11:57:57 -0500695 return dep, major, minor, patch
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700696
697
Allen Webb2e0d5652021-05-03 11:57:57 -0500698def get_clean_package_version(version):
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700699 """Get package version in the format {major}.{minor}.{patch}."""
Allen Webb2e0d5652021-05-03 11:57:57 -0500700 (_, major, minor, patch) = version_to_tuple(version, missing=0)
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700701 return '{}.{}.{}'.format(major, minor, patch)
702
703
Allen Webb2e0d5652021-05-03 11:57:57 -0500704def get_ebuild_dir(name, staging_dir):
705 """Get the directory that contains specific ebuilds."""
706 return os.path.join(staging_dir, 'dev-rust', name)
707
708
709def get_ebuild_path(name, version, staging_dir, make_dir=False):
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700710 """Get path to ebuild in given directory."""
Allen Webb2e0d5652021-05-03 11:57:57 -0500711 ebuild_dir = get_ebuild_dir(name, staging_dir)
712
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700713 ebuild_path = os.path.join(
Allen Webb2e0d5652021-05-03 11:57:57 -0500714 ebuild_dir,
715 '{}-{}.ebuild'.format(name, get_clean_package_version(version)))
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700716
717 if make_dir:
Allen Webb2e0d5652021-05-03 11:57:57 -0500718 os.makedirs(ebuild_dir, exist_ok=True)
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700719
720 return ebuild_path
721
722
723def download_package(package, staging_dir):
724 """Download the crate from crates.io."""
725 dl_uri = CRATE_DL_URI.format(PN=package['name'], PV=package['version'])
726 crate_path = get_crate_path(package, staging_dir)
727
728 # Already downloaded previously
729 if os.path.isfile(crate_path):
730 return
731
Allen Webb2e0d5652021-05-03 11:57:57 -0500732 ret = subprocess.run(
733 ['curl', '-L', dl_uri, '-o', crate_path],
734 stdout=subprocess.DEVNULL,
735 stderr=subprocess.DEVNULL).returncode
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700736
737 if ret:
Allen Webb2e0d5652021-05-03 11:57:57 -0500738 logging.error('Failed to download "%s": %s', dl_uri, ret)
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700739
740
741def get_description(package):
742 """Get a description of the crate from metadata."""
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700743 if package.get('description', None):
Allen Webb2e0d5652021-05-03 11:57:57 -0500744 desc = re.sub("[`']", '"', package['description'])
745 return desc.strip()
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700746
747 return ''
748
749
750def get_homepage(package):
751 """Get the homepage of the crate from metadata or use crates.io."""
752 if package.get('homepage', None):
753 return package['homepage']
754
755 return 'https://crates.io/crates/{}'.format(package['name'])
756
757
758def convert_license(cargo_license, package):
759 """Convert licenses from cargo to a format usable in ebuilds."""
760 cargo_license = '' if not cargo_license else cargo_license
761 has_or = ' OR ' in cargo_license
762 delim = ' OR ' if has_or else '/'
763
Allen Webb2e0d5652021-05-03 11:57:57 -0500764 found = [a.strip() for a in cargo_license.split(delim) if a]
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700765 licenses_or = []
766 for f in found:
767 if f in LICENSES:
768 licenses_or.append(LICENSES[f])
769
770 if not licenses_or:
Allen Webb2e0d5652021-05-03 11:57:57 -0500771 logging.error('"%s" is missing an appropriate license: "%s"', package['name'], cargo_license)
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700772 return "$(die 'Please replace with appropriate license')"
773
774 if len(licenses_or) > 1:
775 lstr = '|| ( {} )'.format(' '.join(licenses_or))
776 else:
777 lstr = licenses_or[0]
778
779 return lstr
780
781
Allen Webb2e0d5652021-05-03 11:57:57 -0500782def convert_dependencies(dependencies, filter_target=False):
783 """Convert crate dependencies to ebuild dependencies."""
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700784 deps = []
785 for dep in dependencies:
Allen Webb81a58ba2021-05-03 10:53:17 -0500786 # Convert version requirement to ebuild DEPEND.
787 try:
788 # Convert requirement to version tuple
Allen Webb2e0d5652021-05-03 11:57:57 -0500789 bounds = (
790 VersionRange.from_str(dep['req'])
791 .to_ebuild_str('dev-rust/{}'.format(dep['name']))
792 )
793 deps.append('\t{}'.format(bounds))
Allen Webb81a58ba2021-05-03 10:53:17 -0500794 except VersionParseError:
Allen Webb2e0d5652021-05-03 11:57:57 -0500795 logging.error('Failed to parse dep version for "%s": %s',
796 dep['name'], dep['req'], exc_info=True)
Allen Webb81a58ba2021-05-03 10:53:17 -0500797 # Rarely dependencies look something like ">=0.6, <0.8"
Allen Webb2e0d5652021-05-03 11:57:57 -0500798 deps.append("\t$(die 'Please replace with proper DEPEND: {} = {}')".format(
799 dep['name'], dep['req']))
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700800
Allen Webb2e0d5652021-05-03 11:57:57 -0500801 remove_target_var = '\nCROS_RUST_REMOVE_TARGET_CFG=1' if filter_target else ''
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700802 if not deps:
Allen Webb2e0d5652021-05-03 11:57:57 -0500803 return '', remove_target_var
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700804
805 # Add DEPEND= into template with all dependencies
806 # RDEPEND="${DEPEND}" required for race in cros-rust
807 fmtstring = 'DEPEND="\n{}\n"\nRDEPEND="${{DEPEND}}"\n'
Allen Webb2e0d5652021-05-03 11:57:57 -0500808 return fmtstring.format('\n'.join(deps)), remove_target_var
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700809
810
Allen Webb2e0d5652021-05-03 11:57:57 -0500811def package_ebuild(package, ebuild_dir, crate_dependencies, filter_target):
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700812 """Create ebuild from metadata and write to ebuild directory."""
Allen Webb2e0d5652021-05-03 11:57:57 -0500813 logging.debug('Processing "%s-%s"', package['name'], package['version'])
814 ebuild_path = get_ebuild_path(package['name'], package['version'], ebuild_dir, make_dir=True)
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700815
816 autogen_notice = AUTOGEN_NOTICE
817
818 # Check if version matches clean version or modify the autogen notice
Allen Webb2e0d5652021-05-03 11:57:57 -0500819 if package['version'] != get_clean_package_version(package['version']):
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700820 autogen_notice = '\n'.join([
821 autogen_notice,
822 '# ${{PV}} was changed from the original {}'.format(
823 package['version'])
824 ])
825
Allen Webb2e0d5652021-05-03 11:57:57 -0500826 (ebuild_dependencies, remove_target_var) = convert_dependencies(crate_dependencies, filter_target)
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700827 template_info = {
828 'copyright_year': datetime.now().year,
Allen Webb2e0d5652021-05-03 11:57:57 -0500829 'remove_target_var': remove_target_var,
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700830 'description': get_description(package),
831 'homepage': get_homepage(package),
832 'license': convert_license(package['license'], package),
Allen Webb2e0d5652021-05-03 11:57:57 -0500833 'dependencies': ebuild_dependencies,
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700834 'autogen_notice': autogen_notice,
835 }
836
837 with open(ebuild_path, 'w') as ebuild:
838 ebuild.write(EBUILD_TEMPLATE.format(**template_info))
839
840
841def upload_gsutil(package, staging_dir, no_upload=False):
842 """Upload crate to distfiles."""
843 if no_upload:
844 return
845
846 crate_path = get_crate_path(package, staging_dir)
847 crate_name = get_clean_crate_name(package)
848 ret = subprocess.run([
849 'gsutil', 'cp', '-a', 'public-read', crate_path,
850 'gs://chromeos-localmirror/distfiles/{}'.format(crate_name)
851 ]).returncode
852
853 if ret:
Allen Webb2e0d5652021-05-03 11:57:57 -0500854 logging.error('Failed to upload "%s" to chromeos-localmirror: %s', crate_name, ret)
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700855
856
Allen Webb2e0d5652021-05-03 11:57:57 -0500857def update_ebuild(package, args):
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700858 """Update ebuild with generated one and generate MANIFEST."""
Allen Webb2e0d5652021-05-03 11:57:57 -0500859 staging_dir = args.staging_dir
860 ebuild_dir = os.path.join(staging_dir, 'ebuild')
861 target_dir = args.target_dir
862 ebuild_src = get_ebuild_path(package['name'], package['version'], ebuild_dir)
863 ebuild_dest = get_ebuild_path(package['name'], package['version'], target_dir, make_dir=True)
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700864
Allen Webb2e0d5652021-05-03 11:57:57 -0500865 # TODO make this aware of the revision numbers of existing versions
866 # when doing a replacement.
867
868 # Do not overwrite existing ebuilds unless explicitly asked to.
Allen Webb81a58ba2021-05-03 10:53:17 -0500869 if args.overwrite_existing_ebuilds or not os.path.exists(ebuild_dest):
870 shutil.copy(ebuild_src, ebuild_dest)
Allen Webb2e0d5652021-05-03 11:57:57 -0500871 upload_gsutil(package, staging_dir, no_upload=args.no_upload)
Allen Webb81a58ba2021-05-03 10:53:17 -0500872 else:
Allen Webb2e0d5652021-05-03 11:57:57 -0500873 logging.info('ebuild %s already exists, skipping.', ebuild_dest)
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700874
875 # Generate manifest w/ ebuild digest
876 ret = subprocess.run(['ebuild', ebuild_dest, 'digest']).returncode
877 if ret:
Allen Webb2e0d5652021-05-03 11:57:57 -0500878 logging.error('ebuild %s digest failed: %s', ebuild_dest, ret)
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700879
880
Allen Webb2e0d5652021-05-03 11:57:57 -0500881def process_package(package, staging_dir, dep_graph):
882 """Process each package listed in the metadata.
883
884 This includes following:
885 * Setting the package as the root of the dep graph if it is included by source (rather than from crates.io).
886 * Adding the package to the the dep_graph.
887 * Downloading the crate file, so it can be potentially uploaded to localmirror.
888 * Generating an ebuild for the crate in the staging directory.
889 """
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700890 ebuild_dir = os.path.join(staging_dir, 'ebuild')
Allen Webb2e0d5652021-05-03 11:57:57 -0500891
892 # Add the user submitted package to the set of required packages.
893 if package.get('source', None) is None:
894 dep_graph.set_root(package)
895 filter_target, crate_dependencies = dep_graph.add_package(package)
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700896
897 download_package(package, staging_dir)
Allen Webb2e0d5652021-05-03 11:57:57 -0500898 package_ebuild(package, ebuild_dir, crate_dependencies, filter_target)
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700899
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700900
Allen Webb2e0d5652021-05-03 11:57:57 -0500901def process_empty_package(name, version, features, args):
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700902 """Process packages that should generate empty ebuilds."""
903 staging_dir = args.staging_dir
904 ebuild_dir = os.path.join(staging_dir, 'ebuild')
905 target_dir = args.target_dir
906
Allen Webb2e0d5652021-05-03 11:57:57 -0500907 ebuild_src = get_ebuild_path(name, version, ebuild_dir, make_dir=True)
908 ebuild_dest = get_ebuild_path(name, version, target_dir, make_dir=True)
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700909
910 crate_features = ''
Allen Webb2e0d5652021-05-03 11:57:57 -0500911 if features:
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700912 crate_features = 'CROS_RUST_EMPTY_CRATE_FEATURES=( {} )'.format(
Allen Webb2e0d5652021-05-03 11:57:57 -0500913 ' '.join(['"{}"'.format(x) for x in features]))
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700914
915 template_info = {
916 'copyright_year': datetime.now().year,
917 'crate_features': crate_features,
918 'autogen_notice': AUTOGEN_NOTICE,
919 }
920
Allen Webb2e0d5652021-05-03 11:57:57 -0500921 logging.debug('Writing empty crate: %s', ebuild_src)
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700922 with open(ebuild_src, 'w') as ebuild:
923 ebuild.write(EMPTY_CRATE.format(**template_info))
924
Allen Webb2e0d5652021-05-03 11:57:57 -0500925 # Do not overwrite existing ebuilds unless explicitly asked to.
926 if not args.overwrite_existing_ebuilds and os.path.exists(ebuild_dest):
927 logging.info('ebuild %s already exists, skipping.', ebuild_dest)
928 return
929
930 if not args.dry_run and name not in args.skip:
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700931 shutil.copy(ebuild_src, ebuild_dest)
932
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700933
934def main(argv):
935 """Convert dependencies from Cargo.toml into ebuilds."""
936 args = parse_args(argv)
937
Allen Webb2e0d5652021-05-03 11:57:57 -0500938 logging_kwargs = {'stream': sys.stderr, 'format': '%(levelname)s: %(message)s'}
939 if args.verbose > 1:
940 logging_kwargs['level'] = logging.DEBUG
941 elif args.verbose > 0:
942 logging_kwargs['level'] = logging.INFO
943 else:
944 logging_kwargs['level'] = logging.WARNING
945 logging.basicConfig(**logging_kwargs)
946
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700947 prepare_staging(args)
948
Allen Webb2e0d5652021-05-03 11:57:57 -0500949 dep_graph = DepGraph()
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700950 metadata = load_metadata(args.manifest_path)
951 for p in metadata['packages']:
Allen Webb2e0d5652021-05-03 11:57:57 -0500952 process_package(p, args.staging_dir, dep_graph)
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700953
Allen Webb2e0d5652021-05-03 11:57:57 -0500954 dep_graph.find_system_crates(args.target_dir)
955
956 required_packages, optional_packages = dep_graph.flatten(args)
957 if args.verbose > 2:
958 print('Dependency graph:')
959 pprint(dict(dep_graph.graph))
960 print('System versions:')
961 pprint(dict(dep_graph.system_crates))
962 print('Required versions:')
963 pprint(required_packages)
964 print('Optional versions:')
965 pprint(optional_packages)
966
967 for (name, version), features in optional_packages.items():
968 process_empty_package(name, version, features, args)
969
970 if not args.dry_run:
971 for p in metadata['packages']:
972 if p['name'] in args.skip:
973 continue
974 identifier = (p['name'], p['version'])
975 if identifier in required_packages:
976 update_ebuild(p, args)
977 display_dir = args.target_dir
978 else:
979 display_dir = '/'.join((args.staging_dir, 'ebuild'))
980 print('Generated ebuilds can be found in: {}'.format(display_dir))
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700981
982
983def parse_args(argv):
984 """Parse the command-line arguments."""
985 parser = argparse.ArgumentParser(description=__doc__)
986 parser.add_argument(
987 '-s',
988 '--staging-dir',
Allen Webb2e0d5652021-05-03 11:57:57 -0500989 default='/tmp/cargo2ebuild-staging',
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -0700990 help='Staging directory for temporary and generated files')
991
992 parser.add_argument(
993 '-d',
994 '--dry-run',
995 action='store_true',
996 help='Generate dependency tree but do not upload anything')
997
998 parser.add_argument('-t',
999 '--target-dir',
Allen Webb2e0d5652021-05-03 11:57:57 -05001000 default='/mnt/host/source/src/third_party/chromiumos-overlay',
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -07001001 help='Path to chromiumos-overlay')
1002
1003 parser.add_argument('-k',
1004 '--skip',
1005 action='append',
1006 help='Skip these packages when updating ebuilds')
1007 parser.add_argument('-n',
1008 '--no-upload',
1009 action='store_true',
1010 help='Skip uploading crates to distfiles')
Allen Webb2e0d5652021-05-03 11:57:57 -05001011 parser.add_argument('-X',
1012 '--no-substitute',
1013 action='store_true',
1014 help='Do not substitute system versions for required crates')
Allen Webb81a58ba2021-05-03 10:53:17 -05001015 parser.add_argument('-x',
1016 '--overwrite-existing-ebuilds',
1017 action='store_true',
Allen Webb2e0d5652021-05-03 11:57:57 -05001018 help='If an ebuild already exists, overwrite it.')
1019 parser.add_argument('-v',
1020 '--verbose',
1021 action='count',
1022 default=0,
1023 help='Enable verbose logging')
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -07001024
1025 parser.add_argument('manifest_path',
Allen Webb2e0d5652021-05-03 11:57:57 -05001026 nargs='?',
1027 default='./Cargo.toml',
Abhishek Pandit-Subedi702740f2021-03-16 21:39:00 -07001028 help='Cargo.toml used to generate ebuilds.')
1029
1030 args = parser.parse_args(argv)
1031
1032 # Require target directory if not dry run
1033 if not args.target_dir and not args.dry_run:
1034 raise Exception('Target directory must be set unless dry-run is True.')
1035
1036 if not args.skip:
1037 args.skip = []
1038
1039 return args
1040
1041
1042if __name__ == '__main__':
1043 sys.exit(main(sys.argv[1:]))