Coverage for src / moai_adk / core / project / phase_executor.py: 15.59%

263 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-20 20:52 +0900

1# type: ignore 

2"""Phase-based installation executor (SPEC-INIT-003 v0.4.2) 

3 

4Runs the project initialization across five phases: 

5- Phase 1: Preparation (create single backup at .moai-backups/backup/) 

6- Phase 2: Directory (build directory structure) 

7- Phase 3: Resource (copy templates while preserving user content) 

8- Phase 4: Configuration (generate configuration files) 

9- Phase 5: Validation (verify and finalize) 

10 

11Test coverage includes 5-phase integration tests with backup, configuration, and validation 

12""" 

13 

14import json 

15import logging 

16import platform 

17import shutil 

18import subprocess 

19from collections.abc import Callable 

20from datetime import datetime 

21from pathlib import Path 

22from typing import Any 

23 

24from rich.console import Console 

25 

26from moai_adk import __version__ 

27from moai_adk.core.project.backup_utils import ( 

28 get_backup_targets, 

29 has_any_moai_files, 

30 is_protected_path, 

31) 

32from moai_adk.core.project.validator import ProjectValidator 

33from moai_adk.core.template.processor import TemplateProcessor 

34from moai_adk.statusline.version_reader import VersionConfig, VersionReader 

35 

36console = Console() 

37 

38# Progress callback type alias 

39ProgressCallback = Callable[[str, int, int], None] 

40 

41 

42class PhaseExecutor: 

43 """Execute the installation across the five phases. 

44 

45 Phases: 

46 1. Preparation: Back up and verify the system. 

47 2. Directory: Create the directory structure. 

48 3. Resource: Copy template resources. 

49 4. Configuration: Generate configuration files. 

50 5. Validation: Perform final checks. 

51 

52 Enhanced with improved version reading and context management. 

53 """ 

54 

55 # Required directory structure 

56 REQUIRED_DIRECTORIES = [ 

57 ".moai/", 

58 ".moai/project/", 

59 ".moai/specs/", 

60 ".moai/reports/", 

61 ".moai/memory/", 

62 ".claude/", 

63 ".claude/logs/", 

64 ".github/", 

65 ] 

66 

67 def __init__(self, validator: ProjectValidator) -> None: 

68 """Initialize the executor. 

69 

70 Args: 

71 validator: Project validation helper. 

72 """ 

73 self.validator = validator 

74 self.total_phases = 5 

75 self.current_phase = 0 

76 self._version_reader: VersionReader | None = None 

77 

78 def _get_version_reader(self) -> VersionReader: 

79 """ 

80 Get or create version reader instance. 

81 

82 Returns: 

83 VersionReader instance with enhanced configuration 

84 """ 

85 if self._version_reader is None: 

86 config = VersionConfig( 

87 cache_ttl_seconds=120, # Longer cache for phase execution 

88 fallback_version=__version__, 

89 version_format_regex=r"^v?(\d+\.\d+\.\d+(-[a-zA-Z0-9]+)?)$", 

90 cache_enabled=True, 

91 debug_mode=False, 

92 ) 

93 self._version_reader = VersionReader(config) 

94 return self._version_reader 

95 

96 def _get_enhanced_version_context(self) -> dict[str, str]: 

97 """ 

98 Get enhanced version context with fallback strategies and comprehensive configuration. 

99 

100 Returns: 

101 Dictionary containing version-related template variables with enhanced formatting 

102 """ 

103 version_context = {} 

104 logger = logging.getLogger(__name__) 

105 

106 try: 

107 version_reader = self._get_version_reader() 

108 moai_version = version_reader.get_version() 

109 

110 # Enhanced version context with multiple format options 

111 version_context["MOAI_VERSION"] = moai_version 

112 version_context["MOAI_VERSION_SHORT"] = self._format_short_version( 

113 moai_version 

114 ) 

115 version_context["MOAI_VERSION_DISPLAY"] = self._format_display_version( 

116 moai_version 

117 ) 

118 version_context["MOAI_VERSION_TRIMMED"] = self._format_trimmed_version( 

119 moai_version, max_length=10 

120 ) 

121 version_context["MOAI_VERSION_SEMVER"] = self._format_semver_version( 

122 moai_version 

123 ) 

124 version_context["MOAI_VERSION_VALID"] = ( 

125 "true" if moai_version != "unknown" else "false" 

126 ) 

