Coverage for /builds/svesch/cikit/src/cikit/env/api.py: 95%

230 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"""Public API for env. 

3 

4This module exposes CiEnvironment, which resolves a set of EnvVar definitions 

5against the current OS environment. It also provides small helper functions 

6useful for consumers, such as preparing directories, producing shell exports, 

7and printing resolved environment values. 

8 

9Author: 

10 Sven Schlender 

11Date: 

12 2026-02-18 

13""" 

14 

15import logging 

16import os 

17import shlex 

18import shutil 

19from pathlib import Path 

20from string import Template, hexdigits 

21from typing import Any, Iterable 

22 

23from cikit.data import RefKey 

24from cikit.artifacts import (Artifact, ArtifactRegistry, ArtifactType, get_artifact_size, 

25 calculate_artifact_hash) 

26from cikit.errors import CiToolsError, ResolvePathError 

27from cikit.factory import build_run_context_from_env 

28from cikit.command import CommandArgv 

29 

30from .provider import BaseProvider, detect_provider 

31 

32 

33LOGGER = logging.getLogger() 

34MASKED = "***" 

35CLEANUP_DIRS = ("${PUBLIC_DIR}",) 

36CREATE_DIRS = ("${PUBLIC_DIR}", "${CIKIT_DATA_DIR}",) 

37 

38 

39_ARTIFACT_TARGET_DIRS = { 

40 ArtifactType.BADGE: Path("badges"), 

41 ArtifactType.RST: Path("rst"), 

42 ArtifactType.TIMELINE: Path("artifacts"), 

43 ArtifactType.XUNIT: Path("artifacts"), 

44 ArtifactType.LINT: Path("artifacts"), 

45 ArtifactType.FOLDER: Path("artifacts"), 

46 ArtifactType.METADATA: Path("artifacts"), 

47 ArtifactType.EXTENSION: Path("artifacts"), 

48 ArtifactType.LOG: Path("artifacts"), 

49 ArtifactType.BUILD: Path("artifacts"), 

50 ArtifactType.BUNDLE: Path("artifacts"), 

51} 

52 

53 

54def _artifact_target_dir(public_dir: Path, artifact_type: ArtifactType) -> Path: 

55 """Return the public target directory for an artifact type. 

56 

57 Artifact types are mapped to fixed subdirectories below ``PUBLIC_DIR``. 

58 This keeps the persistent artifact layout independent from producer-specific 

59 output directories such as temporary badge or report locations. 

60 

61 Args: 

62 public_dir: Base directory for persistent public artifacts. 

63 artifact_type: Artifact type used to select the target subdirectory. 

64 

65 Returns: 

66 Target directory below ``public_dir``. 

67 

68 Raises: 

69 CiToolsError: If no target directory is configured for the artifact type. 

70 """ 

71 try: 

72 relative_dir = _ARTIFACT_TARGET_DIRS[artifact_type] 

73 except KeyError as exp: 

74 raise CiToolsError("No public target directory configured for artifact type " 

75 f"'{artifact_type}'.") from exp 

76 

77 return public_dir / relative_dir 

78 

79 

80def write_file(output_file: Path, data: str): 

81 """Write text data to a file using UTF-8 encoding. 

82 

83 The function writes the provided string to the given file path. 

84 Any existing file will be overwritten. Errors during writing 

85 are wrapped into a CiToolsError for consistent error handling. 

86 

87 Args: 

88 output_file: Target file path to write to. 

89 data: Text content to write into the file. 

90 

91 Raises: 

92 CiToolsError: If writing the file fails due to an OS error. 

93 """ 

94 try: 

95 output_file.write_text(data, encoding="utf-8") 

96 except OSError as exp: 

97 raise CiToolsError(f"Error while writing to '{output_file}': {exp}") from exp 

98 

99 LOGGER.debug("Written %s", output_file) 

100 

101 

102class CiEnvironment: 

