spl-core 7.12.1__tar.gz → 7.14.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. {spl_core-7.12.1 → spl_core-7.14.0}/PKG-INFO +1 -1
  2. {spl_core-7.12.1 → spl_core-7.14.0}/pyproject.toml +1 -1
  3. spl_core-7.14.0/src/spl_core/__init__.py +1 -0
  4. spl_core-7.14.0/src/spl_core/test_utils/artifacts_archiver.py +654 -0
  5. spl_core-7.12.1/src/spl_core/__init__.py +0 -1
  6. spl_core-7.12.1/src/spl_core/test_utils/artifacts_archiver.py +0 -318
  7. {spl_core-7.12.1 → spl_core-7.14.0}/LICENSE +0 -0
  8. {spl_core-7.12.1 → spl_core-7.14.0}/README.md +0 -0
  9. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/__run.py +0 -0
  10. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/common/__init__.py +0 -0
  11. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/common/path.py +0 -0
  12. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/common.cmake +0 -0
  13. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/conan.cmake +0 -0
  14. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/config/KConfig +0 -0
  15. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/gcov_maid/__init__.py +0 -0
  16. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/gcov_maid/gcov_maid.py +0 -0
  17. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kconfig/__init__.py +0 -0
  18. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kconfig/kconfig.py +0 -0
  19. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kconfig.cmake +0 -0
  20. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/__init__.py +0 -0
  21. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/create.py +0 -0
  22. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/application/.vscode/cmake-variants.json +0 -0
  23. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/application/KConfig +0 -0
  24. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/application/src/greeter/CMakeLists.txt +0 -0
  25. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/application/src/greeter/doc/_images/screenshot.png +0 -0
  26. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/application/src/greeter/doc/index.md +0 -0
  27. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/application/src/greeter/src/greeter.c +0 -0
  28. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/application/src/greeter/src/greeter.h +0 -0
  29. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/application/src/greeter/test/test_greeter.cc +0 -0
  30. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/application/src/main/CMakeLists.txt +0 -0
  31. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/application/src/main/doc/index.md +0 -0
  32. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/application/src/main/src/main.c +0 -0
  33. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/application/test/EnglishVariant/test__EnglishVariant.py +0 -0
  34. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/application/test/German/test__GermanVariant.py +0 -0
  35. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/application/variants/EnglishVariant/config.cmake +0 -0
  36. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/application/variants/EnglishVariant/parts.cmake +0 -0
  37. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/application/variants/GermanVariant/config.cmake +0 -0
  38. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/application/variants/GermanVariant/config.txt +0 -0
  39. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/application/variants/GermanVariant/parts.cmake +0 -0
  40. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/.gitignore +0 -0
  41. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/.vscode/cmake-kits.json +0 -0
  42. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/.vscode/extensions.json +0 -0
  43. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/.vscode/launch.json +0 -0
  44. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/.vscode/settings.json +0 -0
  45. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/.vscode/tasks.json +0 -0
  46. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/CMakeLists.txt +0 -0
  47. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/README.md +0 -0
  48. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/bootstrap.json +0 -0
  49. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/build.bat +0 -0
  50. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/build.ps1 +0 -0
  51. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/conf.py +0 -0
  52. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/doc/Doxyfile.in +0 -0
  53. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/doc/common/index.md +0 -0
  54. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/doc/components/index.md +0 -0
  55. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/doc/doxygen-awesome/LICENSE +0 -0
  56. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/doc/doxygen-awesome/doxygen-awesome.css +0 -0
  57. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/doc/software_architecture/index.md +0 -0
  58. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/doc/software_requirements/index.md +0 -0
  59. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/doc/test_report_template.txt +0 -0
  60. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/index.md +0 -0
  61. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/pypeline.yaml +0 -0
  62. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/pyproject.toml +0 -0
  63. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/pytest.ini +0 -0
  64. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/scoopfile.json +0 -0
  65. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/tools/toolchains/clang/toolchain.cmake +0 -0
  66. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/kickstart/templates/project/tools/toolchains/gcc/toolchain.cmake +0 -0
  67. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/main.py +0 -0
  68. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/spl.cmake +0 -0
  69. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/steps/collect_pr_changes.py +0 -0
  70. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/test_utils/archive_artifacts_collection.py +0 -0
  71. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/test_utils/base_variant_test_runner.py +0 -0
  72. {spl_core-7.12.1 → spl_core-7.14.0}/src/spl_core/test_utils/spl_build.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spl-core
3
- Version: 7.12.1
3
+ Version: 7.14.0
4
4
  Summary: Software Product Line Support for CMake
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "spl-core"
3
- version = "7.12.1"
3
+ version = "7.14.0"
4
4
  description = "Software Product Line Support for CMake"
5
5
  authors = ["Avengineers <karsten.guenther@kamg.de>"]
6
6
  license = "MIT"
