Coverage for /builds/svesch/cikit/src/cikit/project_meta.py: 88%

202 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-10-09 14:17 +0000

1# SPDX-License-Identifier: GPL-3.0-or-later 

2"""Project metadata helpers for cikit. 

3 

4This module loads project metadata from ``pyproject.toml`` and resolves a compact 

5metadata object that can be used to generate language-specific version or 

6information files. 

7 

8Author: 

9 Sven Schlender 

10License: 

11 SPDX-License-Identifier: GPL-3.0-or-later 

12Date: 

13 2026-08-24 

14""" 

15 

16import logging 

17import re 

18import time 

19import tomllib 

20from pathlib import Path 

21from string import Template 

22 

23from cikit.errors import CiRecoverableError, CiToolsError 

24from cikit.artifacts import ArtifactType 

25 

26 

27LOGGER = logging.getLogger(__name__) 

28PROJECT_FILE_NAMES = ("pyproject.toml",) 

29_VERSION_KEYS = ("major", "minor", "patch", "build") 

30 

31 

32class ProjectMeta: 

33 """Represent resolved project metadata for template rendering. 

34 

35 The class stores resolved project metadata and provides convenience 

36 accessors for common values. It can render template strings and write 

37 generated metadata files while registering them as CI artifacts. 

38 

39 Args: 

40 data: Resolved project metadata mapping. 

41 """ 

42 

43 def __init__(self, data: dict): 

44 """Initialize the metadata container. 

45 

46 Args: 

47 data: Resolved project metadata mapping. 

48 """ 

49 self._data = dict(data) 

50 

51 @property 

52 def project_name(self) -> str: 

53 """Return the project name. 

54 

55 Returns: 

56 Configured project name. 

57 """ 

58 return self._data.get("project_name", "") 

59 

60 @property 

61 def description(self) -> str: 

62 """Return the project description. 

63 

64 Returns: 

65 Configured project description or an empty string. 

66 """ 

67 return self._data.get("description", "") 

68 

69 @property 

70 def version(self) -> str: 

71 """Return the project version. 

72 

73 Returns: 

74 Configured project version or an empty string. 

75 """ 

76 return self._data.get("version", "") 

77 

78 @property 

79 def data_scope(self) -> str: 

80 """Return the optional cikit data scope. 

81 

82 Returns: 

83 Configured data scope or an empty string. 

84 """ 

85 return self._data.get("data_scope", "") 

86 

87 @property 

88 def revision_name(self) -> str: 

89 """Return the resolved revision name. 

90 

91 Returns: 

92 Revision name derived from configuration, version, or commit hash. 

93 """ 

94 return self._data.get("revision_name", "") 

95 

96 @property 

97 def revision_string(self) -> str: 

98 """Return the human-readable revision string. 

99 

100 Returns: 

101 Combined revision string for display or generated files. 

102 """ 

103 return self._data.get("revision_string", "") 

104 

105 @property 

106 def release_string(self) -> str: 

107 """Return the numeric release string. 

108 

109 Returns: 

110 Release string in the form ``major.minor.patch.build``. 

111 """ 

112 return self._data.get("release_string", "") 

113 

114 @property 

115 def commit_short_hash(self) -> str: 

116 """Return the short commit hash. 

117 

118 Returns: 

119 Short Git commit hash or an empty string. 

120 """ 

121 return self._data.get("commit_short_hash", "") 

122 

123 @property 

124 def build_number(self) -> int: 

125 """Return the normalized build number. 

126 

127 Returns: 

128 Build number as integer. 

129 """ 

130 return self._data.get("build_number", 0) 

131 

132 @property 

133 def project_author(self) -> str: 

134 """Return the project author. 

135 

136 Returns: 

137 Project author name or an empty string. 

138 """ 

139 return self._data.get("project_author", "") 

140 

141 @property 

142 def license(self) -> str: 

143 """Return the project license identifier. 

144 

145 Returns: 

146 License identifier such as ``GPL-3.0-or-later`` or an empty string. 

147 """ 

148 return self._data.get("license", "") 

149 

150 def get(self, key: str, default=""): 

151 """Return one metadata value. 

152 

153 Args: 

154 key: Metadata key to read. 

155 default: Value returned if the key is missing. 

156 

157 Returns: 

158 Stored metadata value or the given default. 

159 """ 

160 return self._data.get(key, default) 

161 

162 def as_dict(self) -> dict: 

163 """Return metadata as a copy. 

164 

165 Returns: 

166 Copy of the resolved metadata mapping. 

167 """ 

168 return dict(self._data) 

169 

170 def render(self, template_text: str) -> str: 

171 """Render metadata into a template string. 

172 

173 Args: 

174 template_text: Template string containing ``${name}`` placeholders. 

175 

176 Returns: 

177 Rendered template content. 

178 

179 Raises: 

180 CiToolsError: If a referenced placeholder is missing. 

181 """ 

