Coverage for /builds/svesch/cikit/src/cikit/artifacts.py: 92%

120 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"""Artifact metadata and local registry support. 

3 

4This module provides the artifact model used by cikit together with a small 

5local registry for persisting artifact metadata per logical run. Runtime 

6artifact paths are absolute, while persisted paths are stored relative to a 

7configurable artifact root for portability across workspaces. 

8 

9Author: 

10 Sven Schlender 

11Date: 

12 2026-09-26 

13""" 

14 

15import hashlib 

16import logging 

17from dataclasses import dataclass 

18from enum import StrEnum 

19from pathlib import Path 

20 

21from cikit.errors import CiToolsError 

22from cikit.json_utils import load_json_file, save_json_file 

23 

24LOGGER = logging.getLogger(__name__) 

25_ARTIFACT_FILE_SUFFIX = ".artifacts.json" 

26_HASH_CHUNK_SIZE = 1024 * 1024 

27 

28 

29class ArtifactType(StrEnum): 

30 """Supported artifact types. 

31 

32 The enum values are persisted in JSON files and therefore must remain 

33 stable across releases. 

34 """ 

35 

36 BADGE = "badge" 

37 TIMELINE = "timeline" 

38 XUNIT = "xunit" 

39 LINT = "lint" 

40 RST = "rst" 

41 FOLDER = "folder" 

42 METADATA = "meta" 

43 EXTENSION = "extension" 

44 LOG = "log" 

45 BUILD = "build" 

46 BUNDLE = "bundle" 

47 

48 

49@dataclass(frozen=True) 

50class Artifact: 

51 """Describe a registered artifact. 

52 

53 Artifact paths are absolute so instances can be used directly for file 

54 system access. Size and hash metadata are optional because directory 

55 artifacts do not provide these values by default. 

56 

57 Args: 

58 path: Absolute path identifying the artifact. 

59 target_name: Final file or directory name of the artifact. 

60 artifact_type: Type describing the artifact purpose. 

61 run: Logical run associated with the artifact. 

62 size: Optional artifact size in bytes. 

63 hash: Optional artifact hash. 

64 """ 

65 

66 path: Path 

67 target_name: str 

68 artifact_type: ArtifactType 

69 run: str 

70 size: int = None 

71 hash: str = None 

72 

73 def __post_init__(self): 

74 """Validate and normalize the artifact path. 

75 

76 Raises: 

77 CiToolsError: If the artifact path is not absolute. 

78 """ 

79 if not self.path.is_absolute(): 

80 raise CiToolsError(f"Artifact path '{self.path}' must be absolute.") 

81 

82 object.__setattr__(self, "path", self.path.resolve()) 

83 

84 

85class ArtifactRegistry: 

86 """Manage locally persisted artifact metadata. 

87 

88 Artifact metadata is stored in one JSON file per logical run below the 

89 configured cache directory. Artifact paths are persisted relative to the 

90 configured artifact root and restored as absolute paths when loaded. 

91 

92 The registry does not interpret the semantic meaning of the artifact root 

93 and has no dependency on CI environment concepts. 

94 """ 

95 

96 def __init__(self, cache_dir: Path, artifact_root: Path): 

97 """Initialize the artifact registry. 

98 

99 Args: 

100 cache_dir: Directory used to store artifact metadata. 

101 artifact_root: Base directory used to persist portable artifact paths. 

102 """ 

103 self.cache_dir = cache_dir 

104 self.artifact_root = artifact_root.resolve() 

105 

106 def add(self, artifact: Artifact) -> Artifact: 

107 """Add or update an artifact entry. 

108 

109 Existing entries with the same persisted path in the same logical run 

110 are overwritten. Size and hash metadata are stored together with the 

111 basic artifact information. 

112 

113 Args: 

114 artifact: Artifact metadata to store. 

115 

116 Returns: 

117 Stored artifact. 

118 

119 Raises: 

120 CiToolsError: If the artifact path lies outside ``artifact_root`` or 

121 metadata cannot be read or written. 

122 """ 

123 try: 

