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, PurePosixPath
29from collections import namedtuple
30
31try:
32    from yaml import CSafeLoader as SafeLoader
33except ImportError:
34    from yaml import SafeLoader
35
36METADATA_SCHEMA = '''
37## A pykwalify schema for basic validation of the structure of a
38## metadata YAML file.
39##
40# The zephyr/module.yml file is a simple list of key value pairs to be used by
41# the build system.
42type: map
43mapping:
44  name:
45    required: false
46    type: str
47  build:
48    required: false
49    type: map
50    mapping:
51      cmake:
52        required: false
53        type: str
54      kconfig:
55        required: false
56        type: str
57      cmake-ext:
58        required: false
59        type: bool
60        default: false
61      kconfig-ext:
62        required: false
63        type: bool
64        default: false
65      sysbuild-cmake:
66        required: false
67        type: str
68      sysbuild-kconfig:
69        required: false
70        type: str
71      sysbuild-cmake-ext:
72        required: false
73        type: bool
74        default: false
75      sysbuild-kconfig-ext:
76        required: false
77        type: bool
78        default: false
79      depends:
80        required: false
81        type: seq
82        sequence:
83          - type: str
84      settings:
85        required: false
86        type: map
87        mapping:
88          board_root:
89            required: false
90            type: str
91          dts_root:
92            required: false
93            type: str
94          snippet_root:
95            required: false
96            type: str
97          soc_root:
98            required: false
99            type: str
100          arch_root:
101            required: false
102            type: str
103          module_ext_root:
104            required: false
105            type: str
106          sca_root:
107            required: false
108            type: str
109  tests:
110    required: false
111    type: seq
112    sequence:
113      - type: str
114  samples:
115    required: false
116    type: seq
117    sequence:
118      - type: str
119  boards:
120    required: false
121    type: seq
122    sequence:
123      - type: str
124  blobs:
125    required: false
126    type: seq
127    sequence:
128      - type: map
129        mapping:
130          path:
131            required: true
132            type: str
133          sha256:
134            required: true
135            type: str
136          type:
137            required: true
138            type: str
139            enum: ['img', 'lib']
140          version:
141            required: true
142            type: str
143          license-path:
144            required: true
145            type: str
146          click-through:
147            required: false
148            type: bool
149            default: false
150          url:
151            required: true
152            type: str
153          description:
154            required: true
155            type: str
156          doc-url:
157            required: false
158            type: str
159  security:
160     required: false
161     type: map
162     mapping:
163       external-references:
164         required: false
165         type: seq
166         sequence:
167            - type: str
168  package-managers:
169    required: false
170    type: map
171    mapping:
172      pip:
173        required: false
174        type: map
175        mapping:
176          requirement-files:
177            required: false
178            type: seq
179            sequence:
180              - type: str
181  runners:
182    required: false
183    type: seq
184    sequence:
185      - type: map
186        mapping:
187          file:
188            required: true
189            type: str
190'''
191
192MODULE_YML_PATH = PurePath('zephyr/module.yml')
193# Path to the blobs folder
194MODULE_BLOBS_PATH = PurePath('zephyr/blobs')
195BLOB_PRESENT = 'A'
196BLOB_NOT_PRESENT = 'D'
197BLOB_OUTDATED = 'M'
198
199schema = yaml.load(METADATA_SCHEMA, Loader=SafeLoader)
200
201
202def validate_setting(setting, module_path, filename=None):
203    if setting is not None:
204        if filename is not None:
205            checkfile = Path(module_path) / setting / filename
206        else:
207            checkfile = Path(module_path) / setting
208        if not checkfile.resolve().is_file():
209            return False
210    return True
211
212
213def process_module(module):
214    module_path = PurePath(module)
215
216    # The input is a module if zephyr/module.{yml,yaml} is a valid yaml file
217    # or if both zephyr/CMakeLists.txt and zephyr/Kconfig are present.
218
219    for module_yml in [module_path / MODULE_YML_PATH,
220                       module_path / MODULE_YML_PATH.with_suffix('.yaml')]:
221        if Path(module_yml).is_file():
222            with Path(module_yml).open('r', encoding='utf-8') as f:
223                meta = yaml.load(f.read(), Loader=SafeLoader)
224
225            try:
226                pykwalify.core.Core(source_data=meta, schema_data=schema)\
227                    .validate()
228            except pykwalify.errors.SchemaError as e:
229                sys.exit('ERROR: Malformed "build" section in file: {}\n{}'
230                        .format(module_yml.as_posix(), e))
231
232            meta['name'] = meta.get('name', module_path.name)
233            meta['name-sanitized'] = re.sub('[^a-zA-Z0-9]', '_', meta['name'])
234            return meta
235
236    if Path(module_path.joinpath('zephyr/CMakeLists.txt')).is_file() and \
237       Path(module_path.joinpath('zephyr/Kconfig')).is_file():
238        return {'name': module_path.name,
239                'name-sanitized': re.sub('[^a-zA-Z0-9]', '_', module_path.name),
240                'build': {'cmake': 'zephyr', 'kconfig': 'zephyr/Kconfig'}}
241
242    return None
243
244
245def process_cmake(module, meta):
246    section = meta.get('build', dict())
247    module_path = PurePath(module)
248    module_yml = module_path.joinpath('zephyr/module.yml')
249
250    cmake_extern = section.get('cmake-ext', False)
251    if cmake_extern:
252        return('\"{}\":\"{}\":\"{}\"\n'
253               .format(meta['name'],
254                       module_path.as_posix(),
255                       "${ZEPHYR_" + meta['name-sanitized'].upper() + "_CMAKE_DIR}"))
256
257    cmake_setting = section.get('cmake', None)
258    if not validate_setting(cmake_setting, module, 'CMakeLists.txt'):
259        sys.exit('ERROR: "cmake" key in {} has folder value "{}" which '
260                 'does not contain a CMakeLists.txt file.'
261                 .format(module_yml.as_posix(), cmake_setting))
262
263    cmake_path = os.path.join(module, cmake_setting or 'zephyr')
264    cmake_file = os.path.join(cmake_path, 'CMakeLists.txt')
265    if os.path.isfile(cmake_file):
266        return('\"{}\":\"{}\":\"{}\"\n'
267               .format(meta['name'],
268                       module_path.as_posix(),
269                       Path(cmake_path).resolve().as_posix()))
270    else:
271        return('\"{}\":\"{}\":\"\"\n'
272               .format(meta['name'],
273                       module_path.as_posix()))
274
275
276def process_sysbuildcmake(module, meta):
277    section = meta.get('build', dict())
278    module_path = PurePath(module)
279    module_yml = module_path.joinpath('zephyr/module.yml')
280
281    cmake_extern = section.get('sysbuild-cmake-ext', False)
282    if cmake_extern:
283        return('\"{}\":\"{}\":\"{}\"\n'
284               .format(meta['name'],
285                       module_path.as_posix(),
286                       "${SYSBUILD_" + meta['name-sanitized'].upper() + "_CMAKE_DIR}"))
287
288    cmake_setting = section.get('sysbuild-cmake', None)
289    if not validate_setting(cmake_setting, module, 'CMakeLists.txt'):
290        sys.exit('ERROR: "cmake" key in {} has folder value "{}" which '
291                 'does not contain a CMakeLists.txt file.'
292                 .format(module_yml.as_posix(), cmake_setting))
293
294    if cmake_setting is None:
295        return ""
296
297    cmake_path = os.path.join(module, cmake_setting or 'zephyr')
298    cmake_file = os.path.join(cmake_path, 'CMakeLists.txt')
299    if os.path.isfile(cmake_file):
300        return('\"{}\":\"{}\":\"{}\"\n'
301               .format(meta['name'],
302                       module_path.as_posix(),
303                       Path(cmake_path).resolve().as_posix()))
304    else:
305        return('\"{}\":\"{}\":\"\"\n'
306               .format(meta['name'],
307                       module_path.as_posix()))
308
309
310def process_settings(module, meta):
311    section = meta.get('build', dict())
312    build_settings = section.get('settings', None)
313    out_text = ""
314
315    if build_settings is not None:
316        for root in ['board', 'dts', 'snippet', 'soc', 'arch', 'module_ext', 'sca']:
317            setting = build_settings.get(root+'_root', None)
318            if setting is not None:
319                root_path = PurePath(module) / setting
320                out_text += f'"{root.upper()}_ROOT":'
321                out_text += f'"{root_path.as_posix()}"\n'
322
323    return out_text
324
325
326def get_blob_status(path, sha256):
327    if not path.is_file():
328        return BLOB_NOT_PRESENT
329    with path.open('rb') as f:
330        m = hashlib.sha256()
331        m.update(f.read())
332        if sha256.lower() == m.hexdigest():
333            return BLOB_PRESENT
334        else:
335            return BLOB_OUTDATED
336
337
338def process_blobs(module, meta):
339    blobs = []
340    mblobs = meta.get('blobs', None)
341    if not mblobs:
342        return blobs
343
344    blobs_path = Path(module) / MODULE_BLOBS_PATH
345    for blob in mblobs:
346        blob['module'] = meta.get('name', None)
347        blob['abspath'] = blobs_path / Path(blob['path'])
348        blob['license-abspath'] = Path(module) / Path(blob['license-path'])
349        blob['status'] = get_blob_status(blob['abspath'], blob['sha256'])
350        blobs.append(blob)
351
352    return blobs
353
354
355def kconfig_module_opts(name_sanitized, blobs, taint_blobs):
356    snippet = [f'config ZEPHYR_{name_sanitized.upper()}_MODULE',
357               '	bool',
358               '	default y']
359
360    if taint_blobs:
361        snippet += ['	select TAINT_BLOBS']
362
363    if blobs:
364        snippet += [f'\nconfig ZEPHYR_{name_sanitized.upper()}_MODULE_BLOBS',
365                    '	bool']
366        if taint_blobs:
367            snippet += ['	default y']
368
369    return snippet
370
371
372def kconfig_snippet(meta, path, kconfig_file=None, blobs=False, taint_blobs=False, sysbuild=False):
373    name = meta['name']
374    name_sanitized = meta['name-sanitized']
375
376    snippet = [f'menu "{name} ({path.as_posix()})"']
377
378    snippet += [f'osource "{kconfig_file.resolve().as_posix()}"' if kconfig_file
379                else f'osource "$(SYSBUILD_{name_sanitized.upper()}_KCONFIG)"' if sysbuild is True
380                else f'osource "$(ZEPHYR_{name_sanitized.upper()}_KCONFIG)"']
381
382    snippet += kconfig_module_opts(name_sanitized, blobs, taint_blobs)
383
384    snippet += ['endmenu\n']
385
386    return '\n'.join(snippet)
387
388
389def process_kconfig_module_dir(module, meta, cmake_output):
390    module_path = PurePath(module)
391    name_sanitized = meta['name-sanitized']
392
393    if cmake_output is False:
394        return f'ZEPHYR_{name_sanitized.upper()}_MODULE_DIR={module_path.as_posix()}\n'
395    return f'list(APPEND kconfig_env_dirs ZEPHYR_{name_sanitized.upper()}_MODULE_DIR={module_path.as_posix()})\n'
396
397
398def process_kconfig(module, meta):
399    blobs = process_blobs(module, meta)
400    taint_blobs = any(b['status'] != BLOB_NOT_PRESENT for b in blobs)
401    section = meta.get('build', dict())
402    module_path = PurePath(module)
403    module_yml = module_path.joinpath('zephyr/module.yml')
404    kconfig_extern = section.get('kconfig-ext', False)
405
406    if kconfig_extern:
407        return kconfig_snippet(meta, module_path, blobs=blobs, taint_blobs=taint_blobs)
408
409    kconfig_setting = section.get('kconfig', None)
410    if not validate_setting(kconfig_setting, module):
411        sys.exit('ERROR: "kconfig" key in {} has value "{}" which does '
412                 'not point to a valid Kconfig file.'
413                 .format(module_yml, kconfig_setting))
414
415    kconfig_file = os.path.join(module, kconfig_setting or 'zephyr/Kconfig')
416    if os.path.isfile(kconfig_file):
417        return kconfig_snippet(meta, module_path, Path(kconfig_file),
418                               blobs=blobs, taint_blobs=taint_blobs)
419    else:
420        name_sanitized = meta['name-sanitized']
421        return '\n'.join(kconfig_module_opts(name_sanitized, blobs, taint_blobs)) + '\n'
422
423
424def process_sysbuildkconfig(module, meta):
425    section = meta.get('build', dict())
426    module_path = PurePath(module)
427    module_yml = module_path.joinpath('zephyr/module.yml')
428    kconfig_extern = section.get('sysbuild-kconfig-ext', False)
429    name_sanitized = meta['name-sanitized']
430
431    if kconfig_extern:
432        return kconfig_snippet(meta, module_path, sysbuild=True)
433
434    kconfig_setting = section.get('sysbuild-kconfig', None)
435    if not validate_setting(kconfig_setting, module):
436        sys.exit('ERROR: "kconfig" key in {} has value "{}" which does '
437                 'not point to a valid Kconfig file.'
438                 .format(module_yml, kconfig_setting))
439
440    if kconfig_setting is not None:
441        kconfig_file = os.path.join(module, kconfig_setting)
442        if os.path.isfile(kconfig_file):
443            return kconfig_snippet(meta, module_path, Path(kconfig_file))
444
445    return (f'config ZEPHYR_{name_sanitized.upper()}_MODULE\n'
446            f'   bool\n'
447            f'   default y\n')
448
449
450def process_twister(module, meta):
451
452    out = ""
453    tests = meta.get('tests', [])
454    samples = meta.get('samples', [])
455    boards = meta.get('boards', [])
456
457    for pth in tests + samples:
458        if pth:
459            dir = os.path.join(module, pth)
460            out += '-T\n{}\n'.format(PurePath(os.path.abspath(dir))
461                                     .as_posix())
462
463    for pth in boards:
464        if pth:
465            dir = os.path.join(module, pth)
466            out += '--board-root\n{}\n'.format(PurePath(os.path.abspath(dir))
467                                               .as_posix())
468
469    return out
470
471def is_valid_git_revision(revision):
472    """
473    Returns True if the given string is a valid git revision hash (40 hex digits).
474    """
475    if not isinstance(revision, str):
476        return False
477    return bool(re.fullmatch(r'[0-9a-fA-F]{40}', revision))
478
479def _create_meta_project(project_path):
480    def git_revision(path):
481        rc = subprocess.Popen(['git', 'rev-parse', '--is-inside-work-tree'],
482                              stdout=subprocess.PIPE,
483                              stderr=subprocess.PIPE,
484                              cwd=path).wait()
485        if rc == 0:
486            # A git repo.
487            popen = subprocess.Popen(['git', 'rev-parse', 'HEAD'],
488                                     stdout=subprocess.PIPE,
489                                     stderr=subprocess.PIPE,
490                                     cwd=path)
491            stdout, stderr = popen.communicate()
492            stdout = stdout.decode('utf-8')
493
494            if not (popen.returncode or stderr):
495                revision = stdout.rstrip()
496
497                rc = subprocess.Popen(['git', 'diff-index', '--quiet', 'HEAD',
498                                       '--'],
499                                      stdout=None,
500                                      stderr=None,
501                                      cwd=path).wait()
502                if rc:
503                    return revision + '-dirty', True
504                return revision, False
505        return "unknown", False
506
507    def git_remote(path):
508        popen = subprocess.Popen(['git', 'remote'],
509                                 stdout=subprocess.PIPE,
510                                 stderr=subprocess.PIPE,
511                                 cwd=path)
512        stdout, stderr = popen.communicate()
513        stdout = stdout.decode('utf-8')
514
515        remotes_name = []
516        if not (popen.returncode or stderr):
517            remotes_name = stdout.rstrip().split('\n')
518
519        remote_url = None
520
521        # If more than one remote, do not return any remote
522        if len(remotes_name) == 1:
523            remote = remotes_name[0]
524            popen = subprocess.Popen(['git', 'remote', 'get-url', remote],
525                                     stdout=subprocess.PIPE,
526                                     stderr=subprocess.PIPE,
527                                     cwd=path)
528            stdout, stderr = popen.communicate()
529            stdout = stdout.decode('utf-8')
530
531            if not (popen.returncode or stderr):
532                remote_url = stdout.rstrip()
533
534        return remote_url
535
536    def git_tags(path, revision):
537        if not revision or len(revision) == 0:
538            return None
539
540        popen = subprocess.Popen(['git', '-P', 'tag', '--points-at', revision],
541                                 stdout=subprocess.PIPE,
542                                 stderr=subprocess.PIPE,
543                                 cwd=path)
544        stdout, stderr = popen.communicate()
545        stdout = stdout.decode('utf-8')
546
547        tags = None
548        if not (popen.returncode or stderr):
549            tags = stdout.rstrip().splitlines()
550
551        return tags
552
553    workspace_dirty = False
554    path = PurePath(project_path).as_posix()
555
556    revision, dirty = git_revision(path)
557    workspace_dirty |= dirty
558    remote = git_remote(path)
559    tags = git_tags(path, revision)
560
561    meta_project = {'path': path,
562                    'revision': revision}
563
564    if remote:
565        meta_project['remote'] = remote
566
567    if tags:
568        meta_project['tags'] = tags
569
570    return meta_project, workspace_dirty
571
572
573def _get_meta_project(meta_projects_list, project_path):
574    projects = [ prj for prj in meta_projects_list[1:] if prj["path"] == project_path ]
575
576    return projects[0] if len(projects) == 1 else None
577
578
579def process_meta(zephyr_base, west_projs, modules, extra_modules=None,
580                 propagate_state=False):
581    # Process zephyr_base, projects, and modules and create a dictionary
582    # with meta information for each input.
583    #
584    # The dictionary will contain meta info in the following lists:
585    # - zephyr:        path and revision
586    # - modules:       name, path, and revision
587    # - west-projects: path and revision
588    #
589    # returns the dictionary with said lists
590
591    meta = {'zephyr': None, 'modules': None, 'workspace': None}
592
593    zephyr_project, zephyr_dirty = _create_meta_project(zephyr_base)
594    zephyr_off = zephyr_project.get("remote") is None
595
596    workspace_dirty = zephyr_dirty
597    workspace_extra = extra_modules is not None
598    workspace_off = zephyr_off
599
600    if zephyr_off and is_valid_git_revision(zephyr_project['revision']):
601        zephyr_project['revision'] += '-off'
602
603    meta['zephyr'] = zephyr_project
604    meta['workspace'] = {}
605
606    if west_projs is not None:
607        from west.manifest import MANIFEST_REV_BRANCH
608        projects = west_projs['projects']
609        meta_projects = []
610
611        manifest_path = projects[0].posixpath
612
613        # Special treatment of manifest project
614        # Git information (remote/revision) are not provided by west for the Manifest (west.yml)
615        # To mitigate this, we check if we don't use the manifest from the zephyr repository or an other project.
616        # If it's from zephyr, reuse zephyr information
617        # If it's from an other project, ignore it, it will be added later
618        # If it's not found, we extract data manually (remote/revision) from the directory
619
620        manifest_project = None
621        manifest_dirty = False
622        manifest_off = False
623
624        if zephyr_base == manifest_path:
625            manifest_project = zephyr_project
626            manifest_dirty = zephyr_dirty
627            manifest_off = zephyr_off
628        elif not [ prj for prj in projects[1:] if prj.posixpath == manifest_path ]:
629            manifest_project, manifest_dirty = _create_meta_project(
630                projects[0].posixpath)
631            manifest_off = manifest_project.get("remote") is None
632            if manifest_off and is_valid_git_revision(manifest_project['revision']):
633                manifest_project["revision"] +=  "-off"
634
635        if manifest_project:
636            workspace_off |= manifest_off
637            workspace_dirty |= manifest_dirty
638            meta_projects.append(manifest_project)
639
640        # Iterates on all projects except the first one (manifest)
641        for project in projects[1:]:
642            meta_project, dirty = _create_meta_project(project.posixpath)
643            workspace_dirty |= dirty
644            meta_projects.append(meta_project)
645
646            off = False
647            if not meta_project.get("remote") or project.sha(MANIFEST_REV_BRANCH) != meta_project['revision'].removesuffix("-dirty"):
648                off = True
649            if not meta_project.get('remote') or project.url != meta_project['remote']:
650                # Force manifest URL and set commit as 'off'
651                meta_project['url'] = project.url
652                off = True
653
654            if off:
655                if is_valid_git_revision(meta_project['revision']):
656                    meta_project['revision'] += '-off'
657                workspace_off |= off
658
659            # If manifest is in project, updates related variables
660            if project.posixpath == manifest_path:
661                manifest_dirty |= dirty
662                manifest_off |= off
663                manifest_project = meta_project
664
665        meta.update({'west': {'manifest': west_projs['manifest_path'],
666                              'projects': meta_projects}})
667        meta['workspace'].update({'off': workspace_off})
668
669    # Iterates on all modules
670    meta_modules = []
671    for module in modules:
672        # Check if modules is not in projects
673        # It allows to have the "-off" flag since `modules` variable` does not provide URL/remote
674        meta_module = _get_meta_project(meta_projects, module.project)
675
676        if not meta_module:
677            meta_module, dirty = _create_meta_project(module.project)
678            workspace_dirty |= dirty
679
680        meta_module['name'] = module.meta.get('name')
681
682        if module.meta.get('security'):
683            meta_module['security'] = module.meta.get('security')
684        meta_modules.append(meta_module)
685
686    meta['modules'] = meta_modules
687
688    meta['workspace'].update({'dirty': workspace_dirty,
689                              'extra': workspace_extra})
690
691    if propagate_state:
692        zephyr_revision = zephyr_project['revision']
693        if is_valid_git_revision(zephyr_revision):
694            if workspace_dirty and not zephyr_dirty:
695                zephyr_revision += '-dirty'
696            if workspace_extra:
697                zephyr_revision += '-extra'
698            if workspace_off and not zephyr_off:
699                zephyr_revision += '-off'
700        zephyr_project.update({'revision': zephyr_revision})
701
702        if west_projs is not None:
703            manifest_revision = manifest_project['revision']
704            if is_valid_git_revision(manifest_revision):
705                if workspace_dirty and not manifest_dirty:
706                    manifest_revision += '-dirty'
707                if workspace_extra:
708                    manifest_revision += '-extra'
709                if workspace_off and not manifest_off:
710                    manifest_revision += '-off'
711            manifest_project.update({'revision': manifest_revision})
712
713    return meta
714
715
716def west_projects(manifest=None):
717    manifest_path = None
718    projects = []
719    # West is imported here, as it is optional
720    # (and thus maybe not installed)
721    # if user is providing a specific modules list.
722    try:
723        from west.manifest import Manifest
724    except ImportError:
725        # West is not installed, so don't return any projects.
726        return None
727
728    # If west *is* installed, we need all of the following imports to
729    # work. West versions that are excessively old may fail here:
730    # west.configuration.MalformedConfig was
731    # west.manifest.MalformedConfig until west v0.14.0, for example.
732    # These should be hard errors.
733    from west.manifest import \
734        ManifestImportFailed, MalformedManifest, ManifestVersionError
735    from west.configuration import MalformedConfig
736    from west.util import WestNotFound
737    from west.version import __version__ as WestVersion
738
739    from packaging import version
740    try:
741        if not manifest:
742            manifest = Manifest.from_file()
743        if version.parse(WestVersion) >= version.parse('0.9.0'):
744            projects = [p for p in manifest.get_projects([])
745                        if manifest.is_active(p)]
746        else:
747            projects = manifest.get_projects([])
748        manifest_path = manifest.path
749        return {'manifest_path': manifest_path, 'projects': projects}
750    except (ManifestImportFailed, MalformedManifest,
751            ManifestVersionError, MalformedConfig) as e:
752        sys.exit(f'ERROR: {e}')
753    except WestNotFound:
754        # Only accept WestNotFound, meaning we are not in a west
755        # workspace. Such setup is allowed, as west may be installed
756        # but the project is not required to use west.
757        pass
758    return None
759
760
761def parse_modules(zephyr_base, manifest=None, west_projs=None, modules=None,
762                  extra_modules=None):
763
764    if modules is None:
765        west_projs = west_projs or west_projects(manifest)
766        modules = ([p.posixpath for p in west_projs['projects']]
767                   if west_projs else [])
768
769    if extra_modules is None:
770        extra_modules = []
771        for var in ['EXTRA_ZEPHYR_MODULES', 'ZEPHYR_EXTRA_MODULES']:
772            extra_module = os.environ.get(var, None)
773            if not extra_module:
774                continue
775            extra_modules.extend(PurePosixPath(p) for p in extra_module.split(';') if p)
776
777    Module = namedtuple('Module', ['project', 'meta', 'depends'])
778
779    all_modules_by_name = {}
780    # dep_modules is a list of all modules that has an unresolved dependency
781    dep_modules = []
782    # start_modules is a list modules with no depends left (no incoming edge)
783    start_modules = []
784    # sorted_modules is a topological sorted list of the modules
785    sorted_modules = []
786
787    for project in modules + extra_modules:
788        # Avoid including Zephyr base project as module.
789        if project == zephyr_base:
790            continue
791
792        meta = process_module(project)
793        if meta:
794            depends = meta.get('build', {}).get('depends', [])
795            all_modules_by_name[meta['name']] = Module(project, meta, depends)
796
797        elif project in extra_modules:
798            sys.exit(f'{project}, given in ZEPHYR_EXTRA_MODULES, '
799                     'is not a valid zephyr module')
800
801    for module in all_modules_by_name.values():
802        if not module.depends:
803            start_modules.append(module)
804        else:
805            dep_modules.append(module)
806
807    # This will do a topological sort to ensure the modules are ordered
808    # according to dependency settings.
809    while start_modules:
810        node = start_modules.pop(0)
811        sorted_modules.append(node)
812        node_name = node.meta['name']
813        to_remove = []
814        for module in dep_modules:
815            if node_name in module.depends:
816                module.depends.remove(node_name)
817                if not module.depends:
818                    start_modules.append(module)
819                    to_remove.append(module)
820        for module in to_remove:
821            dep_modules.remove(module)
822
823    if dep_modules:
824        # If there are any modules with unresolved dependencies, then the
825        # modules contains unmet or cyclic dependencies. Error out.
826        error = 'Unmet or cyclic dependencies in modules:\n'
827        for module in dep_modules:
828            error += f'{module.project} depends on: {module.depends}\n'
829        sys.exit(error)
830
831    return sorted_modules
832
833def write_if_different(file, data):
834    if Path(file).is_file():
835        with open(file, encoding="utf-8") as fp:
836            if fp.read() == data:
837                return
838
839    with open(file, 'w', encoding="utf-8") as fp:
840        fp.write(data)
841
842def main():
843    parser = argparse.ArgumentParser(description='''
844    Process a list of projects and create Kconfig / CMake include files for
845    projects which are also a Zephyr module''', allow_abbrev=False)
846
847    parser.add_argument('--kconfig-out',
848                        help="""File to write with resulting KConfig import
849                             statements.""")
850    parser.add_argument('--twister-out',
851                        help="""File to write with resulting twister
852                             parameters.""")
853    parser.add_argument('--cmake-out',
854                        help="""File to write with resulting <name>:<path>
855                             values to use for including in CMake""")
856    parser.add_argument('--sysbuild-kconfig-out',
857                        help="""File to write with resulting KConfig import
858                             statements.""")
859    parser.add_argument('--sysbuild-cmake-out',
860                        help="""File to write with resulting <name>:<path>
861                             values to use for including in CMake""")
862    parser.add_argument('--meta-out',
863                        help="""Write a build meta YaML file containing a list
864                             of Zephyr modules and west projects.
865                             If a module or project is also a git repository
866                             the current SHA revision will also be written.""")
867    parser.add_argument('--meta-state-propagate', action='store_true',
868                        help="""Propagate state of modules and west projects
869                             to the suffix of the Zephyr SHA and if west is
870                             used, to the suffix of the manifest SHA""")
871    parser.add_argument('--settings-out',
872                        help="""File to write with resulting <name>:<value>
873                             values to use for including in CMake""")
874    parser.add_argument('-m', '--modules', nargs='+',
875                        help="""List of modules to parse instead of using `west
876                             list`""")
877    parser.add_argument('-x', '--extra-modules', nargs='+',
878                        help='List of extra modules to parse')
879    parser.add_argument('-z', '--zephyr-base',
880                        help='Path to zephyr repository')
881    args = parser.parse_args()
882
883    kconfig_module_dirs = ""
884    kconfig_module_dirs_cmake = "set(kconfig_env_dirs)\n"
885    kconfig = ""
886    cmake = ""
887    sysbuild_kconfig = ""
888    sysbuild_cmake = ""
889    twister = ""
890    settings = '''\
891# WARNING. THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!
892#
893# This file contains build system settings derived from your modules.
894#
895# Modules may be set via ZEPHYR_MODULES, ZEPHYR_EXTRA_MODULES,
896# and/or the west manifest file.
897#
898# See the Modules guide for more information.
899'''
900
901    west_projs = west_projects()
902    modules = parse_modules(args.zephyr_base, None, west_projs,
903                            args.modules, args.extra_modules)
904
905    for module in modules:
906        kconfig_module_dirs += process_kconfig_module_dir(module.project, module.meta, False)
907        kconfig_module_dirs_cmake += process_kconfig_module_dir(module.project, module.meta, True)
908        kconfig += process_kconfig(module.project, module.meta)
909        cmake += process_cmake(module.project, module.meta)
910        sysbuild_kconfig += process_sysbuildkconfig(
911            module.project, module.meta)
912        sysbuild_cmake += process_sysbuildcmake(module.project, module.meta)
913        settings += process_settings(module.project, module.meta)
914        twister += process_twister(module.project, module.meta)
915
916    if args.kconfig_out or args.sysbuild_kconfig_out:
917        if args.kconfig_out:
918            kconfig_module_dirs_out = PurePath(args.kconfig_out).parent / 'kconfig_module_dirs.env'
919            kconfig_module_dirs_cmake_out = PurePath(args.kconfig_out).parent / \
920                                            'kconfig_module_dirs.cmake'
921        elif args.sysbuild_kconfig_out:
922            kconfig_module_dirs_out = PurePath(args.sysbuild_kconfig_out).parent / \
923                                      'kconfig_module_dirs.env'
924            kconfig_module_dirs_cmake_out = PurePath(args.sysbuild_kconfig_out).parent / \
925                                      'kconfig_module_dirs.cmake'
926
927        write_if_different(kconfig_module_dirs_out, kconfig_module_dirs)
928        write_if_different(kconfig_module_dirs_cmake_out, kconfig_module_dirs_cmake)
929
930    if args.kconfig_out:
931        write_if_different(args.kconfig_out, kconfig)
932
933    if args.cmake_out:
934        write_if_different(args.cmake_out, cmake)
935
936    if args.sysbuild_kconfig_out:
937        write_if_different(args.sysbuild_kconfig_out, sysbuild_kconfig)
938
939    if args.sysbuild_cmake_out:
940        write_if_different(args.sysbuild_cmake_out, sysbuild_cmake)
941
942    if args.settings_out:
943        write_if_different(args.settings_out, settings)
944
945    if args.twister_out:
946        write_if_different(args.twister_out, twister)
947
948    if args.meta_out:
949        meta = process_meta(args.zephyr_base, west_projs, modules,
950                            args.extra_modules, args.meta_state_propagate)
951
952        # Ignore references and insert data instead
953        yaml.Dumper.ignore_aliases = lambda self, data: True
954        write_if_different(args.meta_out, yaml.dump(meta))
955
956
957if __name__ == "__main__":
958    main()
959