127 version_context["MOAI_VERSION_SOURCE"] = self._get_version_source( 

128 version_reader 

129 ) 

130 

131 # Add performance metrics for debugging 

132 cache_age = version_reader.get_cache_age_seconds() 

133 if cache_age is not None: 

134 version_context["MOAI_VERSION_CACHE_AGE"] = f"{cache_age:.2f}s" 

135 else: 

136 version_context["MOAI_VERSION_CACHE_AGE"] = "uncached" 

137 

138 except Exception as e: 

139 logger.warning(f"Failed to read version for context: {e}") 

140 # Use fallback version with comprehensive fallback formatting 

141 fallback_version = __version__ 

142 version_context["MOAI_VERSION"] = fallback_version 

143 version_context["MOAI_VERSION_SHORT"] = self._format_short_version( 

144 fallback_version 

145 ) 

146 version_context["MOAI_VERSION_DISPLAY"] = self._format_display_version( 

147 fallback_version 

148 ) 

149 version_context["MOAI_VERSION_TRIMMED"] = self._format_trimmed_version( 

150 fallback_version, max_length=10 

151 ) 

152 version_context["MOAI_VERSION_SEMVER"] = self._format_semver_version( 

153 fallback_version 

154 ) 

155 version_context["MOAI_VERSION_VALID"] = "true" 

156 version_context["MOAI_VERSION_SOURCE"] = "fallback_package" 

157 version_context["MOAI_VERSION_CACHE_AGE"] = "unavailable" 

158 

159 return version_context 

160 

161 def _format_short_version(self, version: str) -> str: 

162 """ 

163 Format short version by removing 'v' prefix if present. 

164 

165 Args: 

166 version: Version string 

167 

168 Returns: 

169 Short version string 

170 """ 

171 return version[1:] if version.startswith("v") else version 

172 

173 def _format_display_version(self, version: str) -> str: 

174 """ 

175 Format display version with proper formatting. 

176 

177 Args: 

178 version: Version string 

179 

180 Returns: 

181 Display version string 

182 """ 

183 if version == "unknown": 

184 return "MoAI-ADK unknown version" 

185 elif version.startswith("v"): 

186 return f"MoAI-ADK {version}" 

187 else: 

188 return f"MoAI-ADK v{version}" 

189 

190 def _format_trimmed_version(self, version: str, max_length: int = 10) -> str: 

191 """ 

192 Format version with maximum length, suitable for UI displays. 

193 

194 Args: 

195 version: Version string 

196 max_length: Maximum allowed length for the version string 

197 

198 Returns: 

199 Trimmed version string 

200 """ 

201 if version == "unknown": 

202 return "unknown" 

203 

204 # Remove 'v' prefix for trimming 

205 clean_version = version[1:] if version.startswith("v") else version 

206 

207 # Trim if necessary 

208 if len(clean_version) > max_length: 

209 return clean_version[:max_length] 

210 return clean_version 

211 

212 def _format_semver_version(self, version: str) -> str: 

213 """ 

214 Format version as semantic version with major.minor.patch structure. 

215 

216 Args: 

217 version: Version string 

218 

219 Returns: 

220 Semantic version string 

221 """ 

222 if version == "unknown": 

223 return "0.0.0" 

224 

225 # Remove 'v' prefix and extract semantic version 

226 clean_version = version[1:] if version.startswith("v") else version 

227 

228 # Extract core semantic version (remove pre-release and build metadata) 

229 import re 

230 

231 semver_match = re.match(r"^(\d+\.\d+\.\d+)", clean_version) 

232 if semver_match: 

233 return semver_match.group(1) 

234 return "0.0.0" 

235 

236 def _get_version_source(self, version_reader: VersionReader) -> str: 

237 """ 

238 Determine the source of the version information. 

239 

240 Args: 

241 version_reader: VersionReader instance 

242 

243 Returns: 

244 String indicating version source 

245 """ 

246 config = version_reader.get_config() 

247 

248 # Check if we have a cached version (most likely from config) 

249 cache_age = version_reader.get_cache_age_seconds() 

250 if cache_age is not None and cache_age < config.cache_ttl_seconds: 

251 return "config_cached" 

252 elif cache_age is not None: 

253 return "config_stale" 

254 else: 