182 return _render_template(template_text, self._data) 

183 

184 def render_to_file(self, env, output_file: Path, template_text: str) -> Path: 

185 """Render metadata and write the result to a file. 

186 

187 The generated file is automatically registered as a CI artifact of type 

188 ``METADATA``. Artifact registration failures are logged as warnings. 

189 

190 Args: 

191 env: Resolved CI environment used for artifact registration. 

192 output_file: Target file path. 

193 template_text: Template string containing ``${name}`` placeholders. 

194 

195 Returns: 

196 Path to the written output file. 

197 

198 Raises: 

199 CiToolsError: If rendering or writing fails. 

200 """ 

201 output_file = _write_generated_file(output_file, self.render(template_text)) 

202 

203 try: 

204 env.add_artifact(output_file, ArtifactType.METADATA) 

205 except CiRecoverableError as exp: 

206 LOGGER.warning("Failed to register metadata artifact '%s': %s", output_file, exp) 

207 

208 return output_file 

209 

210 def render_to_file_from_template_file(self, env, output_file: Path, 

211 template_file: Path) -> Path: 

212 """Render metadata from a template file and write the result. 

213 

214 Args: 

215 env: Resolved CI environment used for artifact registration. 

216 output_file: Target file path. 

217 template_file: Path to the template file. 

218 

219 Returns: 

220 Path to the written output file. 

221 

222 Raises: 

223 CiToolsError: If the template file cannot be read, rendering fails, 

224 or writing fails. 

225 """ 

226 try: 

227 template_text = template_file.read_text(encoding="utf-8") 

228 except OSError as exp: 

229 raise CiToolsError(f"Failed to read template file '{template_file}': {exp}") from exp 

230 

231 return self.render_to_file(env, output_file, template_text) 

232 

233 

234def find_project_file(start: Path = None) -> Path: 

235 """Return the nearest supported project metadata file. 

236 

237 The function searches upwards from the given start path and returns the 

238 first matching project metadata file. The supported file name is 

239 ``pyproject.toml``. 

240 

241 Args: 

242 start: Optional start path. Defaults to the current working directory. 

243 

244 Returns: 

245 Path to the detected project metadata file. 

246 

247 Raises: 

248 CiToolsError: If no supported project metadata file could be found. 

249 """ 

250 current = (start or Path.cwd()).resolve() 

251 

252 for parent in [current, *current.parents]: 

253 for filename in PROJECT_FILE_NAMES: 

254 candidate = parent / filename 

255 if candidate.exists(): 

256 return candidate 

257 

258 raise CiToolsError("Could not determine project file.") 

259 

260 

261def load_project_file(path: Path) -> dict: 

262 """Load project metadata from one supported project file. 

263 

264 Args: 

265 path: Path to ``pyproject.toml``. 

266 

267 Returns: 

268 Normalized project metadata dictionary. 

269 

270 Raises: 

271 CiToolsError: If the file type is unsupported or required fields are missing. 

272 """ 

273 if path.name == "pyproject.toml": 

274 return _load_pyproject_toml(path) 

275 

276 raise CiToolsError(f"Unsupported project metadata file '{path.name}'.") 

277 

278 

279def build_project_meta(env, overrides: dict = None) -> ProjectMeta: 

280 """Build project metadata from the active CI environment. 

281 

282 Args: 

283 env: Resolved CI environment. 

284 overrides: Optional explicit metadata overrides. 

285 

286 Returns: 

287 ProjectMeta instance with resolved metadata. 

288 """ 

289 return ProjectMeta(_resolve_project_meta(env, overrides)) 

290 

291 

292def _load_toml_file(path: Path) -> dict: 

293 """Load a TOML file and return the parsed content. 

294 

295 Args: 

296 path: Path to the TOML file. 

297 

298 Returns: 

299 Parsed TOML content as dictionary. 

300 

301 Raises: 

302 CiToolsError: If the file cannot be read or parsed. 

303 """ 

304 try: 

305 data = tomllib.loads(path.read_text(encoding="utf-8")) 

306 except OSError as exp: 

307 raise CiToolsError(f"Failed to read TOML file '{path}': {exp}") from exp 

308 except tomllib.TOMLDecodeError as exp: 

309 raise CiToolsError(f"Invalid TOML in '{path}': {exp}") from exp 

310 

311 if not isinstance(data, dict): 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true

312 raise CiToolsError(f"TOML file '{path}' did not produce a dictionary.") 

313 

314 return data 

315 

316 

317def _clean_string(value) -> str: 

318 """Return a stripped string value or an empty string. 

319 

320 Args: 

321 value: Arbitrary input value. 

322 

323 Returns: 

324 Normalized string value. 

325 """ 

