1#!/usr/bin/env python3 2 3# Copyright (c) 2018,2020 Intel Corporation 4# Copyright (c) 2022 Nordic Semiconductor ASA 5# SPDX-License-Identifier: Apache-2.0 6 7import argparse 8import collections 9from email.utils import parseaddr 10import logging 11import os 12from pathlib import Path 13import re 14import subprocess 15import sys 16import tempfile 17import traceback 18import shlex 19 20from yamllint import config, linter 21 22from junitparser import TestCase, TestSuite, JUnitXml, Skipped, Error, Failure 23import magic 24 25sys.path.insert(0, str(Path(__file__).resolve().parents[1])) 26from get_maintainer import Maintainers, MaintainersError 27 28logger = None 29 30def git(*args, cwd=None, ignore_non_zero=False): 31 # Helper for running a Git command. Returns the rstrip()ed stdout output. 32 # Called like git("diff"). Exits with SystemError (raised by sys.exit()) on 33 # errors if 'ignore_non_zero' is set to False (default: False). 'cwd' is the 34 # working directory to use (default: current directory). 35 36 git_cmd = ("git",) + args 37 try: 38 cp = subprocess.run(git_cmd, capture_output=True, cwd=cwd) 39 except OSError as e: 40 err(f"failed to run '{cmd2str(git_cmd)}': {e}") 41 42 if not ignore_non_zero and (cp.returncode or cp.stderr): 43 err(f"'{cmd2str(git_cmd)}' exited with status {cp.returncode} and/or " 44 f"wrote to stderr.\n" 45 f"==stdout==\n" 46 f"{cp.stdout.decode('utf-8')}\n" 47 f"==stderr==\n" 48 f"{cp.stderr.decode('utf-8')}\n") 49 50 return cp.stdout.decode("utf-8").rstrip() 51 52def get_shas(refspec): 53 """ 54 Returns the list of Git SHAs for 'refspec'. 55 56 :param refspec: 57 :return: 58 """ 59 return git('rev-list', 60 f'--max-count={-1 if "." in refspec else 1}', refspec).split() 61 62def get_files(filter=None, paths=None): 63 filter_arg = (f'--diff-filter={filter}',) if filter else () 64 paths_arg = ('--', *paths) if paths else () 65 out = git('diff', '--name-only', *filter_arg, COMMIT_RANGE, *paths_arg) 66 files = out.splitlines() 67 for file in list(files): 68 if not os.path.isfile(os.path.join(GIT_TOP, file)): 69 # Drop submodule directories from the list. 70 files.remove(file) 71 return files 72 73class FmtdFailure(Failure): 74 75 def __init__(self, severity, title, file, line=None, col=None, desc=""): 76 self.severity = severity 77 self.title = title 78 self.file = file 79 self.line = line 80 self.col = col 81 self.desc = desc 82 description = f':{desc}' if desc else '' 83 msg_body = desc or title 84 85 txt = f'\n{title}{description}\nFile:{file}' + \ 86 (f'\nLine:{line}' if line else '') + \ 87 (f'\nColumn:{col}' if col else '') 88 msg = f'{file}' + (f':{line}' if line else '') + f' {msg_body}' 89 typ = severity.lower() 90 91 super().__init__(msg, typ) 92 93 self.text = txt 94 95 96class ComplianceTest: 97 """ 98 Base class for tests. Inheriting classes should have a run() method and set 99 these class variables: 100 101 name: 102 Test name 103 104 doc: 105 Link to documentation related to what's being tested 106 107 path_hint: 108 The path the test runs itself in. This is just informative and used in 109 the message that gets printed when running the test. 110 111 There are two magic strings that can be used instead of a path: 112 - The magic string "<zephyr-base>" can be used to refer to the 113 environment variable ZEPHYR_BASE or, when missing, the calculated base of 114 the zephyr tree 115 - The magic string "<git-top>" refers to the top-level repository 116 directory. This avoids running 'git' to find the top-level directory 117 before main() runs (class variable assignments run when the 'class ...' 118 statement runs). That avoids swallowing errors, because main() reports 119 them to GitHub 120 """ 121 def __init__(self): 122 self.case = TestCase(type(self).name, "Guidelines") 123 # This is necessary because Failure can be subclassed, but since it is 124 # always restored form the element tree, the subclass is lost upon 125 # restoring 126 self.fmtd_failures = [] 127 128 def _result(self, res, text): 129 res.text = text.rstrip() 130 self.case.result += [res] 131 132 def error(self, text, msg=None, type_="error"): 133 """ 134 Signals a problem with running the test, with message 'msg'. 135 136 Raises an exception internally, so you do not need to put a 'return' 137 after error(). 138 """ 139 err = Error(msg or f'{type(self).name} error', type_) 140 self._result(err, text) 141 142 raise EndTest 143 144 def skip(self, text, msg=None, type_="skip"): 145 """ 146 Signals that the test should be skipped, with message 'msg'. 147 148 Raises an exception internally, so you do not need to put a 'return' 149 after skip(). 150 """ 151 skpd = Skipped(msg or f'{type(self).name} skipped', type_) 152 self._result(skpd, text) 153 154 raise EndTest 155 156 def failure(self, text, msg=None, type_="failure"): 157 """ 158 Signals that the test failed, with message 'msg'. Can be called many 159 times within the same test to report multiple failures. 160 """ 161 fail = Failure(msg or f'{type(self).name} issues', type_) 162 self._result(fail, text) 163 164 def fmtd_failure(self, severity, title, file, line=None, col=None, desc=""): 165 """ 166 Signals that the test failed, and store the information in a formatted 167 standardized manner. Can be called many times within the same test to 168 report multiple failures. 169 """ 170 fail = FmtdFailure(severity, title, file, line, col, desc) 171 self._result(fail, fail.text) 172 self.fmtd_failures.append(fail) 173 174 175class EndTest(Exception): 176 """ 177 Raised by ComplianceTest.error()/skip() to end the test. 178 179 Tests can raise EndTest themselves to immediately end the test, e.g. from 180 within a nested function call. 181 """ 182 183 184class CheckPatch(ComplianceTest): 185 """ 186 Runs checkpatch and reports found issues 187 188 """ 189 name = "Checkpatch" 190 doc = "See https://docs.zephyrproject.org/latest/contribute/guidelines.html#coding-style for more details." 191 path_hint = "<git-top>" 192 193 def run(self): 194 checkpatch = os.path.join(ZEPHYR_BASE, 'scripts', 'checkpatch.pl') 195 if not os.path.exists(checkpatch): 196 self.skip(f'{checkpatch} not found') 197 198 diff = subprocess.Popen(('git', 'diff', COMMIT_RANGE), 199 stdout=subprocess.PIPE, 200 cwd=GIT_TOP) 201 try: 202 subprocess.run((checkpatch, '--mailback', '--no-tree', '-'), 203 check=True, 204 stdin=diff.stdout, 205 stdout=subprocess.PIPE, 206 stderr=subprocess.STDOUT, 207 shell=True, cwd=GIT_TOP) 208 209 except subprocess.CalledProcessError as ex: 210 output = ex.output.decode("utf-8") 211 regex = r'^\s*\S+:(\d+):\s*(ERROR|WARNING):(.+?):(.+)(?:\n|\r\n?)+' \ 212 r'^\s*#(\d+):\s*FILE:\s*(.+):(\d+):' 213 214 matches = re.findall(regex, output, re.MULTILINE) 215 for m in matches: 216 self.fmtd_failure(m[1].lower(), m[2], m[5], m[6], col=None, 217 desc=m[3]) 218 219 # If the regex has not matched add the whole output as a failure 220 if len(matches) == 0: 221 self.failure(output) 222 223 224class DevicetreeBindingsCheck(ComplianceTest): 225 """ 226 Checks if we are introducing any unwanted properties in Devicetree Bindings. 227 """ 228 name = "DevicetreeBindings" 229 doc = "See https://docs.zephyrproject.org/latest/build/dts/bindings.html for more details." 230 path_hint = "<zephyr-base>" 231 232 def run(self, full=True): 233 dts_bindings = self.parse_dt_bindings() 234 235 for dts_binding in dts_bindings: 236 self.required_false_check(dts_binding) 237 238 def parse_dt_bindings(self): 239 """ 240 Returns a list of dts/bindings/**/*.yaml files 241 """ 242 243 dt_bindings = [] 244 for file_name in get_files(filter="d"): 245 if 'dts/bindings/' in file_name and file_name.endswith('.yaml'): 246 dt_bindings.append(file_name) 247 248 return dt_bindings 249 250 def required_false_check(self, dts_binding): 251 with open(dts_binding) as file: 252 line_number = 0 253 for line in file: 254 line_number += 1 255 if 'required: false' in line: 256 self.fmtd_failure( 257 'warning', 'Devicetree Bindings', dts_binding, 258 line_number, col=None, 259 desc="'required: false' is redundant, please remove") 260 261 262class KconfigCheck(ComplianceTest): 263 """ 264 Checks is we are introducing any new warnings/errors with Kconfig, 265 for example using undefined Kconfig variables. 266 """ 267 name = "Kconfig" 268 doc = "See https://docs.zephyrproject.org/latest/guides/kconfig/index.html for more details." 269 path_hint = "<zephyr-base>" 270 271 def run(self, full=True): 272 kconf = self.parse_kconfig() 273 274 self.check_top_menu_not_too_long(kconf) 275 self.check_no_pointless_menuconfigs(kconf) 276 self.check_no_undef_within_kconfig(kconf) 277 self.check_no_redefined_in_defconfig(kconf) 278 self.check_no_enable_in_boolean_prompt(kconf) 279 if full: 280 self.check_no_undef_outside_kconfig(kconf) 281 282 def get_modules(self, modules_file): 283 """ 284 Get a list of modules and put them in a file that is parsed by 285 Kconfig 286 287 This is needed to complete Kconfig sanity tests. 288 289 """ 290 # Invoke the script directly using the Python executable since this is 291 # not a module nor a pip-installed Python utility 292 zephyr_module_path = os.path.join(ZEPHYR_BASE, "scripts", 293 "zephyr_module.py") 294 cmd = [sys.executable, zephyr_module_path, 295 '--kconfig-out', modules_file] 296 try: 297 subprocess.run(cmd, check=True, stdout=subprocess.PIPE, 298 stderr=subprocess.STDOUT) 299 except subprocess.CalledProcessError as ex: 300 self.error(ex.output.decode("utf-8")) 301 302 modules_dir = ZEPHYR_BASE + '/modules' 303 modules = [name for name in os.listdir(modules_dir) if 304 os.path.exists(os.path.join(modules_dir, name, 'Kconfig'))] 305 306 with open(modules_file, 'r') as fp_module_file: 307 content = fp_module_file.read() 308 309 with open(modules_file, 'w') as fp_module_file: 310 for module in modules: 311 fp_module_file.write("ZEPHYR_{}_KCONFIG = {}\n".format( 312 re.sub('[^a-zA-Z0-9]', '_', module).upper(), 313 modules_dir + '/' + module + '/Kconfig' 314 )) 315 fp_module_file.write(content) 316 317 def get_kconfig_dts(self, kconfig_dts_file): 318 """ 319 Generate the Kconfig.dts using dts/bindings as the source. 320 321 This is needed to complete Kconfig compliance tests. 322 323 """ 324 # Invoke the script directly using the Python executable since this is 325 # not a module nor a pip-installed Python utility 326 zephyr_drv_kconfig_path = os.path.join(ZEPHYR_BASE, "scripts", "dts", 327 "gen_driver_kconfig_dts.py") 328 binding_path = os.path.join(ZEPHYR_BASE, "dts", "bindings") 329 cmd = [sys.executable, zephyr_drv_kconfig_path, 330 '--kconfig-out', kconfig_dts_file, '--bindings-dirs', binding_path] 331 try: 332 subprocess.run(cmd, check=True, stdout=subprocess.PIPE, 333 stderr=subprocess.STDOUT) 334 except subprocess.CalledProcessError as ex: 335 self.error(ex.output.decode("utf-8")) 336 337 338 def parse_kconfig(self): 339 """ 340 Returns a kconfiglib.Kconfig object for the Kconfig files. We reuse 341 this object for all tests to avoid having to reparse for each test. 342 """ 343 # Put the Kconfiglib path first to make sure no local Kconfiglib version is 344 # used 345 kconfig_path = os.path.join(ZEPHYR_BASE, "scripts", "kconfig") 346 if not os.path.exists(kconfig_path): 347 self.error(kconfig_path + " not found") 348 349 sys.path.insert(0, kconfig_path) 350 # Import globally so that e.g. kconfiglib.Symbol can be referenced in 351 # tests 352 global kconfiglib 353 import kconfiglib 354 355 # Look up Kconfig files relative to ZEPHYR_BASE 356 os.environ["srctree"] = ZEPHYR_BASE 357 358 # Parse the entire Kconfig tree, to make sure we see all symbols 359 os.environ["SOC_DIR"] = "soc/" 360 os.environ["ARCH_DIR"] = "arch/" 361 os.environ["BOARD_DIR"] = "boards/*/*" 362 os.environ["ARCH"] = "*" 363 os.environ["KCONFIG_BINARY_DIR"] = tempfile.gettempdir() 364 os.environ['DEVICETREE_CONF'] = "dummy" 365 os.environ['TOOLCHAIN_HAS_NEWLIB'] = "y" 366 367 # Older name for DEVICETREE_CONF, for compatibility with older Zephyr 368 # versions that don't have the renaming 369 os.environ["GENERATED_DTS_BOARD_CONF"] = "dummy" 370 371 # For multi repo support 372 self.get_modules(os.path.join(tempfile.gettempdir(), "Kconfig.modules")) 373 374 # For Kconfig.dts support 375 self.get_kconfig_dts(os.path.join(tempfile.gettempdir(), "Kconfig.dts")) 376 377 # Tells Kconfiglib to generate warnings for all references to undefined 378 # symbols within Kconfig files 379 os.environ["KCONFIG_WARN_UNDEF"] = "y" 380 381 try: 382 # Note this will both print warnings to stderr _and_ return 383 # them: so some warnings might get printed 384 # twice. "warn_to_stderr=False" could unfortunately cause 385 # some (other) warnings to never be printed. 386 return kconfiglib.Kconfig() 387 except kconfiglib.KconfigError as e: 388 self.failure(str(e)) 389 raise EndTest 390 391 def get_defined_syms(self, kconf): 392 # Returns a set() with the names of all defined Kconfig symbols (with no 393 # 'CONFIG_' prefix). This is complicated by samples and tests defining 394 # their own Kconfig trees. For those, just grep for 'config FOO' to find 395 # definitions. Doing it "properly" with Kconfiglib is still useful for 396 # the main tree, because some symbols are defined using preprocessor 397 # macros. 398 399 # Warning: Needs to work with both --perl-regexp and the 're' module. 400 # (?:...) is a non-capturing group. 401 regex = r"^\s*(?:menu)?config\s*([A-Z0-9_]+)\s*(?:#|$)" 402 403 # Grep samples/ and tests/ for symbol definitions 404 grep_stdout = git("grep", "-I", "-h", "--perl-regexp", regex, "--", 405 ":samples", ":tests", cwd=ZEPHYR_BASE) 406 407 # Generate combined list of configs and choices from the main Kconfig tree. 408 kconf_syms = kconf.unique_defined_syms + kconf.unique_choices 409 410 # Symbols from the main Kconfig tree + grepped definitions from samples 411 # and tests 412 return set([sym.name for sym in kconf_syms] 413 + re.findall(regex, grep_stdout, re.MULTILINE)) 414 415 416 def check_top_menu_not_too_long(self, kconf): 417 """ 418 Checks that there aren't too many items in the top-level menu (which 419 might be a sign that stuff accidentally got added there) 420 """ 421 max_top_items = 50 422 423 n_top_items = 0 424 node = kconf.top_node.list 425 while node: 426 # Only count items with prompts. Other items will never be 427 # shown in the menuconfig (outside show-all mode). 428 if node.prompt: 429 n_top_items += 1 430 node = node.next 431 432 if n_top_items > max_top_items: 433 self.failure(f""" 434Expected no more than {max_top_items} potentially visible items (items with 435prompts) in the top-level Kconfig menu, found {n_top_items} items. If you're 436deliberately adding new entries, then bump the 'max_top_items' variable in 437{__file__}.""") 438 439 def check_no_redefined_in_defconfig(self, kconf): 440 # Checks that no symbols are (re)defined in defconfigs. 441 442 for node in kconf.node_iter(): 443 if "defconfig" in node.filename and (node.prompt or node.help): 444 self.failure(f""" 445Kconfig node '{node.item.name}' found with prompt or help in {node.filename}. 446Options must not be defined in defconfig files. 447""") 448 continue 449 450 def check_no_enable_in_boolean_prompt(self, kconf): 451 # Checks that boolean's prompt does not start with "Enable...". 452 453 for node in kconf.node_iter(): 454 # skip Kconfig nodes not in-tree (will present an absolute path) 455 if os.path.isabs(node.filename): 456 continue 457 458 # 'kconfiglib' is global 459 # pylint: disable=undefined-variable 460 461 # only process boolean symbols with a prompt 462 if (not isinstance(node.item, kconfiglib.Symbol) or 463 node.item.type != kconfiglib.BOOL or 464 not node.prompt or 465 not node.prompt[0]): 466 continue 467 468 if re.match(r"^[Ee]nable.*", node.prompt[0]): 469 self.failure(f""" 470Boolean option '{node.item.name}' prompt must not start with 'Enable...'. Please 471check Kconfig guidelines. 472""") 473 continue 474 475 def check_no_pointless_menuconfigs(self, kconf): 476 # Checks that there are no pointless 'menuconfig' symbols without 477 # children in the Kconfig files 478 479 bad_mconfs = [] 480 for node in kconf.node_iter(): 481 # 'kconfiglib' is global 482 # pylint: disable=undefined-variable 483 484 # Avoid flagging empty regular menus and choices, in case people do 485 # something with 'osource' (could happen for 'menuconfig' symbols 486 # too, though it's less likely) 487 if node.is_menuconfig and not node.list and \ 488 isinstance(node.item, kconfiglib.Symbol): 489 490 bad_mconfs.append(node) 491 492 if bad_mconfs: 493 self.failure("""\ 494Found pointless 'menuconfig' symbols without children. Use regular 'config' 495symbols instead. See 496https://docs.zephyrproject.org/latest/guides/kconfig/tips.html#menuconfig-symbols. 497 498""" + "\n".join(f"{node.item.name:35} {node.filename}:{node.linenr}" 499 for node in bad_mconfs)) 500 501 def check_no_undef_within_kconfig(self, kconf): 502 """ 503 Checks that there are no references to undefined Kconfig symbols within 504 the Kconfig files 505 """ 506 undef_ref_warnings = "\n\n\n".join(warning for warning in kconf.warnings 507 if "undefined symbol" in warning) 508 509 if undef_ref_warnings: 510 self.failure(f"Undefined Kconfig symbols:\n\n {undef_ref_warnings}") 511 512 def check_no_undef_outside_kconfig(self, kconf): 513 """ 514 Checks that there are no references to undefined Kconfig symbols 515 outside Kconfig files (any CONFIG_FOO where no FOO symbol exists) 516 """ 517 # Grep for symbol references. 518 # 519 # Example output line for a reference to CONFIG_FOO at line 17 of 520 # foo/bar.c: 521 # 522 # foo/bar.c<null>17<null>#ifdef CONFIG_FOO 523 # 524 # 'git grep --only-matching' would get rid of the surrounding context 525 # ('#ifdef '), but it was added fairly recently (second half of 2018), 526 # so we extract the references from each line ourselves instead. 527 # 528 # The regex uses word boundaries (\b) to isolate the reference, and 529 # negative lookahead to automatically whitelist the following: 530 # 531 # - ##, for token pasting (CONFIG_FOO_##X) 532 # 533 # - $, e.g. for CMake variable expansion (CONFIG_FOO_${VAR}) 534 # 535 # - @, e.g. for CMakes's configure_file() (CONFIG_FOO_@VAR@) 536 # 537 # - {, e.g. for Python scripts ("CONFIG_FOO_{}_BAR".format(...)") 538 # 539 # - *, meant for comments like '#endif /* CONFIG_FOO_* */ 540 541 defined_syms = self.get_defined_syms(kconf) 542 543 # Maps each undefined symbol to a list <filename>:<linenr> strings 544 undef_to_locs = collections.defaultdict(list) 545 546 # Warning: Needs to work with both --perl-regexp and the 're' module 547 regex = r"\bCONFIG_[A-Z0-9_]+\b(?!\s*##|[$@{*])" 548 549 # Skip doc/releases, which often references removed symbols 550 grep_stdout = git("grep", "--line-number", "-I", "--null", 551 "--perl-regexp", regex, "--", ":!/doc/releases", 552 cwd=Path(GIT_TOP)) 553 554 # splitlines() supports various line terminators 555 for grep_line in grep_stdout.splitlines(): 556 path, lineno, line = grep_line.split("\0") 557 558 # Extract symbol references (might be more than one) within the 559 # line 560 for sym_name in re.findall(regex, line): 561 sym_name = sym_name[7:] # Strip CONFIG_ 562 if sym_name not in defined_syms and \ 563 sym_name not in self.UNDEF_KCONFIG_WHITELIST: 564 565 undef_to_locs[sym_name].append(f"{path}:{lineno}") 566 567 if not undef_to_locs: 568 return 569 570 # String that describes all referenced but undefined Kconfig symbols, 571 # in alphabetical order, along with the locations where they're 572 # referenced. Example: 573 # 574 # CONFIG_ALSO_MISSING arch/xtensa/core/fatal.c:273 575 # CONFIG_MISSING arch/xtensa/core/fatal.c:264, subsys/fb/cfb.c:20 576 undef_desc = "\n".join(f"CONFIG_{sym_name:35} {', '.join(locs)}" 577 for sym_name, locs in sorted(undef_to_locs.items())) 578 579 self.failure(f""" 580Found references to undefined Kconfig symbols. If any of these are false 581positives, then add them to UNDEF_KCONFIG_WHITELIST in {__file__}. 582 583If the reference is for a comment like /* CONFIG_FOO_* */ (or 584/* CONFIG_FOO_*_... */), then please use exactly that form (with the '*'). The 585CI check knows not to flag it. 586 587More generally, a reference followed by $, @, {{, *, or ## will never be 588flagged. 589 590{undef_desc}""") 591 592 # Many of these are symbols used as examples. Note that the list is sorted 593 # alphabetically, and skips the CONFIG_ prefix. 594 UNDEF_KCONFIG_WHITELIST = { 595 "ALSO_MISSING", 596 "APP_LINK_WITH_", 597 "APP_LOG_LEVEL", # Application log level is not detected correctly as 598 # the option is defined using a template, so it can't 599 # be grepped 600 "ARMCLANG_STD_LIBC", # The ARMCLANG_STD_LIBC is defined in the 601 # toolchain Kconfig which is sourced based on 602 # Zephyr toolchain variant and therefore not 603 # visible to compliance. 604 "BOOT_UPGRADE_ONLY", # Used in example adjusting MCUboot config, but 605 # symbol is defined in MCUboot itself. 606 "BOOT_SERIAL_BOOT_MODE", # Used in (sysbuild-based) test/ 607 # documentation 608 "BOOT_SERIAL_CDC_ACM", # Used in (sysbuild-based) test 609 "BOOT_SERIAL_ENTRANCE_GPIO", # Used in (sysbuild-based) test 610 "BOOT_SERIAL_IMG_GRP_HASH", # Used in documentation 611 "BOOT_SIGNATURE_KEY_FILE", # MCUboot setting used by sysbuild 612 "BOOT_SIGNATURE_TYPE_ECDSA_P256", # MCUboot setting used by sysbuild 613 "BOOT_SIGNATURE_TYPE_ED25519", # MCUboot setting used by sysbuild 614 "BOOT_SIGNATURE_TYPE_NONE", # MCUboot setting used by sysbuild 615 "BOOT_SIGNATURE_TYPE_RSA", # MCUboot setting used by sysbuild 616 "BOOT_VALIDATE_SLOT0", # Used in (sysbuild-based) test 617 "BOOT_WATCHDOG_FEED", # Used in (sysbuild-based) test 618 "BTTESTER_LOG_LEVEL", # Used in tests/bluetooth/tester 619 "BTTESTER_LOG_LEVEL_DBG", # Used in tests/bluetooth/tester 620 "CDC_ACM_PORT_NAME_", 621 "CLOCK_STM32_SYSCLK_SRC_", 622 "CMU", 623 "BT_6LOWPAN", # Defined in Linux, mentioned in docs 624 "CMD_CACHE", # Defined in U-Boot, mentioned in docs 625 "COUNTER_RTC_STM32_CLOCK_SRC", 626 "CRC", # Used in TI CC13x2 / CC26x2 SDK comment 627 "DEEP_SLEEP", # #defined by RV32M1 in ext/ 628 "DESCRIPTION", 629 "ERR", 630 "ESP_DIF_LIBRARY", # Referenced in CMake comment 631 "EXPERIMENTAL", 632 "FFT", # Used as an example in cmake/extensions.cmake 633 "FLAG", # Used as an example 634 "FOO", 635 "FOO_LOG_LEVEL", 636 "FOO_SETTING_1", 637 "FOO_SETTING_2", 638 "LSM6DSO_INT_PIN", 639 "LLVM_USE_LD", # Both LLVM_USE_* are in cmake/toolchain/llvm/Kconfig 640 "LLVM_USE_LLD", # which are only included if LLVM is selected but 641 # not other toolchains. Compliance check would complain, 642 # for example, if you are using GCC. 643 "MCUBOOT_LOG_LEVEL_WRN", # Used in example adjusting MCUboot 644 # config, 645 "MCUBOOT_LOG_LEVEL_INF", 646 "MCUBOOT_DOWNGRADE_PREVENTION", # but symbols are defined in MCUboot 647 # itself. 648 "MCUBOOT_ACTION_HOOKS", # Used in (sysbuild-based) test 649 "MCUBOOT_CLEANUP_ARM_CORE", # Used in (sysbuild-based) test 650 "MCUBOOT_SERIAL", # Used in (sysbuild-based) test/ 651 # documentation 652 "MISSING", 653 "MODULES", 654 "MYFEATURE", 655 "MY_DRIVER_0", 656 "NORMAL_SLEEP", # #defined by RV32M1 in ext/ 657 "OPT", 658 "OPT_0", 659 "PEDO_THS_MIN", 660 "REG1", 661 "REG2", 662 "SAMPLE_MODULE_LOG_LEVEL", # Used as an example in samples/subsys/logging 663 "SAMPLE_MODULE_LOG_LEVEL_DBG", # Used in tests/subsys/logging/log_api 664 "LOG_BACKEND_MOCK_OUTPUT_DEFAULT", #Referenced in tests/subsys/logging/log_syst 665 "LOG_BACKEND_MOCK_OUTPUT_SYST", #Referenced in testcase.yaml of log_syst test 666 "SEL", 667 "SHIFT", 668 "SOC_WATCH", # Issue 13749 669 "SOME_BOOL", 670 "SOME_INT", 671 "SOME_OTHER_BOOL", 672 "SOME_STRING", 673 "SRAM2", # Referenced in a comment in samples/application_development 674 "STACK_SIZE", # Used as an example in the Kconfig docs 675 "STD_CPP", # Referenced in CMake comment 676 "TAGOIO_HTTP_POST_LOG_LEVEL", # Used as in samples/net/cloud/tagoio 677 "TEST1", 678 "TOOLCHAIN_ARCMWDT_SUPPORTS_THREAD_LOCAL_STORAGE", # The symbol is defined in the toolchain 679 # Kconfig which is sourced based on Zephyr 680 # toolchain variant and therefore not visible 681 # to compliance. 682 "TYPE_BOOLEAN", 683 "USB_CONSOLE", 684 "USE_STDC_", 685 "WHATEVER", 686 "EXTRA_FIRMWARE_DIR", # Linux, in boards/xtensa/intel_adsp_cavs25/doc 687 "HUGETLBFS", # Linux, in boards/xtensa/intel_adsp_cavs25/doc 688 "MODVERSIONS", # Linux, in boards/xtensa/intel_adsp_cavs25/doc 689 "SECURITY_LOADPIN", # Linux, in boards/xtensa/intel_adsp_cavs25/doc 690 "ZEPHYR_TRY_MASS_ERASE", # MCUBoot setting described in sysbuild 691 # documentation 692 "ZTEST_FAIL_TEST_", # regex in tests/ztest/fail/CMakeLists.txt 693 } 694 695 696class KconfigBasicCheck(KconfigCheck): 697 """ 698 Checks if we are introducing any new warnings/errors with Kconfig, 699 for example using undefined Kconfig variables. 700 This runs the basic Kconfig test, which is checking only for undefined 701 references inside the Kconfig tree. 702 """ 703 name = "KconfigBasic" 704 doc = "See https://docs.zephyrproject.org/latest/guides/kconfig/index.html for more details." 705 path_hint = "<zephyr-base>" 706 707 def run(self): 708 super().run(full=False) 709 710 711class Nits(ComplianceTest): 712 """ 713 Checks various nits in added/modified files. Doesn't check stuff that's 714 already covered by e.g. checkpatch.pl and pylint. 715 """ 716 name = "Nits" 717 doc = "See https://docs.zephyrproject.org/latest/contribute/guidelines.html#coding-style for more details." 718 path_hint = "<git-top>" 719 720 def run(self): 721 # Loop through added/modified files 722 for fname in get_files(filter="d"): 723 if "Kconfig" in fname: 724 self.check_kconfig_header(fname) 725 self.check_redundant_zephyr_source(fname) 726 727 if fname.startswith("dts/bindings/"): 728 self.check_redundant_document_separator(fname) 729 730 if fname.endswith((".c", ".conf", ".cpp", ".dts", ".overlay", 731 ".h", ".ld", ".py", ".rst", ".txt", ".yaml", 732 ".yml")) or \ 733 "Kconfig" in fname or \ 734 "defconfig" in fname or \ 735 fname == "README": 736 737 self.check_source_file(fname) 738 739 def check_kconfig_header(self, fname): 740 # Checks for a spammy copy-pasted header format 741 742 with open(os.path.join(GIT_TOP, fname), encoding="utf-8") as f: 743 contents = f.read() 744 745 # 'Kconfig - yada yada' has a copy-pasted redundant filename at the 746 # top. This probably means all of the header was copy-pasted. 747 if re.match(r"\s*#\s*(K|k)config[\w.-]*\s*-", contents): 748 self.failure(f""" 749Please use this format for the header in '{fname}' (see 750https://docs.zephyrproject.org/latest/guides/kconfig/index.html#header-comments-and-other-nits): 751 752 # <Overview of symbols defined in the file, preferably in plain English> 753 (Blank line) 754 # Copyright (c) 2019 ... 755 # SPDX-License-Identifier: <License> 756 (Blank line) 757 (Kconfig definitions) 758 759Skip the "Kconfig - " part of the first line, since it's clear that the comment 760is about Kconfig from context. The "# Kconfig - " is what triggers this 761failure. 762""") 763 764 def check_redundant_zephyr_source(self, fname): 765 # Checks for 'source "$(ZEPHYR_BASE)/Kconfig[.zephyr]"', which can be 766 # be simplified to 'source "Kconfig[.zephyr]"' 767 768 with open(os.path.join(GIT_TOP, fname), encoding="utf-8") as f: 769 # Look for e.g. rsource as well, for completeness 770 match = re.search( 771 r'^\s*(?:o|r|or)?source\s*"\$\(?ZEPHYR_BASE\)?/(Kconfig(?:\.zephyr)?)"', 772 f.read(), re.MULTILINE) 773 774 if match: 775 self.failure(""" 776Redundant 'source "$(ZEPHYR_BASE)/{0}" in '{1}'. Just do 'source "{0}"' 777instead. The $srctree environment variable already points to the Zephyr root, 778and all 'source's are relative to it.""".format(match.group(1), fname)) 779 780 def check_redundant_document_separator(self, fname): 781 # Looks for redundant '...' document separators in bindings 782 783 with open(os.path.join(GIT_TOP, fname), encoding="utf-8") as f: 784 if re.search(r"^\.\.\.", f.read(), re.MULTILINE): 785 self.failure(f"""\ 786Redundant '...' document separator in {fname}. Binding YAML files are never 787concatenated together, so no document separators are needed.""") 788 789 def check_source_file(self, fname): 790 # Generic nits related to various source files 791 792 with open(os.path.join(GIT_TOP, fname), encoding="utf-8") as f: 793 contents = f.read() 794 795 if not contents.endswith("\n"): 796 self.failure(f"Missing newline at end of '{fname}'. Check your text " 797 f"editor settings.") 798 799 if contents.startswith("\n"): 800 self.failure(f"Please remove blank lines at start of '{fname}'") 801 802 if contents.endswith("\n\n"): 803 self.failure(f"Please remove blank lines at end of '{fname}'") 804 805 806class GitDiffCheck(ComplianceTest): 807 """ 808 Checks for conflict markers or whitespace errors with git diff --check 809 """ 810 name = "GitDiffCheck" 811 doc = "Git conflict markers and whitespace errors are not allowed in added changes" 812 path_hint = "<git-top>" 813 814 def run(self): 815 offending_lines = [] 816 # Use regex to filter out unnecessay output 817 # Reason: `--check` is mutually exclusive with `--name-only` and `-s` 818 p = re.compile(r"\S+\: .*\.") 819 820 for shaidx in get_shas(COMMIT_RANGE): 821 # Ignore non-zero return status code 822 # Reason: `git diff --check` sets the return code to the number of offending lines 823 diff = git("diff", f"{shaidx}^!", "--check", ignore_non_zero=True) 824 825 lines = p.findall(diff) 826 lines = map(lambda x: f"{shaidx}: {x}", lines) 827 offending_lines.extend(lines) 828 829 if len(offending_lines) > 0: 830 self.failure("\n".join(offending_lines)) 831 832 833class GitLint(ComplianceTest): 834 """ 835 Runs gitlint on the commits and finds issues with style and syntax 836 837 """ 838 name = "Gitlint" 839 doc = "See https://docs.zephyrproject.org/latest/contribute/guidelines.html#commit-guidelines for more details" 840 path_hint = "<git-top>" 841 842 def run(self): 843 # By default gitlint looks for .gitlint configuration only in 844 # the current directory 845 try: 846 subprocess.run('gitlint --commits ' + COMMIT_RANGE, 847 check=True, 848 stdout=subprocess.PIPE, 849 stderr=subprocess.STDOUT, 850 shell=True, cwd=GIT_TOP) 851 852 except subprocess.CalledProcessError as ex: 853 self.failure(ex.output.decode("utf-8")) 854 855 856class PyLint(ComplianceTest): 857 """ 858 Runs pylint on all .py files, with a limited set of checks enabled. The 859 configuration is in the pylintrc file. 860 """ 861 name = "Pylint" 862 doc = "See https://www.pylint.org/ for more details" 863 path_hint = "<git-top>" 864 865 def run(self): 866 # Path to pylint configuration file 867 pylintrc = os.path.abspath(os.path.join(os.path.dirname(__file__), 868 "pylintrc")) 869 870 # Path to additional pylint check scripts 871 check_script_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 872 "../pylint/checkers")) 873 874 # List of files added/modified by the commit(s). 875 files = get_files(filter="d") 876 877 # Filter out everything but Python files. Keep filenames 878 # relative (to GIT_TOP) to stay farther from any command line 879 # limit. 880 py_files = filter_py(GIT_TOP, files) 881 if not py_files: 882 return 883 884 python_environment = os.environ.copy() 885 if "PYTHONPATH" in python_environment: 886 python_environment["PYTHONPATH"] = check_script_dir + ":" + \ 887 python_environment["PYTHONPATH"] 888 else: 889 python_environment["PYTHONPATH"] = check_script_dir 890 891 pylintcmd = ["pylint", "--rcfile=" + pylintrc, 892 "--load-plugins=argparse-checker"] + py_files 893 logger.info(cmd2str(pylintcmd)) 894 try: 895 subprocess.run(pylintcmd, 896 check=True, 897 stdout=subprocess.PIPE, 898 stderr=subprocess.STDOUT, 899 cwd=GIT_TOP, 900 env=python_environment) 901 except subprocess.CalledProcessError as ex: 902 output = ex.output.decode("utf-8") 903 regex = r'^\s*(\S+):(\d+):(\d+):\s*([A-Z]\d{4}):\s*(.*)$' 904 905 matches = re.findall(regex, output, re.MULTILINE) 906 for m in matches: 907 # https://pylint.pycqa.org/en/latest/user_guide/messages/messages_overview.html# 908 severity = 'unknown' 909 if m[3][0] in ('F', 'E'): 910 severity = 'error' 911 elif m[3][0] in ('W','C', 'R', 'I'): 912 severity = 'warning' 913 self.fmtd_failure(severity, m[3], m[0], m[1], col=m[2], 914 desc=m[4]) 915 916 # If the regex has not matched add the whole output as a failure 917 if len(matches) == 0: 918 self.failure(output) 919 920 921def filter_py(root, fnames): 922 # PyLint check helper. Returns all Python script filenames among the 923 # filenames in 'fnames', relative to directory 'root'. 924 # 925 # Uses the python-magic library, so that we can detect Python 926 # files that don't end in .py as well. python-magic is a frontend 927 # to libmagic, which is also used by 'file'. 928 return [fname for fname in fnames 929 if (fname.endswith(".py") or 930 magic.from_file(os.path.join(root, fname), 931 mime=True) == "text/x-python")] 932 933 934class Identity(ComplianceTest): 935 """ 936 Checks if Emails of author and signed-off messages are consistent. 937 """ 938 name = "Identity" 939 doc = "See https://docs.zephyrproject.org/latest/contribute/guidelines.html#commit-guidelines for more details" 940 # git rev-list and git log don't depend on the current (sub)directory 941 # unless explicited 942 path_hint = "<git-top>" 943 944 def run(self): 945 for shaidx in get_shas(COMMIT_RANGE): 946 commit = git("log", "--decorate=short", "-n 1", shaidx) 947 signed = [] 948 author = "" 949 sha = "" 950 parsed_addr = None 951 for line in commit.split("\n"): 952 match = re.search(r"^commit\s([^\s]*)", line) 953 if match: 954 sha = match.group(1) 955 match = re.search(r"^Author:\s(.*)", line) 956 if match: 957 author = match.group(1) 958 parsed_addr = parseaddr(author) 959 match = re.search(r"signed-off-by:\s(.*)", line, re.IGNORECASE) 960 if match: 961 signed.append(match.group(1)) 962 963 error1 = f"{sha}: author email ({author}) needs to match one of " \ 964 f"the signed-off-by entries." 965 error2 = f"{sha}: author email ({author}) does not follow the " \ 966 f"syntax: First Last <email>." 967 error3 = f"{sha}: author email ({author}) must be a real email " \ 968 f"and cannot end in @users.noreply.github.com" 969 failure = None 970 if author not in signed: 971 failure = error1 972 973 if not parsed_addr or len(parsed_addr[0].split(" ")) < 2: 974 if not failure: 975 976 failure = error2 977 else: 978 failure = failure + "\n" + error2 979 elif parsed_addr[1].endswith("@users.noreply.github.com"): 980 failure = error3 981 982 if failure: 983 self.failure(failure) 984 985 986class BinaryFiles(ComplianceTest): 987 """ 988 Check that the diff contains no binary files. 989 """ 990 name = "BinaryFiles" 991 doc = "No binary files allowed." 992 path_hint = "<git-top>" 993 994 def run(self): 995 BINARY_ALLOW_PATHS = ("doc/", "boards/", "samples/") 996 # svg files are always detected as binary, see .gitattributes 997 BINARY_ALLOW_EXT = (".jpg", ".jpeg", ".png", ".svg", ".webp") 998 999 for stat in git("diff", "--numstat", "--diff-filter=A", 1000 COMMIT_RANGE).splitlines(): 1001 added, deleted, fname = stat.split("\t") 1002 if added == "-" and deleted == "-": 1003 if (fname.startswith(BINARY_ALLOW_PATHS) and 1004 fname.endswith(BINARY_ALLOW_EXT)): 1005 continue 1006 self.failure(f"Binary file not allowed: {fname}") 1007 1008 1009class ImageSize(ComplianceTest): 1010 """ 1011 Check that any added image is limited in size. 1012 """ 1013 name = "ImageSize" 1014 doc = "Check the size of image files." 1015 path_hint = "<git-top>" 1016 1017 def run(self): 1018 SIZE_LIMIT = 250 << 10 1019 BOARD_SIZE_LIMIT = 100 << 10 1020 1021 for file in get_files(filter="d"): 1022 full_path = os.path.join(GIT_TOP, file) 1023 mime_type = magic.from_file(full_path, mime=True) 1024 1025 if not mime_type.startswith("image/"): 1026 continue 1027 1028 size = os.path.getsize(full_path) 1029 1030 limit = SIZE_LIMIT 1031 if file.startswith("boards/"): 1032 limit = BOARD_SIZE_LIMIT 1033 1034 if size > limit: 1035 self.failure(f"Image file too large: {file} reduce size to " 1036 f"less than {limit >> 10}kB") 1037 1038 1039class MaintainersFormat(ComplianceTest): 1040 """ 1041 Check that MAINTAINERS file parses correctly. 1042 """ 1043 name = "MaintainersFormat" 1044 doc = "Check that MAINTAINERS file parses correctly." 1045 path_hint = "<git-top>" 1046 1047 def run(self): 1048 MAINTAINERS_FILES = ["MAINTAINERS.yml", "MAINTAINERS.yaml"] 1049 1050 for file in get_files(filter="d"): 1051 if file not in MAINTAINERS_FILES: 1052 continue 1053 1054 try: 1055 Maintainers(file) 1056 except MaintainersError as ex: 1057 self.failure(f"Error parsing {file}: {ex}") 1058 1059 1060class YAMLLint(ComplianceTest): 1061 """ 1062 YAMLLint 1063 """ 1064 name = "YAMLLint" 1065 doc = "Check YAML files with YAMLLint." 1066 path_hint = "<git-top>" 1067 1068 def run(self): 1069 config_file = os.path.join(ZEPHYR_BASE, ".yamllint") 1070 1071 for file in get_files(filter="d"): 1072 if Path(file).suffix not in ['.yaml', '.yml']: 1073 continue 1074 1075 yaml_config = config.YamlLintConfig(file=config_file) 1076 1077 if file.startswith(".github/"): 1078 # Tweak few rules for workflow files. 1079 yaml_config.rules["line-length"] = False 1080 yaml_config.rules["truthy"]["allowed-values"].extend(['on', 'off']) 1081 elif file == ".codecov.yml": 1082 yaml_config.rules["truthy"]["allowed-values"].extend(['yes', 'no']) 1083 1084 with open(file, 'r') as fp: 1085 for p in linter.run(fp, yaml_config): 1086 self.fmtd_failure('warning', f'YAMLLint ({p.rule})', file, 1087 p.line, col=p.column, desc=p.desc) 1088 1089 1090def init_logs(cli_arg): 1091 # Initializes logging 1092 1093 global logger 1094 1095 level = os.environ.get('LOG_LEVEL', "WARN") 1096 1097 console = logging.StreamHandler() 1098 console.setFormatter(logging.Formatter('%(levelname)-8s: %(message)s')) 1099 1100 logger = logging.getLogger('') 1101 logger.addHandler(console) 1102 logger.setLevel(cli_arg or level) 1103 1104 logger.info("Log init completed, level=%s", 1105 logging.getLevelName(logger.getEffectiveLevel())) 1106 1107 1108def inheritors(klass): 1109 subclasses = set() 1110 work = [klass] 1111 while work: 1112 parent = work.pop() 1113 for child in parent.__subclasses__(): 1114 if child not in subclasses: 1115 subclasses.add(child) 1116 work.append(child) 1117 return subclasses 1118 1119 1120def annotate(res): 1121 """ 1122 https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#about-workflow-commands 1123 """ 1124 notice = f'::{res.severity} file={res.file}' + \ 1125 (f',line={res.line}' if res.line else '') + \ 1126 (f',col={res.col}' if res.col else '') + \ 1127 f',title={res.title}::{res.message}' 1128 print(notice) 1129 1130 1131def resolve_path_hint(hint): 1132 if hint == "<zephyr-base>": 1133 return ZEPHYR_BASE 1134 elif hint == "<git-top>": 1135 return GIT_TOP 1136 else: 1137 return hint 1138 1139 1140def parse_args(argv): 1141 1142 default_range = 'HEAD~1..HEAD' 1143 parser = argparse.ArgumentParser( 1144 description="Check for coding style and documentation warnings.", allow_abbrev=False) 1145 parser.add_argument('-c', '--commits', default=default_range, 1146 help=f'''Commit range in the form: a..[b], default is 1147 {default_range}''') 1148 parser.add_argument('-o', '--output', default="compliance.xml", 1149 help='''Name of outfile in JUnit format, 1150 default is ./compliance.xml''') 1151 parser.add_argument('-n', '--no-case-output', action="store_true", 1152 help="Do not store the individual test case output.") 1153 parser.add_argument('-l', '--list', action="store_true", 1154 help="List all checks and exit") 1155 parser.add_argument("-v", "--loglevel", choices=['DEBUG', 'INFO', 'WARNING', 1156 'ERROR', 'CRITICAL'], 1157 help="python logging level") 1158 parser.add_argument('-m', '--module', action="append", default=[], 1159 help="Checks to run. All checks by default. (case " \ 1160 "insensitive)") 1161 parser.add_argument('-e', '--exclude-module', action="append", default=[], 1162 help="Do not run the specified checks (case " \ 1163 "insensitive)") 1164 parser.add_argument('-j', '--previous-run', default=None, 1165 help='''Pre-load JUnit results in XML format 1166 from a previous run and combine with new results.''') 1167 parser.add_argument('--annotate', action="store_true", 1168 help="Print GitHub Actions-compatible annotations.") 1169 1170 return parser.parse_args(argv) 1171 1172def _main(args): 1173 # The "real" main(), which is wrapped to catch exceptions and report them 1174 # to GitHub. Returns the number of test failures. 1175 1176 global ZEPHYR_BASE 1177 ZEPHYR_BASE = os.environ.get('ZEPHYR_BASE') 1178 if not ZEPHYR_BASE: 1179 # Let the user run this script as ./scripts/ci/check_compliance.py without 1180 # making them set ZEPHYR_BASE. 1181 ZEPHYR_BASE = str(Path(__file__).resolve().parents[2]) 1182 1183 # Propagate this decision to child processes. 1184 os.environ['ZEPHYR_BASE'] = ZEPHYR_BASE 1185 1186 # The absolute path of the top-level git directory. Initialize it here so 1187 # that issues running Git can be reported to GitHub. 1188 global GIT_TOP 1189 GIT_TOP = git("rev-parse", "--show-toplevel") 1190 1191 # The commit range passed in --commit, e.g. "HEAD~3" 1192 global COMMIT_RANGE 1193 COMMIT_RANGE = args.commits 1194 1195 init_logs(args.loglevel) 1196 1197 logger.info(f'Running tests on commit range {COMMIT_RANGE}') 1198 1199 if args.list: 1200 for testcase in inheritors(ComplianceTest): 1201 print(testcase.name) 1202 return 0 1203 1204 # Load saved test results from an earlier run, if requested 1205 if args.previous_run: 1206 if not os.path.exists(args.previous_run): 1207 # This probably means that an earlier pass had an internal error 1208 # (the script is currently run multiple times by the ci-pipelines 1209 # repo). Since that earlier pass might've posted an error to 1210 # GitHub, avoid generating a GitHub comment here, by avoiding 1211 # sys.exit() (which gets caught in main()). 1212 print(f"error: '{args.previous_run}' not found", 1213 file=sys.stderr) 1214 return 1 1215 1216 logging.info(f"Loading previous results from {args.previous_run}") 1217 for loaded_suite in JUnitXml.fromfile(args.previous_run): 1218 suite = loaded_suite 1219 break 1220 else: 1221 suite = TestSuite("Compliance") 1222 1223 included = list(map(lambda x: x.lower(), args.module)) 1224 excluded = list(map(lambda x: x.lower(), args.exclude_module)) 1225 1226 for testcase in inheritors(ComplianceTest): 1227 # "Modules" and "testcases" are the same thing. Better flags would have 1228 # been --tests and --exclude-tests or the like, but it's awkward to 1229 # change now. 1230 1231 if included and testcase.name.lower() not in included: 1232 continue 1233 1234 if testcase.name.lower() in excluded: 1235 print("Skipping " + testcase.name) 1236 continue 1237 1238 test = testcase() 1239 try: 1240 print(f"Running {test.name:16} tests in " 1241 f"{resolve_path_hint(test.path_hint)} ...") 1242 test.run() 1243 except EndTest: 1244 pass 1245 1246 # Annotate if required 1247 if args.annotate: 1248 for res in test.fmtd_failures: 1249 annotate(res) 1250 1251 suite.add_testcase(test.case) 1252 1253 if args.output: 1254 xml = JUnitXml() 1255 xml.add_testsuite(suite) 1256 xml.update_statistics() 1257 xml.write(args.output, pretty=True) 1258 1259 failed_cases = [] 1260 name2doc = {testcase.name: testcase.doc 1261 for testcase in inheritors(ComplianceTest)} 1262 1263 for case in suite: 1264 if case.result: 1265 if case.is_skipped: 1266 logging.warning(f"Skipped {case.name}") 1267 else: 1268 failed_cases.append(case) 1269 else: 1270 # Some checks like codeowners can produce no .result 1271 logging.info(f"No JUnit result for {case.name}") 1272 1273 n_fails = len(failed_cases) 1274 1275 if n_fails: 1276 print(f"{n_fails} checks failed") 1277 for case in failed_cases: 1278 for res in case.result: 1279 errmsg = res.text.strip() 1280 logging.error(f"Test {case.name} failed: \n{errmsg}") 1281 if args.no_case_output: 1282 continue 1283 with open(f"{case.name}.txt", "w") as f: 1284 docs = name2doc.get(case.name) 1285 f.write(f"{docs}\n") 1286 for res in case.result: 1287 errmsg = res.text.strip() 1288 f.write(f'\n {errmsg}') 1289 1290 if args.output: 1291 print(f"\nComplete results in {args.output}") 1292 return n_fails 1293 1294 1295def main(argv=None): 1296 args = parse_args(argv) 1297 1298 try: 1299 # pylint: disable=unused-import 1300 from lxml import etree 1301 except ImportError: 1302 print("\nERROR: Python module lxml not installed, unable to proceed") 1303 print("See https://github.com/weiwei/junitparser/issues/99") 1304 return 1 1305 1306 try: 1307 n_fails = _main(args) 1308 except BaseException: 1309 # Catch BaseException instead of Exception to include stuff like 1310 # SystemExit (raised by sys.exit()) 1311 print(f"Python exception in `{__file__}`:\n\n" 1312 f"```\n{traceback.format_exc()}\n```") 1313 1314 raise 1315 1316 sys.exit(n_fails) 1317 1318 1319def cmd2str(cmd): 1320 # Formats the command-line arguments in the iterable 'cmd' into a string, 1321 # for error messages and the like 1322 1323 return " ".join(shlex.quote(word) for word in cmd) 1324 1325 1326def err(msg): 1327 cmd = sys.argv[0] # Empty if missing 1328 if cmd: 1329 cmd += ": " 1330 sys.exit(f"{cmd} error: {msg}") 1331 1332 1333if __name__ == "__main__": 1334 main(sys.argv[1:]) 1335