From e5dc1beed52dcd77d0e71af185b36f8af50f0819 Mon Sep 17 00:00:00 2001 From: jli2 Date: Mon, 15 Jun 2026 11:49:12 +0800 Subject: [PATCH 1/4] 1 --- .../DownloadInstallationSource.cs | 4 +- .../EmbeddedResourceInstallationSource.cs | 3 +- Python.Deployment/InstallationSource.cs | 3 +- Python.Deployment/Installer.cs | 135 ++++++++++++------ Python.Included/Installer.cs | 16 +-- 5 files changed, 108 insertions(+), 53 deletions(-) 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..9411b25 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,9 +290,9 @@ 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; @@ -301,7 +302,7 @@ public static async Task PipInstallModule(string module_name, string version = " if (version.Length > 0) version = $"=={version}"; - await RunCommand($"\"{pipPath}\" install \"{module_name}{version}\" {forceInstall}", token).ConfigureAwait(false); + await RunCommand($"python -u -m \"{pipPath}\" install \"{module_name}{version}\" --no-cache-dir --progress-bar on {forceInstall}", token, progress).ConfigureAwait(false); } /// @@ -315,7 +316,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 +329,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 +339,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 +378,71 @@ 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) { Process process = new Process(); try { - string args = null; - string filename = null; - ProcessStartInfo startInfo = new ProcessStartInfo(); + string filename, args; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { - // Unix/Linux/macOS specific command execution filename = "/bin/bash"; - args = $"-c \"{command} \""; + args = $"-c \"{command}\""; } else { - // Windows specific command execution filename = "cmd.exe"; args = $"/C \"{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, }; + 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(line); + ParsePipProgress(line, progress); + }, token); + + var readStdErr = ReadStreamAsync(process.StandardError, line => + { + Log(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("RunCommand: 已取消"); } catch (Exception e) { @@ -449,6 +454,54 @@ public static async Task RunCommand(string command, CancellationToken token) } } + private static async Task ReadStreamAsync( + StreamReader reader, + Action onLine, + CancellationToken token) + { + string line; + while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) + { + token.ThrowIfCancellationRequested(); + onLine?.Invoke(line); + } + } + + private static void ParsePipProgress(string line, Action progress) + { + if (progress == null) return; + Console.WriteLine(line); + // pip 新版 (≥22.0): " 6.4/15.9 MB 1.2 MB/s" + var newPip = Regex.Match(line, @"([\d.]+)/([\d.]+)\s*(KB|MB|GB)"); + if (newPip.Success) + { + if (float.TryParse(newPip.Groups[1].Value, out float current) && + float.TryParse(newPip.Groups[2].Value, out float total) && + total > 0) + { + progress.Invoke(Math.Min(current / total * 100.0f, 100.0f)); + return; + } + } + + // pip 旧版 (<22.0): " |████████░░░░|" + var oldPip = Regex.Match(line, @"\|(█+)(░*)\|"); + if (oldPip.Success) + { + int filled = oldPip.Groups[1].Value.Length; + int empty = oldPip.Groups[2].Value.Length; + int total = filled + empty; + if (total > 0) + { + progress.Invoke((float)filled / total); + return; + } + } + + // 开始下载时重置为 0 + if (Regex.IsMatch(line, @"Downloading .+\.whl")) + progress.Invoke(0.0f); + } 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 { From 722e2172a5a2dc7d4b26e165a5970e0ebb00e68a Mon Sep 17 00:00:00 2001 From: 869570967 <869570967@qq.com> Date: Mon, 15 Jun 2026 19:35:48 +0800 Subject: [PATCH 2/4] 1 --- Python.Deployment/Installer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Python.Deployment/Installer.cs b/Python.Deployment/Installer.cs index 9411b25..fa52d86 100644 --- a/Python.Deployment/Installer.cs +++ b/Python.Deployment/Installer.cs @@ -302,7 +302,7 @@ public static async Task PipInstallModule(string module_name, string version = " if (version.Length > 0) version = $"=={version}"; - await RunCommand($"python -u -m \"{pipPath}\" install \"{module_name}{version}\" --no-cache-dir --progress-bar on {forceInstall}", token, progress).ConfigureAwait(false); + await RunCommand($"\"{pipPath}\" install \"{module_name}{version}\" {forceInstall}", token, progress).ConfigureAwait(false); } /// From 6f47e1d88a1f8b0ca07e1ced6770fd6d2d6afafe Mon Sep 17 00:00:00 2001 From: jli2 Date: Tue, 16 Jun 2026 09:18:27 +0800 Subject: [PATCH 3/4] 1 --- Python.Deployment/Installer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Python.Deployment/Installer.cs b/Python.Deployment/Installer.cs index 9411b25..0303737 100644 --- a/Python.Deployment/Installer.cs +++ b/Python.Deployment/Installer.cs @@ -302,7 +302,7 @@ public static async Task PipInstallModule(string module_name, string version = " if (version.Length > 0) version = $"=={version}"; - await RunCommand($"python -u -m \"{pipPath}\" install \"{module_name}{version}\" --no-cache-dir --progress-bar on {forceInstall}", token, progress).ConfigureAwait(false); + await RunCommand($"python -u \"{pipPath}\" install \"{module_name}{version}\" --no-cache-dir --progress-bar on{forceInstall}", token, progress).ConfigureAwait(false); } /// From 36e3ac23ce8640acc05f71e7f1b52ff6a9ee034f Mon Sep 17 00:00:00 2001 From: jli2 Date: Mon, 22 Jun 2026 16:07:39 +0800 Subject: [PATCH 4/4] Add progress callback --- Python.Deployment/Installer.cs | 105 +++++++++++++++++++-------------- 1 file changed, 62 insertions(+), 43 deletions(-) diff --git a/Python.Deployment/Installer.cs b/Python.Deployment/Installer.cs index 0303737..2547372 100644 --- a/Python.Deployment/Installer.cs +++ b/Python.Deployment/Installer.cs @@ -297,12 +297,13 @@ public static async Task PipInstallModule(string module_name, string version = " 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($"python -u \"{pipPath}\" install \"{module_name}{version}\" --no-cache-dir --progress-bar on{forceInstall}", token, progress).ConfigureAwait(false); + await RunCommand( + $"-u -m pip install \"{module_name}{version}\" --no-cache-dir --progress-bar raw{forceInstall}", token, progress, pythonPath).ConfigureAwait(false); } /// @@ -378,22 +379,32 @@ 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, Action progress = null) + public static async Task RunCommand(string command, CancellationToken token, Action progress = null, string filename = null) { Process process = new Process(); try { - string filename, args; + string args; if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { - filename = "/bin/bash"; + if (string.IsNullOrEmpty(filename)) + filename = "/bin/bash"; args = $"-c \"{command}\""; } else { - 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}"); @@ -411,6 +422,12 @@ public static async Task RunCommand(string command, CancellationToken token, Act 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(); @@ -422,13 +439,13 @@ public static async Task RunCommand(string command, CancellationToken token, Act var readStdOut = ReadStreamAsync(process.StandardOutput, line => { - Log(line); + Log($"[ERR] '{line}'"); // 看 stderr 收到什么 ParsePipProgress(line, progress); }, token); var readStdErr = ReadStreamAsync(process.StandardError, line => { - Log(line); + Log($"[ERR] '{line}'"); // 看 stderr 收到什么 Console.WriteLine(line); }, token); @@ -454,53 +471,55 @@ public static async Task RunCommand(string command, CancellationToken token, Act } } - private static async Task ReadStreamAsync( - StreamReader reader, - Action onLine, - CancellationToken token) + private static async Task ReadStreamAsync(StreamReader reader, Action callback, CancellationToken token) { - string line; - while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) + var sb = new System.Text.StringBuilder(); + char[] buf = new char[1]; + + while (!reader.EndOfStream && !token.IsCancellationRequested) { - token.ThrowIfCancellationRequested(); - onLine?.Invoke(line); + 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; - Console.WriteLine(line); - // pip 新版 (≥22.0): " 6.4/15.9 MB 1.2 MB/s" - var newPip = Regex.Match(line, @"([\d.]+)/([\d.]+)\s*(KB|MB|GB)"); - if (newPip.Success) - { - if (float.TryParse(newPip.Groups[1].Value, out float current) && - float.TryParse(newPip.Groups[2].Value, out float total) && - total > 0) - { - progress.Invoke(Math.Min(current / total * 100.0f, 100.0f)); - return; - } - } - // pip 旧版 (<22.0): " |████████░░░░|" - var oldPip = Regex.Match(line, @"\|(█+)(░*)\|"); - if (oldPip.Success) + // 匹配 "Progress 已下载 of 总大小" + var match = System.Text.RegularExpressions.Regex.Match( + line, @"Progress (\d+) of (\d+)" + ); + + if (match.Success) { - int filled = oldPip.Groups[1].Value.Length; - int empty = oldPip.Groups[2].Value.Length; - int total = filled + empty; - if (total > 0) + if (long.TryParse(match.Groups[1].Value, out long current) && + long.TryParse(match.Groups[2].Value, out long total) && + total > 0) { - progress.Invoke((float)filled / total); - return; + float percent = (float)current / total * 100f; + progress(Math.Min(Math.Max(percent, 0f), 99f)); } } - - // 开始下载时重置为 0 - if (Regex.IsMatch(line, @"Downloading .+\.whl")) - progress.Invoke(0.0f); } private static bool AreAllFilesAlreadyPresent(ZipArchive zip, string lib) {