diff --git a/Python.Deployment/DownloadInstallationSource.cs b/Python.Deployment/DownloadInstallationSource.cs index aed6dc1..7a695a3 100644 --- a/Python.Deployment/DownloadInstallationSource.cs +++ b/Python.Deployment/DownloadInstallationSource.cs @@ -44,7 +44,7 @@ public class DownloadInstallationSource : InstallationSource /// public string DownloadUrl { get; set; } - public override async Task RetrievePythonZip(string destinationDirectory) + public override async Task RetrievePythonZip(string destinationDirectory, Action progress = null, CancellationToken token = default) { var zipFile = Path.Combine(destinationDirectory, GetPythonZipFileName()); if (!Force && File.Exists(zipFile)) @@ -53,7 +53,7 @@ public override async Task RetrievePythonZip(string destinationDirectory try { Log("Downloading source..."); - await Downloader.Download(DownloadUrl, zipFile, progress => Log($"{progress:F2}%")).ConfigureAwait(false); + await Downloader.Download(DownloadUrl, zipFile, p => { Log($"{p:F2}%"); progress?.Invoke(p); }, token).ConfigureAwait(false); Log("Done!"); return zipFile; } diff --git a/Python.Deployment/EmbeddedResourceInstallationSource.cs b/Python.Deployment/EmbeddedResourceInstallationSource.cs index 57edd38..798fa65 100644 --- a/Python.Deployment/EmbeddedResourceInstallationSource.cs +++ b/Python.Deployment/EmbeddedResourceInstallationSource.cs @@ -28,6 +28,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE using System.Linq; using System.Reflection; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace Python.Deployment @@ -51,7 +52,7 @@ public class EmbeddedResourceInstallationSource : InstallationSource /// public string ResourceName { get; set; } - public override Task RetrievePythonZip(string destinationDirectory) + public override Task RetrievePythonZip(string destinationDirectory, Action progress = null, CancellationToken token = default) { var filePath = Path.Combine(destinationDirectory, ResourceName); if (!Force && File.Exists(filePath)) diff --git a/Python.Deployment/InstallationSource.cs b/Python.Deployment/InstallationSource.cs index 5c36055..ef1e352 100644 --- a/Python.Deployment/InstallationSource.cs +++ b/Python.Deployment/InstallationSource.cs @@ -29,6 +29,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE using System.Reflection; using System.Text; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; namespace Python.Deployment @@ -46,7 +47,7 @@ public abstract class InstallationSource /// /// The directory location where the retrieved zip file should be placed /// - public abstract Task RetrievePythonZip(string destinationDirectory); + public abstract Task RetrievePythonZip(string destinationDirectory, Action progress = null, CancellationToken token = default); /// /// If true, retrieve the python file again even if it already exists at the destination path diff --git a/Python.Deployment/Installer.cs b/Python.Deployment/Installer.cs index 2d660d1..cb8afa0 100644 --- a/Python.Deployment/Installer.cs +++ b/Python.Deployment/Installer.cs @@ -29,6 +29,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE using System.Linq; using System.Reflection; using System.Runtime.InteropServices; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -73,12 +74,12 @@ private static void Log(string message) LogMessage?.Invoke(message); } - public static async Task SetupPython(bool force = false) + public static async Task SetupPython(Action progress = null, CancellationToken token = default, bool force = false) { Environment.SetEnvironmentVariable("PATH", $"{EmbeddedPythonHome};" + Environment.GetEnvironmentVariable("PATH")); if (!force && Directory.Exists(EmbeddedPythonHome) && File.Exists(Path.Combine(EmbeddedPythonHome, "python.exe"))) // python seems installed, so exit return; - var zip = await Source.RetrievePythonZip(InstallPath).ConfigureAwait(false); + var zip = await Source.RetrievePythonZip(InstallPath, progress, token).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(zip)) { Log("SetupPython: Error obtaining zip file from installation source"); @@ -242,7 +243,7 @@ public static async Task PipInstallWheel(Assembly assembly, string resource_name CopyEmbeddedResourceToFile(assembly, key, wheelPath, force); - await TryInstallPip().ConfigureAwait(false); + await TryInstallPip(token: token).ConfigureAwait(false); await RunCommand($"\"{pipPath}\" install \"{wheelPath}\"", token).ConfigureAwait(false); } @@ -289,19 +290,21 @@ public static string GetResourceKey(Assembly assembly, string embedded_file) /// terminate when complete. When true, the command window must be manually closed before /// processing will continue. /// - public static async Task PipInstallModule(string module_name, string version = "", bool force = false, CancellationToken token = default) + public static async Task PipInstallModule(string module_name, string version = "", bool force = false, Action progress = null, CancellationToken token = default) { - await TryInstallPip().ConfigureAwait(false); + await TryInstallPip(progress, token, force).ConfigureAwait(false); if (IsModuleInstalled(module_name) && !force) return; - string pipPath = Path.Combine(EmbeddedPythonHome, "Scripts", "pip"); string forceInstall = force ? " --force-reinstall" : ""; if (version.Length > 0) version = $"=={version}"; - await RunCommand($"\"{pipPath}\" install \"{module_name}{version}\" {forceInstall}", token).ConfigureAwait(false); + await RunPipCommand( + $"-u -m pip install \"{module_name}{version}\" --no-cache-dir --progress-bar raw{forceInstall}", + token, + progress).ConfigureAwait(false); } /// @@ -315,7 +318,7 @@ public static async Task PipInstallModule(string module_name, string version = " /// terminate when complete. When true, the command window must be manually closed before /// processing will continue. /// - public static async Task InstallPip(CancellationToken token = default) + public static async Task InstallPip(Action progress = null, CancellationToken token = default) { string libDir = Path.Combine(EmbeddedPythonHome, "Lib"); @@ -328,7 +331,7 @@ public static async Task InstallPip(CancellationToken token = default) try { Log("Downloading Pip..."); - await Downloader.Download(getPipUrl, getPipFilePath, progress => Log($"{progress:F2}%")).ConfigureAwait(false); + await Downloader.Download(getPipUrl, getPipFilePath, p => { Log($"{p:F2}%"); progress?.Invoke(p); }).ConfigureAwait(false); Log("Done!"); } catch (Exception ex) @@ -337,17 +340,16 @@ public static async Task InstallPip(CancellationToken token = default) return; } - await RunCommand($"cd \"{EmbeddedPythonHome}\" && python.exe Lib\\get-pip.py", token).ConfigureAwait(false); } - public static async Task TryInstallPip(bool force = false) + public static async Task TryInstallPip(Action progress = null, CancellationToken token = default, bool force = false) { if (!IsPipInstalled() || force) { try { - await InstallPip().ConfigureAwait(false); + await InstallPip(progress, token).ConfigureAwait(false); } catch { @@ -449,6 +451,126 @@ public static async Task RunCommand(string command, CancellationToken token) } } + private static async Task RunPipCommand(string pipArguments, CancellationToken token, Action progress = null) + { + Process process = new Process(); + try + { + var pythonPath = Path.Combine(EmbeddedPythonHome, "python.exe"); + + Log($"> \"{pythonPath}\" {pipArguments}"); + + var startInfo = new ProcessStartInfo + { + FileName = pythonPath, + WorkingDirectory = EmbeddedPythonHome, + Arguments = pipArguments, + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + WindowStyle = ProcessWindowStyle.Hidden, + }; + + // pip-specific output/format settings + startInfo.Environment["PYTHONUNBUFFERED"] = "1"; + startInfo.Environment["PYTHONIOENCODING"] = "utf-8"; + startInfo.Environment["PIP_NO_COLOR"] = "1"; + startInfo.Environment["COLUMNS"] = "200"; + + process.StartInfo = startInfo; + process.Start(); + + token.Register(() => + { + try { if (!process.HasExited) process.Kill(); } + catch (Exception) { /* ignore */ } + }); + + var readStdOut = ReadStreamAsync(process.StandardOutput, line => + { + Log($"[OUT] '{line}'"); + ParsePipProgress(line, progress); + }, token); + + var readStdErr = ReadStreamAsync(process.StandardError, line => + { + Log($"[ERR] '{line}'"); + Console.WriteLine(line); + }, token); + + await Task.WhenAll(readStdOut, readStdErr).ConfigureAwait(false); + await Task.Run(() => process.WaitForExit(), token).ConfigureAwait(false); + + if (process.ExitCode == 0) + progress?.Invoke(100.0f); + + Log(" => exit code " + process.ExitCode); + } + catch (OperationCanceledException) + { + Log("RunPipCommand: Cancelled"); + } + catch (Exception e) + { + Log($"RunPipCommand: Error with arguments: '{pipArguments}'\r\n{e.Message}"); + } + finally + { + process?.Dispose(); + } + } + + private static async Task ReadStreamAsync(StreamReader reader, Action callback, CancellationToken token) + { + var sb = new System.Text.StringBuilder(); + char[] buf = new char[1]; + + while (!reader.EndOfStream && !token.IsCancellationRequested) + { + int read = await reader.ReadAsync(buf, 0, 1).ConfigureAwait(false); + if (read == 0) break; + + char c = buf[0]; + if (c == '\n' || c == '\r') + { + if (sb.Length > 0) + { + callback(sb.ToString()); + sb.Clear(); + } + } + else + { + sb.Append(c); + } + } + + if (sb.Length > 0) + callback(sb.ToString()); + } + + private static void ParsePipProgress(string line, Action progress) + { + if (progress == null) return; + + var match = System.Text.RegularExpressions.Regex.Match( + line, @"Progress (\d+) of (\d+)" + ); + + if (match.Success) + { + if (long.TryParse(match.Groups[1].Value, out long current) && + long.TryParse(match.Groups[2].Value, out long total) && + total > 0) + { + float percent = (float)current / total * 100f; + progress(Math.Min(Math.Max(percent, 0f), 99f)); + } + } + } + private static bool AreAllFilesAlreadyPresent(ZipArchive zip, string lib) { var allFilesAllReadyPresent = true; @@ -465,4 +587,4 @@ private static bool AreAllFilesAlreadyPresent(ZipArchive zip, string lib) return allFilesAllReadyPresent; } } -} +} \ No newline at end of file diff --git a/Python.Included/Installer.cs b/Python.Included/Installer.cs index af13084..908ff98 100644 --- a/Python.Included/Installer.cs +++ b/Python.Included/Installer.cs @@ -61,7 +61,7 @@ private static void Log(string message) LogMessage?.Invoke(message); } - public static async Task SetupPython(bool force = false) + public static async Task SetupPython(Action progress = null, CancellationToken token = default, bool force = false) { if (!PythonEnv.DeployEmbeddedPython) return; @@ -75,7 +75,7 @@ public static async Task SetupPython(bool force = false) Python.Deployment.Installer.Source = GetInstallationSource(); Python.Deployment.Installer.PythonDirectoryName = InstallDirectory; Python.Deployment.Installer.InstallPath = InstallPath; - await Python.Deployment.Installer.SetupPython(force).ConfigureAwait(false); + await Python.Deployment.Installer.SetupPython(progress, token, force).ConfigureAwait(false); } finally { @@ -149,7 +149,7 @@ public static async Task PipInstallWheel(Assembly assembly, string resource_name /// /// The module/package to install /// When true, reinstall the packages even if it is already up-to-date. - public static async Task PipInstallModule(string module_name, string version = "", bool force = false, CancellationToken token = default) + public static async Task PipInstallModule(string module_name, string version = "", bool force = false, Action progress = null, CancellationToken token = default) { try { @@ -157,7 +157,7 @@ public static async Task PipInstallModule(string module_name, string version = " Python.Deployment.Installer.Source = GetInstallationSource(); Python.Deployment.Installer.PythonDirectoryName = InstallDirectory; Python.Deployment.Installer.InstallPath = InstallPath; - await Python.Deployment.Installer.PipInstallModule(module_name, version, force, token).ConfigureAwait(false); + await Python.Deployment.Installer.PipInstallModule(module_name, version, force, progress, token).ConfigureAwait(false); } finally { @@ -171,7 +171,7 @@ public static async Task PipInstallModule(string module_name, string version = " /// /// Creates the lib folder under if it does not exist. /// - public static async Task InstallPip(CancellationToken token = default) + public static async Task InstallPip(Action progress = null, CancellationToken token = default) { try { @@ -179,7 +179,7 @@ public static async Task InstallPip(CancellationToken token = default) Python.Deployment.Installer.Source = GetInstallationSource(); Python.Deployment.Installer.PythonDirectoryName = InstallDirectory; Python.Deployment.Installer.InstallPath = InstallPath; - await Python.Deployment.Installer.InstallPip(token).ConfigureAwait(false); + await Python.Deployment.Installer.InstallPip(progress, token).ConfigureAwait(false); } finally { @@ -187,13 +187,13 @@ public static async Task InstallPip(CancellationToken token = default) } } - public static async Task TryInstallPip(bool force = false) + public static async Task TryInstallPip(Action progress = null, CancellationToken token = default, bool force = false) { if (!IsPipInstalled() || force) { try { - await InstallPip().ConfigureAwait(false); + await InstallPip(progress, token).ConfigureAwait(false); } catch {