@@ -0,0 +1 @@
1
+ __version__ = "7.14.0"
@@ -0,0 +1,654 @@
1
+ import json
2
+ import os
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ import py7zr
9
+ from py_app_dev.core.logging import logger
10
+ from py_app_dev.core.subprocess import SubprocessExecutor
11
+
12
+
13
+ @dataclass
14
+ class BuildMetadata:
15
+ """
16
+ Contains build metadata extracted from environment variables.
17
+
18
+ Attributes:
19
+ branch_name: The branch name, PR identifier (e.g., "PR-123"), or tag name
20
+ build_number: The build number or "local_build"
21
+ is_tag: Whether this is a tag build
22
+ pr_number: The PR number (without "PR-" prefix) for pull request builds, None otherwise
23
+ """
24
+
25
+ branch_name: str
26
+ build_number: str
27
+ is_tag: bool
28
+ pr_number: Optional[str]
29
+
30
+
31
+ @dataclass
32
+ class GitMetadata:
33
+ """
34
+ Contains git metadata extracted from environment variables or git commands.
35
+
36
+ Attributes:
37
+ commit_id: The git commit SHA (full hash)
38
+ commit_message: The git commit message subject line (first line only)
39
+ repository_url: The git repository URL (from remote.origin.url)
40
+ """
41
+
42
+ commit_id: Optional[str]
43
+ commit_message: Optional[str]
44
+ repository_url: Optional[str]
45
+
46
+
47
+ class ArtifactsArchive:
48
+ """
49
+ This class represents a single archive containing artifacts.
50
+ It collects artifacts to be packed and archived.
51
+
52
+ Currently supports 7z and Artifactory.
53
+ """
54
+
55
+ @dataclass
56
+ class ArchiveArtifact:
57
+ """
58
+ Represents a single artifact to be archived.
59
+ This class holds the archive path (relative to the output directory of the 7z archive)
60
+ and the absolute path of the artifact.
61
+ It is used to ensure that artifacts are correctly archived with their intended paths.
62
+ """
63
+
64
+ archive_path: Path
65
+ absolute_path: Path
66
+
67
+ def __init__(self, out_dir: Path, archive_name: str) -> None:
68
+ self.out_dir: Path = out_dir
69
+ self.archive_name: str = archive_name
70
+ self.archive_artifacts: List[ArtifactsArchive.ArchiveArtifact] = []
71
+
72
+ def register(self, artifacts: List[Path]) -> None:
73
+ """
74
+ Register artifacts for archiving.
75
+ Args:
76
+ artifacts: List of paths to artifacts (files or directories) to be archived.
77
+ """
78
+ for artifact in artifacts:
79
+ self._add_artifact(artifact)
80
+
81
+ def _add_artifact(self, artifact_path: Path) -> None:
82
+ """
83
+ Add an artifact (file or directory) to the archive list.
84
+ Args:
85
+ artifact_path: path to the artifact to be archived.
86
+ """
87
+ # Convert to absolute path first
88
+ absolute_path = artifact_path.resolve() if not artifact_path.is_absolute() else artifact_path
89
+
90
+ # Calculate the relative path from out_dir for the archive
91
+ if absolute_path.is_relative_to(self.out_dir.absolute()):
92
+ archive_path = absolute_path.relative_to(self.out_dir.absolute())
93
+ else:
94
+ # If not relative to out_dir, just use the name
95
+ archive_path = Path(absolute_path.name)
96
+
97
+ self.archive_artifacts.append(
98
+ self.ArchiveArtifact(
99
+ archive_path=archive_path,
100
+ absolute_path=absolute_path,
101
+ )
102
+ )
103
+
104
+ def create_archive(self) -> Path:
105
+ """
106
+ Create a 7z file containing the collected artifacts.
107
+ Returns:
108
+ Path: The path to the created 7z file.
109
+ Raises:
110
+ Exception: If there is an error creating the 7z file.
111
+ """
112
+ # Construct the full archive path
113
+ archive_path = self.out_dir / self.archive_name
114
+
115
+ # Create output directory if it doesn't exist
116
+ archive_path.parent.mkdir(parents=True, exist_ok=True)
117
+
118
+ # Delete the file if it already exists
119
+ if archive_path.exists():
120
+ archive_path.unlink()
121
+
122
+ if not self.archive_artifacts:
123
+ logger.warning("No artifacts registered for archiving")
124
+ # Create empty 7z file
125
+ with py7zr.SevenZipFile(archive_path, "w") as archive:
126
+ pass
127
+ return archive_path
128
+
129
+ try:
130
+ with py7zr.SevenZipFile(archive_path, "w") as archive:
131
+ for artifact in self.archive_artifacts:
132
+ if not artifact.absolute_path.exists():
133
+ logger.warning(f"Artifact {artifact.absolute_path} does not exist, skipping")
134
+ continue
135
+
136
+ try:
137
+ if artifact.absolute_path.is_file():
138
+ archive.write(artifact.absolute_path, arcname=str(artifact.archive_path))
139
+ elif artifact.absolute_path.is_dir():
140
+ # py7zr can handle directories directly
141
+ archive.writeall(artifact.absolute_path, arcname=str(artifact.archive_path))
142
+ except Exception as file_error:
143
+ logger.warning(f"Failed to add {artifact.absolute_path} to archive: {file_error}")
144
+ continue
145
+
146
+ logger.info(f"7z file created at: {archive_path}")
147
+ return archive_path
148
+ except Exception as e:
149
+ logger.error(f"Error creating artifacts 7z file: {e}")
150
+ raise e
151
+
152
+
153
+ class ArtifactsArchiver:
154
+ """
155
+ This class manages multiple ArtifactsArchive instances.
156
+ It provides a unified interface for registering artifacts to different archives.
157
+ """
158
+
159
+ def __init__(self, artifactory_base_url: Optional[str] = None) -> None:
160
+ self.archives: Dict[str, ArtifactsArchive] = {}
161
+ self._target_repos: Dict[str, str] = {}
162
+ self.artifactory_base_url = artifactory_base_url
163
+
164
+ def add_archive(self, out_dir: Path, archive_filename: str, target_repo: Optional[str] = None, archive_name: str = "default") -> ArtifactsArchive:
165
+ """
166
+ Add a new archive to the archiver.
167
+
168
+ Args:
169
+ out_dir: Output directory for the archive
170
+ archive_filename: Filename for the archive
171
+ target_repo: Target repository path for Artifactory upload (optional)
172
+ archive_name: Name identifier for the archive (defaults to "default")
173
+
174
+ Returns:
175
+ The created ArtifactsArchive instance
176
+ """
177
+ archive = ArtifactsArchive(out_dir, archive_filename)
178
+ self.archives[archive_name] = archive
179
+ # Store the target repo information for this archive only if provided
180
+ if target_repo is not None:
181
+ self._target_repos[archive_name] = target_repo
182
+ return archive
183
+
184
+ def register(self, artifacts: List[Path], archive_name: str = "default") -> None:
185
+ """
186
+ Register artifacts for archiving to a specific archive.
187
+
188
+ Args:
189
+ artifacts: List of paths to artifacts (files or directories) to be archived.
190
+ archive_name: Name of the archive to register artifacts to (defaults to "default")
191
+
192
+ Raises:
193
+ KeyError: If the specified archive_name doesn't exist
194
+ """
195
+ if archive_name not in self.archives:
196
+ raise KeyError(f"Archive '{archive_name}' not found. Available archives: {list(self.archives.keys())}")
197
+
198
+ self.archives[archive_name].register(artifacts)
199
+
200
+ def get_archive(self, archive_name: str) -> ArtifactsArchive:
201
+ """
202
+ Get a specific archive by name.
203
+
204
+ Args:
205
+ archive_name: Name of the archive to retrieve
206
+
207
+ Returns:
208
+ The ArtifactsArchive instance
209
+
210
+ Raises:
211
+ KeyError: If the specified archive_name doesn't exist
212
+ """
213
+ if archive_name not in self.archives:
214
+ raise KeyError(f"Archive '{archive_name}' not found. Available archives: {list(self.archives.keys())}")
215
+
216
+ return self.archives[archive_name]
217
+
218
+ def get_archive_url(self, archive_name: str = "default") -> Optional[str]:
219
+ """
220
+ Get the Artifactory URL for a specific archive.
221
+
222
+ Args:
223
+ archive_name: Name of the archive (defaults to "default")
224
+
225
+ Returns:
226
+ The full Artifactory URL for the archive, or None if no target repo configured
227
+
228
+ Example:
229
+ "https://artifactory.example.com/artifactory/my-repo/results/develop/123/result.7z"
230
+ """
231
+ if archive_name not in self.archives:
232
+ return None
233
+
234
+ if archive_name not in self._target_repos:
235
+ return None
236
+
237
+ if self.artifactory_base_url is None:
238
+ return None
239
+
240
+ archive = self.archives[archive_name]
241
+ target_repo = self._target_repos[archive_name]
242
+ metadata = self._get_build_metadata()
243
+
244
+ # Construct the URL following the same pattern as create_rt_upload_json
245
+ archive_url = f"{self.artifactory_base_url}/{target_repo}/{metadata.branch_name}/{metadata.build_number}/{archive.archive_name}"
246
+
247
+ return archive_url
248
+
249
+ def create_all_archives(self) -> Dict[str, Path]:
250
+ """
251
+ Create all registered archives.
252
+
253
+ Returns:
254
+ Dictionary mapping archive names to their created file paths
255
+ """
256
+ created_archives = {}
257
+ for archive_name, archive in self.archives.items():
258
+ created_archives[archive_name] = archive.create_archive()
259
+ return created_archives
260
+
261
+ @staticmethod
262
+ def calculate_retention_period(branch_name: str, is_tag: bool) -> int:
263
+ """
264
+ Calculate the retention period in days based on branch name or tag.
265
+
266
+ Args:
267
+ branch_name: The name of the branch
268
+ is_tag: Whether this is a tag build
269
+
270
+ Returns:
271
+ Retention period in days:
272
+ - 84 days for "develop" branch
273
+ - -1 (infinite) for release branches (release/*)
274
+ - -1 (infinite) for tags
275
+ - 28 days for everything else (PRs, feature branches, etc.)
276
+ """
277
+ if is_tag:
278
+ return -1 # Infinite retention for tags
279
+ elif branch_name == "develop":
280
+ return 84 # Length of a PI (Program Increment)
281
+ elif branch_name.startswith("release/"):
282
+ return -1 # Infinite retention for release branches
283
+ else:
284
+ return 28 # 4 weeks for PRs, feature branches, and other branches
285
+
286
+ @staticmethod
287
+ def _get_build_metadata() -> BuildMetadata:
288
+ """
289
+ Get build metadata from environment variables or defaults.
290
+
291
+ Detects Jenkins environment variables when available, otherwise falls back
292
+ to local development defaults.
293
+
294
+ Returns:
295
+ BuildMetadata instance containing:
296
+ - branch_name: The branch name, PR identifier (e.g., "PR-123"), or tag name
297
+ - build_number: The build number or "local_build"
298
+ - is_tag: Whether this is a tag build
299
+ - pr_number: The PR number (without "PR-" prefix) for pull requests, None otherwise
300
+ """
301
+ branch_name = "local_branch"
302
+ build_number = "local_build"
303
+ is_tag = False
304
+ pr_number = None
305
+
306
+ if os.environ.get("JENKINS_URL"):
307
+ change_id = os.environ.get("CHANGE_ID")
308
+ jenkins_branch_name = os.environ.get("BRANCH_NAME")
309
+ jenkins_build_number = os.environ.get("BUILD_NUMBER")
310
+ tag_name = os.environ.get("TAG_NAME")
311
+
312
+ if change_id:
313
+ # Pull request case
314
+ branch_name = f"PR-{change_id}"
315
+ pr_number = change_id
316
+ elif tag_name:
317
+ # Tag build case
318
+ branch_name = tag_name
319
+ is_tag = True
320
+ elif jenkins_branch_name:
321
+ # Regular branch case
322
+ branch_name = jenkins_branch_name
323
+
324
+ if jenkins_build_number:
325
+ build_number = jenkins_build_number
326
+
327
+ return BuildMetadata(
328
+ branch_name=branch_name,
329
+ build_number=build_number,
330
+ is_tag=is_tag,
331
+ pr_number=pr_number,
332
+ )
333
+
334
+ @staticmethod
335
+ def _get_git_metadata() -> GitMetadata:
336
+ """
337
+ Get git metadata from environment variables or git commands.
338
+
339
+ Attempts to retrieve git information in the following order:
340
+ 1. Environment variables (GIT_COMMIT, GIT_URL) - typically set by Jenkins Git plugin
341
+ 2. Git commands as fallback - executed locally using git CLI
342
+
343
+ The commit message captured is only the subject line (first line), not the full message.
344
+
345
+ Returns:
346
+ GitMetadata instance containing:
347
+ - commit_id: The git commit SHA, or None if unavailable
348
+ - commit_message: The commit subject line (first line only), or None if unavailable
349
+ - repository_url: The git repository URL, or None if unavailable
350
+ """
351
+ commit_id = None
352
+ commit_message = None
353
+ repository_url = None
354
+
355
+ # Try environment variables first (Jenkins Git plugin)
356
+ env_commit = os.environ.get("GIT_COMMIT")
357
+ env_url = os.environ.get("GIT_URL")
358
+
359
+ if env_commit:
360
+ commit_id = env_commit if env_commit.strip() else None
361
+ if env_url:
362
+ repository_url = env_url if env_url.strip() else None
363
+
364
+ # Fallback to git commands if environment variables not available
365
+ # Get commit ID
366
+ if not commit_id:
367
+ try:
368
+ result = SubprocessExecutor(["git", "rev-parse", "HEAD"]).execute(handle_errors=False)
369
+ if result and result.returncode == 0:
370
+ value = result.stdout.strip()
371
+ commit_id = value if value else None
372
+ except Exception as e:
373
+ logger.warning(f"Failed to get commit ID from git: {e}")
374
+
375
+ # Get commit message (subject line only)
376
+ if not commit_message:
377
+ try:
378
+ result = SubprocessExecutor(["git", "log", "-1", "--format=%s"]).execute(handle_errors=False)
379
+ if result and result.returncode == 0:
380
+ value = result.stdout.strip()
381
+ commit_message = value if value else None
382
+ except Exception as e:
383
+ logger.warning(f"Failed to get commit message from git: {e}")
384
+
385
+ # Get repository URL
386
+ if not repository_url:
387
+ try:
388
+ result = SubprocessExecutor(["git", "config", "--get", "remote.origin.url"]).execute(handle_errors=False)
389
+ if result and result.returncode == 0:
390
+ value = result.stdout.strip()
391
+ repository_url = value if value else None
392
+ except Exception as e:
393
+ logger.warning(f"Failed to get repository URL from git: {e}")
394
+
395
+ return GitMetadata(
396
+ commit_id=commit_id,
397
+ commit_message=commit_message,
398
+ repository_url=repository_url,
399
+ )
400
+
401
+ def create_rt_upload_json(self, out_dir: Path) -> Path:
402
+ """
403
+ Create a single rt-upload.json file containing all archives.
404
+
405
+ This function replicates the logic from the Jenkinsfile for determining the RT_TARGET
406
+ and creating the upload specification file. It uses Jenkins environment variables
407
+ when available, otherwise falls back to default values.
408
+
409
+ Args:
410
+ output_dir: Directory where the rt-upload.json file will be created
411
+
412
+ Returns:
413
+ Path to the created rt-upload.json file
414
+ """
415
+ # Get build metadata from environment or defaults
416
+ metadata = self._get_build_metadata()
417
+
418
+ # Calculate retention period based on branch/tag
419
+ retention_period = self.calculate_retention_period(metadata.branch_name, metadata.is_tag)
420
+
421
+ # Create the files array for Artifactory upload format
422
+ files_array = []
423
+
424
+ for archive_name, archive in self.archives.items():
425
+ if archive_name in self._target_repos:
426
+ target_repo = self._target_repos[archive_name]
427
+
428
+ # Construct the RT target path
429
+ rt_target = f"{target_repo}/{metadata.branch_name}/{metadata.build_number}/"
430
+
431
+ # Add this archive to the files array with retention_period property
432
+ files_array.append(
433
+ {
434
+ "pattern": archive.archive_name,
435
+ "target": rt_target,
436
+ "recursive": "false",
437
+ "flat": "false",
438
+ "regexp": "false",
439
+ "props": f"retention_period={retention_period}",
440
+ }
441
+ )
442
+
443
+ # Create the single rt-upload.json file
444
+ json_path = out_dir / "rt-upload.json"
445
+
446
+ spec = {"files": files_array}
447
+
448
+ with open(json_path, "w") as f:
449
+ json.dump(spec, f, indent=4)
450
+
451
+ return json_path
452
+
453
+ def create_artifacts_json(self, variant: str, out_dir: Path) -> Path:
454
+ """
455
+ Create an initial artifacts.json file with build metadata structure.
456
+
457
+ This function creates a fresh artifacts.json file with build metadata
458
+ but no artifacts. Use update_artifacts_json() to add artifact categories.
459
+ It uses Jenkins environment variables when available, otherwise falls back to default values.
460
+
461
+ The JSON file includes conditional keys based on the build type:
462
+ - For pull requests: includes "pull_request" key with the PR number (e.g., "117")
463
+ - For tag builds: includes "tag" key with the tag name (e.g., "v1.2.3")
464
+ - For regular branch builds: includes "branch" key with the branch name (e.g., "develop")
465
+
466
+ Optional fields (included only if available):
467
+ - build_url: Jenkins build URL from BUILD_URL environment variable
468
+ - commit_id: Git commit SHA from GIT_COMMIT env var or git rev-parse HEAD
469
+ - commit_message: Git commit subject line from git log (first line only)
470
+ - repository_url: Git repository URL from GIT_URL env var or git config
471
+
472
+ Args:
473
+ variant: The variant name (e.g., "Disco")
474
+ out_dir: Directory where the artifacts.json file will be created
475
+
476
+ Returns:
477
+ Path to the created artifacts.json file
478
+
479
+ Raises:
480
+ ValueError: If variant is empty or None
481
+ """
482
+ # Input validation
483
+ if not variant or not variant.strip():
484
+ raise ValueError("Variant name cannot be empty or None")
485
+
486
+ # Get metadata from environment or defaults
487
+ build_metadata = self._get_build_metadata()
488
+ git_metadata = self._get_git_metadata()
489
+
490
+ # Create the initial artifacts.json structure with base metadata
491
+ artifacts_data: Dict[str, Any] = {
492
+ "variant": variant,
493
+ "build_timestamp": datetime.now(timezone.utc).isoformat(timespec="seconds") + "Z",
494
+ "build_number": build_metadata.build_number,
495
+ }
496
+
497
+ # Add build_url if available
498
+ build_url = os.environ.get("BUILD_URL")
499
+ if build_url:
500
+ artifacts_data["build_url"] = build_url
501
+
502
+ # Add conditional keys based on build type
503
+ if build_metadata.pr_number:
504
+ # Pull request build
505
+ artifacts_data["pull_request"] = build_metadata.pr_number
506
+ elif build_metadata.is_tag:
507
+ # Tag build
508
+ artifacts_data["tag"] = build_metadata.branch_name
509
+ else:
510
+ # Regular branch build (or local build)
511
+ artifacts_data["branch"] = build_metadata.branch_name
512
+
513
+ # Add git metadata if available
514
+ if git_metadata.commit_id:
515
+ artifacts_data["commit_id"] = git_metadata.commit_id
516
+ if git_metadata.commit_message:
517
+ artifacts_data["commit_message"] = git_metadata.commit_message
518
+ if git_metadata.repository_url:
519
+ artifacts_data["repository_url"] = git_metadata.repository_url
520
+
521
+ # Add empty artifacts dictionary
522
+ artifacts_data["artifacts"] = {}
523
+
524
+ # Create the artifacts.json file
525
+ json_path = out_dir / "artifacts.json"
526
+ json_path.parent.mkdir(parents=True, exist_ok=True)
527
+
528
+ with open(json_path, "w") as f:
529
+ json.dump(artifacts_data, f, indent=2)
530
+
531
+ return json_path
532
+
533
+ def update_artifacts_json(self, category: str, artifacts: Dict[str, str], artifacts_json_path: Path) -> Path:
534
+ """
535
+ Add or update artifacts in a specific category for the artifacts.json file.
536
+
537
+ Args:
538
+ category: The artifact category (e.g., "test_reports", "sca_reports", "build_binaries")
539
+ artifacts: Dictionary mapping artifact names to their URLs/paths
540
+ artifacts_json_path: Path to the artifacts.json file to be updated
541
+
542
+ Returns:
543
+ Path to the updated artifacts.json file
544
+
545
+ Raises:
546
+ ValueError: If category is empty, artifacts dictionary is empty, or JSON structure is invalid
547
+ FileNotFoundError: If artifacts.json file does not exist
548
+ """
549
+ # Input validation
550
+ if not category or not category.strip():
551
+ raise ValueError("Category name cannot be empty or None")
552
+ if not artifacts:
553
+ raise ValueError("Artifacts dictionary cannot be empty")
554
+
555
+ # Check if artifacts.json file exists
556
+ if not artifacts_json_path.exists():
557
+ raise FileNotFoundError(f"artifacts.json file does not exist at {artifacts_json_path}. Please create it first using create_artifacts_json().")
558
+
559
+ # Read existing artifacts.json file
560
+ try:
561
+ with open(artifacts_json_path) as f:
562
+ artifacts_data = json.load(f)
563
+ except json.JSONDecodeError as e:
564
+ raise ValueError(f"Could not parse artifacts.json: {e}") from e
565
+ except OSError as e:
566
+ raise ValueError(f"Could not read artifacts.json: {e}") from e
567
+
568
+ # Validate that the file has the expected structure
569
+ if not artifacts_data or "artifacts" not in artifacts_data:
570
+ raise ValueError("artifacts.json file has invalid structure. Expected 'artifacts' section not found.")
571
+
572
+ # Update the specific category with new artifacts
573
+ if category in artifacts_data["artifacts"]:
574
+ artifacts_data["artifacts"][category].update(artifacts)
575
+ else:
576
+ artifacts_data["artifacts"][category] = artifacts.copy()
577
+
578
+ # Write the updated data back to the file
579
+ with open(artifacts_json_path, "w") as f:
580
+ json.dump(artifacts_data, f, indent=2)
581
+
582
+ return artifacts_json_path
583
+
584
+ def list_archives(self) -> List[str]:
585
+ """
586
+ Get a list of all archive names.
587
+
588
+ Returns:
589
+ List of archive names
590
+ """
591
+ return list(self.archives.keys())
592
+
593
+ def create_archive(self, archive_name: str = "default") -> Path:
594
+ """
595
+ Create a specific archive (convenience method for single-archive use case).
596
+
597
+ Args:
598
+ archive_name: Name of the archive to create (defaults to "default")
599
+
600
+ Returns:
601
+ Path to the created archive file
602
+
603
+ Raises:
604
+ KeyError: If the specified archive_name doesn't exist
605
+ """
606
+ if archive_name not in self.archives:
607
+ raise KeyError(f"Archive '{archive_name}' not found. Available archives: {list(self.archives.keys())}")
608
+
609
+ return self.archives[archive_name].create_archive()
610
+
611
+
612
+ # Example usage:
613
+ #
614
+ # ## Simple single-archive use case with target repo:
615
+ # archiver = ArtifactsArchiver()
616
+ # archiver.add_archive(Path("./build/output"), "results.7z", "my-repo/results") # uses "default" name
617
+ # archiver.register([Path("./build/test_report.xml"), Path("./build/coverage.html")]) # registers to "default"
618
+ # archive_path = archiver.create_archive() # creates the "default" archive
619
+ # upload_json = archiver.create_rt_upload_json(Path("./build/output"))
620
+ #
621
+ # ## Simple single-archive use case without target repo (archive only):
622
+ # archiver = ArtifactsArchiver()
623
+ # archiver.add_archive(Path("./build/output"), "results.7z") # no target repo, uses "default" name
624
+ # archiver.register([Path("./build/test_report.xml"), Path("./build/coverage.html")])
625
+ # archive_path = archiver.create_archive() # creates the "default" archive
626
+ # # upload_json = archiver.create_rt_upload_json(Path("./build/output")) # would create empty JSON
627
+ #
628
+ # ## Multi-archive use case:
629
+ # archiver = ArtifactsArchiver()
630
+ # archiver.add_archive(Path("./build/output"), "test_results.7z", "my-repo/test-results", "test_results")
631
+ # archiver.add_archive(Path("./build/output"), "coverage.7z", "my-repo/coverage", "coverage_reports")
632
+ # archiver.add_archive(Path("./build/output"), "docs.7z", None, "documentation") # no target repo for docs
633
+ #
634
+ # archiver.register([Path("./build/test_report.xml")], "test_results")
635
+ # archiver.register([Path("./build/coverage.html")], "coverage_reports")
636
+ # archiver.register([Path("./build/docs/")], "documentation")
637
+ #
638
+ # created_files = archiver.create_all_archives()
639
+ # upload_json = archiver.create_rt_upload_json(Path("./build/output")) # only includes archives with target repos
640
+ #
641
+ # ## Artifacts.json use case (variant-specific metadata):
642
+ # archiver = ArtifactsArchiver()
643
+ # variant = "Disco"
644
+ # out_dir = Path("./build/output")
645
+ #
646
+ # # Create initial artifacts.json file first, then add categories
647
+ # # The resulting JSON will contain conditional keys based on build type:
648
+ # # - For PRs: {"variant": "Disco", "build_timestamp": "...", "build_number": "123", "pull_request": "117", "artifacts": {}}
649
+ # # - For tags: {"variant": "Disco", "build_timestamp": "...", "build_number": "123", "tag": "v1.2.3", "artifacts": {}}
650
+ # # - For branches: {"variant": "Disco", "build_timestamp": "...", "build_number": "123", "branch": "develop", "artifacts": {}}
651
+ # artifacts_json_path = archiver.create_artifacts_json(variant, out_dir)
652
+ # archiver.update_artifacts_json("test_reports", test_reports, artifacts_json_path)
653
+ # archiver.update_artifacts_json("sca_reports", sca_reports, artifacts_json_path)
654
+ # archiver.update_artifacts_json("build_binaries", build_binaries, artifacts_json_path)
@@ -1 +0,0 @@
1
- __version__ = "7.12.1"
@@ -1,318 +0,0 @@
1
- import json
2
- import os
3
- from dataclasses import dataclass
4
- from pathlib import Path
5
- from typing import Dict, List, Optional
6
-
7
- import py7zr
8
-
9
-
10
- class ArtifactsArchive:
11
- """
12
- This class represents a single archive containing artifacts.
13
- It collects artifacts to be packed and archived.
14
-
15
- Currently supports 7z and Artifactory.
16
- """
17
-
18
- @dataclass
19
- class ArchiveArtifact:
20
- """
21
- Represents a single artifact to be archived.
22
- This class holds the archive path (relative to the output directory of the 7z archive)
23
- and the absolute path of the artifact.
24
- It is used to ensure that artifacts are correctly archived with their intended paths.
25
- """
26
-
27
- archive_path: Path
28
- absolute_path: Path
29
-
30
- def __init__(self, out_dir: Path, archive_name: str) -> None:
31
- self.out_dir: Path = out_dir
32
- self.archive_name: str = archive_name
33
- self.archive_artifacts: List[ArtifactsArchive.ArchiveArtifact] = []
34
-
35
- def register(self, artifacts: List[Path]) -> None:
36
- """
37
- Register artifacts for archiving.
38
- Args:
39
- artifacts: List of paths to artifacts (files or directories) to be archived.
40
- """
41
- for artifact in artifacts:
42
- self._add_artifact(artifact)
43
-
44
- def _add_artifact(self, artifact_path: Path) -> None:
45
- """
46
- Add an artifact (file or directory) to the archive list.
47
- Args:
48
- artifact_path: path to the artifact to be archived.
49
- """
50
- # Convert to absolute path first
51
- absolute_path = artifact_path.resolve() if not artifact_path.is_absolute() else artifact_path
52
-
53
- # Calculate the relative path from out_dir for the archive
54
- if absolute_path.is_relative_to(self.out_dir.absolute()):
55
- archive_path = absolute_path.relative_to(self.out_dir.absolute())
56
- else:
57
- # If not relative to out_dir, just use the name
58
- archive_path = Path(absolute_path.name)
59
-
60
- self.archive_artifacts.append(
61
- self.ArchiveArtifact(
62
- archive_path=archive_path,
63
- absolute_path=absolute_path,
64
- )
65
- )
66
-
67
- def create_archive(self) -> Path:
68
- """
69
- Create a 7z file containing the collected artifacts.
70
- Returns:
71
- Path: The path to the created 7z file.
72
- Raises:
73
- Exception: If there is an error creating the 7z file.
74
- """
75
- # Construct the full archive path
76
- archive_path = self.out_dir / self.archive_name
77
-
78
- # Create output directory if it doesn't exist
79
- archive_path.parent.mkdir(parents=True, exist_ok=True)
80
-
81
- # Delete the file if it already exists
82
- if archive_path.exists():
83
- archive_path.unlink()
84
-
85
- if not self.archive_artifacts:
86
- print("Warning: No artifacts registered for archiving")
87
- # Create empty 7z file
88
- with py7zr.SevenZipFile(archive_path, "w") as archive:
89
- pass
90
- return archive_path
91
-
92
- try:
93
- with py7zr.SevenZipFile(archive_path, "w") as archive:
94
- for artifact in self.archive_artifacts:
95
- if not artifact.absolute_path.exists():
96
- print(f"Warning: Artifact {artifact.absolute_path} does not exist, skipping")
97
- continue
98
-
99
- try:
100
- if artifact.absolute_path.is_file():
101
- archive.write(artifact.absolute_path, arcname=str(artifact.archive_path))
102
- elif artifact.absolute_path.is_dir():
103
- # py7zr can handle directories directly
104
- archive.writeall(artifact.absolute_path, arcname=str(artifact.archive_path))
105
- except Exception as file_error:
106
- print(f"Warning: Failed to add {artifact.absolute_path} to archive: {file_error}")
107
- continue
108
-
109
- print(f"7z file created at: {archive_path}")
110
- return archive_path
111
- except Exception as e:
112
- print(f"Error creating artifacts 7z file: {e}")
113
- raise e
114
-
115
-
116
- class ArtifactsArchiver:
117
- """
118
- This class manages multiple ArtifactsArchive instances.
119
- It provides a unified interface for registering artifacts to different archives.
120
- """
121
-
122
- def __init__(self) -> None:
123
- self.archives: Dict[str, ArtifactsArchive] = {}
124
- self._target_repos: Dict[str, str] = {}
125
-
126
- def add_archive(self, out_dir: Path, archive_filename: str, target_repo: Optional[str] = None, archive_name: str = "default") -> ArtifactsArchive:
127
- """
128
- Add a new archive to the archiver.
129
-
130
- Args:
131
- out_dir: Output directory for the archive
132
- archive_filename: Filename for the archive
133
- target_repo: Target repository path for Artifactory upload (optional)
134
- archive_name: Name identifier for the archive (defaults to "default")
135
-
136
- Returns:
137
- The created ArtifactsArchive instance
138
- """
139
- archive = ArtifactsArchive(out_dir, archive_filename)
140
- self.archives[archive_name] = archive
141
- # Store the target repo information for this archive only if provided
142
- if target_repo is not None:
143
- self._target_repos[archive_name] = target_repo
144
- return archive
145
-
146
- def register(self, artifacts: List[Path], archive_name: str = "default") -> None:
147
- """
148
- Register artifacts for archiving to a specific archive.
149
-
150
- Args:
151
- artifacts: List of paths to artifacts (files or directories) to be archived.
152
- archive_name: Name of the archive to register artifacts to (defaults to "default")
153
-
154
- Raises:
155
- KeyError: If the specified archive_name doesn't exist
156
- """
157
- if archive_name not in self.archives:
158
- raise KeyError(f"Archive '{archive_name}' not found. Available archives: {list(self.archives.keys())}")
159
-
160
- self.archives[archive_name].register(artifacts)
161
-
162
- def get_archive(self, archive_name: str) -> ArtifactsArchive:
163
- """
164
- Get a specific archive by name.
165
-
166
- Args:
167
- archive_name: Name of the archive to retrieve
168
-
169
- Returns:
170
- The ArtifactsArchive instance
171
-
172
- Raises:
173
- KeyError: If the specified archive_name doesn't exist
174
- """
175
- if archive_name not in self.archives:
176
- raise KeyError(f"Archive '{archive_name}' not found. Available archives: {list(self.archives.keys())}")
177
-
178
- return self.archives[archive_name]
179
-
180
- def create_all_archives(self) -> Dict[str, Path]:
181
- """
182
- Create all registered archives.
183
-
184
- Returns:
185
- Dictionary mapping archive names to their created file paths
186
- """
187
- created_archives = {}
188
- for archive_name, archive in self.archives.items():
189
- created_archives[archive_name] = archive.create_archive()
190
- return created_archives
191
-
192
- def create_rt_upload_json(self, out_dir: Path) -> Path:
193
- """
194
- Create a single rt-upload.json file containing all archives.
195
-
196
- This function replicates the logic from the Jenkinsfile for determining the RT_TARGET
197
- and creating the upload specification file. It uses Jenkins environment variables
198
- when available, otherwise falls back to default values.
199
-
200
- Args:
201
- output_dir: Directory where the rt-upload.json file will be created
202
-
203
- Returns:
204
- Path to the created rt-upload.json file
205
- """
206
- # Set local defaults first
207
- change_id = None
208
- branch_name = "local_branch"
209
- build_number = "local_build"
210
-
211
- # Adapt values when Jenkins environment is detected
212
- # TODO: check if an existing library can be used for CI context detection
213
- if os.environ.get("JENKINS_URL"):
214
- change_id = os.environ.get("CHANGE_ID")
215
- jenkins_branch_name = os.environ.get("BRANCH_NAME")
216
- jenkins_build_number = os.environ.get("BUILD_NUMBER")
217
- tag_name = os.environ.get("TAG_NAME")
218
-
219
- if change_id:
220
- # Pull request case
221
- branch_name = f"PR-{change_id}"
222
- elif tag_name:
223
- # Tag build case
224
- branch_name = tag_name
225
- elif jenkins_branch_name:
226
- # Regular branch case
227
- branch_name = jenkins_branch_name
228
-
229
- if jenkins_build_number:
230
- build_number = jenkins_build_number
231
-
232
- # Create the files array for Artifactory upload format
233
- files_array = []
234
-
235
- for archive_name, archive in self.archives.items():
236
- if archive_name in self._target_repos:
237
- target_repo = self._target_repos[archive_name]
238
-
239
- # Construct the RT target path
240
- rt_target = f"{target_repo}/{branch_name}/{build_number}/"
241
-
242
- # Add this archive to the files array
243
- files_array.append(
244
- {
245
- "pattern": archive.archive_name,
246
- "target": rt_target,
247
- "recursive": "false",
248
- "flat": "false",
249
- "regexp": "false",
250
- }
251
- )
252
-
253
- # Create the single rt-upload.json file
254
- json_path = out_dir / "rt-upload.json"
255
-
256
- spec = {"files": files_array}
257
-
258
- with open(json_path, "w") as f:
259
- json.dump(spec, f, indent=4)
260
-
261
- return json_path
262
-
263
- def list_archives(self) -> List[str]:
264
- """
265
- Get a list of all archive names.
266
-
267
- Returns:
268
- List of archive names
269
- """
270
- return list(self.archives.keys())
271
-
272
- def create_archive(self, archive_name: str = "default") -> Path:
273
- """
274
- Create a specific archive (convenience method for single-archive use case).
275
-
276
- Args:
277
- archive_name: Name of the archive to create (defaults to "default")
278
-
279
- Returns:
280
- Path to the created archive file
281
-
282
- Raises:
283
- KeyError: If the specified archive_name doesn't exist
284
- """
285
- if archive_name not in self.archives:
286
- raise KeyError(f"Archive '{archive_name}' not found. Available archives: {list(self.archives.keys())}")
287
-
288
- return self.archives[archive_name].create_archive()
289
-
290
-
291
- # Example usage:
292
- #
293
- # ## Simple single-archive use case with target repo:
294
- # archiver = ArtifactsArchiver()
295
- # archiver.add_archive(Path("./build/output"), "results.7z", "my-repo/results") # uses "default" name
296
- # archiver.register([Path("./build/test_report.xml"), Path("./build/coverage.html")]) # registers to "default"
297
- # archive_path = archiver.create_archive() # creates the "default" archive
298
- # upload_json = archiver.create_rt_upload_json(Path("./build/output"))
299
- #
300
- # ## Simple single-archive use case without target repo (archive only):
301
- # archiver = ArtifactsArchiver()
302
- # archiver.add_archive(Path("./build/output"), "results.7z") # no target repo, uses "default" name
303
- # archiver.register([Path("./build/test_report.xml"), Path("./build/coverage.html")])
304
- # archive_path = archiver.create_archive() # creates the "default" archive
305
- # # upload_json = archiver.create_rt_upload_json(Path("./build/output")) # would create empty JSON
306
- #
307
- # ## Multi-archive use case:
308
- # archiver = ArtifactsArchiver()
309
- # archiver.add_archive(Path("./build/output"), "test_results.7z", "my-repo/test-results", "test_results")
310
- # archiver.add_archive(Path("./build/output"), "coverage.7z", "my-repo/coverage", "coverage_reports")
311
- # archiver.add_archive(Path("./build/output"), "docs.7z", None, "documentation") # no target repo for docs
312
- #
313
- # archiver.register([Path("./build/test_report.xml")], "test_results")
314
- # archiver.register([Path("./build/coverage.html")], "coverage_reports")
315
- # archiver.register([Path("./build/docs/")], "documentation")
316
- #
317
- # created_files = archiver.create_all_archives()
318
- # upload_json = archiver.create_rt_upload_json(Path("./build/output")) # only includes archives with target repos
File without changes
File without changes