103 """Resolved CI environment with provider metadata access. 

104 

105 This class resolves a set of EnvVar definitions in declaration order and 

106 stores the resulting values as a stable environment snapshot. Variables 

107 may reference previously resolved variables via ``${VAR}`` templates. 

108 

109 In addition to resolved environment values, the environment exposes 

110 provider-specific metadata such as pipeline identifiers, branch names 

111 and commit information through dedicated properties. 

112 

113 The class also provides common helpers for path resolution, command 

114 argument expansion and environment export. 

115 """ 

116 

117 def __init__(self, variables: list, *, provider: BaseProvider = None, cwd: str | Path = None): 

118 """Resolve environment variables in definition order. 

119 

120 Variables are resolved sequentially in the order they appear in 

121 ``variables``. Each variable may reference previously resolved 

122 variables via ${VAR} template syntax. 

123 

124 Args: 

125 variables: Ordered list of ``EnvVar`` definitions to resolve. 

126 provider: Optional CI provider instance. If ``None``, the provider 

127 is automatically detected. 

128 cwd: Optional default working directory for command execution. 

129 If omitted, ``PROJECT_ROOT`` is used. 

130 

131 Raises: 

132 CiToolsError: If duplicate variables are configured or required 

133 values cannot be resolved. 

134 """ 

135 self.provider = provider if provider is not None else detect_provider() 

136 

137 vars_by_key = {} 

138 for var in variables: 

139 if var.key in vars_by_key: 

140 raise CiToolsError(f"Duplicate EnvVar definition: {var.key}") 

141 vars_by_key[var.key] = var 

142 

143 self.variables = vars_by_key 

144 self.values = {} 

145 

146 os_env = dict(os.environ) 

147 for var in vars_by_key.values(): 

148 self.values[var.key] = var.resolve(os_env, self.values) 

149 

150 self.cwd = Path(cwd or self["PROJECT_ROOT"]).resolve() 

151 

152 self._warn_relative_paths() 

153 

154 def _warn_relative_paths(self): 

155 """Log a single warning for all resolved relative Path values. 

156 

157 After all environment variables have been resolved, this helper scans 

158 the final value map for Path objects that are still relative. Relative 

159 paths may lead to side effects that depend on the current working 

160 directory. 

161 """ 

162 relative = [] 

163 

164 for key, value in self.values.items(): 

165 if isinstance(value, Path) and not value.is_absolute(): 165 ↛ 166line 165 didn't jump to line 166 because the condition on line 165 was never true

166 relative.append((key, value)) 

167 

168 if not relative: 168 ↛ 171line 168 didn't jump to line 171 because the condition on line 168 was always true

169 return 

170 

171 lines = [ 

172 "Resolved environment contains relative paths.", 

173 "CI scripts are most reliable with absolute paths.", 

174 "Relative path variables:", 

175 ] 

176 lines.extend(f" - {key}: {value}" for key, value in sorted(relative)) 

177 LOGGER.warning("\n".join(lines)) 

178 

179 def _resolve_required(self, key: str) -> Any: 

180 """Return the resolved value for a required environment variable. 

181 

182 This helper only accesses values that were resolved during 

183 ``CiEnvironment`` initialization. It does not consult the provider 

184 again and therefore treats the environment as a closed value set. 

185 

186 Args: 

187 key: Environment variable name. 

188 

189 Returns: 

190 Resolved value for ``key``. 

191 

192 Raises: 

193 CiToolsError: If ``key`` is not present in the resolved environment. 

194 """ 

195 try: 

196 return self.values[key] 

197 except KeyError as exp: 

198 raise CiToolsError(f"Missing required environment variable: {key}") from exp 

199 

200 def __getitem__(self, key: str) -> Any: 

