| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - Batch Project Validation Tool |
| 4 | |
| 5 | Checks the structural integrity and compliance of multiple projects at once. |
| 6 | |
| 7 | Usage: |
| 8 | python3 scripts/batch_validate.py examples |
| 9 | python3 scripts/batch_validate.py projects |
| 10 | python3 scripts/batch_validate.py --all |
| 11 | python3 scripts/batch_validate.py examples projects |
| 12 | """ |
| 13 | |
| 14 | import argparse |
| 15 | import sys |
| 16 | from collections import defaultdict |
| 17 | from pathlib import Path |
| 18 | |
| 19 | from console_encoding import configure_utf8_stdio |
| 20 | |
| 21 | configure_utf8_stdio() |
| 22 | |
| 23 | try: |
| 24 | from project_utils import ( |
| 25 | find_all_projects, |
| 26 | get_project_info, |
| 27 | validate_project_structure, |
| 28 | validate_svg_viewbox, |
| 29 | CANVAS_FORMATS |
| 30 | ) |
| 31 | except ImportError: |
| 32 | print("Error: Unable to import project_utils module") |
| 33 | print("Please ensure project_utils.py is in the same directory") |
| 34 | sys.exit(1) |
| 35 | |
| 36 | |
| 37 | class BatchValidator: |
| 38 | """Batch validator""" |
| 39 | |
| 40 | def __init__(self): |
| 41 | self.results: list[dict[str, object]] = [] |
| 42 | self.summary = { |
| 43 | 'total': 0, |
| 44 | 'valid': 0, |
| 45 | 'has_errors': 0, |
| 46 | 'has_warnings': 0, |
| 47 | 'missing_readme': 0, |
| 48 | 'missing_spec': 0, |
| 49 | 'svg_issues': 0 |
| 50 | } |
| 51 | |
| 52 | def validate_directory(self, directory: str, recursive: bool = False) -> list[dict[str, object]]: |
| 53 | """ |
| 54 | Validate all projects in a directory |
| 55 | |
| 56 | Args: |
| 57 | directory: Directory path |
| 58 | recursive: Whether to recursively search subdirectories |
| 59 | |
| 60 | Returns: |
| 61 | List of validation results |
| 62 | """ |
| 63 | dir_path = Path(directory) |
| 64 | if not dir_path.exists(): |
| 65 | print(f"[ERROR] Directory does not exist: {directory}") |
| 66 | return [] |
| 67 | |
| 68 | print(f"\n[SCAN] Scanning directory: {directory}") |
| 69 | print("=" * 80) |
| 70 | |
| 71 | projects = find_all_projects(directory) |
| 72 | |
| 73 | if not projects: |
| 74 | print(f"[WARN] No projects found") |
| 75 | return [] |
| 76 | |
| 77 | print(f"Found {len(projects)} project(s)\n") |
| 78 | |
| 79 | for project_path in projects: |
| 80 | self.validate_project(str(project_path)) |
| 81 | |
| 82 | return self.results |
| 83 | |
| 84 | def validate_project(self, project_path: str) -> dict[str, object]: |
| 85 | """ |
| 86 | Validate a single project |
| 87 | |
| 88 | Args: |
| 89 | project_path: Project path |
| 90 | |
| 91 | Returns: |
| 92 | Validation result dictionary |
| 93 | """ |
| 94 | self.summary['total'] += 1 |
| 95 | |
| 96 | # Get project info |
| 97 | info = get_project_info(project_path) |
| 98 | |
| 99 | # Validate project structure |
| 100 | is_valid, errors, warnings = validate_project_structure(project_path) |
| 101 | |
| 102 | # Validate SVG viewBox |
| 103 | svg_warnings = [] |
| 104 | if info['svg_files']: |
| 105 | project_path_obj = Path(project_path) |
| 106 | svg_files = [project_path_obj / 'svg_output' / |
| 107 | f for f in info['svg_files']] |
| 108 | svg_warnings = validate_svg_viewbox(svg_files, info['format']) |
| 109 | |
| 110 | # Aggregate results |
| 111 | result = { |
| 112 | 'path': project_path, |
| 113 | 'name': info['name'], |
| 114 | 'format': info['format_name'], |
| 115 | 'date': info['date_formatted'], |
| 116 | 'svg_count': info['svg_count'], |
| 117 | 'is_valid': is_valid, |
| 118 | 'errors': errors, |
| 119 | 'warnings': warnings + svg_warnings, |
| 120 | 'has_readme': info['has_readme'], |
| 121 | 'has_spec': info['has_spec'] |
| 122 | } |
| 123 | |
| 124 | self.results.append(result) |
| 125 | |
| 126 | # Update statistics |
| 127 | if is_valid and not warnings and not svg_warnings: |
| 128 | self.summary['valid'] += 1 |
| 129 | status = "[OK]" |
| 130 | elif errors: |
| 131 | self.summary['has_errors'] += 1 |
| 132 | status = "[ERROR]" |
| 133 | else: |
| 134 | self.summary['has_warnings'] += 1 |
| 135 | status = "[WARN]" |
| 136 | |
| 137 | if not info['has_readme']: |
| 138 | self.summary['missing_readme'] += 1 |
| 139 | if not info['has_spec']: |
| 140 | self.summary['missing_spec'] += 1 |
| 141 | if svg_warnings: |
| 142 | self.summary['svg_issues'] += 1 |
| 143 | |
| 144 | # Print result |
| 145 | print(f"{status} {info['name']}") |
| 146 | print(f" Path: {project_path}") |
| 147 | print( |
| 148 | f" Format: {info['format_name']} | SVG: {info['svg_count']} file(s) | Date: {info['date_formatted']}") |
| 149 | |
| 150 | if errors: |
| 151 | print(f" [ERROR] Errors ({len(errors)}):") |
| 152 | for error in errors: |
| 153 | print(f" - {error}") |
| 154 | |
| 155 | if warnings or svg_warnings: |
| 156 | all_warnings = warnings + svg_warnings |
| 157 | print(f" [WARN] Warnings ({len(all_warnings)}):") |
| 158 | for warning in all_warnings[:3]: # Only show first 3 warnings |
| 159 | print(f" - {warning}") |
| 160 | if len(all_warnings) > 3: |
| 161 | print(f" ... and {len(all_warnings) - 3} more warning(s)") |
| 162 | |
| 163 | print() |
| 164 | |
| 165 | return result |
| 166 | |
| 167 | def print_summary(self) -> None: |
| 168 | """Print a summary of validation results.""" |
| 169 | print("\n" + "=" * 80) |
| 170 | print("[Summary] Validation Summary") |
| 171 | print("=" * 80) |
| 172 | |
| 173 | print(f"\nTotal projects: {self.summary['total']}") |
| 174 | print( |
| 175 | f" [OK] Fully valid: {self.summary['valid']} ({self._percentage(self.summary['valid'])}%)") |
| 176 | print( |
| 177 | f" [WARN] With warnings: {self.summary['has_warnings']} ({self._percentage(self.summary['has_warnings'])}%)") |
| 178 | print( |
| 179 | f" [ERROR] With errors: {self.summary['has_errors']} ({self._percentage(self.summary['has_errors'])}%)") |
| 180 | |
| 181 | print(f"\nCommon issues:") |
| 182 | print(f" Missing README.md: {self.summary['missing_readme']} project(s)") |
| 183 | print(f" Missing design spec: {self.summary['missing_spec']} project(s)") |
| 184 | print(f" SVG format issues: {self.summary['svg_issues']} project(s)") |
| 185 | |
| 186 | # Group statistics by format |
| 187 | format_stats = defaultdict(int) |
| 188 | for result in self.results: |
| 189 | format_stats[result['format']] += 1 |
| 190 | |
| 191 | if format_stats: |
| 192 | print(f"\nCanvas format distribution:") |
| 193 | for fmt, count in sorted(format_stats.items(), key=lambda x: x[1], reverse=True): |
| 194 | print(f" {fmt}: {count} project(s)") |
| 195 | |
| 196 | # Provide fix suggestions |
| 197 | if self.summary['has_errors'] > 0 or self.summary['has_warnings'] > 0: |
| 198 | print(f"\n[TIP] Fix suggestions:") |
| 199 | |
| 200 | if self.summary['missing_readme'] > 0: |
| 201 | print(f" 1. Create documentation for projects missing README") |
| 202 | print( |
| 203 | f" Include the project goal, sources, canvas, artifacts, and export path") |
| 204 | |
| 205 | if self.summary['svg_issues'] > 0: |
| 206 | print(f" 2. Check SVG root viewBox settings") |
| 207 | print(f" The SVG root viewBox is the export canvas authority") |
| 208 | |
| 209 | if self.summary['missing_spec'] > 0: |
| 210 | print(f" 3. Add design specification files") |
| 211 | |
| 212 | def _percentage(self, count: int) -> int: |
| 213 | """Calculate percentage""" |
| 214 | if self.summary['total'] == 0: |
| 215 | return 0 |
| 216 | return int(count / self.summary['total'] * 100) |
| 217 | |
| 218 | def export_report(self, output_file: str = 'validation_report.txt') -> None: |
| 219 | """ |
| 220 | Export validation report to file |
| 221 | |
| 222 | Args: |
| 223 | output_file: Output file path |
| 224 | """ |
| 225 | with open(output_file, 'w', encoding='utf-8') as f: |
| 226 | f.write("PPT Master Project Validation Report\n") |
| 227 | f.write("=" * 80 + "\n\n") |
| 228 | |
| 229 | for result in self.results: |
| 230 | status = "[OK] Valid" if result['is_valid'] and not result['warnings'] else \ |
| 231 | "[ERROR] Error" if result['errors'] else "[WARN] Warning" |
| 232 | |
| 233 | f.write(f"{status} - {result['name']}\n") |
| 234 | f.write(f"Path: {result['path']}\n") |
| 235 | f.write( |
| 236 | f"Format: {result['format']} | SVG: {result['svg_count']} file(s)\n") |
| 237 | |
| 238 | if result['errors']: |
| 239 | f.write(f"\nErrors:\n") |
| 240 | for error in result['errors']: |
| 241 | f.write(f" - {error}\n") |
| 242 | |
| 243 | if result['warnings']: |
| 244 | f.write(f"\nWarnings:\n") |
| 245 | for warning in result['warnings']: |
| 246 | f.write(f" - {warning}\n") |
| 247 | |
| 248 | f.write("\n" + "-" * 80 + "\n\n") |
| 249 | |
| 250 | # Write summary |
| 251 | f.write("\n" + "=" * 80 + "\n") |
| 252 | f.write("Validation Summary\n") |
| 253 | f.write("=" * 80 + "\n\n") |
| 254 | f.write(f"Total projects: {self.summary['total']}\n") |
| 255 | f.write(f"Fully valid: {self.summary['valid']}\n") |
| 256 | f.write(f"With warnings: {self.summary['has_warnings']}\n") |
| 257 | f.write(f"With errors: {self.summary['has_errors']}\n") |
| 258 | |
| 259 | print(f"\n[REPORT] Validation report exported: {output_file}") |
| 260 | |
| 261 | |
| 262 | def build_parser() -> argparse.ArgumentParser: |
| 263 | """Build the command-line parser.""" |
| 264 | parser = argparse.ArgumentParser( |
| 265 | description="Validate one or more PPT Master project directories.", |
| 266 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 267 | epilog="""Examples: |
| 268 | python3 scripts/batch_validate.py examples |
| 269 | python3 scripts/batch_validate.py projects |
| 270 | python3 scripts/batch_validate.py examples projects |
| 271 | python3 scripts/batch_validate.py --all |
| 272 | """, |
| 273 | ) |
| 274 | parser.add_argument("directories", nargs="*", help="Directories to scan") |
| 275 | parser.add_argument("--all", action="store_true", help="Validate examples and projects") |
| 276 | parser.add_argument("--export", action="store_true", help="Write a validation report") |
| 277 | parser.add_argument( |
| 278 | "--output", |
| 279 | default="validation_report.txt", |
| 280 | help="Report path when --export is used", |
| 281 | ) |
| 282 | return parser |
| 283 | |
| 284 | |
| 285 | def main(argv: list[str] | None = None) -> int: |
| 286 | """Run the CLI entry point.""" |
| 287 | parser = build_parser() |
| 288 | args = parser.parse_args(argv) |
| 289 | |
| 290 | validator = BatchValidator() |
| 291 | |
| 292 | if args.all: |
| 293 | directories = ['examples', 'projects'] |
| 294 | else: |
| 295 | directories = args.directories |
| 296 | |
| 297 | if not directories: |
| 298 | parser.print_help() |
| 299 | print( |
| 300 | "\n[ERROR] Provide at least one directory or pass --all.", |
| 301 | file=sys.stderr, |
| 302 | ) |
| 303 | return 1 |
| 304 | |
| 305 | # Validate each directory |
| 306 | for directory in directories: |
| 307 | if Path(directory).exists(): |
| 308 | validator.validate_directory(directory) |
| 309 | else: |
| 310 | print(f"[WARN] Skipping non-existent directory: {directory}\n") |
| 311 | |
| 312 | if validator.summary['total'] == 0: |
| 313 | print( |
| 314 | "[ERROR] No projects were found in the requested directories.", |
| 315 | file=sys.stderr, |
| 316 | ) |
| 317 | return 1 |
| 318 | |
| 319 | # Print summary |
| 320 | validator.print_summary() |
| 321 | |
| 322 | # Export report (if specified) |
| 323 | if args.export: |
| 324 | validator.export_report(args.output) |
| 325 | |
| 326 | # Return exit code |
| 327 | if validator.summary['has_errors'] > 0: |
| 328 | return 1 |
| 329 | elif validator.summary['has_warnings'] > 0: |
| 330 | return 2 |
| 331 | return 0 |
| 332 | |
| 333 | |
| 334 | if __name__ == '__main__': |
| 335 | raise SystemExit(main()) |
| 336 |