// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using EpicGames.Core; using Microsoft.Extensions.Logging; using UnrealBuildBase; namespace UnrealBuildTool.Modes { /// /// Identifies plugins with python requirements and attempts to install all dependencies using pip. /// [ToolMode("PipInstall", ToolModeOptions.XmlConfig | ToolModeOptions.BuildPlatformsHostOnly | ToolModeOptions.SingleInstance | ToolModeOptions.StartPrefetchingEngine | ToolModeOptions.ShowExecutionTime)] class PipInstallMode : ToolMode { private enum ActionBits : byte { NoOp = 0, GenReqs = 1, SetupPip = 2, ParseReqs = 4, InstallReqs = 8, ViewLicenses = 16, } public enum PipAction : byte { OnlySetupParse = ActionBits.SetupPip | ActionBits.ParseReqs, OnlyInstall = ActionBits.InstallReqs, GenRequirements = ActionBits.GenReqs, Setup = GenRequirements | ActionBits.SetupPip, Parse = Setup | ActionBits.ParseReqs, Install = Parse | ActionBits.InstallReqs, ViewLicenses = Install | ActionBits.ViewLicenses, } /// /// Full path to python interpreter engine was built with (if unspecified use value in PythonSDKRoot.txt) /// [CommandLine("-PythonInterpreter=", Description = "Full path to python interpreter to use in case the engine is built against an external python SDK")] public FileReference? PythonInterpreter = null; /// /// The action the pip install tool should implement (GenRequirements, Setup, Parse, Install, ViewLicenses, default: Install) /// [CommandLine("-PipAction=", Description = "Pip action: [GenRequirements, Setup, Parse, Install, ViewLicenses, default: Install]")] public PipAction? Action = null; /// /// Disable requiring hashes in pip install requirements (NOTE: this is insecure and may simplify supply-chain attacks) /// [CommandLine("-IgnoreHashes", Description = "Do not require package hashes (WARNING: Enabling this flag is security risk)")] public bool bIgnoreHashes = false; /// /// Allow overriding the index url (this will also disable extra-urls) /// [CommandLine("-OverrideIndexUrl", Description = "Use the specified index-url (WARNING: Should not be combined with IgnoreHashes)")] public string? OverrideIndexUrl = null; /// /// Run pip installer on all plugins in project and engine /// [CommandLine("-AllPlugins", Description = "Run pip installer on all plugins in project and engine")] public bool bAllPlugins = false; /// /// Execute the command /// /// Command line arguments /// /// Exit code public override Task ExecuteAsync(CommandLineArguments Arguments, ILogger Logger) { // HACK: This env var must be cleared or it carries into python subprocesses and python sys.executable detection breaking venvs Environment.SetEnvironmentVariable("PYTHONEXECUTABLE", null); Arguments.ApplyTo(this); // Create the build configuration object, and read the settings BuildConfiguration BuildConfiguration = new BuildConfiguration(); XmlConfig.ApplyTo(BuildConfiguration); Arguments.ApplyTo(BuildConfiguration); // Parse all the target descriptors List TargetDescriptors = TargetDescriptor.ParseCommandLine(Arguments, BuildConfiguration.bUsePrecompiled, BuildConfiguration.bSkipRulesCompile, BuildConfiguration.bForceRulesCompile, Logger); foreach (TargetDescriptor TargetDescriptor in TargetDescriptors) { //Logger.LogInformation("Pip Installer: {TargetDescriptor}", TargetDescriptor); int retcode = PipInstallProjectDependencies(TargetDescriptor, BuildConfiguration, Logger); if (retcode != 0) { return Task.FromResult(retcode); } } return Task.FromResult(0); } private int PipInstallProjectDependencies(TargetDescriptor TargetDescriptor, BuildConfiguration BuildConfiguration, ILogger Logger) { if (TargetDescriptor.ProjectFile == null) { Logger.LogError("No valid project file for Target: {}", TargetDescriptor.ToString()); return 1; } UEBuildTarget Target = UEBuildTarget.Create(TargetDescriptor, BuildConfiguration, Logger); if (Target.TargetType != TargetType.Editor) { Logger.LogWarning("PipInstall unsupported for non-editor target: {TargetName} (Skipping)", TargetDescriptor.Name); return 0; } Action ??= PipAction.Install; DirectoryReference ProjectDir = DirectoryReference.FromFile(TargetDescriptor.ProjectFile); DirectoryReference InstallDir = DirectoryReference.Combine(ProjectDir, "Intermediate", "PipInstall"); UnrealTargetPlatform Platform = TargetDescriptor.Platform; // Let env variable override pip install path DirectoryReference? EnvInstallPath = DirectoryReference.FromString(Environment.GetEnvironmentVariable("UE_PIPINSTALL_PATH")); if (EnvInstallPath != null) { InstallDir = EnvInstallPath; } PipEnv Pip = new(InstallDir, Platform, Logger, ProgressWriter.bWriteMarkup); if ((Action & (PipAction)ActionBits.GenReqs) != 0) { // Make sure the virtual environment used for installs is compatible with python interpreter version Pip.RemoveInvalidVenv(PythonInterpreter); Pip.WritePluginsListing(Target, Logger, bAllPlugins); if (!Pip.WritePluginDependencies()) { return 1; } } if ((Action & (PipAction)ActionBits.SetupPip) != 0) { if (!Pip.SetupPipEnv(PythonInterpreter)) { return 1; } } if ((Action & (PipAction)ActionBits.ParseReqs) != 0) { if (!Pip.ParsePluginDependencies(!bIgnoreHashes)) { return 1; } } if ((Action & (PipAction)ActionBits.InstallReqs) != 0) { if (!Pip.InstallPluginDependencies(!bIgnoreHashes, false, OverrideIndexUrl)) { return 1; } } if ((Action & (PipAction)ActionBits.ViewLicenses) != 0) { if (!Pip.ViewInstalledLicenses()) { return 1; } } return 0; } } /// /// PipEnv helper class for setting up a self-contained pip environment and running pip commands (particularly pip install) within that env. /// class PipEnv { // Don't bother to re-install pip install tools if this version is already installed // NOTE: This version must also be changed in PipInstall.cpp in order to support editor startup process private const string PipInstallUtilsVer = "0.1.5"; // Generated from enabled plugins list private const string PluginsListingFilename = "pyreqs_plugins.list"; // List full-paths to all enabled plugins site-package dirs (general/current platform) private const string PluginsSitePackageFilename = "plugin_site_package.pth"; // This is the unparsed merged requirements file private const string MergedReqsInFilename = "merged_requirements.in"; // These files are used as input for the pip installer private const string ExtraUrlsFilename = "extra_urls.txt"; private const string MergedRequirementsFilename = "merged_requirements.txt"; private UnrealTargetPlatform TargetPlatform; private DirectoryReference InstallDir; private FileReference PythonVenvExe; private string? PythonVenvVer; private ILogger Logger; private IBaseProgressLogFactory LoggerFactory; public PipEnv(DirectoryReference InInstallDir, UnrealTargetPlatform InPlatform, ILogger InLogger, bool UseProgressWriter = false) { Logger = InLogger; InstallDir = InInstallDir; TargetPlatform = InPlatform; LoggerFactory = (UseProgressWriter) ? new PipProgressLogCreator(Logger) : new SimpleCmdLogCreator(Logger); PythonVenvExe = GetVenvInterpreter(InstallDir, TargetPlatform); PythonVenvVer = ParseVenvVersion(InstallDir); } public void RemoveInvalidVenv(FileReference? EnginePython) { // Invalid or non-existent virtual env if (PythonVenvVer == null) { // Only delete virtual environment if version mismatch FileReference VenvConfig = FileReference.Combine(InstallDir, "pyvenv.cfg"); if (FileReference.Exists(VenvConfig)) { CleanVenvDir(); } return; } FileReference EnginePythonInterp = GetEnginePythonInterpreter(EnginePython); if (!FileReference.Exists(EnginePythonInterp)) { Logger.LogError("PipInstall: Invalid path to UE python interpreter: {Interp}", EnginePythonInterp.ToString()); return; } using (IBaseCmdProgressLogger SimpleLogger = new SimpleCmdLogger(Logger)) { const string PyInterpVerCheckCmd = "import sys; exit(0) if f'{sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]}' == sys.argv[1] else exit(1)"; if (RunPythonCmd(EnginePythonInterp, $"-c \"{PyInterpVerCheckCmd}\" \"{PythonVenvVer}\"", SimpleLogger) != 0) { Logger.LogWarning("PipInstall: Found Incompatible virtual environment ({VenvVer}), removing...", PythonVenvVer); CleanVenvDir(); } } } public void WritePluginsListing(UEBuildTarget Target, ILogger Logger, bool bAllPlugins = false) { // TODO: The path listing file won't match the .pth generated in-engine. // In particular the additional paths setting FileReference PluginsListingFile = FileReference.Combine(InstallDir, PluginsListingFilename); DirectoryReference PipSitePackagesPath = DirectoryReference.Combine(InstallDir, "Lib", "site-packages"); FileReference PyPluginsSitePackageFile = FileReference.Combine(PipSitePackagesPath, PluginsSitePackageFilename); if (FileReference.Exists(PluginsListingFile)) { FileReference.Delete(PluginsListingFile); } List CheckPlugins = new List(); if (bAllPlugins) { CheckPlugins.AddAll(Plugins.ReadEnginePlugins(Unreal.EngineDirectory).ToArray()); CheckPlugins.AddAll(Plugins.ReadProjectPlugins(Target.ProjectDirectory).ToArray()); } else if (Target.EnabledPlugins != null) { foreach (UEBuildPlugin Plugin in Target.EnabledPlugins) { CheckPlugins.Add(Plugin.Info); } } else { return; } List PluginsSitePackages = new List(); List PluginsList = new List(); foreach (PluginInfo Plugin in CheckPlugins) { DirectoryReference PythonContentPath = DirectoryReference.Combine(Plugin.Directory, "Content", "Python"); DirectoryReference PluginPlatformSitePackagesPath = DirectoryReference.Combine(PythonContentPath, "Lib", Target.Platform.ToString(), "site-packages"); DirectoryReference PluginGeneralSitePackagesPath = DirectoryReference.Combine(PythonContentPath, "Lib", "site-packages"); // Write platform/general site-packages paths per-plugin to .pth file to account for packaged python dependencies during pip install if (DirectoryReference.Exists(PluginPlatformSitePackagesPath)) { PluginsSitePackages.Add(PluginPlatformSitePackagesPath.ToString()); } if (DirectoryReference.Exists(PluginGeneralSitePackagesPath)) { PluginsSitePackages.Add(PluginPlatformSitePackagesPath.ToString()); } if (!JsonObject.TryRead(Plugin.File, out JsonObject? PluginJson)) { Logger.LogWarning("Unable to parse {PluginFile}", Plugin.File.ToString()); continue; } foreach (JsonObject PlatformReqs in PipEnv.CompatibleRequirements(PluginJson, Target.Platform)) { PluginsList.Add(Plugin.File.ToString()); break; } } // Add UE_PYTHONPATH to .pth file string? UEPythonPaths = Environment.GetEnvironmentVariable("UE_PYTHONPATH"); if (UEPythonPaths != null) { string[] EnvPaths = UEPythonPaths.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); foreach (string EnvPath in EnvPaths) { PluginsSitePackages.Add(EnvPath); } } if (!DirectoryReference.Exists(PipSitePackagesPath)) { DirectoryReference.CreateDirectory(PipSitePackagesPath); } FileReference.WriteAllLines(PluginsListingFile, PluginsList); FileReference.WriteAllLines(PyPluginsSitePackageFile, PluginsSitePackages); } public bool WritePluginDependencies() { FileReference PluginsListingFile = FileReference.Combine(InstallDir, PluginsListingFilename); FileReference MergedReqsInFile = FileReference.Combine(InstallDir, MergedReqsInFilename); FileReference ExtraUrlsFile = FileReference.Combine(InstallDir, ExtraUrlsFilename); // Make merged requirements input file if (!MergeRequirements(PluginsListingFile, out List? MergedRequirements, out List? ExtraIndexUrls)) { return false; } FileReference.WriteAllLines(MergedReqsInFile, MergedRequirements!); FileReference.WriteAllLines(ExtraUrlsFile, ExtraIndexUrls!); return true; } public bool SetupPipEnv(FileReference? EnginePython, bool ForceRebuild = false) { using (IBaseCmdProgressLogger CmdLogger = LoggerFactory.Create("Creating pip installer virtual environment", 5)) { if (!ForceRebuild && FileReference.Exists(PythonVenvExe)) { return SetupPipInstallUtils(CmdLogger); } if (ForceRebuild && DirectoryReference.Exists(InstallDir)) { DirectoryReference.Delete(InstallDir, true); } FileReference EnginePythonInterp = GetEnginePythonInterpreter(EnginePython); if (!FileReference.Exists(EnginePythonInterp)) { Logger.LogError("PipInstall: Invalid path to UE python interpreter: {Interp}", EnginePythonInterp.ToString()); return false; } int result = RunPythonCmd(EnginePythonInterp, $"-m venv \"{InstallDir}\"", CmdLogger); if (result != 0 || !FileReference.Exists(PythonVenvExe)) { return false; } return SetupPipInstallUtils(CmdLogger); } } public bool ParsePluginDependencies(bool bPipStrictHashCheck = true) { FileReference MergedReqsInFile = FileReference.Combine(InstallDir, MergedReqsInFilename); FileReference MergedRequirmentsFile = FileReference.Combine(InstallDir, MergedRequirementsFilename); // NOTE: Hashes are all-or-nothing so if we are ignoring, just remove them all with the parser string DisableHashing = ""; if (!bPipStrictHashCheck) { DisableHashing = "--disable-hashes"; } using (IBaseCmdProgressLogger CmdLogger = new PythonCmdLogger(Logger)) { return (RunPythonVenv($"-m ue_parse_plugin_reqs {DisableHashing} -vv \"{MergedReqsInFile}\" \"{MergedRequirmentsFile}\"", CmdLogger) == 0); } } public bool InstallPluginDependencies(bool bPipStrictHashCheck = true, bool OfflineOnly = false, string? ForceIndexUrl = null) { FileReference MergedRequirementsFile = FileReference.Combine(InstallDir, MergedRequirementsFilename); FileReference ExtraUrlsFile = FileReference.Combine(InstallDir, ExtraUrlsFilename); if (!FileReference.Exists(MergedRequirementsFile)) { return true; } string[]? ExtraUrls = null; if (FileReference.Exists(ExtraUrlsFile)) { ExtraUrls = FileReference.ReadAllLines(ExtraUrlsFile); } // Pip install from merged requirments // TODO: Support pip-tools compile/sync system return PipInstall(MergedRequirementsFile, ExtraUrls, bPipStrictHashCheck, OfflineOnly, ForceIndexUrl); } public bool ViewInstalledLicenses() { // TODO: Check that install is up to date and reverse-map package to plugin requirements //DirectoryReference WorkDir = DirectoryReference.FromFile(PluginsListingFile); using (IBaseCmdProgressLogger CmdLogger = new PythonCmdLogger(Logger)) { return (RunPythonVenv($"-m ue_py_license_check -vv", CmdLogger) == 0); } } public static IEnumerable CompatibleRequirements(JsonObject PluginJson, UnrealTargetPlatform Platform) { // Check for python requirements field if (!PluginJson.TryGetObjectArrayField("PythonRequirements", out JsonObject[]? RequirementsJson)) { yield break; } foreach (JsonObject PlatformReqs in RequirementsJson) { PlatformReqs.TryGetStringField("Platform", out string? PlatformField); if (!CompatiblePlatform(PlatformField, Platform)) { continue; } yield return PlatformReqs; } } private static bool CompatiblePlatform(string? PlatformField, UnrealTargetPlatform Platform) { return (PlatformField == null) || String.Equals(PlatformField, Platform.ToString(), StringComparison.InvariantCultureIgnoreCase) || String.Equals(PlatformField, "All", StringComparison.InvariantCultureIgnoreCase); } private string? ParseVenvVersion(DirectoryReference VenvDir) { FileReference VenvConfig = FileReference.Combine(VenvDir, "pyvenv.cfg"); if (!FileReference.Exists(VenvConfig)) { return null; } string ConfigInfo = FileReference.ReadAllText(VenvConfig); Match m = Regex.Match(ConfigInfo, @"version\s*=\s*(\d+\.\d+\.\d+)", RegexOptions.IgnoreCase); if (!m.Success) { Logger.LogWarning("PipInstall: Unable to match venv version config: {VenvFile}", ConfigInfo); return null; } return m.Groups[1].Value; } private void CleanVenvDir() { if (!DirectoryReference.Exists(InstallDir)) { DirectoryReference.CreateDirectory(InstallDir); return; } // HACK: On windows these script files are set read-only and can't be deleted foreach (FileReference File in DirectoryReference.EnumerateFiles(DirectoryReference.Combine(InstallDir, "Scripts"))) { FileReference.SetAttributes(File, FileAttributes.Normal); } DirectoryReference.Delete(InstallDir, true); DirectoryReference.CreateDirectory(InstallDir); } private bool PipInstall(FileReference RequirementsFile, string[]? ExtraUrls, bool bPipStrictHashCheck, bool OfflineOnly, string? ForceIndexUrl) { string[] Reqs = FileReference.ReadAllLines(RequirementsFile); int RequirementsCount = Reqs.Length; if (RequirementsCount == 0) { return true; } string Args = "-m pip install --disable-pip-version-check --only-binary=:all:"; if (bPipStrictHashCheck) { Args += " --require-hashes"; } if (OfflineOnly) { Args += " --no-index"; } else if (ForceIndexUrl != null) { Args += "--index-url " + ForceIndexUrl; } else if (ExtraUrls != null) { foreach (string Url in ExtraUrls) { Args += " --extra-index-url " + Url; } } Args += " -r \"" + RequirementsFile.ToString() + "\""; using (IBaseCmdProgressLogger StatusLogger = LoggerFactory.Create("Installing Python Dependencies...", RequirementsCount)) { int Result = RunPythonVenv(Args, StatusLogger); return (Result == 0); } } private bool MergeRequirements(FileReference PluginsListingFile, out List? MergedRequirements, out List? ExtraIndexUrls) { ExtraIndexUrls = null; MergedRequirements = null; if (!FileReference.Exists(PluginsListingFile)) { return false; } // TODO: Support --index-url in per-plugin manner (split pip calls when necessary) List RequirementsList = new(); List ExtraUrlsList = new(); string[] PythonPlugins = FileReference.ReadAllLines(PluginsListingFile); foreach (string PluginPath in PythonPlugins) { FileReference PluginFile = FileReference.FromString(PluginPath); if (!FileReference.Exists(PluginFile)) { continue; } JsonObject PluginJson = JsonObject.Read(PluginFile); // This shouldn't happen here as we pre-filter, but check in case that changes if (!PluginJson.TryGetObjectArrayField("PythonRequirements", out JsonObject[]? RequirementsJson)) { Logger.LogDebug("PipInstall: Warning: Plugin has no requirements (but in lising): {Plugin}", PluginFile.ToString()); continue; } foreach (JsonObject PlatformReqs in CompatibleRequirements(PluginJson, TargetPlatform)) { if (!PlatformReqs.TryGetStringArrayField("Requirements", out string[]? Reqs)) { Logger.LogError("PythonRequirements missing 'Requirements' field in {Plugin} (Skipping)", PluginsListingFile.ToString()); continue; } if (PlatformReqs.TryGetStringArrayField("ExtraIndexUrls", out string[]? ExtraUrls)) { ExtraUrlsList.AddRange(ExtraUrls); } foreach (string Req in Reqs) { RequirementsList.Add(Req + " # " + PluginFile.GetFileName()); } } } RequirementsList.Sort(); ExtraIndexUrls = ExtraUrlsList; MergedRequirements = RequirementsList; return true; } public bool CheckPipInstallUtils() { // Verify that correct version of pip install utils is already available string Args = $"-c \"import pkg_resources;dist=pkg_resources.working_set.find(pkg_resources.Requirement.parse('ue-pipinstall-utils'));exit(dist.version!='{PipInstallUtilsVer}' if dist is not None else 1)\""; using IBaseCmdProgressLogger CmdLogger = new PythonCmdLogger(Logger); return (RunPythonVenv(Args, CmdLogger) == 0); } private bool SetupPipInstallUtils(IBaseCmdProgressLogger StatusLogger) { if (CheckPipInstallUtils()) { return true; } Logger.LogInformation("PipInstall: Updating UE PipInstall Utilities"); FileReference? PythonScriptPlugin = GetPythonScriptPlugin(); if (PythonScriptPlugin == null) { Logger.LogError("PipInstall: Unable to locate engine PythonScriptPlugin"); return false; } DirectoryReference PythonScriptDir = DirectoryReference.FromFile(PythonScriptPlugin); DirectoryReference PipInstallUtilsDir = DirectoryReference.Combine(PythonScriptDir, "Content", "Python", "PipInstallUtils"); DirectoryReference PipWheelsDir = DirectoryReference.Combine(PythonScriptDir, "Content", "Python", "Lib", "wheels"); FileReference RequirementsFile = FileReference.Combine(PipInstallUtilsDir, "requirements.txt"); string Args = $"-m pip install --upgrade --no-index --find-links \"{PipWheelsDir}\" -r \"{RequirementsFile}\" ue-pipinstall-utils=={PipInstallUtilsVer}"; int Result = RunPythonVenv(Args, StatusLogger); return (Result == 0); } private int RunPythonVenv(string Args, IBaseCmdProgressLogger InCmdLogger) { return RunPythonCmd(PythonVenvExe, Args, InCmdLogger); } private int RunPythonCmd(FileReference PythonBin, string Args, IBaseCmdProgressLogger InCmdLogger) { Logger.LogDebug("PythonCmd: {PythonBin} {Args}", PythonBin.ToString(), Args); Process Process = new Process(); Process.StartInfo.WindowStyle = ProcessWindowStyle.Hidden; Process.StartInfo.CreateNoWindow = true; Process.StartInfo.UseShellExecute = false; Process.StartInfo.RedirectStandardInput = true; Process.StartInfo.RedirectStandardOutput = true; Process.StartInfo.RedirectStandardError = true; Process.StartInfo.FileName = PythonBin.ToString(); Process.StartInfo.Arguments = Args; Process.OutputDataReceived += (object o, DataReceivedEventArgs args) => { InCmdLogger.OutputData(args); }; Process.ErrorDataReceived += (object o, DataReceivedEventArgs args) => { InCmdLogger.ErrorData(args); }; Process.Start(); Process.BeginOutputReadLine(); Process.BeginErrorReadLine(); Process.WaitForExit(); return Process.ExitCode; } private FileReference GetEnginePythonInterpreter(FileReference? EnginePython) { if (EnginePython != null) { return EnginePython; } DirectoryReference EngineDir = Unreal.EngineDirectory; DirectoryReference PythonSDKRoot = DirectoryReference.Combine(EngineDir, "Binaries", "ThirdParty", "Python3", TargetPlatform.ToString()); FileReference PythonSDKFile = FileReference.Combine(PythonSDKRoot, "PythonSDKRoot.txt"); if (FileReference.Exists(PythonSDKFile)) { string SDKRootText = FileReference.ReadAllText(PythonSDKFile).Trim(); PythonSDKRoot = new DirectoryReference(SDKRootText.Replace("{ENGINE_DIR}", EngineDir.ToString())); } else { Logger.LogWarning("PipInstall: PythonSDKRoot.txt does not exist, assming engine was built using internal python."); } return GetBaseInterpreter(PythonSDKRoot, TargetPlatform); } private FileReference? GetPythonScriptPlugin() { List AllPlugins = Plugins.ReadAvailablePlugins(Unreal.EngineDirectory, null, null); PluginInfo? ScriptPlugin = AllPlugins.Find(x => x.Name == "PythonScriptPlugin"); return ScriptPlugin?.File; } static FileReference GetVenvInterpreter(DirectoryReference VenvDir, UnrealTargetPlatform InPlatform) { if (InPlatform.IsInGroup(UnrealPlatformGroup.Windows)) { return FileReference.Combine(VenvDir, "Scripts", "python.exe"); } else { return FileReference.Combine(VenvDir, "bin", "python3"); } } static FileReference GetBaseInterpreter(DirectoryReference PyDir, UnrealTargetPlatform InPlatform) { if (InPlatform.IsInGroup(UnrealPlatformGroup.Windows)) { return FileReference.Combine(PyDir, "python.exe"); } else { return FileReference.Combine(PyDir, "bin", "python3"); } } } /// /// Simple interface for logging command stdout/stderr with progress tags if supported /// interface IBaseCmdProgressLogger : IDisposable { public void OutputData(DataReceivedEventArgs DataLine); public void ErrorData(DataReceivedEventArgs ErrorLine); public void FinishProgress(); } interface IBaseProgressLogFactory { public IBaseCmdProgressLogger Create(string Message, int GuessSteps); } /// /// Simple factory types so that users don't need to implement the factory /// class SimpleCmdLogCreator : IBaseProgressLogFactory { private ILogger Logger; public SimpleCmdLogCreator(ILogger InLogger) { Logger = InLogger; } public IBaseCmdProgressLogger Create(string Message, int GuessSteps) { return new SimpleCmdLogger(Logger); } } class PipProgressLogCreator : IBaseProgressLogFactory { private ILogger Logger; public PipProgressLogCreator(ILogger InLogger) { Logger = InLogger; } public IBaseCmdProgressLogger Create(string Message, int GuessSteps) { return new PipProgressLogger(Logger, Message, GuessSteps); } } /// /// Basic command logger which just echos output/errors to Logger (indent stdout data by 2 spaces) /// class SimpleCmdLogger : IBaseCmdProgressLogger { private ILogger Logger; public SimpleCmdLogger(ILogger InLogger) { Logger = InLogger; } public void OutputData(DataReceivedEventArgs DataLine) { if (String.IsNullOrEmpty(DataLine.Data)) { return; } Logger.LogInformation(" {Data}", DataLine.Data); } public void ErrorData(DataReceivedEventArgs ErrorLine) { if (String.IsNullOrEmpty(ErrorLine.Data)) { return; } Logger.LogError("{ErrorData}", ErrorLine.Data); } public void Dispose() { } public void FinishProgress() { } } /// /// Pyuthon command wraps python logging facility outputs to appropriate log channel /// class PythonCmdLogger : IBaseCmdProgressLogger { private ILogger Logger; public PythonCmdLogger(ILogger InLogger) { Logger = InLogger; } public void OutputData(DataReceivedEventArgs DataLine) { // NOTE: By default python's logging functionality writes to stderr (at least on windows) // but we run this code for both stdout/stderr anyway if (String.IsNullOrEmpty(DataLine.Data)) { return; } HandleLogging(DataLine.Data); } public void ErrorData(DataReceivedEventArgs ErrorLine) { // NOTE: By default python's logging functionality writes to stderr (at least on windows) // but we run this code for both stdout/stderr anyway if (String.IsNullOrEmpty(ErrorLine.Data)) { return; } HandleLogging(ErrorLine.Data); } public void Dispose() { } public void FinishProgress() { } private void HandleLogging(string LogLine) { SplitOn(LogLine, ':', out string CheckTag, out string? LineTag); if (LineTag == null) { Logger.LogInformation("{Data}", LogLine); return; } switch (CheckTag) { case "DEBUG": Logger.LogDebug(" {Line}", SplitRight(LineTag, ':').Trim()); break; case "INFO": Logger.LogInformation(" {Line}", SplitRight(LineTag, ':').Trim()); break; case "WARNING": Logger.LogWarning("Warning: {Line}", SplitRight(LineTag, ':').Trim()); break; case "ERROR": Logger.LogError("Error: {Line}", SplitRight(LineTag, ':').Trim()); break; case "CRITICAL": Logger.LogCritical("Error: {Line}", SplitRight(LineTag, ':').Trim()); break; default: Logger.LogInformation(" {Line}", LogLine); break; } } private static void SplitOn(string InStr, char SplitChar, out string Left, out string? Right) { Left = InStr; Right = null; int SplitIdx = InStr.IndexOf(SplitChar); if (SplitIdx < 0) { return; } Left = InStr.Substring(0, SplitIdx); Right = InStr.Substring(SplitIdx + 1); } private static string SplitRight(string InStr, char SplitChar) { SplitOn(InStr, SplitChar, out string Left, out string? Right); // Return the string if the split works if (Right == null) { return Left; } return Right; } } /// /// Basic command writer that redirects stdout to file, and writes stderr normally (used to output pip freeze to file) /// class FileCmdLogger : IBaseCmdProgressLogger { private ILogger Logger; private TextWriter TextWriter; public FileCmdLogger(FileReference InTargetFile, ILogger InLogger) { Logger = InLogger; TextWriter = new StreamWriter(InTargetFile.ToString()); } public void OutputData(DataReceivedEventArgs DataLine) { if (String.IsNullOrEmpty(DataLine.Data)) { return; } TextWriter.WriteLine(DataLine.Data.Trim()); } public void ErrorData(DataReceivedEventArgs ErrorLine) { if (String.IsNullOrEmpty(ErrorLine.Data)) { return; } Logger.LogError("{ErrorData}", ErrorLine.Data); } public void Dispose() { TextWriter.Close(); } public void FinishProgress() { } } /// /// Simple interface for logging command stdout/stderr with progress tags if supported /// class PipProgressLogger : IBaseCmdProgressLogger { private ILogger Logger; private ProgressWriter Writer; private int StepsDone; private int TotalSteps; // Start strings to use private static readonly string[] MatchStrs = { "Requirement", "Collecting", "Installing" }; private readonly Dictionary LogReplaceStrs = new(); public PipProgressLogger(ILogger InLogger, string message, int GuessSteps) { Logger = InLogger; StepsDone = 0; TotalSteps = Math.Max(GuessSteps, 1); Writer = new ProgressWriter(message, true, Logger); LogReplaceStrs["Installing collected packages:"] = "Installing collected python package dependencies:"; } static bool CheckUpdateStr(string CheckStr) { foreach (string ProgressMatch in MatchStrs) { if (CheckStr.StartsWith(ProgressMatch, StringComparison.InvariantCultureIgnoreCase)) { return true; } } return false; } string ReplaceMatchStr(string CheckStr) { foreach (KeyValuePair ChkPair in LogReplaceStrs) { if (CheckStr.Contains(ChkPair.Key)) { return CheckStr.Replace(ChkPair.Key, ChkPair.Value); } } return CheckStr; } public void OutputData(DataReceivedEventArgs DataLine) { // Currently we assume only one pip command so it should be finished on (null) if (String.IsNullOrEmpty(DataLine.Data)) { return; } string CheckStr = DataLine.Data.Trim(); bool ShouldUpdate = CheckUpdateStr(CheckStr); if (ShouldUpdate) { CheckStr = ReplaceMatchStr(CheckStr); Writer = new ProgressWriter(CheckStr, true, Logger); Writer.Write(StepsDone, TotalSteps); StepsDone += 1; TotalSteps = Math.Max(TotalSteps, StepsDone + 1); } else { Logger.LogInformation("{CheckStr}", CheckStr); } } public void Dispose() { FinishProgress(); Writer.Dispose(); } public void FinishProgress() { StepsDone = TotalSteps; Writer.Write(StepsDone, TotalSteps); } public void ErrorData(DataReceivedEventArgs ErrorLine) { if (String.IsNullOrEmpty(ErrorLine.Data)) { return; } Logger.LogError("PipInstall: {ErrorData}", ErrorLine.Data); } } }