201 """Return the resolved environment value for ``key``. 

202 

203 This accessor is intended for required variables and delegates to 

204 ``_resolve_required()``. Missing keys are reported as ``CiToolsError`` 

205 instead of exposing a raw ``KeyError``. 

206 

207 Args: 

208 key: Environment variable name. 

209 

210 Returns: 

211 Resolved value associated with ``key``. 

212 

213 Raises: 

214 CiToolsError: If ``key`` is not present in the resolved environment. 

215 """ 

216 return self._resolve_required(key) 

217 

218 def get(self, key: str, default: Any = "") -> Any: 

219 """Return the resolved environment value for ``key`` or default. 

220 

221 This method returns ``default`` when the requested key is missing. 

222 It also returns ``default`` when the resolved value is ``None``. 

223 

224 Args: 

225 key: Environment variable name. 

226 default: Value returned if ``key`` is missing or resolved to ``None``. 

227 

228 Returns: 

229 Resolved value for ``key`` or ``default``. 

230 """ 

231 try: 

232 value = self._resolve_required(key) 

233 except CiToolsError: 

234 return default 

235 

236 if value is None: 

237 return default 

238 

239 return value 

240 

241 def as_dict(self, secrets: bool = False) -> dict[str, Any]: 

242 """Return resolved values as a dictionary. 

243 

244 Args: 

245 secrets: If False, secret values are replaced by ``MASKED``. 

246 

247 Returns: 

248 Mapping of variable keys to resolved values. 

249 """ 

250 return {var.key: (self.values[var.key] if secrets or not var.secret else MASKED) 

251 for var in self.variables.values()} 

252 

253 def as_str_map(self, secrets: bool = True) -> dict[str, str]: 

254 """Return resolved environment values as a string-only mapping. 

255 

256 This method converts all resolved values to strings and replaces 

257 ``None`` with an empty string. It is intended for command execution 

258 and template expansion. 

259 

260 Args: 

261 secrets: If True, include secret values. 

262 

263 Returns: 

264 Mapping of environment variable names to string values. 

265 """ 

266 values = self.as_dict(secrets=secrets) 

267 out = {} 

268 

269 for key, val in values.items(): 

270 out[key] = "" if val is None else str(val) 

271 

272 return out 

273 

274 def argv(self, cmd, subcommand: str = None, extra_args: list = None, 

275 no_common: bool = False) -> CommandArgv: 

276 """Build environment-aware command arguments. 

277 

278 The command argv is built via ``Command.argv()`` and resolved twice: 

279 once with all environment values for process execution and once with 

280 secret values masked for safe display. 

281 

282 If enabled on the Command, ``EXEC_TOOL`` is prefixed to both argument 

283 representations. 

284 

285 Args: 

286 cmd: Command definition used to build argv. 

287 subcommand: Optional subcommand name. 

288 extra_args: Optional additional command arguments. 

289 no_common: If True, do not include command common arguments. 

290 

291 Returns: 

292 Resolved command arguments for execution and safe display. 

293 """ 

294 argv = cmd.argv(subcommand, extra_args, no_common) 

295 

296 exec_values = self.as_str_map(secrets=True) 

297 masked_values = self.as_str_map(secrets=False) 

298 

299 exec_argv = [Template(str(arg)).safe_substitute(exec_values) for arg in argv] 

300 masked_argv = [Template(str(arg)).safe_substitute(masked_values) for arg in argv] 

301 

302 if cmd.use_exec_tool: 

303 prefix = self.get("EXEC_TOOL") 

304 if prefix: 304 ↛ 309line 304 didn't jump to line 309 because the condition on line 304 was always true

305 prefix_argv = shlex.split(str(prefix)) 

306 exec_argv = prefix_argv + exec_argv 

307 masked_argv = prefix_argv + masked_argv 

308 

309 return CommandArgv(exec_argv, masked_argv) 

310 

311 def resolve_path(self, path) -> Path: 

312 """Resolve an environment-expanded path relative to PROJECT_ROOT. 

313 

314 Args: 

315 path: Absolute path, relative path, or path template. 

316 

317 Returns: 

318 Resolved absolute path. 

319 

320 Raises: 

321 ResolvePathError: If a referenced environment variable is unknown. 

322 """ 