124 relative_path = artifact.path.relative_to(self.artifact_root) 

125 except ValueError as exp: 

126 raise CiToolsError(f"Artifact path '{artifact.path}' is outside artifact root " 

127 f"'{self.artifact_root}'.") from exp 

128 

129 filepath = _get_run_artifacts_filename(self.cache_dir, artifact.run) 

130 artifacts = load_json_file(filepath, description="artifact data") 

131 

132 if not isinstance(artifacts, dict): 132 ↛ 133line 132 didn't jump to line 133 because the condition on line 132 was never true

133 artifacts = {} 

134 

135 artifacts[relative_path.as_posix()] = { 

136 "target_name": artifact.target_name, 

137 "type": artifact.artifact_type.value, 

138 "size": artifact.size, 

139 "hash": artifact.hash, 

140 } 

141 

142 save_json_file(filepath, artifacts, description="artifact data") 

143 return artifact 

144 

145 def _create_artifact(self, path: str, meta: dict, run_name: str, 

146 artifact_type: ArtifactType) -> Artifact: 

147 """Create an artifact from persisted registry metadata. 

148 

149 Args: 

150 path: Persisted artifact path. 

151 meta: Persisted artifact metadata. 

152 run_name: Logical run associated with the artifact. 

153 artifact_type: Resolved artifact type. 

154 

155 Returns: 

156 Restored artifact instance. 

157 

158 Raises: 

159 CiToolsError: If required artifact metadata is missing. 

160 """ 

161 try: 

162 target_name = meta["target_name"] 

163 size = meta["size"] 

164 artifact_hash = meta["hash"] 

165 except KeyError as exp: 

166 raise CiToolsError(f"Artifact entry '{path}' is incomplete.") from exp 

167 

168 artifact_path = self._resolve_artifact_path(Path(path)) 

169 

170 return Artifact(artifact_path, target_name, artifact_type, run_name, size, artifact_hash) 

171 

172 def get(self, run: str = None, artifact_type: ArtifactType = None) -> list[Artifact]: 

173 """Return locally stored artifacts. 

174 

175 Artifacts can optionally be filtered by logical run and artifact type. 

176 If ``run`` is omitted, artifacts from all locally available runs are 

177 returned. 

178 

179 Args: 

180 run: Optional logical run name. If omitted, all runs are included. 

181 artifact_type: Optional artifact type filter. 

182 

183 Returns: 

184 Sorted list of matching artifacts with absolute paths. 

185 

186 Raises: 

187 CiToolsError: If artifact metadata cannot be read or contains invalid 

188 persisted data. 

189 """ 

190 result = [] 

191 

192 if run is not None: 

193 artifact_files = [_get_run_artifacts_filename(self.cache_dir, run)] 

194 else: 

195 artifact_files = sorted(self.cache_dir.glob(f"*{_ARTIFACT_FILE_SUFFIX}")) 

196 

197 for filepath in artifact_files: 

198 run_name = _get_run_name_from_path(filepath) 

199 artifacts = load_json_file(filepath, description="artifact data") 

200 

201 if not isinstance(artifacts, dict): 201 ↛ 202line 201 didn't jump to line 202 because the condition on line 201 was never true

202 continue 

203 

204 for path, meta in artifacts.items(): 

205 if not isinstance(meta, dict): 

206 continue 

207 

208 type_value = meta.get("type", "") 

209 

210 try: 

211 type_enum = ArtifactType(type_value) 

212 except ValueError: 

213 LOGGER.warning("Got unknown artifact type '%s'!", type_value) 

214 continue 

215 

216 if artifact_type is not None and type_enum != artifact_type: 

217 continue 

218 

219 result.append(self._create_artifact(path, meta, run_name, type_enum)) 

220 

221 return sorted(result, key=lambda item: (item.run, item.path.as_posix())) 

222 

223 def _resolve_artifact_path(self, path: Path) -> Path: 

224 """Resolve a persisted artifact path below the artifact root. 

225 

226 Args: 

227 path: Persisted artifact path relative to ``artifact_root``. 

228 

229 Returns: 

230 Absolute artifact path. 

231 

232 Raises: 

233 CiToolsError: If the persisted path is absolute or escapes the 

234 configured artifact root. 

235 """ 