255 return config.fallback_version 

256 

257 def execute_preparation_phase( 

258 self, 

259 project_path: Path, 

260 backup_enabled: bool = True, 

261 progress_callback: ProgressCallback | None = None, 

262 ) -> None: 

263 """Phase 1: preparation and backup. 

264 

265 Args: 

266 project_path: Project path. 

267 backup_enabled: Whether backups are enabled. 

268 progress_callback: Optional progress callback. 

269 """ 

270 self.current_phase = 1 

271 self._report_progress("Phase 1: Preparation and backup...", progress_callback) 

272 

273 # Validate system requirements 

274 self.validator.validate_system_requirements() 

275 

276 # Verify the project path 

277 self.validator.validate_project_path(project_path) 

278 

279 # Create a backup when needed 

280 if backup_enabled and has_any_moai_files(project_path): 

281 self._create_backup(project_path) 

282 

283 def execute_directory_phase( 

284 self, 

285 project_path: Path, 

286 progress_callback: ProgressCallback | None = None, 

287 ) -> None: 

288 """Phase 2: create directories. 

289 

290 Args: 

291 project_path: Project path. 

292 progress_callback: Optional progress callback. 

293 """ 

294 self.current_phase = 2 

295 self._report_progress( 

296 "Phase 2: Creating directory structure...", progress_callback 

297 ) 

298 

299 for directory in self.REQUIRED_DIRECTORIES: 

300 dir_path = project_path / directory 

301 dir_path.mkdir(parents=True, exist_ok=True) 

302 

303 def execute_resource_phase( 

304 self, 

305 project_path: Path, 

306 config: dict[str, str] | None = None, 

307 progress_callback: ProgressCallback | None = None, 

308 ) -> list[str]: 

309 """Phase 3: install resources with variable substitution. 

310 

311 Args: 

312 project_path: Project path. 

313 config: Configuration dictionary for template variable substitution. 

314 progress_callback: Optional progress callback. 

315 

316 Returns: 

317 List of created files or directories. 

318 """ 

319 import stat 

320 

321 self.current_phase = 3 

322 self._report_progress("Phase 3: Installing resources...", progress_callback) 

323 

324 # Copy resources via TemplateProcessor in silent mode 

325 processor = TemplateProcessor(project_path) 

326 

327 # Set template variable context (if provided) 

328 if config: 

329 language_config: dict[str, Any] = config.get("language", {}) 

330 if not isinstance(language_config, dict): 

331 language_config = {} 

332 

333 # Detect OS for cross-platform Hook path configuration 

334 hook_project_dir = ( 

335 "%CLAUDE_PROJECT_DIR%" 

336 if platform.system() == "Windows" 

337 else "$CLAUDE_PROJECT_DIR" 

338 ) 

339 

340 # Get enhanced version context with fallback strategies 

341 version_context = self._get_enhanced_version_context() 

342 

343 context = { 

344 **version_context, 

345 "CREATION_TIMESTAMP": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), 

346 "PROJECT_NAME": config.get("name", "unknown"), 

347 "PROJECT_DESCRIPTION": config.get("description", ""), 

348 "PROJECT_MODE": config.get("mode", "personal"), 

349 "PROJECT_VERSION": config.get("version", "0.1.0"), 

350 "PROJECT_OWNER": config.get("author", "@user"), 

351 "AUTHOR": config.get("author", "@user"), 

352 "CONVERSATION_LANGUAGE": language_config.get( 

353 "conversation_language", "en" 

354 ), 

355 "CONVERSATION_LANGUAGE_NAME": language_config.get( 

356 "conversation_language_name", "English" 

357 ), 

358 "CODEBASE_LANGUAGE": config.get("language", "generic"), 

359 "PROJECT_DIR": hook_project_dir, 

360 } 

361 processor.set_context(context) 

362 

363 processor.copy_templates( 

364 backup=False, silent=True 

365 ) # Avoid progress bar conflicts 

366 

367 # Post-process: Set executable permission on shell scripts 

368 # This is necessary because git may not preserve file permissions during clone/checkout 

369 scripts_dir = project_path / ".moai" / "scripts" 

370 logger = logging.getLogger(__name__) 

371 if scripts_dir.exists(): 

372 logger.debug(f"Processing shell scripts in {scripts_dir}") 

373 for script_file in scripts_dir.glob("*.sh"): 