323 try: 

324 resolved = Template(str(path)).substitute(self.as_str_map(secrets=True)) 

325 except KeyError as exp: 

326 raise ResolvePathError(f"Unresolved path template variable: {exp}") from exp 

327 

328 resolved_path = Path(resolved) 

329 

330 if resolved_path.is_absolute(): 

331 return resolved_path 

332 

333 return self["PROJECT_ROOT"] / resolved_path 

334 

335 @property 

336 def pipeline_id(self) -> str: 

337 """Return the provider-specific pipeline identifier. 

338 

339 Returns: 

340 Provider-specific pipeline identifier. 

341 """ 

342 return self.provider.pipeline_id 

343 

344 @property 

345 def pipeline_alias(self) -> str: 

346 """Return the provider-specific pipeline alias. 

347 

348 Returns: 

349 Provider-specific human-readable pipeline identifier. 

350 """ 

351 return self.provider.pipeline_alias 

352 

353 @property 

354 def branch_name(self) -> str: 

355 """Return the active branch name. 

356 

357 Returns: 

358 Active branch name. 

359 """ 

360 return self.provider.branch_name 

361 

362 @property 

363 def commit_sha(self) -> str: 

364 """Return the full commit SHA. 

365 

366 Returns: 

367 Full commit SHA. 

368 """ 

369 return self.provider.commit_sha 

370 

371 @property 

372 def commit_short_sha(self) -> str: 

373 """Return the short commit SHA. 

374 

375 Returns: 

376 Short commit SHA. 

377 """ 

378 return self.provider.commit_short_sha 

379 

380 @property 

381 def commit_title(self) -> str: 

382 """Return the commit title. 

383 

384 Returns: 

385 Commit title. 

386 """ 

387 return self.provider.commit_title 

388 

389 @property 

390 def commit_message(self) -> str: 

391 """Return the full commit message. 

392 

393 Returns: 

394 Full commit message. 

395 """ 

396 return self.provider.commit_message 

397 

398 @property 

399 def commit_author(self) -> str: 

400 """Return the commit author. 

401 

402 Returns: 

403 Commit author. 

404 """ 

405 return self.provider.commit_author 

406 

407 @property 

408 def commit_timestamp(self) -> str: 

409 """Return the commit timestamp. 

410 

411 Returns: 

412 Commit timestamp. 

413 """ 

414 return self.provider.commit_timestamp 

415 

416 

417class CiRunEnvironment(CiEnvironment): 

418 """CI environment with run data and artifact support. 

419 

420 This class extends ``CiEnvironment`` with a run-bound ``RunContext`` for 

421 cikit data operations and a local artifact registry. It provides helpers 

422 for updating run metrics, managing artifacts, and querying timelines while 

423 reusing all generic environment functionality. 

424 """ 

425 

426 def __init__(self, variables: list, *, provider: BaseProvider = None, run_name: str, 

427 cwd: str | Path = None): 

428 """Initialize a CI environment with run data and artifact support. 

429 

430 This environment extends the base ``CiEnvironment`` by binding a 

431 ``RunContext`` to a logical run and creating an ``ArtifactRegistry`` for 

432 persisted artifact metadata. The run context provides access to cikit data 

433 operations, while the artifact registry resolves persisted artifact paths 

434 relative to ``PUBLIC_DIR``. 

435 

436 Args: 

437 variables: List of environment variable definitions to resolve. 

438 provider: Optional CI provider used for provider metadata. 

439 run_name: Logical name of the run used to create and identify the 

440 associated run context. 

441 cwd: Optional default working directory for command execution. 

442 If omitted, ``PROJECT_ROOT`` is used. 

443 

444 Raises: 

445 CiToolsError: If the run context or artifact registry cannot be created from the 

446 resolved environment. 

447 """ 

448 super().__init__(variables, provider=provider, cwd=cwd) 