236 if path.is_absolute(): 

237 raise CiToolsError(f"Persisted artifact path '{path}' must be relative.") 

238 

239 artifact_path = (self.artifact_root / path).resolve() 

240 

241 try: 

242 artifact_path.relative_to(self.artifact_root) 

243 except ValueError as exp: 

244 raise CiToolsError(f"Persisted artifact path '{path}' escapes artifact root " 

245 f"'{self.artifact_root}'.") from exp 

246 

247 return artifact_path 

248 

249 

250def calculate_artifact_hash(path: Path, artifact_type: ArtifactType) -> str: 

251 """Calculate the SHA-256 hash of an artifact. 

252 

253 Folder artifacts do not provide a hash and therefore return ``None``. 

254 File artifacts are processed incrementally to avoid loading large files 

255 completely into memory. 

256 

257 Args: 

258 path: Artifact path. 

259 artifact_type: Type describing the artifact purpose. 

260 

261 Returns: 

262 SHA-256 digest or ``None`` for folder artifacts. 

263 

264 Raises: 

265 CiToolsError: If the file cannot be read. 

266 """ 

267 if artifact_type == ArtifactType.FOLDER: 

268 return None 

269 

270 digest = hashlib.sha256() 

271 

272 try: 

273 with path.open("rb") as file: 

274 for chunk in iter(lambda: file.read(_HASH_CHUNK_SIZE), b""): 

275 digest.update(chunk) 

276 except OSError as exp: 

277 raise CiToolsError(f"Failed to calculate hash for artifact '{path}': {exp}") from exp 

278 

279 return digest.hexdigest() 

280 

281 

282def get_artifact_size(path: Path, artifact_type: ArtifactType) -> int: 

283 """Return the size of an artifact. 

284 

285 Folder artifacts do not provide a size and therefore return ``None``. 

286 File artifacts return their size in bytes. 

287 

288 Args: 

289 path: Artifact path. 

290 artifact_type: Type describing the artifact purpose. 

291 

292 Returns: 

293 File size in bytes or ``None`` for folder artifacts. 

294 

295 Raises: 

296 CiToolsError: If the file metadata cannot be read. 

297 """ 

298 if artifact_type == ArtifactType.FOLDER: 

299 return None 

300 

301 try: 

302 return path.stat().st_size 

303 except OSError as exp: 

304 raise CiToolsError(f"Failed to determine artifact size for '{path}': {exp}") from exp 

305 

306 

307def _get_run_artifacts_filename(cache_dir: Path, run: str) -> Path: 

308 """Return the artifact metadata file path for a run. 

309 

310 Args: 

311 cache_dir: Base cache directory. 

312 run: Logical run name. 

313 

314 Returns: 

315 Full path to the run artifact metadata file. 

316 """ 

317 return cache_dir / f"{run}{_ARTIFACT_FILE_SUFFIX}" 

318 

319 

320def _get_run_name_from_path(filepath: Path) -> str: 

321 """Extract the logical run name from an artifact metadata file. 

322 

323 Args: 

324 filepath: Path to an artifact metadata file. 

325 

326 Returns: 

327 Extracted logical run name. 

328 

329 Raises: 

330 CiToolsError: If the file name does not contain a run name. 

331 """ 

332 name = filepath.name 

333 

334 if name.endswith(_ARTIFACT_FILE_SUFFIX): 334 ↛ 337line 334 didn't jump to line 337 because the condition on line 334 was always true

335 run_name = name.removesuffix(_ARTIFACT_FILE_SUFFIX) 

336 else: 

337 run_name = filepath.stem 

338 

339 if not run_name: 339 ↛ 340line 339 didn't jump to line 340 because the condition on line 339 was never true

340 raise CiToolsError(f"Cannot extract run name from file '{filepath.name}'") 

341 

342 return run_name 

343 

344 

345__all__ = ["Artifact", "ArtifactRegistry", "ArtifactType", "calculate_artifact_hash"]