Coverage for lobster/tools/cpptest/cpptest.py: 98%

156 statements  

« prev     ^ index     » next       coverage.py v7.10.5, created at 2025-08-27 13:02 +0000

1#!/usr/bin/env python3 

2# 

3# lobster_cpptest - Extract C++ tracing tags from comments in tests for LOBSTER 

4# Copyright (C) 2024-2025 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) 

5# 

6# This program is free software: you can redistribute it and/or modify 

7# it under the terms of the GNU Affero General Public License as 

8# published by the Free Software Foundation, either version 3 of the 

9# License, or (at your option) any later version. 

10# 

11# This program is distributed in the hope that it will be useful, but 

12# WITHOUT ANY WARRANTY; without even the implied warranty of 

13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 

14# Affero General Public License for more details. 

15# 

16# You should have received a copy of the GNU Affero General Public 

17# License along with this program. If not, see 

18# <https://www.gnu.org/licenses/>. 

19 

20import sys 

21from argparse import Namespace 

22import os.path 

23from copy import copy 

24from dataclasses import dataclass, field 

25from typing import List, Optional, Sequence, Union 

26from enum import Enum 

27import yaml 

28from lobster.common.errors import LOBSTER_Error 

29from lobster.common.exceptions import LOBSTER_Exception 

30from lobster.common.items import Tracing_Tag, Activity 

31from lobster.common.location import File_Reference 

32from lobster.common.io import lobster_write 

33from lobster.common.file_tag_generator import FileTagGenerator 

34from lobster.tools.cpptest.constants import Constants 

35from lobster.tools.cpptest.requirements_parser import \ 

36 ParserForRequirements 

37from lobster.common.meta_data_tool_base import MetaDataToolBase 

38 

39OUTPUT_FILE = "output_file" 

40CODEBEAMER_URL = "codebeamer_url" 

41KIND = "kind" 

42FILES = "files" 

43REQUIREMENTS = 'requirements' 

44 

45NAMESPACE_CPP = "cpp" 

46FRAMEWORK_CPP_TEST = "cpptest" 

47KIND_FUNCTION = "Function" 

48CB_PREFIX = "CB-#" 

49MISSING = "Missing" 

50ORPHAN_TESTS = "OrphanTests" 

51 

52TOOL_NAME = "lobster-cpptest" 

53 

54 

55class KindTypes(str, Enum): 

56 REQ = "req" 

57 ACT = "act" 

58 IMP = "imp" 

59 

60 

61@dataclass 

62class Config: 

63 codebeamer_url: str 

64 kind: KindTypes = KindTypes.REQ 

65 files: List[str] = field(default_factory=lambda: ["."]) 

66 output_file: str = "report.lobster" 

67 

68 

69SUPPORTED_KINDS = [kind_type.value for kind_type in KindTypes] 

70 

71 

72def parse_config_file(file_name: str) -> Config: 

73 """ 

74 Parse the configuration dictionary from the given YAML config file. 

75 

76 The configuration dictionary for cpptest must contain `codebeamer_url`. 

77 It may also contain `kind`, `files`, and `output_file` keys. 

78 

79 Parameters 

80 ---------- 

81 file_name : str 

82 The file name of the cpptest YAML config file. 

83 

84 Returns 

85 ------- 

86 dict 

87 The dictionary containing the configuration for cpptest. 

88 

89 Raises 

90 ------ 

91 Exception 

92 If the config dictionary does not contain the required keys 

93 or is improperly formatted. 

94 """ 

95 if not os.path.isfile(file_name): 

96 raise FileNotFoundError(f'{file_name} is not an existing file!') 

97 

98 with open(file_name, "r", encoding='utf-8') as file: 

99 try: 

100 config_dict = yaml.safe_load(file) 

101 except yaml.scanner.ScannerError as ex: 

102 raise LOBSTER_Exception(message="Invalid config file") from ex 

103 

104 if (not config_dict or 

105 CODEBEAMER_URL not in config_dict): 

106 raise KeyError(f'Please follow the right config file structure! ' 

107 f'Missing attribute {CODEBEAMER_URL}') 

108 

109 codebeamer_url = config_dict.get(CODEBEAMER_URL) 

110 kind = config_dict.get(KIND, KindTypes.REQ.value) 

111 files = config_dict.get(FILES, ["."]) 

112 output_file = config_dict.get(OUTPUT_FILE, "report.lobster") 

113 

114 if not isinstance(output_file, str): 

115 raise ValueError(f'Please follow the right config file structure! ' 

116 f'{OUTPUT_FILE} must be a string but got ' 

117 f'{type(output_file).__name__}.') 

118 

119 if not isinstance(kind, str): 