449 

450 self.run = build_run_context_from_env(self, run_name) 

451 self._artifacts = ArtifactRegistry(self["CIKIT_DATA_DIR"], self["PUBLIC_DIR"]) 

452 

453 @property 

454 def name(self) -> str: 

455 """Return the logical run name. 

456 

457 Returns: 

458 Logical run name bound to this environment. 

459 """ 

460 return self.run.run_name 

461 

462 def data_set(self, key: str, value): 

463 """Set a single cikit data entry for the active run. 

464 

465 Stores a key-value pair for the active run. If the key already exists, 

466 its value is overwritten and persisted through the configured cikit 

467 data backend. 

468 

469 Args: 

470 key: Name of the data field. 

471 value: Value to store for the given key. 

472 

473 Returns: 

474 Parsed backend response or merged local run data. 

475 """ 

476 return self.run.set(key, value) 

477 

478 def data_update(self, data: dict): 

479 """Merge cikit data values and persist them immediately. 

480 

481 The provided mapping is merged into the current run data state. 

482 Existing keys are overwritten. The update is persisted through the 

483 configured cikit data backend. 

484 

485 Args: 

486 data: Mapping containing data fields to update. 

487 

488 Returns: 

489 Parsed backend response or merged local run data. 

490 """ 

491 return self.run.update(data) 

492 

493 @property 

494 def data(self) -> dict: 

495 """Return a copy of the current run data mapping. 

496 

497 The returned dictionary is a shallow copy of the locally cached run 

498 data. Modifying the returned mapping does not affect the stored data 

499 and does not trigger a backend update. 

500 

501 Returns: 

502 Copy of the current run data mapping. 

503 """ 

504 return self.run.get_data() 

505 

506 def copy_artifact(self, path: Path, artifact_type: ArtifactType, 

507 target_name: str = None, artifact_hash: str = None) -> Artifact: 

508 """Copy an artifact to its public target directory and register it. 

509 

510 Relative source paths are resolved below ``PROJECT_ROOT``. Absolute source 

511 paths may point to any accessible location, including temporary directories. 

512 Artifact size and hash metadata are determined after copying. A supplied 

513 hash is preserved, otherwise a SHA-256 hash is calculated automatically. 

514 

515 Args: 

516 path: Source artifact path, absolute or relative to ``PROJECT_ROOT``. 

517 artifact_type: Type used to select the public target directory. 

518 target_name: Optional target file or directory name. If omitted, the 

519 source name is preserved. 

520 artifact_hash: Optional precomputed SHA-256 hash. 

521 

522 Returns: 

523 Registered artifact. 

524 

525 Raises: 

526 CiToolsError: If the source does not exist, the supplied hash is not a valid 

527 SHA-256 digest, or the artifact cannot be copied, inspected, hashed, or 

528 registered. 

529 """ 

530 project_root = self["PROJECT_ROOT"] 

531 public_dir = self["PUBLIC_DIR"] 

532 

533 source = path if path.is_absolute() else project_root / path 

534 source = source.resolve() 

535 

536 if not source.exists(): 

537 raise CiToolsError(f"Artifact path '{source}' does not exist.") 

538 

539 if artifact_hash is not None: 

540 if len(artifact_hash) != 64 or any(char not in hexdigits for char in artifact_hash): 

541 raise CiToolsError("Invalid artifact hash, expected a 64-character SHA-256 digest.") 

542 

543 artifact_hash = artifact_hash.lower() 

544 

545 target_dir = _artifact_target_dir(public_dir, artifact_type) 

546 target_dir.mkdir(parents=True, exist_ok=True) 

547 

548 target = target_dir / (target_name or source.name) 

549 

550 try: 

551 if source.is_dir(): 

552 shutil.copytree(source, target, dirs_exist_ok=True) 

553 else: 

554 target.parent.mkdir(parents=True, exist_ok=True) 

555 shutil.copy2(source, target) 

