Files
git.stella-ops.org/tools/slntools/sln_generator.py
StellaOps Bot cec4265a40 save progress
2025-12-28 01:40:52 +02:00

396 lines
11 KiB
Python

#!/usr/bin/env python3
"""
StellaOps Solution Generator.
Generates consistent .sln files for:
- Main solution (src/StellaOps.sln) with all projects
- Module solutions (src/<Module>/StellaOps.<Module>.sln) with external deps in __External/
Usage:
python sln_generator.py [OPTIONS]
Options:
--src-root PATH Root of src/ directory (default: ./src)
--main-only Only regenerate main solution
--module NAME Regenerate specific module solution only
--all Regenerate all solutions (default)
--dry-run Show changes without writing
--check CI mode: exit 1 if solutions need updating
-v, --verbose Verbose output
"""
import argparse
import logging
import sys
from pathlib import Path
from lib.csproj_parser import find_all_csproj, parse_csproj
from lib.dependency_graph import (
collect_all_external_dependencies,
get_module_projects,
)
from lib.models import CsprojProject
from lib.sln_writer import (
build_external_folder_hierarchy,
build_folder_hierarchy,
generate_solution_content,
has_bypass_marker,
write_solution_file,
)
logger = logging.getLogger(__name__)
# Directories under src/ that are modules (have their own solutions)
# Excludes special directories like __Libraries, __Tests, __Analyzers, Web, etc.
EXCLUDED_FROM_MODULE_SOLUTIONS = {
"__Libraries",
"__Tests",
"__Analyzers",
".nuget",
".cache",
".vs",
"Web", # Angular project, not .NET
"plugins",
"app",
"Api",
"Sdk",
"DevPortal",
"Mirror",
"Provenance",
"Symbols",
"Unknowns",
}
def setup_logging(verbose: bool) -> None:
"""Configure logging based on verbosity."""
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format="%(levelname)s: %(message)s",
)
def discover_modules(src_root: Path) -> list[Path]:
"""
Discover all module directories under src/.
A module is a directory that:
- Is a direct child of src/
- Is not in EXCLUDED_FROM_MODULE_SOLUTIONS
- Contains at least one .csproj file
Returns:
List of absolute paths to module directories
"""
modules: list[Path] = []
for item in src_root.iterdir():
if not item.is_dir():
continue
if item.name in EXCLUDED_FROM_MODULE_SOLUTIONS:
continue
if item.name.startswith("."):
continue
# Check if it contains any csproj files
csproj_files = list(item.rglob("*.csproj"))
if csproj_files:
modules.append(item.resolve())
return sorted(modules)
def load_all_projects(src_root: Path) -> tuple[list[CsprojProject], dict[Path, CsprojProject]]:
"""
Load and parse all projects under src/.
Returns:
Tuple of (list of all projects, map from path to project)
"""
csproj_files = find_all_csproj(src_root)
logger.info(f"Found {len(csproj_files)} .csproj files")
projects: list[CsprojProject] = []
project_map: dict[Path, CsprojProject] = {}
for csproj_path in csproj_files:
project = parse_csproj(csproj_path, src_root)
if project:
projects.append(project)
project_map[project.path] = project
else:
logger.warning(f"Failed to parse: {csproj_path}")
logger.info(f"Successfully parsed {len(projects)} projects")
return projects, project_map
def generate_main_solution(
src_root: Path,
projects: list[CsprojProject],
dry_run: bool = False,
) -> bool:
"""
Generate the main StellaOps.sln with all projects.
Args:
src_root: Root of src/ directory
projects: All parsed projects
dry_run: If True, don't write files
Returns:
True if successful
"""
sln_path = src_root / "StellaOps.sln"
# Check for bypass marker
if has_bypass_marker(sln_path):
logger.info(f"Skipping {sln_path} (has bypass marker)")
return True
logger.info(f"Generating main solution: {sln_path}")
# Build folder hierarchy matching physical structure
folders = build_folder_hierarchy(projects, src_root)
# Generate solution content
content = generate_solution_content(
sln_path=sln_path,
projects=[], # Projects are in folders
folders=folders,
external_folders=None,
add_bypass_marker=False,
)
return write_solution_file(sln_path, content, dry_run)
def generate_module_solution(
module_dir: Path,
src_root: Path,
all_projects: list[CsprojProject],
project_map: dict[Path, CsprojProject],
dry_run: bool = False,
) -> bool:
"""
Generate a module-specific solution.
Args:
module_dir: Root directory of the module
src_root: Root of src/ directory
all_projects: All parsed projects
project_map: Map from path to project
dry_run: If True, don't write files
Returns:
True if successful
"""
module_name = module_dir.name
sln_path = module_dir / f"StellaOps.{module_name}.sln"
# Check for bypass marker
if has_bypass_marker(sln_path):
logger.info(f"Skipping {sln_path} (has bypass marker)")
return True
logger.info(f"Generating module solution: {sln_path}")
# Get projects within this module
module_projects = get_module_projects(module_dir, all_projects)
if not module_projects:
logger.warning(f"No projects found in module: {module_name}")
return True
logger.debug(f" Found {len(module_projects)} projects in module")
# Build internal folder hierarchy
internal_folders = build_folder_hierarchy(module_projects, module_dir)
# Collect external dependencies
external_groups = collect_all_external_dependencies(
projects=module_projects,
module_dir=module_dir,
src_root=src_root,
project_map=project_map,
)
# Build external folder hierarchy
external_folders = {}
if external_groups:
external_folders = build_external_folder_hierarchy(external_groups, src_root)
ext_count = sum(len(v) for v in external_groups.values())
logger.debug(f" Found {ext_count} external dependencies")
# Generate solution content
content = generate_solution_content(
sln_path=sln_path,
projects=[], # Projects are in folders
folders=internal_folders,
external_folders=external_folders,
add_bypass_marker=False,
)
return write_solution_file(sln_path, content, dry_run)
def check_solutions_up_to_date(
src_root: Path,
modules: list[Path],
all_projects: list[CsprojProject],
project_map: dict[Path, CsprojProject],
) -> bool:
"""
Check if solutions need updating (for --check mode).
Args:
src_root: Root of src/ directory
modules: List of module directories
all_projects: All parsed projects
project_map: Map from path to project
Returns:
True if all solutions are up to date
"""
# This is a simplified check - in a real implementation,
# you would compare generated content with existing files
logger.info("Checking if solutions are up to date...")
# For now, just check if files exist
main_sln = src_root / "StellaOps.sln"
if not main_sln.exists():
logger.error(f"Main solution missing: {main_sln}")
return False
for module_dir in modules:
module_name = module_dir.name
module_sln = module_dir / f"StellaOps.{module_name}.sln"
if has_bypass_marker(module_sln):
continue
if not module_sln.exists():
logger.error(f"Module solution missing: {module_sln}")
return False
logger.info("All solutions appear to be up to date")
return True
def main() -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate StellaOps solution files",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--src-root",
type=Path,
default=Path("src"),
help="Root of src/ directory (default: ./src)",
)
parser.add_argument(
"--main-only",
action="store_true",
help="Only regenerate main solution",
)
parser.add_argument(
"--module",
type=str,
help="Regenerate specific module solution only",
)
parser.add_argument(
"--all",
action="store_true",
dest="regenerate_all",
help="Regenerate all solutions (default)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show changes without writing",
)
parser.add_argument(
"--check",
action="store_true",
help="CI mode: exit 1 if solutions need updating",
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Verbose output",
)
args = parser.parse_args()
setup_logging(args.verbose)
# Resolve src root
src_root = args.src_root.resolve()
if not src_root.exists():
logger.error(f"Source root does not exist: {src_root}")
return 1
logger.info(f"Source root: {src_root}")
# Load all projects
all_projects, project_map = load_all_projects(src_root)
if not all_projects:
logger.error("No projects found")
return 1
# Discover modules
modules = discover_modules(src_root)
logger.info(f"Discovered {len(modules)} modules")
# Check mode
if args.check:
if check_solutions_up_to_date(src_root, modules, all_projects, project_map):
return 0
return 1
# Determine what to generate
success = True
if args.module:
# Specific module only
module_dir = src_root / args.module
if not module_dir.exists():
logger.error(f"Module directory does not exist: {module_dir}")
return 1
success = generate_module_solution(
module_dir, src_root, all_projects, project_map, args.dry_run
)
elif args.main_only:
# Main solution only
success = generate_main_solution(src_root, all_projects, args.dry_run)
else:
# Generate all (default)
# Main solution
if not generate_main_solution(src_root, all_projects, args.dry_run):
success = False
# Module solutions
for module_dir in modules:
if not generate_module_solution(
module_dir, src_root, all_projects, project_map, args.dry_run
):
success = False
if args.dry_run:
logger.info("Dry run complete - no files were modified")
return 0 if success else 1
if __name__ == "__main__":
sys.exit(main())