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..2547372 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,20 @@ 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 pythonPath = Path.Combine(EmbeddedPythonHome, "python.exe"); string forceInstall = force ? " --force-reinstall" : ""; if (version.Length > 0) version = $"=={version}"; - await RunCommand($"\"{pipPath}\" install \"{module_name}{version}\" {forceInstall}", token).ConfigureAwait(false); + await RunCommand( + $"-u -m pip install \"{module_name}{version}\" --no-cache-dir --progress-bar raw{forceInstall}", token, progress, pythonPath).ConfigureAwait(false); } /// @@ -315,7 +317,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 +330,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) @@ -338,16 +340,16 @@ public static async Task InstallPip(CancellationToken token = default) } - await RunCommand($"cd \"{EmbeddedPythonHome}\" && python.exe Lib\\get-pip.py", token).ConfigureAwait(false); + await RunCommand($"cd \"{EmbeddedPythonHome}\" && python.exe Lib\\get-pip.py", token, progress).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 { @@ -377,67 +379,87 @@ public static bool IsModuleInstalled(string module) return Directory.Exists(moduleDir) && File.Exists(Path.Combine(moduleDir, "__init__.py")); } - public static async Task RunCommand(string command, CancellationToken token) + public static async Task RunCommand(string command, CancellationToken token, Action progress = null, string filename = null) { Process process = new Process(); try { - string args = null; - string filename = null; - ProcessStartInfo startInfo = new ProcessStartInfo(); + string args; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { - // Unix/Linux/macOS specific command execution - filename = "/bin/bash"; - args = $"-c \"{command} \""; + if (string.IsNullOrEmpty(filename)) + filename = "/bin/bash"; + args = $"-c \"{command}\""; } else { - // Windows specific command execution - filename = "cmd.exe"; - args = $"/C \"{command}\""; + if (string.IsNullOrEmpty(filename)) + { + // 没有指定 filename,走 cmd.exe + filename = "cmd.exe"; + args = $"/C \"{command}\""; + } + else + { + // 直接启动指定程序,不加 /C + args = command; + } } + Log($"> {filename} {args}"); - startInfo = new ProcessStartInfo + + var startInfo = new ProcessStartInfo { FileName = filename, WorkingDirectory = EmbeddedPythonHome, Arguments = args, - - // If the UseShellExecute property is true, the CreateNoWindow property value is ignored and a new window is created. - // .NET Core does not support creating windows directly on Unix/Linux/macOS and the property is ignored. - CreateNoWindow = true, - UseShellExecute = false, // necessary for stdout redirection + UseShellExecute = false, RedirectStandardError = true, RedirectStandardInput = true, RedirectStandardOutput = true, WindowStyle = ProcessWindowStyle.Hidden, }; + + // 关键:禁用 Python 和 pip 的输出缓冲 + startInfo.Environment["PYTHONUNBUFFERED"] = "1"; + startInfo.Environment["PYTHONIOENCODING"] = "utf-8"; + startInfo.Environment["PIP_NO_COLOR"] = "1"; // 去掉颜色转义字符干扰 + startInfo.Environment["COLUMNS"] = "200"; // 避免 pip 截断进度行 + process.StartInfo = startInfo; process.Start(); - // Note: see https://github.com/henon/Python.Included/issues/55#issuecomment-1634750418 - // as to why the following lines are commented out - //process.BeginOutputReadLine(); - //process.BeginErrorReadLine(); + token.Register(() => { - try - { - if (!process.HasExited) - process.Kill(); - } + try { if (!process.HasExited) process.Kill(); } catch (Exception) { /* ignore */ } }); - // The documentation for Process.StandardOutput says to read before you wait otherwise you can deadlock! - string output = process.StandardOutput.ReadToEnd(); - Log(output); - await Task.Run(() => { process.WaitForExit(); }, token).ConfigureAwait(false); - if (process.ExitCode != 0) + + var readStdOut = ReadStreamAsync(process.StandardOutput, line => { - Log(process.StandardError.ReadToEnd()); - Log(" => exit code " + process.ExitCode); - } + Log($"[ERR] '{line}'"); // 看 stderr 收到什么 + ParsePipProgress(line, progress); + }, token); + + var readStdErr = ReadStreamAsync(process.StandardError, line => + { + Log($"[ERR] '{line}'"); // 看 stderr 收到什么 + 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("RunCommand: 已取消"); } catch (Exception e) { @@ -449,6 +471,56 @@ public static async Task RunCommand(string command, CancellationToken token) } } + 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; + + // 匹配 "Progress 已下载 of 总大小" + 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; 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 {