556 except OSError as exp: 

557 raise CiToolsError(f"Failed to copy artifact '{source}' to '{target}': {exp}") from exp 

558 

559 size = get_artifact_size(target, artifact_type) 

560 

561 if artifact_hash is None: 

562 artifact_hash = calculate_artifact_hash(target, artifact_type) 

563 

564 artifact = Artifact(target, target.name, artifact_type, self.name, size, artifact_hash) 

565 self._artifacts.add(artifact) 

566 

567 LOGGER.info("Registered %s artifact '%s'", artifact_type.value, target.name) 

568 

569 return artifact 

570 

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

572 """Return registered artifacts. 

573 

574 Args: 

575 run: Optional logical run name. If omitted, artifacts from all runs 

576 are returned. 

577 artifact_type: Optional artifact type filter. 

578 

579 Returns: 

580 Sorted list of matching artifacts. 

581 

582 Raises: 

583 CiToolsError: If artifact metadata cannot be read. 

584 """ 

585 return self._artifacts.get(run, artifact_type) 

586 

587 def get_run_artifacts(self, artifact_type: ArtifactType = None) -> list[Artifact]: 

588 """Return artifacts registered for the active run. 

589 

590 This is a convenience wrapper around ``get_artifacts()`` that automatically 

591 uses the logical run bound to this ``CiRunEnvironment``. It is intended for 

592 callers that operate on the current run and therefore should not need to 

593 pass ``env.name`` explicitly. 

594 

595 Args: 

596 artifact_type: Optional artifact type filter. If omitted, all artifact 

597 types registered for the active run are returned. 

598 

599 Returns: 

600 Sorted list of artifacts registered for the active run. 

601 

602 Raises: 

603 CiToolsError: If artifact metadata cannot be read. 

604 """ 

605 return self.get_artifacts(self.name, artifact_type) 

606 

607 def get_artifact(self, target_name: str, run: str = None, 

608 artifact_type: ArtifactType = None) -> Artifact: 

609 """Return one registered artifact. 

610 

611 Args: 

612 target_name: Registered artifact file or directory name. 

613 run: Optional logical run name. If omitted, all runs are searched. 

614 artifact_type: Optional artifact type used to restrict the search. 

615 

616 Returns: 

617 Matching artifact. 

618 

619 Raises: 

620 CiToolsError: If no matching artifact is found, the result is ambiguous, 

621 or artifact metadata cannot be read. 

622 """ 

623 artifacts = self.get_artifacts(run, artifact_type) 

624 matches = [artifact for artifact in artifacts if artifact.target_name == target_name] 

625 

626 if not matches: 

627 raise CiToolsError(f"Artifact '{target_name}' not found.") 

628 

629 if len(matches) > 1: 

630 raise CiToolsError(f"Artifact '{target_name}' is ambiguous.") 

631 

632 return matches[0] 

633 

634 def get_run_artifact(self, target_name: str, artifact_type: ArtifactType = None) -> Artifact: 

635 """Return one artifact registered for the active run. 

636 

637 This is a convenience wrapper around ``get_artifact()`` that restricts the 

638 lookup to the logical run bound to this ``CiRunEnvironment``. It avoids 

639 repeating ``env.name`` at call sites while preserving the general 

640 ``get_artifact()`` behavior where ``run=None`` searches across all runs. 

641 

642 Args: 

643 target_name: Registered artifact file or directory name. 

644 artifact_type: Optional artifact type used to restrict the lookup. 

645 

646 Returns: 

647 Matching artifact registered for the active run. 

648 

649 Raises: 

650 CiToolsError: If no matching artifact is found, the result is 

651 ambiguous, or artifact metadata cannot be read. 

652 """ 

653 return self.get_artifact(target_name, self.name, artifact_type) 

654 

655 def timeline(self, keys: list[str], ref_key: RefKey = RefKey.INDEX_DESC, 

656 limit: int = None) -> dict: 

