Coverage for /builds/svesch/cikit/src/cikit/env/provider.py: 99%
190 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"""CI provider abstraction for cikit.
4This module provides provider-specific CI and VCS metadata access.
5It separates runtime configuration from provider-derived context such as
6pipeline identifiers, branch names and commit metadata.
8Author:
9 Sven Schlender
10Date:
11 2026-05-29
12"""
14import os
15import re
16import time
17import uuid
18from abc import ABC
19from enum import Enum
20from pathlib import Path
22from cikit.command import run_command
23from cikit.errors import CiToolsError
26class CIProvider(Enum):
27 """Supported CI providers.
29 The enum values identify the currently active CI or local provider.
30 They are used for provider comparisons and provider-specific behavior
31 in macros and logging helpers.
32 """
34 GITLAB = "gitlab"
35 LOCALGIT = "localgit"
36 AZURE_DEVOPS = "azure_devops"
39def _clean_section_name(title: str) -> str:
40 """Return a CI-safe section name.
42 The title is normalized to lowercase and all characters not accepted by
43 common CI section markers are replaced by underscores. Leading and trailing
44 underscores are removed to keep generated section identifiers compact.
46 Args:
47 title: Human-readable section title.
49 Returns:
50 Normalized section name.
51 """
52 name = title.lower()
53 name = re.sub(r"[^a-z0-9_.-]+", "_", name)
55 return name.strip("_")
58def _run_git(repo_root: Path, args: list) -> str:
59 """Run one Git command in the given repository.
61 The command is executed through the shared command runner so that process
62 handling and error reporting stay consistent with the rest of cikit.
64 Args:
65 repo_root: Repository directory used as command working directory.
66 args: Git arguments without the leading ``git`` executable.
68 Returns:
69 Stripped stdout returned by the Git command.
71 Raises:
72 CiToolsError: If the Git command cannot be started or exits with an
73 error code.
74 """
75 result = run_command(["git", *args], cwd=repo_root, live_output=False)
77 return result.stdout.strip()
80class BaseProvider(ABC):
81 """Base class for CI providers.
83 Providers expose CI and VCS context through explicit properties. The base
84 implementation returns empty or neutral fallback values so callers can use
85 the provider interface without checking for optional capabilities first.
86 """
88 name: CIProvider = CIProvider.LOCALGIT
90 @property
91 def provider_scope(self) -> str:
92 """Return the provider-specific project scope.
94 Returns:
95 Provider-specific project scope or an empty string if unavailable.
96 """
97 return ""
99 @property
100 def provider_project(self) -> str:
101 """Return the provider-specific project name.
103 Returns:
104 Provider-specific project name or an empty string if unavailable.
105 """
106 return ""
108 @property
109 def pipeline_id(self) -> str:
110 """Return the provider-specific pipeline identifier.
112 Returns:
113 Pipeline identifier or an empty string if unavailable.
114 """
115 return ""
117 @property
118 def pipeline_alias(self) -> str:
119 """Return the provider-specific human-readable pipeline identifier.
121 Returns:
122 Human-readable pipeline identifier or ``pipeline_id`` if no separate
123 alias is available.
124 """
125 return self.pipeline_id
127 @property
128 def branch_name(self) -> str:
129 """Return the active branch name.
131 Returns:
132 Branch name or ``DETACHED`` if no branch can be resolved.
133 """
134 return "DETACHED"
136 @property
137 def commit_sha(self) -> str:
138 """Return the full commit SHA.
140 Returns:
141 Full commit SHA or an empty string if unavailable.
142 """
143 return ""
145 @property
146 def commit_short_sha(self) -> str:
147 """Return the short commit SHA.
149 Returns:
150 Short commit SHA or an empty string if unavailable.
151 """
152 return ""
154 @property
155 def commit_title(self) -> str:
156 """Return the commit title.
158 Returns:
159 First line of the commit message or an empty string if unavailable.
160 """
161 return ""
163 @property
164 def commit_message(self) -> str:
165 """Return the full commit message.
167 Returns:
168 Full commit message or an empty string if unavailable.
169 """
170 return ""
172 @property
173 def commit_author(self) -> str:
174 """Return the commit author.
176 Returns:
177 Commit author name or an empty string if unavailable.
178 """
179 return ""
181 @property
182 def commit_timestamp(self) -> str:
183 """Return the commit timestamp.
185 Returns:
186 Commit timestamp or an empty string if unavailable.
187 """
188 return ""
190 def section_start(self, _title: str, _collapsed: bool = False) -> str:
191 """Return a provider-specific section start marker.
193 Providers that support collapsible CI log sections can override this
194 method. The default implementation returns an empty string and therefore
195 disables provider-specific section markup.
197 Args:
198 _title: Human-readable section title.
199 _collapsed: Whether the section should start collapsed.
201 Returns:
202 Provider-specific section start marker or an empty string.
203 """
204 return ""
206 def section_end(self, _title: str) -> str:
207 """Return a provider-specific section end marker.
209 Providers that support CI log sections can override this method. The
210 default implementation returns an empty string and therefore disables
211 provider-specific section markup.
213 Args:
214 _title: Human-readable section title.
216 Returns:
217 Provider-specific section end marker or an empty string.
218 """
219 return ""
221 def log_prefix(self) -> str:
222 """Return a provider-specific log prefix.
224 The prefix can be used by logging helpers to indent or mark output
225 inside provider-specific sections. The default implementation returns
226 an empty string and therefore does not modify log output.
228 Returns:
229 Provider-specific log prefix or an empty string.
230 """
231 return ""
233 def __str__(self) -> str:
234 """Return the provider name.
236 Returns:
237 Uppercase enum name of the provider.
238 """
239 return self.name.name
241 def __eq__(self, other):
242 """Compare this provider with a CIProvider enum value.
244 Args:
245 other: Object to compare with this provider.
247 Returns:
248 True if ``other`` matches this provider's ``CIProvider`` value.
249 NotImplemented if the comparison type is unsupported.
250 """
251 if isinstance(other, CIProvider):
252 return self.name == other
254 return NotImplemented
257class LocalGitProvider(BaseProvider):
258 """Local Git provider with metadata fallback from the current repository.
260 This provider is used outside a supported CI environment. It derives
261 pipeline, branch and commit context from the local Git repository when
262 possible. Missing Git metadata is treated as unavailable instead of failing
263 local CI helper execution.
264 """
266 name = CIProvider.LOCALGIT
268 def __init__(self, repo_root: Path = None):
269 """Initialize the local Git provider.
271 Args:
272 repo_root: Optional repository root. If omitted, the current working
273 directory is used as Git command working directory.
274 """
275 self.repo_root = Path(repo_root or os.getcwd()).resolve()
276 self._git_metadata = None
278 @property
279 def pipeline_id(self) -> str:
280 """Return a generated local pipeline id.
282 The id is generated lazily and cached in the process environment for
283 the lifetime of the current local execution.
285 Returns:
286 Generated local pipeline identifier.
287 """
288 pipeline_id = os.getenv("CIKIT_PIPELINE_ID")
289 if pipeline_id:
290 return pipeline_id
292 timestamp = int(time.time())
293 short = uuid.uuid4().hex[:8]
294 pipeline_id = f"auto-{timestamp}-{short}"
295 os.environ["CIKIT_PIPELINE_ID"] = pipeline_id
297 return pipeline_id
299 @property
300 def branch_name(self) -> str:
301 """Return the local Git branch name.
303 Returns:
304 Current Git branch name or ``DETACHED`` if unavailable.
305 """
306 return self._git_value("branch_name", "DETACHED")
308 @property
309 def commit_sha(self) -> str:
310 """Return the local Git commit SHA.
312 Returns:
313 Full commit SHA or an empty string if unavailable.
314 """
315 return self._git_value("commit_sha")
317 @property
318 def commit_short_sha(self) -> str:
319 """Return the local Git short commit SHA.
321 Returns:
322 Short commit SHA or an empty string if unavailable.
323 """
324 return self._git_value("commit_short_sha")
326 @property
327 def commit_title(self) -> str:
328 """Return the local Git commit title.
330 Returns:
331 First line of the current commit message or an empty string if
332 unavailable.
333 """
334 return self._git_value("commit_title")
336 @property
337 def commit_message(self) -> str:
338 """Return the local Git commit message.
340 Returns:
341 Full current commit message or an empty string if unavailable.
342 """
343 return self._git_value("commit_message")
345 @property
346 def commit_author(self) -> str:
347 """Return the local Git commit author.
349 Returns:
350 Current commit author or an empty string if unavailable.
351 """
352 return self._git_value("commit_author")
354 @property
355 def commit_timestamp(self) -> str:
356 """Return the local Git commit timestamp.
358 Returns:
359 Current commit timestamp or an empty string if unavailable.
360 """
361 return self._git_value("commit_timestamp")
363 def log_prefix(self) -> str:
364 """Return the local log prefix.
366 Returns:
367 Prefix used to indent local section output.
368 """
369 return " "
371 def _git_value(self, key: str, default: str = "") -> str:
372 """Return one cached Git metadata value.
374 Args:
375 key: Metadata field name.
376 default: Value returned if the field is unavailable.
378 Returns:
379 Cached metadata value or default.
380 """
381 metadata = self._load_git_metadata()
383 return metadata.get(key, default)
385 def _load_git_metadata(self) -> dict:
386 """Load and cache Git metadata from the repository.
388 Metadata is loaded only once. Subsequent calls return the cached
389 dictionary. If Git metadata cannot be obtained, an empty dictionary is
390 cached and returned.
392 Returns:
393 Dictionary containing cached Git metadata.
394 """
395 if self._git_metadata is not None:
396 return self._git_metadata
398 try:
399 self._git_metadata = {
400 "branch_name": _run_git(self.repo_root, ["rev-parse", "--abbrev-ref", "HEAD"]),
401 "commit_sha": _run_git(self.repo_root, ["log", "-1", "--pretty=format:%H"]),
402 "commit_short_sha": _run_git(self.repo_root, ["log", "-1", "--pretty=format:%h"]),
403 "commit_title": _run_git(self.repo_root, ["log", "-1", "--pretty=format:%s"]),
404 "commit_message": _run_git(self.repo_root, ["log", "-1", "--pretty=format:%B"]),
405 "commit_author": _run_git(self.repo_root, ["log", "-1", "--pretty=format:%an"]),
406 "commit_timestamp": _run_git(self.repo_root, ["log", "-1", "--pretty=format:%aI"]),
407 }
408 except CiToolsError:
409 self._git_metadata = {}
411 return self._git_metadata
414class GitLabProvider(BaseProvider):
415 """GitLab CI provider.
417 This provider reads CI and commit context from GitLab CI environment
418 variables. It also provides GitLab section markers for collapsible CI log
419 sections.
420 """
422 name = CIProvider.GITLAB
424 @property
425 def provider_scope(self) -> str:
426 """Return the GitLab project namespace.
428 Returns:
429 GitLab project namespace or an empty string if unavailable.
430 """
431 return os.getenv("CI_PROJECT_NAMESPACE", "")
433 @property
434 def provider_project(self) -> str:
435 """Return the GitLab project name.
437 Returns:
438 GitLab project name or an empty string if unavailable.
439 """
440 return os.getenv("CI_PROJECT_NAME", "")
442 @property
443 def pipeline_id(self) -> str:
444 """Return the GitLab pipeline id.
446 Returns:
447 GitLab pipeline id or an empty string if unavailable.
448 """
449 return os.getenv("CI_PIPELINE_ID", "")
451 @property
452 def branch_name(self) -> str:
453 """Return the GitLab branch name.
455 Returns:
456 GitLab commit ref name or ``DETACHED`` if unavailable.
457 """
458 return os.getenv("CI_COMMIT_REF_NAME", "DETACHED")
460 @property
461 def commit_sha(self) -> str:
462 """Return the GitLab commit SHA.
464 Returns:
465 Full GitLab commit SHA or an empty string if unavailable.
466 """
467 return os.getenv("CI_COMMIT_SHA", "")
469 @property
470 def commit_short_sha(self) -> str:
471 """Return the GitLab short commit SHA.
473 Returns:
474 Short GitLab commit SHA or an empty string if unavailable.
475 """
476 return os.getenv("CI_COMMIT_SHORT_SHA", "")
478 @property
479 def commit_title(self) -> str:
480 """Return the GitLab commit title.
482 Returns:
483 GitLab commit title or an empty string if unavailable.
484 """
485 return os.getenv("CI_COMMIT_TITLE", "")
487 @property
488 def commit_message(self) -> str:
489 """Return the GitLab commit message.
491 Returns:
492 GitLab commit message or an empty string if unavailable.
493 """
494 return os.getenv("CI_COMMIT_MESSAGE", "")
496 @property
497 def commit_author(self) -> str:
498 """Return the GitLab commit author.
500 Returns:
501 GitLab commit author or an empty string if unavailable.
502 """
503 return os.getenv("CI_COMMIT_AUTHOR", "")
505 @property
506 def commit_timestamp(self) -> str:
507 """Return the GitLab commit timestamp.
509 Returns:
510 GitLab commit timestamp or an empty string if unavailable.
511 """
512 return os.getenv("CI_COMMIT_TIMESTAMP", "")
514 def section_start(self, title: str, collapsed: bool = False) -> str:
515 """Return the GitLab section start marker.
517 Args:
518 title: Human-readable section title.
519 collapsed: Whether the section should start collapsed.
521 Returns:
522 GitLab-compatible section start marker.
523 """
524 timestamp = int(time.time())
525 flag = "[collapsed=true]" if collapsed else ""
526 name = _clean_section_name(title)
528 return f"section_start:{timestamp}:{name}{flag}\r\033[0K{title}"
530 def section_end(self, title: str) -> str:
531 """Return the GitLab section end marker.
533 Args:
534 title: Human-readable section title.
536 Returns:
537 GitLab-compatible section end marker.
538 """
539 timestamp = int(time.time())
540 name = _clean_section_name(title)
542 return f"section_end:{timestamp}:{name}\r\033[0K"
545class AzureDevOpsProvider(LocalGitProvider):
546 """Azure DevOps provider with Git metadata from the checked-out repository.
548 Azure DevOps provides pipeline metadata through environment variables. Git
549 metadata is inherited from ``LocalGitProvider`` and resolved from the local
550 checkout instead of Azure-specific environment variables.
551 """
553 name = CIProvider.AZURE_DEVOPS
555 @property
556 def provider_scope(self) -> str:
557 """Return the Azure DevOps project name.
559 Returns:
560 Azure DevOps project name from ``SYSTEM_TEAMPROJECT`` or an empty
561 string if unavailable.
562 """
563 return os.getenv("SYSTEM_TEAMPROJECT", "")
565 @property
566 def provider_project(self) -> str:
567 """Return the Azure DevOps repository name.
569 Returns:
570 Azure DevOps repository name from ``BUILD_REPOSITORY_NAME`` or an
571 empty string if unavailable.
572 """
573 return os.getenv("BUILD_REPOSITORY_NAME", "")
575 @property
576 def pipeline_id(self) -> str:
577 """Return the Azure DevOps technical pipeline run id.
579 Returns:
580 Pipeline id from ``BUILD_BUILDID``.
581 """
582 return os.getenv("BUILD_BUILDID", "")
584 @property
585 def pipeline_alias(self) -> str:
586 """Return the Azure DevOps human-readable pipeline number.
588 Returns:
589 Pipeline alias ``BUILD_BUILDNUMBER``.
590 If unavailable, ``pipeline_id`` is used.
591 """
592 return os.getenv("BUILD_BUILDNUMBER", "") or self.pipeline_id
594 @property
595 def branch_name(self) -> str:
596 """Return the Azure DevOps source branch name."""
597 source_branch = os.getenv("BUILD_SOURCEBRANCH", "")
599 if source_branch.startswith("refs/heads/"):
600 return source_branch.removeprefix("refs/heads/")
602 if source_branch.startswith("refs/tags/"):
603 return source_branch.removeprefix("refs/tags/")
605 return os.getenv("BUILD_SOURCEBRANCHNAME") or super().branch_name
607 def section_start(self, title: str, collapsed: bool = False) -> str:
608 """Return the Azure DevOps log group start marker.
610 Azure DevOps supports collapsible log groups through logging commands.
611 The provider interface exposes the ``collapsed`` argument for compatibility
612 with other CI providers, but Azure DevOps does not support controlling the
613 initial collapsed state through the group marker.
615 Args:
616 title: Human-readable section title.
617 collapsed: Ignored for Azure DevOps.
619 Returns:
620 Azure DevOps-compatible log group start marker.
621 """
622 del collapsed
623 return f"##[group]{title}"
626 def section_end(self, _title: str) -> str:
627 """Return the Azure DevOps log group end marker.
629 Args:
630 _title: Human-readable section title. The value is unused because
631 Azure DevOps group end markers do not include the title.
633 Returns:
634 Azure DevOps-compatible log group end marker.
635 """
636 return "##[endgroup]"
639def detect_provider(repo_root: Path = None) -> BaseProvider:
640 """Detect and return the active CI provider.
642 GitLab CI is detected through the ``GITLAB_CI`` environment variable. If no
643 supported CI environment is detected, a local Git provider is returned.
645 Args:
646 repo_root: Optional repository root used by ``LocalGitProvider``.
648 Returns:
649 Detected provider instance.
650 """
651 if os.getenv("GITLAB_CI", "").lower() == "true":
652 return GitLabProvider()
654 if os.getenv("TF_BUILD", "").lower() == "true":
655 return AzureDevOpsProvider(repo_root)
657 return LocalGitProvider(repo_root)