120 raise ValueError(f'Please follow the right config file structure! ' 

121 f'{KIND} must be a string but got ' 

122 f'{type(kind).__name__}.') 

123 

124 if kind not in SUPPORTED_KINDS: 

125 raise ValueError(f'Please follow the right config file structure! ' 

126 f'{KIND} must be one of ' 

127 f'{",".join(SUPPORTED_KINDS)} but got {kind}.') 

128 

129 return Config( 

130 codebeamer_url=codebeamer_url, 

131 kind=kind, 

132 files=files if isinstance(files, list) else [files], 

133 output_file=output_file 

134 ) 

135 

136 

137def get_test_file_list(file_dir_list: list, extension_list: list) -> list: 

138 """ 

139 Gets the list of test files. 

140 

141 Given file names are added to the test file list without 

142 validating against the extension list. 

143 From given directory names only file names will be added 

144 to the test file list if their extension matches against 

145 the extension list. 

146 

147 Parameters 

148 ---------- 

149 file_dir_list : list 

150 A list containing file names and/or directory names 

151 to parse for file names. 

152 extension_list : list 

153 The list of file name extensions. 

154 

155 Returns 

156 ------- 

157 list 

158 The list of test files 

159 

160 Raises 

161 ------ 

162 Exception 

163 If the config dict does not contain the required keys 

164 or contains not supported values. 

165 """ 

166 test_file_list = [] 

167 

168 for file_dir_entry in file_dir_list: 

169 if os.path.isfile(file_dir_entry): 

170 test_file_list.append(file_dir_entry) 

171 elif os.path.isdir(file_dir_entry): 

172 for path, _, files in os.walk(file_dir_entry): 

173 for filename in files: 

174 _, ext = os.path.splitext(filename) 

175 if ext in extension_list: 

176 test_file_list.append(os.path.join(path, filename)) 

177 else: 

178 raise FileNotFoundError(f'"{file_dir_entry}" is not a file or directory.') 

179 

180 if len(test_file_list) == 0: 

181 raise ValueError(f'"{file_dir_list}" does not contain any test file.') 

182 

183 return test_file_list 

184 

185 

186def collect_test_cases_from_test_files(test_file_list: list, 

187 codebeamer_url: str) -> list: 

188 """ 

189 Collects the list of test cases from the given test files. 

190 

191 Parameters 

192 ---------- 

193 test_file_list : list 

194 The list of test files. 

195 codebeamer_url: str 

196 

197 Returns 

198 ------- 

199 list 

200 The list of test cases. 

201 """ 

202 parser = ParserForRequirements() 

203 test_case_list = parser.collect_test_cases_for_test_files( 

204 test_files=test_file_list, 

205 codebeamer_url=codebeamer_url 

206 ) 

207 return test_case_list 

208 

209 

210def create_lobster_items_output_dict_from_test_cases( 

211 test_case_list: list, 

212 config: Config) -> dict: 

213 """ 

214 Creates the lobster items dictionary for the given test cases grouped by 

215 configured output. 

216 

217 Parameters 

218 ---------- 

219 test_case_list : list 

220 The list of test cases. 

221 config : Config 

222 The configuration setting. 

223 

224 Returns 

225 ------- 

226 dict 

227 The lobster items dictionary for the given test cases 

228 grouped by configured output. 

229 """ 

230 

231 output_file = config.output_file 

232 lobster_items_output_dict = {ORPHAN_TESTS: {}, output_file: {}} 

233 

234 file_tag_generator = FileTagGenerator() 

235 for test_case in test_case_list: 

236 function_name: str = test_case.test_name 

237 file_name = os.path.abspath(test_case.file_name) 

238 line_nr = int(test_case.docu_start_line) 

239 function_uid = f"{file_tag_generator.get_tag(file_name)}" \ 

240 f":{function_name}:{line_nr}" 

241 tag = Tracing_Tag(NAMESPACE_CPP, function_uid) 

242 loc = File_Reference(file_name, line_nr) 

243 key = tag.key() 

244 

245 activity = \ 

246 Activity( 

247 tag=tag, 

248 location=loc, 

249 framework=FRAMEWORK_CPP_TEST, 

250 kind=KIND_FUNCTION 

251 ) 

252 

253 contains_no_tracing_target = True 

254 

255 tracing_target_list = [] 

256 tracing_target_kind = config.kind 

257 for test_case_marker_value in getattr( 

258 test_case, 

259 REQUIREMENTS 

260 ): 

261 if MISSING not in test_case_marker_value: 261 ↛ 257line 261 didn't jump to line 257 because the condition on line 261 was always true

262 test_case_marker_value = ( 

263 test_case_marker_value.replace(CB_PREFIX, "")) 

