Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,645 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Project Utilities Module
|
||||
|
||||
Provides common functions for project information parsing and validation,
|
||||
reusable by other tools.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from console_encoding import configure_utf8_stdio
|
||||
from slide_roster import discover_slide_svgs
|
||||
from svg_to_pptx.canvas_contract import (
|
||||
CanvasContractError,
|
||||
parse_project_viewbox,
|
||||
read_project_viewbox,
|
||||
)
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
# Canvas format definitions (unified source)
|
||||
try:
|
||||
from config import CANVAS_FORMATS
|
||||
except ImportError:
|
||||
# Fallback: maintain minimal usable configuration to avoid runtime crashes
|
||||
CANVAS_FORMATS = {
|
||||
'ppt169': {
|
||||
'name': 'PPT 16:9',
|
||||
'dimensions': '1280×720',
|
||||
'viewbox': '0 0 1280 720',
|
||||
'aspect_ratio': '16:9'
|
||||
},
|
||||
'ppt43': {
|
||||
'name': 'PPT 4:3',
|
||||
'dimensions': '1024×768',
|
||||
'viewbox': '0 0 1024 768',
|
||||
'aspect_ratio': '4:3'
|
||||
},
|
||||
'wechat': {
|
||||
'name': 'WeChat Article Header',
|
||||
'dimensions': '900×383',
|
||||
'viewbox': '0 0 900 383',
|
||||
'aspect_ratio': '2.35:1'
|
||||
},
|
||||
'xiaohongshu': {
|
||||
'name': '小红书',
|
||||
'dimensions': '1242×1660',
|
||||
'viewbox': '0 0 1242 1660',
|
||||
'aspect_ratio': '3:4'
|
||||
},
|
||||
'moments': {
|
||||
'name': 'Moments/Instagram',
|
||||
'dimensions': '1080×1080',
|
||||
'viewbox': '0 0 1080 1080',
|
||||
'aspect_ratio': '1:1'
|
||||
},
|
||||
'story': {
|
||||
'name': 'Story/Vertical',
|
||||
'dimensions': '1080×1920',
|
||||
'viewbox': '0 0 1080 1920',
|
||||
'aspect_ratio': '9:16'
|
||||
},
|
||||
'banner': {
|
||||
'name': 'Horizontal Banner',
|
||||
'dimensions': '1920×1080',
|
||||
'viewbox': '0 0 1920 1080',
|
||||
'aspect_ratio': '16:9'
|
||||
},
|
||||
'a4': {
|
||||
'name': 'A4 Print',
|
||||
'dimensions': '1240×1754',
|
||||
'viewbox': '0 0 1240 1754',
|
||||
'aspect_ratio': '√2:1'
|
||||
}
|
||||
}
|
||||
|
||||
CANVAS_FORMAT_ALIASES = {
|
||||
'xhs': 'xiaohongshu',
|
||||
'wechat_moment': 'moments',
|
||||
'wechat-moment': 'moments',
|
||||
'朋友圈': 'moments',
|
||||
'小红书': 'xiaohongshu',
|
||||
}
|
||||
|
||||
_DESIGN_SPEC_NAMES = (
|
||||
'design_spec.md',
|
||||
'设计规范与内容大纲.md',
|
||||
'design_specification.md',
|
||||
'设计规范.md',
|
||||
)
|
||||
_COMMUNICATION_TRACE_KEYS = (
|
||||
'audience',
|
||||
'objective',
|
||||
'core_message',
|
||||
)
|
||||
|
||||
|
||||
def normalize_canvas_format(format_key: str) -> str:
|
||||
"""Normalize canvas format key name (supports common aliases)."""
|
||||
if not format_key:
|
||||
return ''
|
||||
key = format_key.strip().lower()
|
||||
return CANVAS_FORMAT_ALIASES.get(key, key)
|
||||
|
||||
|
||||
def parse_project_name(dir_name: str) -> Dict[str, str]:
|
||||
"""
|
||||
Parse project information from the project directory name.
|
||||
|
||||
Args:
|
||||
dir_name: Project directory name
|
||||
|
||||
Returns:
|
||||
Dictionary containing name, format, date
|
||||
"""
|
||||
result = {
|
||||
'name': dir_name,
|
||||
'format': 'unknown',
|
||||
'format_name': 'Unknown format',
|
||||
'date': 'unknown',
|
||||
'date_formatted': 'Unknown date'
|
||||
}
|
||||
|
||||
dir_name_lower = dir_name.lower()
|
||||
|
||||
# Extract date (format: _YYYYMMDD)
|
||||
date_match = re.search(r'_(\d{8})$', dir_name)
|
||||
if date_match:
|
||||
date_str = date_match.group(1)
|
||||
result['date'] = date_str
|
||||
try:
|
||||
date_obj = datetime.strptime(date_str, '%Y%m%d')
|
||||
result['date_formatted'] = date_obj.strftime('%Y-%m-%d')
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Prefer parsing standard format: name_format_YYYYMMDD
|
||||
full_match = re.match(r'^(?P<name>.+)_(?P<format>[a-z0-9_-]+)_(?P<date>\d{8})$', dir_name_lower)
|
||||
if full_match:
|
||||
raw_format = full_match.group('format')
|
||||
normalized_format = normalize_canvas_format(raw_format)
|
||||
if normalized_format in CANVAS_FORMATS:
|
||||
result['format'] = normalized_format
|
||||
result['format_name'] = CANVAS_FORMATS[normalized_format]['name']
|
||||
result['name'] = dir_name[:len(full_match.group('name'))]
|
||||
return result
|
||||
|
||||
# Fallback: only match trailing `_format` to avoid deleting parts of the project name
|
||||
sorted_formats = sorted(CANVAS_FORMATS.keys(), key=len, reverse=True)
|
||||
for fmt_key in sorted_formats:
|
||||
if re.search(rf'_{re.escape(fmt_key)}(?:_\d{{8}})?$', dir_name_lower):
|
||||
result['format'] = fmt_key
|
||||
result['format_name'] = CANVAS_FORMATS[fmt_key]['name']
|
||||
break
|
||||
|
||||
# Extract project name (only remove trailing date and format suffix)
|
||||
name = re.sub(r'_\d{8}$', '', dir_name)
|
||||
if result['format'] != 'unknown':
|
||||
name = re.sub(rf'_{re.escape(result["format"])}$', '', name, flags=re.IGNORECASE)
|
||||
result['name'] = name
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_project_info(project_path: str) -> Dict:
|
||||
"""
|
||||
Get detailed project information.
|
||||
|
||||
Args:
|
||||
project_path: Project directory path
|
||||
|
||||
Returns:
|
||||
Project information dictionary
|
||||
"""
|
||||
project_path = Path(project_path)
|
||||
|
||||
# Parse directory name
|
||||
parsed = parse_project_name(project_path.name)
|
||||
|
||||
info = {
|
||||
'path': str(project_path),
|
||||
'dir_name': project_path.name,
|
||||
'name': parsed['name'],
|
||||
'format': parsed['format'],
|
||||
'format_name': parsed['format_name'],
|
||||
'date': parsed['date'],
|
||||
'date_formatted': parsed['date_formatted'],
|
||||
'exists': project_path.exists(),
|
||||
'svg_count': 0,
|
||||
'has_spec': False,
|
||||
'has_readme': False,
|
||||
'has_source': False,
|
||||
'source_count': 0,
|
||||
'spec_file': None,
|
||||
'svg_files': []
|
||||
}
|
||||
|
||||
if not project_path.exists():
|
||||
return info
|
||||
|
||||
# Check README.md
|
||||
info['has_readme'] = (project_path / 'README.md').exists()
|
||||
|
||||
# Check design specification files (current standard + legacy names)
|
||||
for spec_file in _DESIGN_SPEC_NAMES:
|
||||
if (project_path / spec_file).exists():
|
||||
info['has_spec'] = True
|
||||
info['spec_file'] = spec_file
|
||||
break
|
||||
|
||||
# Check source documents
|
||||
legacy_source_file = project_path / '来源文档.md'
|
||||
sources_dir = project_path / 'sources'
|
||||
info['has_source'] = legacy_source_file.exists() or sources_dir.exists()
|
||||
|
||||
if sources_dir.exists():
|
||||
info['source_count'] = len([p for p in sources_dir.iterdir() if p.is_file()])
|
||||
|
||||
# Count SVG files
|
||||
svg_output = project_path / 'svg_output'
|
||||
if svg_output.exists():
|
||||
svg_files = discover_slide_svgs(svg_output)
|
||||
info['svg_count'] = len(svg_files)
|
||||
info['svg_files'] = [f.name for f in svg_files]
|
||||
|
||||
# Get canvas format details
|
||||
if info['format'] in CANVAS_FORMATS:
|
||||
info['canvas_info'] = CANVAS_FORMATS[info['format']]
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def validate_communication_trace(
|
||||
project_path: str | Path,
|
||||
*,
|
||||
check_lock: bool = True,
|
||||
check_design: bool = True,
|
||||
) -> List[str]:
|
||||
"""Validate either or both communication-trace surfaces."""
|
||||
root = Path(project_path)
|
||||
design_spec = next(
|
||||
(root / name for name in _DESIGN_SPEC_NAMES if (root / name).is_file()),
|
||||
None,
|
||||
)
|
||||
if design_spec is None or not (check_lock or check_design):
|
||||
return []
|
||||
|
||||
errors: List[str] = []
|
||||
lock_path = root / 'spec_lock.md'
|
||||
if check_lock:
|
||||
if not lock_path.is_file():
|
||||
return [
|
||||
'Communication trace: missing spec_lock.md with a '
|
||||
'## communication section.',
|
||||
]
|
||||
try:
|
||||
lock_text = lock_path.read_text(encoding='utf-8-sig')
|
||||
except OSError as exc:
|
||||
return [f'Communication trace: unable to read specification files: {exc}']
|
||||
|
||||
communication_match = re.search(
|
||||
r'^##[ \t]+communication[ \t]*$',
|
||||
lock_text,
|
||||
flags=re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
if communication_match is None:
|
||||
errors.append(
|
||||
'Communication trace: spec_lock.md must contain a '
|
||||
'## communication section.',
|
||||
)
|
||||
else:
|
||||
next_section = re.search(
|
||||
r'^##[ \t]+',
|
||||
lock_text[communication_match.end():],
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
section_end = (
|
||||
communication_match.end() + next_section.start()
|
||||
if next_section
|
||||
else len(lock_text)
|
||||
)
|
||||
communication_block = lock_text[
|
||||
communication_match.end():section_end
|
||||
]
|
||||
missing_keys = [
|
||||
key
|
||||
for key in _COMMUNICATION_TRACE_KEYS
|
||||
if re.search(
|
||||
rf'^-[ \t]+{re.escape(key)}[ \t]*:',
|
||||
communication_block,
|
||||
flags=re.MULTILINE,
|
||||
) is None
|
||||
]
|
||||
if missing_keys:
|
||||
errors.append(
|
||||
'Communication trace: spec_lock.md ## communication is '
|
||||
f'missing key line(s): {", ".join(missing_keys)}.',
|
||||
)
|
||||
|
||||
if not check_design:
|
||||
return errors
|
||||
try:
|
||||
design_text = design_spec.read_text(encoding='utf-8-sig')
|
||||
except OSError as exc:
|
||||
return [f'Communication trace: unable to read specification files: {exc}']
|
||||
|
||||
outline_match = re.search(
|
||||
r'^##[ \t]+IX\.[ \t]+Content Outline\b.*$',
|
||||
design_text,
|
||||
flags=re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
if outline_match is None:
|
||||
errors.append(
|
||||
'Communication trace: design_spec.md must contain '
|
||||
'## IX. Content Outline.',
|
||||
)
|
||||
return errors
|
||||
next_section = re.search(
|
||||
r'^##[ \t]+',
|
||||
design_text[outline_match.end():],
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
outline_end = (
|
||||
outline_match.end() + next_section.start()
|
||||
if next_section
|
||||
else len(design_text)
|
||||
)
|
||||
outline = design_text[outline_match.end():outline_end]
|
||||
slide_matches = list(re.finditer(
|
||||
r'^#{3,6}[ \t]+Slide[ \t]+([0-9]+|NN)\b.*$',
|
||||
outline,
|
||||
flags=re.IGNORECASE | re.MULTILINE,
|
||||
))
|
||||
if not slide_matches:
|
||||
errors.append(
|
||||
'Communication trace: design_spec.md §IX contains no Slide blocks.',
|
||||
)
|
||||
return errors
|
||||
|
||||
missing_moves = []
|
||||
for index, slide_match in enumerate(slide_matches):
|
||||
block_end = (
|
||||
slide_matches[index + 1].start()
|
||||
if index + 1 < len(slide_matches)
|
||||
else len(outline)
|
||||
)
|
||||
slide_block = outline[slide_match.end():block_end]
|
||||
if re.search(
|
||||
r'^[ \t]*-[ \t]+(?:\*\*)?Audience move(?:\*\*)?[ \t]*:',
|
||||
slide_block,
|
||||
flags=re.IGNORECASE | re.MULTILINE,
|
||||
) is None:
|
||||
missing_moves.append(slide_match.group(1))
|
||||
if missing_moves:
|
||||
errors.append(
|
||||
'Communication trace: every design_spec.md §IX Slide block must '
|
||||
'contain an Audience move line; missing on Slide '
|
||||
f'{", ".join(missing_moves)}.',
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def validate_project_structure(
|
||||
project_path: str,
|
||||
verbose: bool = False,
|
||||
*,
|
||||
validate_communication: bool = True,
|
||||
) -> Tuple[bool, List[str], List[str]]:
|
||||
"""
|
||||
Validate project structure completeness.
|
||||
|
||||
Args:
|
||||
project_path: Project directory path
|
||||
verbose: Whether to show detailed fix suggestions
|
||||
validate_communication: Whether to run the communication trace check
|
||||
|
||||
Returns:
|
||||
(is_valid, error_list, warning_list)
|
||||
"""
|
||||
project_path = Path(project_path)
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
# Try to import error helper
|
||||
try:
|
||||
from error_helper import ErrorHelper
|
||||
use_helper = True
|
||||
except ImportError:
|
||||
use_helper = False
|
||||
|
||||
# Check if directory exists
|
||||
if not project_path.exists():
|
||||
msg = f"Project directory does not exist: {project_path}"
|
||||
if use_helper and verbose:
|
||||
msg += "\n" + ErrorHelper.format_error_message('missing_directory',
|
||||
{'project_path': str(project_path)})
|
||||
errors.append(msg)
|
||||
return False, errors, warnings
|
||||
|
||||
if not project_path.is_dir():
|
||||
errors.append(f"Not a valid directory: {project_path}")
|
||||
return False, errors, warnings
|
||||
|
||||
# Check required files
|
||||
if not (project_path / 'README.md').exists():
|
||||
msg = "Missing required file: README.md"
|
||||
if use_helper and verbose:
|
||||
msg += "\n" + ErrorHelper.format_error_message('missing_readme',
|
||||
{'project_path': str(project_path)})
|
||||
errors.append(msg)
|
||||
|
||||
# Check design specification file
|
||||
has_spec = any((project_path / name).exists() for name in _DESIGN_SPEC_NAMES)
|
||||
if not has_spec:
|
||||
msg = "Missing design specification file (suggested filename: design_spec.md)"
|
||||
if use_helper and verbose:
|
||||
msg += "\n" + ErrorHelper.format_error_message('missing_spec')
|
||||
warnings.append(msg)
|
||||
elif validate_communication:
|
||||
errors.extend(validate_communication_trace(project_path))
|
||||
|
||||
# Check svg_output directory
|
||||
svg_output = project_path / 'svg_output'
|
||||
if not svg_output.exists():
|
||||
msg = "Missing svg_output directory"
|
||||
if use_helper and verbose:
|
||||
msg += "\n" + \
|
||||
ErrorHelper.format_error_message('missing_svg_output')
|
||||
errors.append(msg)
|
||||
elif not svg_output.is_dir():
|
||||
errors.append("svg_output is not a directory")
|
||||
else:
|
||||
# Check for SVG files
|
||||
svg_files = list(svg_output.glob('*.svg'))
|
||||
if not svg_files:
|
||||
msg = "svg_output directory is empty, no SVG files found"
|
||||
if use_helper and verbose:
|
||||
msg += "\n" + \
|
||||
ErrorHelper.format_error_message('empty_svg_output')
|
||||
warnings.append(msg)
|
||||
else:
|
||||
# Validate SVG file naming (consistent with project_manager.py)
|
||||
for svg_file in svg_files:
|
||||
if not re.match(r'^(slide_\d+_\w+|P?\d+_.+)\.svg$', svg_file.name):
|
||||
msg = f"Non-standard SVG file naming: {svg_file.name}"
|
||||
if use_helper and verbose:
|
||||
msg += "\n" + ErrorHelper.format_error_message('invalid_svg_naming',
|
||||
{'file_name': svg_file.name})
|
||||
warnings.append(msg)
|
||||
|
||||
# Check directory naming format
|
||||
dir_name = project_path.name
|
||||
if not re.search(r'_\d{8}$', dir_name):
|
||||
msg = f"Directory name missing date suffix (_YYYYMMDD): {dir_name}"
|
||||
if use_helper and verbose:
|
||||
msg += "\n" + \
|
||||
ErrorHelper.format_error_message('missing_date_suffix')
|
||||
warnings.append(msg)
|
||||
|
||||
is_valid = len(errors) == 0
|
||||
return is_valid, errors, warnings
|
||||
|
||||
|
||||
def validate_svg_viewbox(svg_files: List[Path], expected_format: Optional[str] = None) -> List[str]:
|
||||
"""
|
||||
Validate the viewBox settings of SVG files.
|
||||
|
||||
Args:
|
||||
svg_files: List of SVG files
|
||||
expected_format: Expected canvas format (e.g. 'ppt169')
|
||||
|
||||
Returns:
|
||||
List of warnings
|
||||
"""
|
||||
warnings = []
|
||||
viewboxes = {}
|
||||
|
||||
# Determine expected viewBox
|
||||
expected_viewbox = None
|
||||
if expected_format and expected_format in CANVAS_FORMATS:
|
||||
expected_viewbox = parse_project_viewbox(
|
||||
CANVAS_FORMATS[expected_format]['viewbox'],
|
||||
context=f"canvas format {expected_format!r}",
|
||||
)
|
||||
|
||||
for svg_file in svg_files:
|
||||
try:
|
||||
viewbox = read_project_viewbox(svg_file)
|
||||
except CanvasContractError as exc:
|
||||
warnings.append(str(exc))
|
||||
continue
|
||||
viewboxes[svg_file.name] = viewbox
|
||||
if expected_viewbox and viewbox != expected_viewbox:
|
||||
warnings.append(
|
||||
f"{svg_file.name}: root viewBox '{viewbox.canonical}' must match "
|
||||
f"project format '{expected_format}' ({expected_viewbox.canonical})"
|
||||
)
|
||||
|
||||
# Check for multiple different viewBoxes
|
||||
distinct = {viewbox for viewbox in viewboxes.values()}
|
||||
if len(distinct) > 1:
|
||||
details = ", ".join(
|
||||
f"{name}={viewbox.canonical}"
|
||||
for name, viewbox in sorted(viewboxes.items())
|
||||
)
|
||||
warnings.append(
|
||||
"All project SVG root viewBoxes must match; found " + details
|
||||
)
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def find_all_projects(base_dir: str) -> List[Path]:
|
||||
"""
|
||||
Find all projects under the specified directory.
|
||||
|
||||
Args:
|
||||
base_dir: Base directory path
|
||||
|
||||
Returns:
|
||||
List of project directories
|
||||
"""
|
||||
base_path = Path(base_dir)
|
||||
if not base_path.exists():
|
||||
return []
|
||||
|
||||
projects = []
|
||||
for item in base_path.iterdir():
|
||||
if item.is_dir() and not item.name.startswith('.'):
|
||||
# Check if it's a valid project directory (contains svg_output or design spec)
|
||||
has_svg_output = (item / 'svg_output').exists()
|
||||
has_spec = any((item / f).exists() for f in
|
||||
['design_spec.md', '设计规范与内容大纲.md', 'design_specification.md', '设计规范.md'])
|
||||
|
||||
if has_svg_output or has_spec:
|
||||
projects.append(item)
|
||||
|
||||
return sorted(projects)
|
||||
|
||||
|
||||
def format_file_size(size_bytes: int) -> str:
|
||||
"""
|
||||
Format file size.
|
||||
|
||||
Args:
|
||||
size_bytes: File size in bytes
|
||||
|
||||
Returns:
|
||||
Formatted file size string
|
||||
"""
|
||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||
if size_bytes < 1024.0:
|
||||
return f"{size_bytes:.1f} {unit}"
|
||||
size_bytes /= 1024.0
|
||||
return f"{size_bytes:.1f} TB"
|
||||
|
||||
|
||||
def get_project_stats(project_path: str) -> Dict:
|
||||
"""
|
||||
Get project statistics.
|
||||
|
||||
Args:
|
||||
project_path: Project directory path
|
||||
|
||||
Returns:
|
||||
Statistics dictionary
|
||||
"""
|
||||
project_path = Path(project_path)
|
||||
stats = {
|
||||
'total_files': 0,
|
||||
'svg_files': 0,
|
||||
'md_files': 0,
|
||||
'html_files': 0,
|
||||
'total_size': 0,
|
||||
'svg_size': 0
|
||||
}
|
||||
|
||||
if not project_path.exists():
|
||||
return stats
|
||||
|
||||
for file in project_path.rglob('*'):
|
||||
if file.is_file():
|
||||
stats['total_files'] += 1
|
||||
file_size = file.stat().st_size
|
||||
stats['total_size'] += file_size
|
||||
|
||||
if file.suffix == '.svg':
|
||||
stats['svg_files'] += 1
|
||||
stats['svg_size'] += file_size
|
||||
elif file.suffix == '.md':
|
||||
stats['md_files'] += 1
|
||||
elif file.suffix == '.html':
|
||||
stats['html_files'] += 1
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""Build the command-line parser for the diagnostic entry point."""
|
||||
parser = argparse.ArgumentParser(description="Inspect and validate a PPT Master project.")
|
||||
parser.add_argument("project_path", help="Project directory")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Run the diagnostic CLI entry point."""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
project_path = args.project_path
|
||||
info = get_project_info(project_path)
|
||||
|
||||
print(f"\nProject Info: {info['dir_name']}")
|
||||
print("=" * 60)
|
||||
print(f"Project Name: {info['name']}")
|
||||
print(f"Canvas Format: {info['format_name']} ({info['format']})")
|
||||
print(f"Created: {info['date_formatted']}")
|
||||
print(f"SVG Files: {info['svg_count']}")
|
||||
print(f"README: {'Yes' if info['has_readme'] else 'No'}")
|
||||
print(f"Design Spec: {'Yes' if info['has_spec'] else 'No'}")
|
||||
|
||||
print("\nValidation Results:")
|
||||
print("-" * 60)
|
||||
is_valid, errors, warnings = validate_project_structure(project_path)
|
||||
|
||||
if errors:
|
||||
print("[ERROR]")
|
||||
for error in errors:
|
||||
print(f" - {error}")
|
||||
|
||||
if warnings:
|
||||
print("[WARN]")
|
||||
for warning in warnings:
|
||||
print(f" - {warning}")
|
||||
|
||||
if is_valid and not warnings:
|
||||
print("[OK] Project structure is complete, no issues found")
|
||||
return 0 if is_valid else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user