657 """Return timeline data for multiple keys of the active run. 

658 

659 This convenience wrapper queries the bound RunContext for the given 

660 cikit data keys of the current logical run. The returned structure can 

661 be used directly for chart generation. 

662 

663 Args: 

664 keys: List of cikit data keys to query. 

665 ref_key: Reference key used for the X-axis. 

666 limit: Optional maximum number of timeline entries returned by the API. 

667 

668 Returns: 

669 Parsed timeline response containing ``refs`` and ``series``. 

670 

671 Raises: 

672 CiRecoverableError: If no remote cikit data access is configured. 

673 CiToolsError: If the timeline query fails. 

674 """ 

675 return self.run.timeline(keys, ref_key, limit) 

676 

677 

678def prepare_dirs(env: CiEnvironment): 

679 """Create required output directories and clean selected directories. 

680 

681 Args: 

682 env: Resolved CI environment. 

683 """ 

684 for directory in CLEANUP_DIRS: 

685 try: 

686 path = env.resolve_path(directory) 

687 except ResolvePathError as exp: 

688 LOGGER.warning("Skipping cleanup directory: %s", exp) 

689 continue 

690 

691 if path.exists(): 

692 LOGGER.info("Removing dir '%s'", path) 

693 shutil.rmtree(path) 

694 

695 for directory in CREATE_DIRS: 

696 try: 

697 path = env.resolve_path(directory) 

698 except ResolvePathError as exp: 

699 LOGGER.warning("Skipping create directory: %s", exp) 

700 continue 

701 

702 path.mkdir(parents=True, exist_ok=True) 

703 

704 

705def shell_export_lines(env: CiEnvironment, secrets: bool = False) -> Iterable[str]: 

706 """Yield shell export lines for resolved environment variables. 

707 

708 Args: 

709 env: Resolved CI environment. 

710 secrets: If True, include secret values. 

711 

712 Yields: 

713 Export lines suitable for shell evaluation. 

714 """ 

715 for var in env.variables.values(): 

716 if var.secret and not secrets: 

717 continue 

718 

719 val = env.values.get(var.key) 

720 if val is None: 720 ↛ 721line 720 didn't jump to line 721 because the condition on line 720 was never true

721 yield f"export {var.key}=" 

722 else: 

723 yield f"export {var.key}={shlex.quote(str(val))}" 

724 

725 

726def pretty_print_env(env: CiEnvironment, secrets: bool = False): 

727 """Print environment and provider values in a human-readable format. 

728 

729 Args: 

730 env: Resolved CI environment. 

731 secrets: If True, include secret environment values. 

732 """ 

733 def _render_value(value) -> str: 

734 if isinstance(value, str): 

735 return f'"{value}"' 

736 if value is None: 736 ↛ 737line 736 didn't jump to line 737 because the condition on line 736 was never true

737 return "" 

738 return str(value) 

739 

740 def _print_block(title: str, values: dict): 

741 LOGGER.info(title) 

742 LOGGER.info("-" * len(title)) 

743 

744 width = max((len(key) for key in values), default=0) 

745 for key in sorted(values): 

746 LOGGER.info("%-*s %s", width, key, _render_value(values[key])) 

747 

748 LOGGER.info("") 

749 

750 LOGGER.info("Provider: %s", env.provider) 

751 LOGGER.info("") 

752 

753 _print_block("Environment", env.as_dict(secrets=secrets)) 

754 

755 provider_values = { 

756 "pipeline_id": env.pipeline_id, 

757 "pipeline_alias": env.pipeline_alias, 

758 "branch_name": env.branch_name, 

759 "commit_sha": env.commit_sha, 

760 "commit_short_sha": env.commit_short_sha, 

761 "commit_title": env.commit_title, 

762 "commit_message": env.commit_message, 

763 "commit_author": env.commit_author, 

764 "commit_timestamp": env.commit_timestamp, 

765 } 

766 

767 _print_block("Provider Metadata", provider_values)