374 try: 

375 # Add execute permission for user, group, and others 

376 current_mode = script_file.stat().st_mode 

377 new_mode = current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH 

378 script_file.chmod(new_mode) 

379 logger.debug(f"Set executable permission on {script_file}: {oct(current_mode)} -> {oct(new_mode)}") 

380 except Exception as e: 

381 logger.warning(f"Failed to set executable permission on {script_file}: {e}") 

382 else: 

383 logger.debug(f"Scripts directory not found: {scripts_dir}") 

384 

385 # Return a simplified list of generated assets 

386 return [ 

387 ".claude/", 

388 ".moai/", 

389 ".github/", 

390 "CLAUDE.md", 

391 ".gitignore", 

392 ] 

393 

394 def execute_configuration_phase( 

395 self, 

396 project_path: Path, 

397 config: dict[str, str | bool | dict[Any, Any]], 

398 progress_callback: ProgressCallback | None = None, 

399 ) -> list[str]: 

400 """Phase 4: generate configuration. 

401 

402 Args: 

403 project_path: Project path. 

404 config: Configuration dictionary. 

405 progress_callback: Optional progress callback. 

406 

407 Returns: 

408 List of created files. 

409 """ 

410 self.current_phase = 4 

411 self._report_progress( 

412 "Phase 4: Generating configurations...", progress_callback 

413 ) 

414 

415 logger = logging.getLogger(__name__) 

416 

417 # Read existing config to preserve user settings (Issue #165) 

418 config_path = project_path / ".moai" / "config" / "config.json" 

419 existing_config: dict[str, Any] = {} 

420 if config_path.exists(): 

421 try: 

422 with open(config_path, "r", encoding="utf-8") as f: 

423 existing_config = json.load(f) 

424 logger.debug(f"Successfully read existing config from {config_path}") 

425 except (json.JSONDecodeError, OSError) as e: 

426 logger.warning(f"Failed to read existing config: {e}. Starting fresh.") 

427 existing_config = {} 

428 

429 # Enhanced config merging with comprehensive version preservation 

430 merged_config = self._merge_configuration_preserving_versions( 

431 config, existing_config 

432 ) 

433 

434 # Enhanced version handling using VersionReader for consistency 

435 try: 

436 version_reader = self._get_version_reader() 

437 current_config_version = version_reader.get_version() 

438 

439 # Ensure version consistency across the merged config 

440 self._ensure_version_consistency( 

441 merged_config, current_config_version, existing_config 

442 ) 

443 

444 logger.debug( 

445 f"Version consistency check completed. Current version: {current_config_version}" 

446 ) 

447 except Exception as e: 

448 logger.warning( 

449 f"Version consistency check failed: {e}. Using fallback version." 

450 ) 

451 merged_config["moai"]["version"] = __version__ 

452 

453 # Write final config with enhanced formatting 

454 self._write_configuration_file(config_path, merged_config) 

455 logger.info(f"Configuration file written to {config_path}") 

456 

457 return [str(config_path)] 

458 

459 def _merge_configuration_preserving_versions( 

460 self, new_config: dict[str, Any], existing_config: dict[str, Any] 

461 ) -> dict[str, Any]: 

462 """ 

463 Merge configurations while preserving user settings and version information. 

464 

465 Args: 

466 new_config: New configuration from initialization 

467 existing_config: Existing configuration from project 

468 

469 Returns: 

470 Merged configuration dictionary 

471 """ 

472 logger = logging.getLogger(__name__) 

473 merged_config = new_config.copy() 

474 

475 # Define configuration sections with their merge strategies 

476 config_sections = { 

477 "moai": {"preserve_all": True, "priority": "user"}, 

478 "user": {"preserve_keys": ["nickname"], "priority": "user"}, 

479 "language": { 

480 "preserve_keys": [], 

481 "priority": "new", 

482 }, # Use new language config during init 

483 "project": {"preserve_keys": [], "priority": "new"}, 

484 "git": {"preserve_keys": [], "priority": "new"}, 

485 } 

486 

487 for section_name, strategy in config_sections.items(): 

488 if section_name in existing_config: 

489 logger.debug(f"Merging section: {section_name}") 

490 self._merge_config_section( 

491 merged_config, existing_config, section_name, strategy 

492 ) 

493 

494 return merged_config 

495 