326 if value is None: 

327 return "" 

328 if isinstance(value, str): 328 ↛ 330line 328 didn't jump to line 330 because the condition on line 328 was always true

329 return value.strip() 

330 return str(value).strip() 

331 

332 

333def _normalize_build_number(value) -> int: 

334 """Return a normalized integer build number. 

335 

336 Args: 

337 value: Arbitrary input value. 

338 

339 Returns: 

340 Normalized build number as integer. 

341 """ 

342 if value in (None, ""): 

343 return 0 

344 try: 

345 return int(value) 

346 except (TypeError, ValueError): 

347 return 0 

348 

349 

350def _load_pyproject_toml(path: Path) -> dict: 

351 """Load project metadata from ``pyproject.toml``. 

352 

353 Args: 

354 path: Path to ``pyproject.toml``. 

355 

356 Returns: 

357 Normalized project metadata dictionary. 

358 

359 Raises: 

360 CiToolsError: If the required ``[project]`` section or ``project.name`` is missing. 

361 """ 

362 data = _load_toml_file(path) 

363 project = data.get("project") 

364 

365 if not isinstance(project, dict): 

366 raise CiToolsError(f"Missing [project] in '{path}'.") 

367 

368 name = project.get("name") 

369 if not isinstance(name, str) or not name.strip(): 

370 raise CiToolsError(f"Missing project.name in '{path}'.") 

371 

372 dynamic = project.get("dynamic", []) 

373 if not isinstance(dynamic, list): 373 ↛ 374line 373 didn't jump to line 374 because the condition on line 373 was never true

374 dynamic = [] 

375 

376 version = "" 

377 if "version" not in dynamic: 

378 version = _clean_string(project.get("version")) 

379 

380 authors = project.get("authors", []) 

381 author = "" 

382 author_email = "" 

383 

384 if isinstance(authors, list) and authors: 

385 first = authors[0] 

386 if isinstance(first, dict): 386 ↛ 390line 386 didn't jump to line 390 because the condition on line 386 was always true

387 author = _clean_string(first.get("name")) 

388 author_email = _clean_string(first.get("email")) 

389 

390 license_name = _clean_string(project.get("license")) 

391 

392 tool = data.get("tool", {}) 

393 if not isinstance(tool, dict): 393 ↛ 394line 393 didn't jump to line 394 because the condition on line 393 was never true

394 tool = {} 

395 

396 cikit = tool.get("cikit", {}) 

397 if not isinstance(cikit, dict): 397 ↛ 398line 397 didn't jump to line 398 because the condition on line 397 was never true

398 cikit = {} 

399 

400 return { 

401 "project_file": str(path), 

402 "project_file_type": "pyproject", 

403 "project_name": name.strip(), 

404 "description": _clean_string(project.get("description")), 

405 "version": version, 

406 "license": license_name, 

407 "data_scope": _clean_string(cikit.get("data_scope")), 

408 "revision_name": _clean_string(cikit.get("revision_name")), 

409 "build_number": _normalize_build_number(cikit.get("build_number")), 

410 "project_author": author, 

411 "project_author_email": author_email, 

412 } 

413 

414 

415def _parse_version(version: str) -> dict: 

416 """Parse a version string into numeric components. 

417 

418 Args: 

419 version: Version string such as ``1.2.3`` or ``1.2.3-rc1``. 

420 

421 Returns: 

422 Dictionary containing the normalized version string and numeric components. 

423 """ 

424 version = _clean_string(version) 

425 numbers = [int(value) for value in re.findall(r"\d+", version)] 

426 

427 parsed = dict.fromkeys(_VERSION_KEYS, 0) 

428 for key, value in zip(_VERSION_KEYS, numbers[:4]): 

429 parsed[key] = value 

430 

431 parsed["version"] = version 

432 return parsed 

433 

434 

435def _build_date_info() -> dict: 

436 """Return build date information for generated metadata. 

437 

438 Returns: 

439 Dictionary containing compact build date fields. 

440 """ 

441 build_date = time.strftime("%y%m%d") 

442 build_year_full = int(time.strftime("%Y")) 

443 

444 return { 

445 "build_date": build_date, 

446 "build_year": build_year_full, 

447 "build_month": int(build_date[2:4]), 

448 "build_day": int(build_date[4:6]), 

449 } 

450 

451 

452def _build_revision_string(meta: dict) -> str: 

453 """Build a compact human-readable revision string. 

454 

455 Args: 

456 meta: Normalized project metadata dictionary. 

457 

458 Returns: 

459 Revision string based on project name, revision, hash, and date. 

460 """ 

461 left = " ".join(filter(None, [meta.get("project_name"), meta.get("revision_name")])) 