264 tracing_target = Tracing_Tag( 

265 tracing_target_kind, 

266 test_case_marker_value 

267 ) 

268 tracing_target_list.append(tracing_target) 

269 

270 if len(tracing_target_list) >= 1: 

271 contains_no_tracing_target = False 

272 lobster_item = copy(activity) 

273 for tracing_target in tracing_target_list: 

274 lobster_item.add_tracing_target(tracing_target) 

275 

276 lobster_items_output_dict[output_file][key] = ( 

277 lobster_item) 

278 

279 if contains_no_tracing_target: 

280 lobster_items_output_dict.get(ORPHAN_TESTS)[key] = ( 

281 activity) 

282 

283 return lobster_items_output_dict 

284 

285 

286def write_lobster_items_output_dict(lobster_items_output_dict: dict): 

287 """ 

288 Write the lobster items to the output. 

289 If the output file name is empty everything is written to stdout. 

290 

291 Parameters 

292 ---------- 

293 lobster_items_output_dict : dict 

294 The lobster items dictionary grouped by output. 

295 """ 

296 lobster_generator = Constants.LOBSTER_GENERATOR 

297 orphan_test_items = lobster_items_output_dict.get(ORPHAN_TESTS, {}) 

298 for output_file_name, lobster_items in lobster_items_output_dict.items(): 

299 if output_file_name == ORPHAN_TESTS: 

300 continue 

301 

302 lobster_items_dict: dict = copy(lobster_items) 

303 lobster_items_dict.update(orphan_test_items) 

304 item_count = len(lobster_items_dict) 

305 

306 if output_file_name: 306 ↛ 298line 306 didn't jump to line 298 because the condition on line 306 was always true

307 with open(output_file_name, "w", encoding="UTF-8") as output_file: 

308 lobster_write( 

309 output_file, 

310 Activity, 

311 lobster_generator, 

312 lobster_items_dict.values() 

313 ) 

314 print(f'Written {item_count} lobster items to ' 

315 f'"{output_file_name}".') 

316 

317 

318def lobster_cpptest(config: Config): 

319 """ 

320 The main function to parse requirements from comments 

321 for the given list of files and/or directories and write the 

322 created lobster dictionary to the configured outputs. 

323 

324 Parameters 

325 ---------- 

326 config : Config 

327 The configuration setting 

328 """ 

329 test_file_list = \ 

330 get_test_file_list( 

331 file_dir_list=config.files, 

332 extension_list=[".cpp", ".cc", ".c", ".h"] 

333 ) 

334 

335 test_case_list = \ 

336 collect_test_cases_from_test_files( 

337 test_file_list=test_file_list, 

338 codebeamer_url=config.codebeamer_url 

339 ) 

340 

341 lobster_items_output_dict: dict = \ 

342 create_lobster_items_output_dict_from_test_cases( 

343 test_case_list=test_case_list, 

344 config=config 

345 ) 

346 

347 write_lobster_items_output_dict( 

348 lobster_items_output_dict=lobster_items_output_dict 

349 ) 

350 

351 

352class CppTestTool(MetaDataToolBase): 

353 def __init__(self): 

354 super().__init__( 

355 name="cpptest", 

356 description="Extract C++ tracing tags from comments in tests for LOBSTER", 

357 official=True, 

358 ) 

359 self._argument_parser.add_argument( 

360 "--config", 

361 help=("Path to YAML file with arguments, " 

362 "by default (cpptest-config.yaml)"), 

363 default="cpptest-config.yaml", 

364 ) 

365 

366 def _run_impl(self, options: Namespace) -> int: 

367 try: 

368 self._execute(options) 

369 return 0 

370 except FileNotFoundError as file_not_found_error: 

371 self._print_error(file_not_found_error) 

372 except ValueError as value_error: 

373 self._print_error(value_error) 

374 except KeyError as key_error: 

375 self._print_error(key_error) 

376 except LOBSTER_Error as lobster_error: 

377 self._print_error(lobster_error) 

378 return 1 

379 

380 @staticmethod 

381 def _print_error(error: Union[Exception, str]): 

382 print(f"{TOOL_NAME}: {error}", file=sys.stderr) 

383 

384 @staticmethod 

385 def _execute(options: Namespace) -> None: 

386 config = parse_config_file(options.config) 

387 

388 lobster_cpptest( 

389 config=config 

390 ) 

391 

392 

393def cpptest_items_to_lobster_file(config: Config) -> None: 

394 """Loads items from cpptests and serializes them in the LOBSTER interchange 

395 format to the given file. 

396 """ 

397 # This is an API function. 

398 lobster_cpptest(config=config) 

399 

400 

401def main(args: Optional[Sequence[str]] = None) -> int: 

402 return CppTestTool().run(args)