Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Python.Deployment/DownloadInstallationSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public class DownloadInstallationSource : InstallationSource
/// </summary>
public string DownloadUrl { get; set; }

public override async Task<string> RetrievePythonZip(string destinationDirectory)
public override async Task<string> RetrievePythonZip(string destinationDirectory, Action<float> progress = null, CancellationToken token = default)
{
var zipFile = Path.Combine(destinationDirectory, GetPythonZipFileName());
if (!Force && File.Exists(zipFile))
Expand All @@ -53,7 +53,7 @@ public override async Task<string> 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;
}
Expand Down
3 changes: 2 additions & 1 deletion Python.Deployment/EmbeddedResourceInstallationSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -51,7 +52,7 @@ public class EmbeddedResourceInstallationSource : InstallationSource
/// </summary>
public string ResourceName { get; set; }

public override Task<string> RetrievePythonZip(string destinationDirectory)
public override Task<string> RetrievePythonZip(string destinationDirectory, Action<float> progress = null, CancellationToken token = default)
{
var filePath = Path.Combine(destinationDirectory, ResourceName);
if (!Force && File.Exists(filePath))
Expand Down
3 changes: 2 additions & 1 deletion Python.Deployment/InstallationSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -46,7 +47,7 @@ public abstract class InstallationSource
/// </summary>
/// <param name="destinationDirectory">The directory location where the retrieved zip file should be placed</param>
/// <returns></returns>
public abstract Task<string> RetrievePythonZip(string destinationDirectory);
public abstract Task<string> RetrievePythonZip(string destinationDirectory, Action<float> progress = null, CancellationToken token = default);

/// <summary>
/// If true, retrieve the python file again even if it already exists at the destination path
Expand Down
162 changes: 117 additions & 45 deletions Python.Deployment/Installer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<float> 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");
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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.
/// </param>
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<float> 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);
}

/// <summary>
Expand All @@ -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.
/// </param>
public static async Task InstallPip(CancellationToken token = default)
public static async Task InstallPip(Action<float> progress = null, CancellationToken token = default)
{
string libDir = Path.Combine(EmbeddedPythonHome, "Lib");

Expand All @@ -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)
Expand All @@ -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<bool> TryInstallPip(bool force = false)
public static async Task<bool> TryInstallPip(Action<float> progress = null, CancellationToken token = default, bool force = false)
{
if (!IsPipInstalled() || force)
{
try
{
await InstallPip().ConfigureAwait(false);
await InstallPip(progress, token).ConfigureAwait(false);
}
catch
{
Expand Down Expand Up @@ -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<float> 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)
{
Expand All @@ -449,6 +471,56 @@ public static async Task RunCommand(string command, CancellationToken token)
}
}

private static async Task ReadStreamAsync(StreamReader reader, Action<string> 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<float> 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;
Expand Down
16 changes: 8 additions & 8 deletions Python.Included/Installer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<float> progress = null, CancellationToken token = default, bool force = false)
{
if (!PythonEnv.DeployEmbeddedPython)
return;
Expand All @@ -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
{
Expand Down Expand Up @@ -149,15 +149,15 @@ public static async Task PipInstallWheel(Assembly assembly, string resource_name
/// </summary>
/// <param name="module_name">The module/package to install </param>
/// <param name="force">When true, reinstall the packages even if it is already up-to-date.</param>
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<float> progress = null, CancellationToken token = default)
{
try
{
Python.Deployment.Installer.LogMessage += Log;
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
{
Expand All @@ -171,29 +171,29 @@ public static async Task PipInstallModule(string module_name, string version = "
/// <remarks>
/// Creates the lib folder under <see cref="EmbeddedPythonHome"/> if it does not exist.
/// </remarks>
public static async Task InstallPip(CancellationToken token = default)
public static async Task InstallPip(Action<float> progress = null, CancellationToken token = default)
{
try
{
Python.Deployment.Installer.LogMessage += Log;
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
{
Python.Deployment.Installer.LogMessage -= Log;
}
}

public static async Task<bool> TryInstallPip(bool force = false)
public static async Task<bool> TryInstallPip(Action<float> progress = null, CancellationToken token = default, bool force = false)
{
if (!IsPipInstalled() || force)
{
try
{
await InstallPip().ConfigureAwait(false);
await InstallPip(progress, token).ConfigureAwait(false);
}
catch
{
Expand Down