Files
Brandyn / Techy fcc1b09210 init
2026-04-04 15:40:51 -05:00

438 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright 2025 Timothé Lapetite and contributors
# Released under the MIT license https://opensource.org/license/MIT/
# NOTE : This code was generated by Claude AI.
import json
import os
import re
import shutil
import sys
from pathlib import Path
PLUGIN_NAME = "PCGExtendedToolkit"
MODULE_PREFIX = "PCGEx"
EDITOR_SUFFIX = "Editor"
PLATFORMS = {
"runtime": ["Win64", "Mac", "IOS", "Android", "Linux", "LinuxArm64"],
"editor": ["Win64", "Mac", "Linux"],
}
# Resolve paths relative to script location (assumes script is in plugin root or Config/)
script_dir = Path(__file__).parent.resolve()
plugin_root = (
script_dir
if (script_dir / f"{PLUGIN_NAME}.uplugin").exists()
else script_dir.parent
)
# Project root is typically two levels up from plugin (Plugins/PCGExtendedToolkit/)
project_root = plugin_root.parent.parent
PATHS = {
"uplugin": plugin_root / f"{PLUGIN_NAME}.uplugin",
"modules_dir": plugin_root / "Source",
"binaries": plugin_root / "Binaries",
"intermediate": plugin_root / "Intermediate",
# Project-level config takes priority, falls back to plugin config
"project_submodules_config": project_root / "Config" / "PCGExSubModulesConfig.ini",
"plugin_submodules_config": plugin_root / "Config" / "PCGExSubModulesConfig.ini",
"plugins_deps": plugin_root / "Config" / "PluginsDeps.ini",
}
# =============================================================================
# Fuzzy Matching
# =============================================================================
def levenshtein_distance(a: str, b: str) -> int:
if len(a) < len(b):
a, b = b, a
if len(b) == 0:
return len(a)
prev_row = list(range(len(b) + 1))
for i, ca in enumerate(a):
curr_row = [i + 1]
for j, cb in enumerate(b):
insertions = prev_row[j + 1] + 1
deletions = curr_row[j] + 1
substitutions = prev_row[j] + (ca != cb)
curr_row.append(min(insertions, deletions, substitutions))
prev_row = curr_row
return prev_row[-1]
def find_closest_match(name: str, candidates: list[str], max_distance: int = 3) -> str | None:
best = None
best_distance = float("inf")
for candidate in candidates:
distance = levenshtein_distance(name.lower(), candidate.lower())
if distance < best_distance and distance <= max_distance:
best_distance = distance
best = candidate
return best
# =============================================================================
# INI Parsing
# =============================================================================
def resolve_submodules_config_path() -> tuple[Path, bool] | None:
"""Returns (path, is_project_level) or None if not found."""
# Check project-level config first
if PATHS["project_submodules_config"].exists():
return (PATHS["project_submodules_config"], True)
# Fall back to plugin config
if PATHS["plugin_submodules_config"].exists():
return (PATHS["plugin_submodules_config"], False)
return None
def parse_submodules_config(file_path: Path) -> list[str]:
modules = []
content = file_path.read_text(encoding="utf-8")
for line in content.splitlines():
trimmed = line.strip()
if not trimmed or trimmed.startswith("#") or trimmed.startswith(";"):
continue
# Support both "ModuleName=1" and plain "ModuleName" formats
parts = trimmed.split("=")
module_name = parts[0].strip()
if len(parts) == 1 or parts[1].strip() == "1":
if module_name.startswith(MODULE_PREFIX):
modules.append(module_name)
return modules
def parse_plugins_deps(file_path: Path) -> dict[str, list[str]]:
deps: dict[str, list[str]] = {}
if not file_path.exists():
print(f"PluginsDeps.ini not found at: {file_path} - skipping plugin dependencies")
return deps
content = file_path.read_text(encoding="utf-8")
current_module = None
for line in content.splitlines():
trimmed = line.strip()
if not trimmed or trimmed.startswith("#") or trimmed.startswith(";"):
continue
# Section header: [ModuleName]
section_match = re.match(r"^\[(.+)\]$", trimmed)
if section_match:
current_module = section_match.group(1)
if current_module not in deps:
deps[current_module] = []
continue
# Plugin name under current section
if current_module and trimmed:
deps[current_module].append(trimmed)
return deps
# =============================================================================
# Module Discovery
# =============================================================================
def get_available_modules() -> list[str]:
modules_dir = PATHS["modules_dir"]
if not modules_dir.exists():
return []
return [
name
for name in os.listdir(modules_dir)
if (modules_dir / name).is_dir() and name.startswith(MODULE_PREFIX)
]
def module_exists(module_name: str) -> bool:
module_path = PATHS["modules_dir"] / module_name
return module_path.is_dir()
def get_editor_companion(module_name: str) -> str | None:
if module_name.endswith(EDITOR_SUFFIX):
return None
editor_name = module_name + EDITOR_SUFFIX
return editor_name if module_exists(editor_name) else None
def scan_build_cs_dependencies(module_name: str) -> list[str]:
build_file = PATHS["modules_dir"] / module_name / f"{module_name}.Build.cs"
if not build_file.exists():
return []
content = build_file.read_text(encoding="utf-8")
deps = []
# Match: PublicDependencyModuleNames.AddRange(new[] { ... }) or similar
block_pattern = re.compile(
r"(?:Public|Private)DependencyModuleNames\s*\.\s*AddRange\s*\(\s*new\s*(?:string)?\s*\[\s*\]\s*\{([^}]*)\}",
re.DOTALL,
)
name_pattern = re.compile(r'"(PCGEx\w+)"')
for block_match in block_pattern.finditer(content):
array_content = block_match.group(1)
for name_match in name_pattern.finditer(array_content):
dep = name_match.group(1)
if dep != module_name and dep not in deps:
deps.append(dep)
return deps
# =============================================================================
# Module Resolution
# =============================================================================
def validate_requested_modules(
requested_modules: list[str], available_modules: list[str]
) -> tuple[list[str], list[dict]]:
valid = []
invalid = []
for module_name in requested_modules:
if module_exists(module_name):
valid.append(module_name)
else:
suggestion = find_closest_match(module_name, available_modules)
invalid.append({"name": module_name, "suggestion": suggestion})
return valid, invalid
def resolve_all_modules(requested_modules: list[str]) -> set[str]:
resolved: set[str] = set()
queue = list(requested_modules)
while queue:
module_name = queue.pop(0)
if module_name in resolved:
continue
if not module_exists(module_name):
continue # Already warned during validation
resolved.add(module_name)
# Add editor companion if exists
editor = get_editor_companion(module_name)
if editor and editor not in resolved:
queue.append(editor)
# Add PCGEx dependencies
deps = scan_build_cs_dependencies(module_name)
for dep in deps:
if dep not in resolved:
queue.append(dep)
return resolved
# =============================================================================
# Uplugin Generation
# =============================================================================
def load_existing_uplugin() -> str:
uplugin_path = PATHS["uplugin"]
if not uplugin_path.exists():
print(f".uplugin not found at: {uplugin_path}", file=sys.stderr)
sys.exit(1)
return uplugin_path.read_text(encoding="utf-8")
def build_module_entry(name: str) -> dict:
is_editor = name.endswith(EDITOR_SUFFIX)
return {
"Name": name,
"Type": "Editor" if is_editor else "Runtime",
"LoadingPhase": "Default",
"PlatformAllowList": list(PLATFORMS["editor"] if is_editor else PLATFORMS["runtime"]),
}
def build_plugin_entry(name: str) -> dict:
return {"Name": name, "Enabled": True}
def resolve_required_plugins(modules: set[str], plugins_deps: dict[str, list[str]]) -> set[str]:
plugins = {"PCG"} # PCG is always required
for module_name in modules:
required = plugins_deps.get(module_name)
if required:
plugins.update(required)
return plugins
def module_sort_key(name: str) -> tuple[int, int, str]:
is_umbrella = name == PLUGIN_NAME or name == PLUGIN_NAME + EDITOR_SUFFIX
if is_umbrella:
# Main umbrella first (0), then editor umbrella (1)
return (0, 0 if name == PLUGIN_NAME else 1, name)
return (1, 0, name)
def generate_uplugin(existing_uplugin: dict, modules: set[str], plugins: set[str]) -> dict:
# Always include umbrella modules
all_modules = set(modules)
all_modules.add(PLUGIN_NAME)
all_modules.add(PLUGIN_NAME + EDITOR_SUFFIX)
# Build new uplugin preserving all existing metadata
new_uplugin = dict(existing_uplugin)
new_uplugin["Modules"] = [
build_module_entry(name) for name in sorted(all_modules, key=module_sort_key)
]
def plugin_sort_key(name: str) -> tuple[int, str]:
return (0, name) if name == "PCG" else (1, name)
new_uplugin["Plugins"] = [
build_plugin_entry(name) for name in sorted(plugins, key=plugin_sort_key)
]
return new_uplugin
# =============================================================================
# Build Artifact Cleanup
# =============================================================================
def delete_folder(folder_path: Path) -> bool:
if not folder_path.exists():
return False
shutil.rmtree(folder_path)
return True
def clean_build_artifacts():
print("\n[CLEANUP] .uplugin changed - invalidating build artifacts...")
cleaned = False
if delete_folder(PATHS["binaries"]):
print(f" Deleted: {PATHS['binaries']}")
cleaned = True
if delete_folder(PATHS["intermediate"]):
print(f" Deleted: {PATHS['intermediate']}")
cleaned = True
if not cleaned:
print(" No build artifacts to clean.")
# =============================================================================
# Main
# =============================================================================
def main():
print(f"Generating {PLUGIN_NAME}.uplugin...")
print(f"Plugin root: {plugin_root}")
print(f"Project root: {project_root}")
# Resolve config path (project-level or plugin-level)
config_info = resolve_submodules_config_path()
if not config_info:
print("PCGExSubModulesConfig.ini not found.", file=sys.stderr)
print("Searched:", file=sys.stderr)
print(f" - {PATHS['project_submodules_config']}", file=sys.stderr)
print(f" - {PATHS['plugin_submodules_config']}", file=sys.stderr)
sys.exit(1)
config_path, is_project_level = config_info
config_source = "project" if is_project_level else "plugin"
print(f"Using {config_source}-level config: {config_path}")
# Get available modules for validation and suggestions
available_modules = get_available_modules()
print(f"Available modules on disk: {len(available_modules)}")
# Parse configs
requested_modules = parse_submodules_config(config_path)
print(f"Requested modules: {len(requested_modules)}")
# Validate requested modules
valid, invalid = validate_requested_modules(requested_modules, available_modules)
if invalid:
print("\n[WARNING] Invalid module names in PCGExSubModulesConfig.ini:", file=sys.stderr)
for item in invalid:
name = item["name"]
suggestion = item["suggestion"]
if suggestion:
print(f" - '{name}' not found. Did you mean '{suggestion}'?", file=sys.stderr)
else:
print(f" - '{name}' not found.", file=sys.stderr)
print(file=sys.stderr)
plugins_deps = parse_plugins_deps(PATHS["plugins_deps"])
print(f"Plugin dependencies defined for: {len(plugins_deps)} modules")
# Resolve all modules (with deps and editor companions)
all_modules = resolve_all_modules(valid)
print(f"Resolved modules (with dependencies): {len(all_modules)}")
# Resolve required plugins
required_plugins = resolve_required_plugins(all_modules, plugins_deps)
print(f"Required plugins: {', '.join(sorted(required_plugins))}")
# Load existing uplugin content (as string for comparison)
existing_content = load_existing_uplugin()
existing_uplugin = json.loads(existing_content)
# Generate new uplugin
new_uplugin = generate_uplugin(existing_uplugin, all_modules, required_plugins)
new_content = json.dumps(new_uplugin, indent=2, ensure_ascii=False)
# Check if content changed
has_changed = existing_content != new_content
if has_changed:
# Clean build artifacts before writing new uplugin
# clean_build_artifacts()
# Write new uplugin
PATHS["uplugin"].write_text(new_content, encoding="utf-8")
print(f"\nGenerated {PATHS['uplugin']}")
else:
print("\n.uplugin unchanged - skipping write.")
print(f" Modules: {len(new_uplugin['Modules'])}")
print(f" Plugins: {len(new_uplugin['Plugins'])}")
# Exit with warning code if there were invalid modules
if invalid:
sys.exit(0) # Still succeeds, but you could use sys.exit(1) to fail the build
if __name__ == "__main__":
main()