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
« 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.
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.
8Author:
9 Sven Schlender
10License:
11 SPDX-License-Identifier: GPL-3.0-or-later
12Date:
13 2026-08-24
14"""
16import logging
17import re
18import time
19import tomllib
20from pathlib import Path
21from string import Template
23from cikit.errors import CiRecoverableError, CiToolsError
24from cikit.artifacts import ArtifactType
27LOGGER = logging.getLogger(__name__)
28PROJECT_FILE_NAMES = ("pyproject.toml",)
29_VERSION_KEYS = ("major", "minor", "patch", "build")
32class ProjectMeta:
33 """Represent resolved project metadata for template rendering.
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.
39 Args:
40 data: Resolved project metadata mapping.
41 """
43 def __init__(self, data: dict):
44 """Initialize the metadata container.
46 Args:
47 data: Resolved project metadata mapping.
48 """
49 self._data = dict(data)
51 @property
52 def project_name(self) -> str:
53 """Return the project name.
55 Returns:
56 Configured project name.
57 """
58 return self._data.get("project_name", "")
60 @property
61 def description(self) -> str:
62 """Return the project description.
64 Returns:
65 Configured project description or an empty string.
66 """
67 return self._data.get("description", "")
69 @property
70 def version(self) -> str:
71 """Return the project version.
73 Returns:
74 Configured project version or an empty string.
75 """
76 return self._data.get("version", "")
78 @property
79 def data_scope(self) -> str:
80 """Return the optional cikit data scope.
82 Returns:
83 Configured data scope or an empty string.
84 """
85 return self._data.get("data_scope", "")
87 @property
88 def revision_name(self) -> str:
89 """Return the resolved revision name.
91 Returns:
92 Revision name derived from configuration, version, or commit hash.
93 """
94 return self._data.get("revision_name", "")
96 @property
97 def revision_string(self) -> str:
98 """Return the human-readable revision string.
100 Returns:
101 Combined revision string for display or generated files.
102 """
103 return self._data.get("revision_string", "")
105 @property
106 def release_string(self) -> str:
107 """Return the numeric release string.
109 Returns:
110 Release string in the form ``major.minor.patch.build``.
111 """
112 return self._data.get("release_string", "")
114 @property
115 def commit_short_hash(self) -> str:
116 """Return the short commit hash.
118 Returns:
119 Short Git commit hash or an empty string.
120 """
121 return self._data.get("commit_short_hash", "")
123 @property
124 def build_number(self) -> int:
125 """Return the normalized build number.
127 Returns:
128 Build number as integer.
129 """
130 return self._data.get("build_number", 0)
132 @property
133 def project_author(self) -> str:
134 """Return the project author.
136 Returns:
137 Project author name or an empty string.
138 """
139 return self._data.get("project_author", "")
141 @property
142 def license(self) -> str:
143 """Return the project license identifier.
145 Returns:
146 License identifier such as ``GPL-3.0-or-later`` or an empty string.
147 """
148 return self._data.get("license", "")
150 def get(self, key: str, default=""):
151 """Return one metadata value.
153 Args:
154 key: Metadata key to read.
155 default: Value returned if the key is missing.
157 Returns:
158 Stored metadata value or the given default.
159 """
160 return self._data.get(key, default)
162 def as_dict(self) -> dict:
163 """Return metadata as a copy.
165 Returns:
166 Copy of the resolved metadata mapping.
167 """
168 return dict(self._data)
170 def render(self, template_text: str) -> str:
171 """Render metadata into a template string.
173 Args:
174 template_text: Template string containing ``${name}`` placeholders.
176 Returns:
177 Rendered template content.
179 Raises:
180 CiToolsError: If a referenced placeholder is missing.
181 """
182 return _render_template(template_text, self._data)
184 def render_to_file(self, env, output_file: Path, template_text: str) -> Path:
185 """Render metadata and write the result to a file.
187 The generated file is automatically registered as a CI artifact of type
188 ``METADATA``. Artifact registration failures are logged as warnings.
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.
195 Returns:
196 Path to the written output file.
198 Raises:
199 CiToolsError: If rendering or writing fails.
200 """
201 output_file = _write_generated_file(output_file, self.render(template_text))
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)
208 return output_file
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.
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.
219 Returns:
220 Path to the written output file.
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
231 return self.render_to_file(env, output_file, template_text)
234def find_project_file(start: Path = None) -> Path:
235 """Return the nearest supported project metadata file.
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``.
241 Args:
242 start: Optional start path. Defaults to the current working directory.
244 Returns:
245 Path to the detected project metadata file.
247 Raises:
248 CiToolsError: If no supported project metadata file could be found.
249 """
250 current = (start or Path.cwd()).resolve()
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
258 raise CiToolsError("Could not determine project file.")
261def load_project_file(path: Path) -> dict:
262 """Load project metadata from one supported project file.
264 Args:
265 path: Path to ``pyproject.toml``.
267 Returns:
268 Normalized project metadata dictionary.
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)
276 raise CiToolsError(f"Unsupported project metadata file '{path.name}'.")
279def build_project_meta(env, overrides: dict = None) -> ProjectMeta:
280 """Build project metadata from the active CI environment.
282 Args:
283 env: Resolved CI environment.
284 overrides: Optional explicit metadata overrides.
286 Returns:
287 ProjectMeta instance with resolved metadata.
288 """
289 return ProjectMeta(_resolve_project_meta(env, overrides))
292def _load_toml_file(path: Path) -> dict:
293 """Load a TOML file and return the parsed content.
295 Args:
296 path: Path to the TOML file.
298 Returns:
299 Parsed TOML content as dictionary.
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
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.")
314 return data
317def _clean_string(value) -> str:
318 """Return a stripped string value or an empty string.
320 Args:
321 value: Arbitrary input value.
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()
333def _normalize_build_number(value) -> int:
334 """Return a normalized integer build number.
336 Args:
337 value: Arbitrary input value.
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
350def _load_pyproject_toml(path: Path) -> dict:
351 """Load project metadata from ``pyproject.toml``.
353 Args:
354 path: Path to ``pyproject.toml``.
356 Returns:
357 Normalized project metadata dictionary.
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")
365 if not isinstance(project, dict):
366 raise CiToolsError(f"Missing [project] in '{path}'.")
368 name = project.get("name")
369 if not isinstance(name, str) or not name.strip():
370 raise CiToolsError(f"Missing project.name in '{path}'.")
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 = []
376 version = ""
377 if "version" not in dynamic:
378 version = _clean_string(project.get("version"))
380 authors = project.get("authors", [])
381 author = ""
382 author_email = ""
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"))
390 license_name = _clean_string(project.get("license"))
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 = {}
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 = {}
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 }
415def _parse_version(version: str) -> dict:
416 """Parse a version string into numeric components.
418 Args:
419 version: Version string such as ``1.2.3`` or ``1.2.3-rc1``.
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)]
427 parsed = dict.fromkeys(_VERSION_KEYS, 0)
428 for key, value in zip(_VERSION_KEYS, numbers[:4]):
429 parsed[key] = value
431 parsed["version"] = version
432 return parsed
435def _build_date_info() -> dict:
436 """Return build date information for generated metadata.
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"))
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 }
452def _build_revision_string(meta: dict) -> str:
453 """Build a compact human-readable revision string.
455 Args:
456 meta: Normalized project metadata dictionary.
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")]))
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
469def _build_release_string(meta: dict) -> str:
470 """Build a compact numeric release string.
472 Args:
473 meta: Normalized project metadata dictionary.
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)
481def _resolve_project_meta(env, overrides: dict = None) -> dict:
482 """Resolve project metadata for the active CI environment.
484 Args:
485 env: Resolved CI environment or compatible mapping.
486 overrides: Optional explicit metadata overrides.
488 Returns:
489 Dictionary containing normalized project metadata.
490 """
491 meta = load_project_file(env["PROJECT_FILE"])
493 meta.update(_parse_version(meta.get("version", "")))
494 meta.update(_build_date_info())
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
508 configured_build_number = meta.get("build_number")
510 if not meta.get("revision_name"):
511 meta["revision_name"] = meta.get("version") or meta.get("commit_short_hash")
513 if not meta.get("build_number"):
514 meta["build_number"] = _normalize_build_number(meta.get("pipeline_id"))
516 if configured_build_number:
517 meta["build"] = _normalize_build_number(configured_build_number)
519 if overrides:
520 meta.update(overrides)
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")
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"])
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"])
533 meta["revision_string"] = _build_revision_string(meta)
534 meta["release_string"] = _build_release_string(meta)
536 return meta
539def _render_template(template_text: str, meta: dict) -> str:
540 """Render a template string with metadata values.
542 Args:
543 template_text: Template string containing ``${name}`` placeholders.
544 meta: Metadata mapping used for substitution.
546 Returns:
547 Rendered string.
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()}
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
561def _write_generated_file(output_file: Path, content: str) -> Path:
562 """Write generated metadata content to a file.
564 Args:
565 output_file: Target file path.
566 content: Rendered text content.
568 Returns:
569 The written output file path.
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
580 return output_file