496 def _merge_config_section( 

497 self, 

498 merged_config: dict[str, Any], 

499 existing_config: dict[str, Any], 

500 section_name: str, 

501 strategy: dict[str, Any], 

502 ) -> None: 

503 """ 

504 Merge a specific configuration section. 

505 

506 Args: 

507 merged_config: Target configuration to merge into 

508 existing_config: Source configuration to merge from 

509 section_name: Name of the section to merge 

510 strategy: Merge strategy for this section 

511 """ 

512 logger = logging.getLogger(__name__) 

513 if section_name not in merged_config: 

514 merged_config[section_name] = {} 

515 

516 section_config = merged_config[section_name] 

517 existing_section = existing_config[section_name] 

518 

519 if strategy["priority"] == "user": 

520 # User priority: preserve existing values 

521 preserve_keys = strategy.get("preserve_keys", []) 

522 # Convert frozenset to list if needed 

523 if isinstance(preserve_keys, frozenset): 

524 preserve_keys = list(preserve_keys) 

525 elif not isinstance(preserve_keys, list): 

526 preserve_keys = list(preserve_keys) if preserve_keys else [] 

527 

528 for key, value in existing_section.items(): 

529 if strategy.get("preserve_all", False) or key in preserve_keys: 

530 section_config[key] = value 

531 logger.debug(f"Preserved {section_name}.{key} = {value}") 

532 else: 

533 # New priority: keep new config, but don't overwrite if exists 

534 for key, value in existing_section.items(): 

535 if key not in section_config: 

536 section_config[key] = value 

537 logger.debug(f"Inherited {section_name}.{key} = {value}") 

538 

539 def _ensure_version_consistency( 

540 self, 

541 config: dict[str, Any], 

542 current_version: str, 

543 existing_config: dict[str, Any], 

544 ) -> None: 

545 """ 

546 Ensure version consistency across the configuration. 

547 

548 Args: 

549 config: Configuration to update 

550 current_version: Current version from VersionReader 

551 existing_config: Existing configuration for reference 

552 """ 

553 logger = logging.getLogger(__name__) 

554 

555 # Ensure moai section exists 

556 if "moai" not in config: 

557 config["moai"] = {} 

558 

559 # Version field priority strategy: 

560 # 1. User explicitly set in existing config -> preserve 

561 # 2. Version from config file -> use 

562 # 3. Current version from VersionReader -> use 

563 # 4. Package version -> fallback 

564 

565 existing_moai = existing_config.get("moai", {}) 

566 config_moai = config["moai"] 

567 

568 # Check if user explicitly set a version in existing config 

569 if "version" in existing_moai: 

570 user_version = existing_moai["version"] 

571 logger.debug(f"User explicitly set version: {user_version}") 

572 config_moai["version"] = user_version 

573 elif "version" in config_moai: 

574 # Version already in new config, validate it 

575 config_version = config_moai["version"] 

576 if config_version == "unknown" or not self._is_valid_version_format( 

577 config_version 

578 ): 

579 logger.debug( 

580 f"Invalid config version {config_version}, updating to current: {current_version}" 

581 ) 

582 config_moai["version"] = current_version 

583 else: 

584 # No version found, use current version 

585 logger.debug(f"No version found, setting to current: {current_version}") 

586 config_moai["version"] = current_version 

587 

588 def _is_valid_version_format(self, version: str) -> bool: 

589 """ 

590 Check if version format is valid. 

591 

592 Args: 

593 version: Version string to validate 

594 

595 Returns: 

596 True if version format is valid 

597 """ 

598 import re 

599 

600 pattern = r"^v?(\d+\.\d+\.\d+(-[a-zA-Z0-9]+)?)$" 

601 return bool(re.match(pattern, version)) 

602 

603 def _write_configuration_file( 

604 self, config_path: Path, config: dict[str, Any] 

605 ) -> None: 

606 """ 

607 Write configuration file with enhanced formatting and error handling. 

608 

609 Args: 

610 config_path: Path to write configuration file 

611 config: Configuration dictionary to write 

612 """ 

613 logger = logging.getLogger(__name__) 

614 

615 try: 

616 # Ensure parent directory exists 

617 config_path.parent.mkdir(parents=True, exist_ok=True) 

618 

619 # Write with enhanced formatting 

620 with open(config_path, "w", encoding="utf-8") as f: 

