1#!/usr/bin/env python3 2# 3# Copyright (c) 2019, Nordic Semiconductor ASA 4# 5# SPDX-License-Identifier: Apache-2.0 6 7'''Tool for parsing a list of projects to determine if they are Zephyr 8projects. If no projects are given then the output from `west list` will be 9used as project list. 10 11Include file is generated for Kconfig using --kconfig-out. 12A <name>:<path> text file is generated for use with CMake using --cmake-out. 13 14Using --twister-out <filename> an argument file for twister script will 15be generated which would point to test and sample roots available in modules 16that can be included during a twister run. This allows testing code 17maintained in modules in addition to what is available in the main Zephyr tree. 18''' 19 20import argparse 21import hashlib 22import os 23import re 24import subprocess 25import sys 26import yaml 27import pykwalify.core 28from pathlib import Path, PurePath 29from collections import namedtuple 30 31METADATA_SCHEMA = ''' 32## A pykwalify schema for basic validation of the structure of a 33## metadata YAML file. 34## 35# The zephyr/module.yml file is a simple list of key value pairs to be used by 36# the build system. 37type: map 38mapping: 39 name: 40 required: false 41 type: str 42 build: 43 required: false 44 type: map 45 mapping: 46 cmake: 47 required: false 48 type: str 49 kconfig: 50 required: false 51 type: str 52 cmake-ext: 53 required: false 54 type: bool 55 default: false 56 kconfig-ext: 57 required: false 58 type: bool 59 default: false 60 sysbuild-cmake: 61 required: false 62 type: str 63 sysbuild-kconfig: 64 required: false 65 type: str 66 sysbuild-cmake-ext: 67 required: false 68 type: bool 69 default: false 70 sysbuild-kconfig-ext: 71 required: false 72 type: bool 73 default: false 74 depends: 75 required: false 76 type: seq 77 sequence: 78 - type: str 79 settings: 80 required: false 81 type: map 82 mapping: 83 board_root: 84 required: false 85 type: str 86 dts_root: 87 required: false 88 type: str 89 snippet_root: 90 required: false 91 type: str 92 soc_root: 93 required: false 94 type: str 95 arch_root: 96 required: false 97 type: str 98 module_ext_root: 99 required: false 100 type: str 101 sca_root: 102 required: false 103 type: str 104 tests: 105 required: false 106 type: seq 107 sequence: 108 - type: str 109 samples: 110 required: false 111 type: seq 112 sequence: 113 - type: str 114 boards: 115 required: false 116 type: seq 117 sequence: 118 - type: str 119 blobs: 120 required: false 121 type: seq 122 sequence: 123 - type: map 124 mapping: 125 path: 126 required: true 127 type: str 128 sha256: 129 required: true 130 type: str 131 type: 132 required: true 133 type: str 134 enum: ['img', 'lib'] 135 version: 136 required: true 137 type: str 138 license-path: 139 required: true 140 type: str 141 url: 142 required: true 143 type: str 144 description: 145 required: true 146 type: str 147 doc-url: 148 required: false 149 type: str 150''' 151 152MODULE_YML_PATH = PurePath('zephyr/module.yml') 153# Path to the blobs folder 154MODULE_BLOBS_PATH = PurePath('zephyr/blobs') 155BLOB_PRESENT = 'A' 156BLOB_NOT_PRESENT = 'D' 157BLOB_OUTDATED = 'M' 158 159schema = yaml.safe_load(METADATA_SCHEMA) 160 161 162def validate_setting(setting, module_path, filename=None): 163 if setting is not None: 164 if filename is not None: 165 checkfile = os.path.join(module_path, setting, filename) 166 else: 167 checkfile = os.path.join(module_path, setting) 168 if not os.path.isfile(checkfile): 169 return False 170 return True 171 172 173def process_module(module): 174 module_path = PurePath(module) 175 176 # The input is a module if zephyr/module.{yml,yaml} is a valid yaml file 177 # or if both zephyr/CMakeLists.txt and zephyr/Kconfig are present. 178 179 for module_yml in [module_path / MODULE_YML_PATH, 180 module_path / MODULE_YML_PATH.with_suffix('.yaml')]: 181 if Path(module_yml).is_file(): 182 with Path(module_yml).open('r') as f: 183 meta = yaml.safe_load(f.read()) 184 185 try: 186 pykwalify.core.Core(source_data=meta, schema_data=schema)\ 187 .validate() 188 except pykwalify.errors.SchemaError as e: 189 sys.exit('ERROR: Malformed "build" section in file: {}\n{}' 190 .format(module_yml.as_posix(), e)) 191 192 meta['name'] = meta.get('name', module_path.name) 193 meta['name-sanitized'] = re.sub('[^a-zA-Z0-9]', '_', meta['name']) 194 return meta 195 196 if Path(module_path.joinpath('zephyr/CMakeLists.txt')).is_file() and \ 197 Path(module_path.joinpath('zephyr/Kconfig')).is_file(): 198 return {'name': module_path.name, 199 'name-sanitized': re.sub('[^a-zA-Z0-9]', '_', module_path.name), 200 'build': {'cmake': 'zephyr', 'kconfig': 'zephyr/Kconfig'}} 201 202 return None 203 204 205def process_cmake(module, meta): 206 section = meta.get('build', dict()) 207 module_path = PurePath(module) 208 module_yml = module_path.joinpath('zephyr/module.yml') 209 210 cmake_extern = section.get('cmake-ext', False) 211 if cmake_extern: 212 return('\"{}\":\"{}\":\"{}\"\n' 213 .format(meta['name'], 214 module_path.as_posix(), 215 "${ZEPHYR_" + meta['name-sanitized'].upper() + "_CMAKE_DIR}")) 216 217 cmake_setting = section.get('cmake', None) 218 if not validate_setting(cmake_setting, module, 'CMakeLists.txt'): 219 sys.exit('ERROR: "cmake" key in {} has folder value "{}" which ' 220 'does not contain a CMakeLists.txt file.' 221 .format(module_yml.as_posix(), cmake_setting)) 222 223 cmake_path = os.path.join(module, cmake_setting or 'zephyr') 224 cmake_file = os.path.join(cmake_path, 'CMakeLists.txt') 225 if os.path.isfile(cmake_file): 226 return('\"{}\":\"{}\":\"{}\"\n' 227 .format(meta['name'], 228 module_path.as_posix(), 229 Path(cmake_path).resolve().as_posix())) 230 else: 231 return('\"{}\":\"{}\":\"\"\n' 232 .format(meta['name'], 233 module_path.as_posix())) 234 235 236def process_sysbuildcmake(module, meta): 237 section = meta.get('build', dict()) 238 module_path = PurePath(module) 239 module_yml = module_path.joinpath('zephyr/module.yml') 240 241 cmake_extern = section.get('sysbuild-cmake-ext', False) 242 if cmake_extern: 243 return('\"{}\":\"{}\":\"{}\"\n' 244 .format(meta['name'], 245 module_path.as_posix(), 246 "${SYSBUILD_" + meta['name-sanitized'].upper() + "_CMAKE_DIR}")) 247 248 cmake_setting = section.get('sysbuild-cmake', None) 249 if not validate_setting(cmake_setting, module, 'CMakeLists.txt'): 250 sys.exit('ERROR: "cmake" key in {} has folder value "{}" which ' 251 'does not contain a CMakeLists.txt file.' 252 .format(module_yml.as_posix(), cmake_setting)) 253 254 if cmake_setting is None: 255 return "" 256 257 cmake_path = os.path.join(module, cmake_setting or 'zephyr') 258 cmake_file = os.path.join(cmake_path, 'CMakeLists.txt') 259 if os.path.isfile(cmake_file): 260 return('\"{}\":\"{}\":\"{}\"\n' 261 .format(meta['name'], 262 module_path.as_posix(), 263 Path(cmake_path).resolve().as_posix())) 264 else: 265 return('\"{}\":\"{}\":\"\"\n' 266 .format(meta['name'], 267 module_path.as_posix())) 268 269 270def process_settings(module, meta): 271 section = meta.get('build', dict()) 272 build_settings = section.get('settings', None) 273 out_text = "" 274 275 if build_settings is not None: 276 for root in ['board', 'dts', 'snippet', 'soc', 'arch', 'module_ext', 'sca']: 277 setting = build_settings.get(root+'_root', None) 278 if setting is not None: 279 root_path = PurePath(module) / setting 280 out_text += f'"{root.upper()}_ROOT":' 281 out_text += f'"{root_path.as_posix()}"\n' 282 283 return out_text 284 285 286def get_blob_status(path, sha256): 287 if not path.is_file(): 288 return BLOB_NOT_PRESENT 289 with path.open('rb') as f: 290 m = hashlib.sha256() 291 m.update(f.read()) 292 if sha256.lower() == m.hexdigest(): 293 return BLOB_PRESENT 294 else: 295 return BLOB_OUTDATED 296 297 298def process_blobs(module, meta): 299 blobs = [] 300 mblobs = meta.get('blobs', None) 301 if not mblobs: 302 return blobs 303 304 blobs_path = Path(module) / MODULE_BLOBS_PATH 305 for blob in mblobs: 306 blob['module'] = meta.get('name', None) 307 blob['abspath'] = blobs_path / Path(blob['path']) 308 blob['status'] = get_blob_status(blob['abspath'], blob['sha256']) 309 blobs.append(blob) 310 311 return blobs 312 313 314def kconfig_snippet(meta, path, kconfig_file=None, blobs=False, sysbuild=False): 315 name = meta['name'] 316 name_sanitized = meta['name-sanitized'] 317 318 snippet = [f'menu "{name} ({path.as_posix()})"', 319 f'osource "{kconfig_file.resolve().as_posix()}"' if kconfig_file 320 else f'osource "$(SYSBUILD_{name_sanitized.upper()}_KCONFIG)"' if sysbuild is True 321 else f'osource "$(ZEPHYR_{name_sanitized.upper()}_KCONFIG)"', 322 f'config ZEPHYR_{name_sanitized.upper()}_MODULE', 323 ' bool', 324 ' default y', 325 'endmenu\n'] 326 327 if blobs: 328 snippet.insert(-1, ' select TAINT_BLOBS') 329 return '\n'.join(snippet) 330 331 332def process_kconfig(module, meta): 333 blobs = process_blobs(module, meta) 334 taint_blobs = len(tuple(filter(lambda b: b['status'] != 'D', blobs))) != 0 335 section = meta.get('build', dict()) 336 module_path = PurePath(module) 337 module_yml = module_path.joinpath('zephyr/module.yml') 338 kconfig_extern = section.get('kconfig-ext', False) 339 if kconfig_extern: 340 return kconfig_snippet(meta, module_path, blobs=taint_blobs) 341 342 kconfig_setting = section.get('kconfig', None) 343 if not validate_setting(kconfig_setting, module): 344 sys.exit('ERROR: "kconfig" key in {} has value "{}" which does ' 345 'not point to a valid Kconfig file.' 346 .format(module_yml, kconfig_setting)) 347 348 kconfig_file = os.path.join(module, kconfig_setting or 'zephyr/Kconfig') 349 if os.path.isfile(kconfig_file): 350 return kconfig_snippet(meta, module_path, Path(kconfig_file), 351 blobs=taint_blobs) 352 else: 353 return "" 354 355 356def process_sysbuildkconfig(module, meta): 357 section = meta.get('build', dict()) 358 module_path = PurePath(module) 359 module_yml = module_path.joinpath('zephyr/module.yml') 360 kconfig_extern = section.get('sysbuild-kconfig-ext', False) 361 if kconfig_extern: 362 return kconfig_snippet(meta, module_path, sysbuild=True) 363 364 kconfig_setting = section.get('sysbuild-kconfig', None) 365 if not validate_setting(kconfig_setting, module): 366 sys.exit('ERROR: "kconfig" key in {} has value "{}" which does ' 367 'not point to a valid Kconfig file.' 368 .format(module_yml, kconfig_setting)) 369 370 if kconfig_setting is None: 371 return "" 372 373 kconfig_file = os.path.join(module, kconfig_setting) 374 if os.path.isfile(kconfig_file): 375 return kconfig_snippet(meta, module_path, Path(kconfig_file)) 376 else: 377 return "" 378 379 380def process_twister(module, meta): 381 382 out = "" 383 tests = meta.get('tests', []) 384 samples = meta.get('samples', []) 385 boards = meta.get('boards', []) 386 387 for pth in tests + samples: 388 if pth: 389 dir = os.path.join(module, pth) 390 out += '-T\n{}\n'.format(PurePath(os.path.abspath(dir)) 391 .as_posix()) 392 393 for pth in boards: 394 if pth: 395 dir = os.path.join(module, pth) 396 out += '--board-root\n{}\n'.format(PurePath(os.path.abspath(dir)) 397 .as_posix()) 398 399 return out 400 401 402def process_meta(zephyr_base, west_projs, modules, extra_modules=None, 403 propagate_state=False): 404 # Process zephyr_base, projects, and modules and create a dictionary 405 # with meta information for each input. 406 # 407 # The dictionary will contain meta info in the following lists: 408 # - zephyr: path and revision 409 # - modules: name, path, and revision 410 # - west-projects: path and revision 411 # 412 # returns the dictionary with said lists 413 414 meta = {'zephyr': None, 'modules': None, 'workspace': None} 415 416 workspace_dirty = False 417 workspace_extra = extra_modules is not None 418 workspace_off = False 419 420 def git_revision(path): 421 rc = subprocess.Popen(['git', 'rev-parse', '--is-inside-work-tree'], 422 stdout=subprocess.PIPE, 423 stderr=subprocess.PIPE, 424 cwd=path).wait() 425 if rc == 0: 426 # A git repo. 427 popen = subprocess.Popen(['git', 'rev-parse', 'HEAD'], 428 stdout=subprocess.PIPE, 429 stderr=subprocess.PIPE, 430 cwd=path) 431 stdout, stderr = popen.communicate() 432 stdout = stdout.decode('utf-8') 433 434 if not (popen.returncode or stderr): 435 revision = stdout.rstrip() 436 437 rc = subprocess.Popen(['git', 'diff-index', '--quiet', 'HEAD', 438 '--'], 439 stdout=None, 440 stderr=None, 441 cwd=path).wait() 442 if rc: 443 return revision + '-dirty', True 444 return revision, False 445 return None, False 446 447 zephyr_revision, zephyr_dirty = git_revision(zephyr_base) 448 zephyr_project = {'path': zephyr_base, 449 'revision': zephyr_revision} 450 meta['zephyr'] = zephyr_project 451 meta['workspace'] = {} 452 workspace_dirty |= zephyr_dirty 453 454 if west_projs is not None: 455 from west.manifest import MANIFEST_REV_BRANCH 456 projects = west_projs['projects'] 457 meta_projects = [] 458 459 # Special treatment of manifest project. 460 manifest_proj_path = PurePath(projects[0].posixpath).as_posix() 461 manifest_revision, manifest_dirty = git_revision(manifest_proj_path) 462 workspace_dirty |= manifest_dirty 463 manifest_project = {'path': manifest_proj_path, 464 'revision': manifest_revision} 465 meta_projects.append(manifest_project) 466 467 for project in projects[1:]: 468 project_path = PurePath(project.posixpath).as_posix() 469 revision, dirty = git_revision(project_path) 470 workspace_dirty |= dirty 471 if project.sha(MANIFEST_REV_BRANCH) != revision: 472 revision += '-off' 473 workspace_off = True 474 meta_project = {'path': project_path, 475 'revision': revision} 476 meta_projects.append(meta_project) 477 478 meta.update({'west': {'manifest': west_projs['manifest_path'], 479 'projects': meta_projects}}) 480 meta['workspace'].update({'off': workspace_off}) 481 482 meta_projects = [] 483 for module in modules: 484 module_path = PurePath(module.project).as_posix() 485 revision, dirty = git_revision(module_path) 486 workspace_dirty |= dirty 487 meta_project = {'name': module.meta['name'], 488 'path': module_path, 489 'revision': revision} 490 meta_projects.append(meta_project) 491 meta['modules'] = meta_projects 492 493 meta['workspace'].update({'dirty': workspace_dirty, 494 'extra': workspace_extra}) 495 496 if propagate_state: 497 if workspace_dirty and not zephyr_dirty: 498 zephyr_revision += '-dirty' 499 if workspace_extra: 500 zephyr_revision += '-extra' 501 if workspace_off: 502 zephyr_revision += '-off' 503 zephyr_project.update({'revision': zephyr_revision}) 504 505 if west_projs is not None: 506 if workspace_dirty and not manifest_dirty: 507 manifest_revision += '-dirty' 508 if workspace_extra: 509 manifest_revision += '-extra' 510 if workspace_off: 511 manifest_revision += '-off' 512 manifest_project.update({'revision': manifest_revision}) 513 514 return meta 515 516 517def west_projects(manifest = None): 518 manifest_path = None 519 projects = [] 520 # West is imported here, as it is optional 521 # (and thus maybe not installed) 522 # if user is providing a specific modules list. 523 try: 524 from west.manifest import Manifest 525 except ImportError: 526 # West is not installed, so don't return any projects. 527 return None 528 529 # If west *is* installed, we need all of the following imports to 530 # work. West versions that are excessively old may fail here: 531 # west.configuration.MalformedConfig was 532 # west.manifest.MalformedConfig until west v0.14.0, for example. 533 # These should be hard errors. 534 from west.manifest import \ 535 ManifestImportFailed, MalformedManifest, ManifestVersionError 536 from west.configuration import MalformedConfig 537 from west.util import WestNotFound 538 from west.version import __version__ as WestVersion 539 540 from packaging import version 541 try: 542 if not manifest: 543 manifest = Manifest.from_file() 544 if version.parse(WestVersion) >= version.parse('0.9.0'): 545 projects = [p for p in manifest.get_projects([]) 546 if manifest.is_active(p)] 547 else: 548 projects = manifest.get_projects([]) 549 manifest_path = manifest.path 550 return {'manifest_path': manifest_path, 'projects': projects} 551 except (ManifestImportFailed, MalformedManifest, 552 ManifestVersionError, MalformedConfig) as e: 553 sys.exit(f'ERROR: {e}') 554 except WestNotFound: 555 # Only accept WestNotFound, meaning we are not in a west 556 # workspace. Such setup is allowed, as west may be installed 557 # but the project is not required to use west. 558 pass 559 return None 560 561 562def parse_modules(zephyr_base, manifest=None, west_projs=None, modules=None, 563 extra_modules=None): 564 565 if modules is None: 566 west_projs = west_projs or west_projects(manifest) 567 modules = ([p.posixpath for p in west_projs['projects']] 568 if west_projs else []) 569 570 if extra_modules is None: 571 extra_modules = [] 572 573 Module = namedtuple('Module', ['project', 'meta', 'depends']) 574 575 all_modules_by_name = {} 576 # dep_modules is a list of all modules that has an unresolved dependency 577 dep_modules = [] 578 # start_modules is a list modules with no depends left (no incoming edge) 579 start_modules = [] 580 # sorted_modules is a topological sorted list of the modules 581 sorted_modules = [] 582 583 for project in modules + extra_modules: 584 # Avoid including Zephyr base project as module. 585 if project == zephyr_base: 586 continue 587 588 meta = process_module(project) 589 if meta: 590 depends = meta.get('build', {}).get('depends', []) 591 all_modules_by_name[meta['name']] = Module(project, meta, depends) 592 593 elif project in extra_modules: 594 sys.exit(f'{project}, given in ZEPHYR_EXTRA_MODULES, ' 595 'is not a valid zephyr module') 596 597 for module in all_modules_by_name.values(): 598 if not module.depends: 599 start_modules.append(module) 600 else: 601 dep_modules.append(module) 602 603 # This will do a topological sort to ensure the modules are ordered 604 # according to dependency settings. 605 while start_modules: 606 node = start_modules.pop(0) 607 sorted_modules.append(node) 608 node_name = node.meta['name'] 609 to_remove = [] 610 for module in dep_modules: 611 if node_name in module.depends: 612 module.depends.remove(node_name) 613 if not module.depends: 614 start_modules.append(module) 615 to_remove.append(module) 616 for module in to_remove: 617 dep_modules.remove(module) 618 619 if dep_modules: 620 # If there are any modules with unresolved dependencies, then the 621 # modules contains unmet or cyclic dependencies. Error out. 622 error = 'Unmet or cyclic dependencies in modules:\n' 623 for module in dep_modules: 624 error += f'{module.project} depends on: {module.depends}\n' 625 sys.exit(error) 626 627 return sorted_modules 628 629 630def main(): 631 parser = argparse.ArgumentParser(description=''' 632 Process a list of projects and create Kconfig / CMake include files for 633 projects which are also a Zephyr module''', allow_abbrev=False) 634 635 parser.add_argument('--kconfig-out', 636 help="""File to write with resulting KConfig import 637 statements.""") 638 parser.add_argument('--twister-out', 639 help="""File to write with resulting twister 640 parameters.""") 641 parser.add_argument('--cmake-out', 642 help="""File to write with resulting <name>:<path> 643 values to use for including in CMake""") 644 parser.add_argument('--sysbuild-kconfig-out', 645 help="""File to write with resulting KConfig import 646 statements.""") 647 parser.add_argument('--sysbuild-cmake-out', 648 help="""File to write with resulting <name>:<path> 649 values to use for including in CMake""") 650 parser.add_argument('--meta-out', 651 help="""Write a build meta YaML file containing a list 652 of Zephyr modules and west projects. 653 If a module or project is also a git repository 654 the current SHA revision will also be written.""") 655 parser.add_argument('--meta-state-propagate', action='store_true', 656 help="""Propagate state of modules and west projects 657 to the suffix of the Zephyr SHA and if west is 658 used, to the suffix of the manifest SHA""") 659 parser.add_argument('--settings-out', 660 help="""File to write with resulting <name>:<value> 661 values to use for including in CMake""") 662 parser.add_argument('-m', '--modules', nargs='+', 663 help="""List of modules to parse instead of using `west 664 list`""") 665 parser.add_argument('-x', '--extra-modules', nargs='+', 666 help='List of extra modules to parse') 667 parser.add_argument('-z', '--zephyr-base', 668 help='Path to zephyr repository') 669 args = parser.parse_args() 670 671 kconfig = "" 672 cmake = "" 673 sysbuild_kconfig = "" 674 sysbuild_cmake = "" 675 settings = "" 676 twister = "" 677 678 west_projs = west_projects() 679 modules = parse_modules(args.zephyr_base, None, west_projs, 680 args.modules, args.extra_modules) 681 682 for module in modules: 683 kconfig += process_kconfig(module.project, module.meta) 684 cmake += process_cmake(module.project, module.meta) 685 sysbuild_kconfig += process_sysbuildkconfig(module.project, module.meta) 686 sysbuild_cmake += process_sysbuildcmake(module.project, module.meta) 687 settings += process_settings(module.project, module.meta) 688 twister += process_twister(module.project, module.meta) 689 690 if args.kconfig_out: 691 with open(args.kconfig_out, 'w', encoding="utf-8") as fp: 692 fp.write(kconfig) 693 694 if args.cmake_out: 695 with open(args.cmake_out, 'w', encoding="utf-8") as fp: 696 fp.write(cmake) 697 698 if args.sysbuild_kconfig_out: 699 with open(args.sysbuild_kconfig_out, 'w', encoding="utf-8") as fp: 700 fp.write(sysbuild_kconfig) 701 702 if args.sysbuild_cmake_out: 703 with open(args.sysbuild_cmake_out, 'w', encoding="utf-8") as fp: 704 fp.write(sysbuild_cmake) 705 706 if args.settings_out: 707 with open(args.settings_out, 'w', encoding="utf-8") as fp: 708 fp.write('''\ 709# WARNING. THIS FILE IS AUTO-GENERATED. DO NOT MODIFY! 710# 711# This file contains build system settings derived from your modules. 712# 713# Modules may be set via ZEPHYR_MODULES, ZEPHYR_EXTRA_MODULES, 714# and/or the west manifest file. 715# 716# See the Modules guide for more information. 717''') 718 fp.write(settings) 719 720 if args.twister_out: 721 with open(args.twister_out, 'w', encoding="utf-8") as fp: 722 fp.write(twister) 723 724 if args.meta_out: 725 meta = process_meta(args.zephyr_base, west_projs, modules, 726 args.extra_modules, args.meta_state_propagate) 727 728 with open(args.meta_out, 'w', encoding="utf-8") as fp: 729 fp.write(yaml.dump(meta)) 730 731 732if __name__ == "__main__": 733 main() 734