462 right = " ".join(filter(None, [meta.get("commit_short_hash"), meta.get("build_date")])) 

463 

464 if left and right: 464 ↛ 466line 464 didn't jump to line 466 because the condition on line 464 was always true

465 return f"{left}, {right}" 

466 return left or right 

467 

468 

469def _build_release_string(meta: dict) -> str: 

470 """Build a compact numeric release string. 

471 

472 Args: 

473 meta: Normalized project metadata dictionary. 

474 

475 Returns: 

476 Release string in the form ``major.minor.patch.build``. 

477 """ 

478 return ".".join(str(meta.get(key, 0)) for key in _VERSION_KEYS) 

479 

480 

481def _resolve_project_meta(env, overrides: dict = None) -> dict: 

482 """Resolve project metadata for the active CI environment. 

483 

484 Args: 

485 env: Resolved CI environment or compatible mapping. 

486 overrides: Optional explicit metadata overrides. 

487 

488 Returns: 

489 Dictionary containing normalized project metadata. 

490 """ 

491 meta = load_project_file(env["PROJECT_FILE"]) 

492 

493 meta.update(_parse_version(meta.get("version", ""))) 

494 meta.update(_build_date_info()) 

495 

496 meta["pipeline_id"] = env.pipeline_id 

497 meta["pipeline_alias"] = env.pipeline_alias 

498 meta["project_root"] = str(env["PROJECT_ROOT"]) 

499 meta["ci_provider"] = str(env.provider) 

500 meta["branch_name"] = env.branch_name 

501 meta["commit_hash"] = env.commit_sha 

502 meta["commit_short_hash"] = env.commit_short_sha 

503 meta["commit_title"] = env.commit_title 

504 meta["commit_message"] = env.commit_message 

505 meta["commit_author"] = env.commit_author 

506 meta["commit_timestamp"] = env.commit_timestamp 

507 

508 configured_build_number = meta.get("build_number") 

509 

510 if not meta.get("revision_name"): 

511 meta["revision_name"] = meta.get("version") or meta.get("commit_short_hash") 

512 

513 if not meta.get("build_number"): 

514 meta["build_number"] = _normalize_build_number(meta.get("pipeline_id")) 

515 

516 if configured_build_number: 

517 meta["build"] = _normalize_build_number(configured_build_number) 

518 

519 if overrides: 

520 meta.update(overrides) 

521 

522 if "revision_name" not in overrides and not meta.get("revision_name"): 522 ↛ 523line 522 didn't jump to line 523 because the condition on line 522 was never true

523 meta["revision_name"] = meta.get("version") or meta.get("commit_short_hash") 

524 

525 if "build" in overrides and "build_number" not in overrides: 525 ↛ 529line 525 didn't jump to line 529 because the condition on line 525 was always true

526 meta["build_number"] = _normalize_build_number(overrides["build"]) 

527 meta["build"] = _normalize_build_number(overrides["build"]) 

528 

529 if "build_number" in overrides: 529 ↛ 530line 529 didn't jump to line 530 because the condition on line 529 was never true

530 meta["build_number"] = _normalize_build_number(overrides["build_number"]) 

531 meta["build"] = _normalize_build_number(overrides["build_number"]) 

532 

533 meta["revision_string"] = _build_revision_string(meta) 

534 meta["release_string"] = _build_release_string(meta) 

535 

536 return meta 

537 

538 

539def _render_template(template_text: str, meta: dict) -> str: 

540 """Render a template string with metadata values. 

541 

542 Args: 

543 template_text: Template string containing ``${name}`` placeholders. 

544 meta: Metadata mapping used for substitution. 

545 

546 Returns: 

547 Rendered string. 

548 

549 Raises: 

550 CiToolsError: If a referenced placeholder is missing. 

551 """ 

552 values = {key: "" if value is None else str(value) for key, value in meta.items()} 

553 

554 try: 

555 return Template(template_text).substitute(values) 

556 except KeyError as exp: 

557 missing_key = exp.args[0] if exp.args else "<unknown>" 

558 raise CiToolsError(f"Missing template variable '{missing_key}'.") from exp 

559 

560 

561def _write_generated_file(output_file: Path, content: str) -> Path: 

562 """Write generated metadata content to a file. 

563 

564 Args: 

565 output_file: Target file path. 

566 content: Rendered text content. 

567 

568 Returns: 

569 The written output file path. 

570 

571 Raises: 

572 CiToolsError: If the file cannot be written. 

573 """ 

574 try: 

575 output_file.parent.mkdir(parents=True, exist_ok=True) 

576 output_file.write_text(content, encoding="utf-8") 

577 except OSError as exp: 

578 raise CiToolsError(f"Failed to write file '{output_file}': {exp}") from exp 

579 

580 return output_file