621 json.dump(config, f, indent=2, ensure_ascii=False) 

622 

623 logger.info(f"Configuration successfully written to {config_path}") 

624 

625 except Exception as e: 

626 logger.error(f"Failed to write configuration file: {e}") 

627 raise 

628 

629 def execute_validation_phase( 

630 self, 

631 project_path: Path, 

632 mode: str = "personal", 

633 progress_callback: ProgressCallback | None = None, 

634 ) -> None: 

635 """Phase 5: validation and wrap-up. 

636 

637 

638 Args: 

639 project_path: Project path. 

640 mode: Project mode (personal/team). 

641 progress_callback: Optional progress callback. 

642 """ 

643 self.current_phase = 5 

644 self._report_progress( 

645 "Phase 5: Validation and finalization...", progress_callback 

646 ) 

647 

648 # Validate installation results 

649 # Comprehensive installation validation 

650 # Verifies all required files including 4 Alfred command files: 

651 # - 0-project.md, 1-plan.md, 2-run.md, 3-sync.md 

652 self.validator.validate_installation(project_path) 

653 

654 # Initialize Git for team mode 

655 if mode == "team": 

656 self._initialize_git(project_path) 

657 

658 def _create_backup(self, project_path: Path) -> None: 

659 """Create a single backup (v0.4.2). 

660 

661 Maintains only one backup at .moai-backups/backup/. 

662 

663 Args: 

664 project_path: Project path. 

665 """ 

666 # Define backup directory 

667 backups_dir = project_path / ".moai-backups" 

668 backup_path = backups_dir / "backup" 

669 

670 # Remove existing backup if present 

671 if backup_path.exists(): 

672 shutil.rmtree(backup_path) 

673 

674 # Create backup directories 

675 backups_dir.mkdir(parents=True, exist_ok=True) 

676 backup_path.mkdir(parents=True, exist_ok=True) 

677 

678 # Collect backup targets 

679 targets = get_backup_targets(project_path) 

680 backed_up_files: list[str] = [] 

681 

682 # Execute the backup 

683 for target in targets: 

684 src_path = project_path / target 

685 dst_path = backup_path / target 

686 

687 if src_path.is_dir(): 

688 self._copy_directory_selective(src_path, dst_path) 

689 backed_up_files.append(f"{target}/") 

690 else: 

691 dst_path.parent.mkdir(parents=True, exist_ok=True) 

692 shutil.copy2(src_path, dst_path) 

693 backed_up_files.append(target) 

694 

695 # Avoid additional console messages to prevent progress bar conflicts 

696 

697 def _copy_directory_selective(self, src: Path, dst: Path) -> None: 

698 """Copy a directory while skipping protected paths. 

699 

700 Args: 

701 src: Source directory. 

702 dst: Destination directory. 

703 """ 

704 dst.mkdir(parents=True, exist_ok=True) 

705 

706 for item in src.rglob("*"): 

707 rel_path = item.relative_to(src) 

708 

709 # Skip protected paths 

710 if is_protected_path(rel_path): 

711 continue 

712 

713 dst_item = dst / rel_path 

714 if item.is_file(): 

715 dst_item.parent.mkdir(parents=True, exist_ok=True) 

716 shutil.copy2(item, dst_item) 

717 elif item.is_dir(): 

718 dst_item.mkdir(parents=True, exist_ok=True) 

719 

720 def _initialize_git(self, project_path: Path) -> None: 

721 """Initialize a Git repository. 

722 

723 Args: 

724 project_path: Project path. 

725 """ 

726 try: 

727 subprocess.run( 

728 ["git", "init"], 

729 cwd=project_path, 

730 check=True, 

731 capture_output=True, 

732 timeout=30, # Default timeout for git operations 

733 ) 

734 # Intentionally avoid printing to keep progress output clean 

735 except subprocess.TimeoutExpired: 

736 # Timeout is non-fatal 

737 pass 

738 except subprocess.CalledProcessError: 

739 # Only log on error; failures are non-fatal 

740 pass 

741 

742 def _report_progress(self, message: str, callback: ProgressCallback | None) -> None: 

743 """Report progress. 

744 

745 Args: 

746 message: Progress message. 

747 callback: Callback function. 

748 """ 

749 if callback: 

750 callback(message, self.current_phase, self.total_phases)