From 0754331f0afa9666b00c3d9a1ebc8b7d97702da9 Mon Sep 17 00:00:00 2001 From: Anthony Shaw <anthony.p.shaw@gmail.com> Date: Fri, 11 Oct 2024 15:08:16 +1100 Subject: [PATCH] Add Conda Environment Management and Locator Support (#232) * Refactor environment management into an abstracted interface. Separate the logic from the python builder and environment builder. Create a conda locator (folder locator) and a conda environment manager * Refactor python process spawn into static utils * refactor locators. get conda data from runtime settings * Add code to create environment from yml * Setup conda in ci * Allow overriding the req * Use CONDA * Include stderr in exceptions * Add error to exception message * minimise deps * Fix the linux path from the env var * clean up conda in GHA * Shell execute environment creation * Create the environment in CI * Don't try and create environments for now. * add docs updates * add environment page to docs * clarify calling for conda * Move the logger to the constructor of the environment management classes * Update src/CSnakes.Runtime/Locators/CondaLocator.cs Co-authored-by: Aaron Powell <me@aaron-powell.com> * use file-scoped namespace * Fix merge foo * Fix logger resolver. --------- Co-authored-by: Aaron Powell <me@aaron-powell.com> --- .github/workflows/dotnet-ci.yml | 10 ++- docs/environments.md | 58 +++++++++++++ docs/getting-started.md | 4 +- docs/reference.md | 42 ++++++--- mkdocs.yml | 1 + .../Locators/PythonLocatorTests.cs | 8 +- .../CondaEnvironmentManagement.cs | 41 +++++++++ .../IEnvironmentManagement.cs | 25 ++++++ .../VenvEnvironmentManagement.cs | 46 ++++++++++ .../IPythonEnvironmentBuilder.cs | 15 +++- src/CSnakes.Runtime/Locators/CondaLocator.cs | 54 ++++++++++++ .../Locators/EnvironmentVariableLocator.cs | 4 +- src/CSnakes.Runtime/Locators/FolderLocator.cs | 6 +- .../Locators/MacOSInstallerLocator.cs | 4 +- src/CSnakes.Runtime/Locators/NuGetLocator.cs | 4 +- src/CSnakes.Runtime/Locators/PythonLocator.cs | 4 +- src/CSnakes.Runtime/Locators/SourceLocator.cs | 4 +- .../Locators/WindowsInstallerLocator.cs | 4 +- .../Locators/WindowsStoreLocator.cs | 4 +- .../IPythonPackageInstaller.cs | 6 +- .../PackageManagement/PipInstaller.cs | 16 ++-- src/CSnakes.Runtime/ProcessUtils.cs | 78 +++++++++++++++++ src/CSnakes.Runtime/PythonEnvironment.cs | 86 +++---------------- .../PythonEnvironmentBuilder.cs | 41 +++++++-- .../PythonEnvironmentOptions.cs | 2 +- .../ServiceCollectionExtensions.cs | 49 +++++++++-- src/CSnakes.sln | 7 ++ src/Conda.Tests/BasicTests.cs | 12 +++ src/Conda.Tests/Conda.Tests.csproj | 40 +++++++++ src/Conda.Tests/CondaTestBase.cs | 47 ++++++++++ src/Conda.Tests/python/environment.yml | 3 + src/Conda.Tests/python/test_simple.py | 5 ++ 32 files changed, 597 insertions(+), 133 deletions(-) create mode 100644 docs/environments.md create mode 100644 src/CSnakes.Runtime/EnvironmentManagement/CondaEnvironmentManagement.cs create mode 100644 src/CSnakes.Runtime/EnvironmentManagement/IEnvironmentManagement.cs create mode 100644 src/CSnakes.Runtime/EnvironmentManagement/VenvEnvironmentManagement.cs create mode 100644 src/CSnakes.Runtime/Locators/CondaLocator.cs create mode 100644 src/CSnakes.Runtime/ProcessUtils.cs create mode 100644 src/Conda.Tests/BasicTests.cs create mode 100644 src/Conda.Tests/Conda.Tests.csproj create mode 100644 src/Conda.Tests/CondaTestBase.cs create mode 100644 src/Conda.Tests/python/environment.yml create mode 100644 src/Conda.Tests/python/test_simple.py diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml index fecd38e1..0516eaf8 100644 --- a/.github/workflows/dotnet-ci.yml +++ b/.github/workflows/dotnet-ci.yml @@ -20,7 +20,15 @@ jobs: steps: - uses: actions/checkout@v4 - + - uses: conda-incubator/setup-miniconda@v3 + id: setup-conda + with: + auto-update-conda: true + python-version: ${{ matrix.python-version }} + activate-environment: csnakes_test + environment-file: src/Conda.Tests/python/environment.yml + - name: cleanup conda-incubator/setup-miniconda + run: conda clean --all --yes - name: Setup Python id: installpython uses: actions/setup-python@v5 diff --git a/docs/environments.md b/docs/environments.md new file mode 100644 index 00000000..5c415388 --- /dev/null +++ b/docs/environments.md @@ -0,0 +1,58 @@ +# Environment and Package Management + +CSnakes comes with support for executing Python within a virtual environment and the specification of dependencies. + +There are two main package management solutions for Python, `pip` and `conda`. `pip` is the default package manager for Python and is included with the Python installation. `conda` is a package manager that is included with the Anaconda distribution of Python. Both package managers can be used to install packages and manage dependencies. + +There are various ways to create "virtual" environments in Python, where the dependencies are isolated from the system Python installation. The most common way is to use the `venv` module that is included with Python. The `venv` module is used to create virtual environments and manage dependencies. + +Virtual Environment creation and package management are separate concerns in Python, but some tools (like conda) combine them into a single workflow. CSnakes separates these concerns to give you more flexibility in managing your Python environments. + +## Virtual Environments with `venv` + +Use the `.WithVirtualEnvironment(path)` method to specify the path to the virtual environment. + +You can also optionally use the `.WithPipInstaller()` method to install packages listed in a `requirements.txt` file in the virtual environment. If you don't use this method, you need to install the packages manually before running the application. + +```csharp +... +services + .WithPython() + .WithVirtualEnvironment(Path.Join(home, ".venv")) + // Python locators + .WithPipInstaller(); // Optional - installs packages listed in requirements.txt on startup +``` + +### Disabling automatic environment creation + +## Virtual Environments with `conda` + +To use the `conda` package manager, you need to specify the path to the `conda` executable and the name of the environment you want to use: + +1. Add the `FromConda()` extension method the host builder. +1. Use the `.WithCondaEnvironment(name)` method to specify the name of the environment you want to use. + +```csharp +... +services + .WithPython() + .FromConda(condaBinPath) + .WithCondaEnvironment("name_of_environment"); +``` + +The Conda Environment manager doesn't currently support automatic creation of environments or installing packages from an `environment.yml` file, so you need to create the environment and install the packages manually before running the application, by using `conda env create -n name_of_environment -f environment.yml` + +## Installing dependencies with `pip` + +If you want to install dependencies using `pip`, you can use the `.WithPipInstaller()` method. This method will install the packages listed in a `requirements.txt` file in the virtual environment. + +```csharp +... +services + .WithPython() + .WithVirtualEnvironment(Path.Join(home, ".venv")) + .WithPipInstaller(); // Optional - installs packages listed in requirements.txt on startup +``` + +`.WithPipInstaller()` takes an optional argument that specifies the path to the `requirements.txt` file. If you don't specify a path, it will look for a `requirements.txt` file in the virtual environment directory. + diff --git a/docs/getting-started.md b/docs/getting-started.md index df303d4d..74c20b90 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -113,7 +113,7 @@ Check out the sample project in the [samples](https://github.com/tonybaloney/CSn ## Using Virtual Environments -Since most Python projects require external dependencies outside of the Python standard library, CSnakes supports execution within a Python virtual environment. +Since most Python projects require external dependencies outside of the Python standard library, CSnakes supports execution within a Python virtual environment and Conda environments. Use the `.WithVirtualEnvironment` method to specify the path to the virtual environment. @@ -128,6 +128,8 @@ services .WithPipInstaller(); // Optional - installs packages listed in requirements.txt on startup ``` +See [Environment and Package Management](environments.md) for more information on managing Python environments and dependencies. + ## Calling CSnakes code from C#.NET Once you have a Python environment, you can call any Python function from C# using the `IPythonEnvironment` interface. diff --git a/docs/reference.md b/docs/reference.md index 81881233..113e7aa5 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -110,8 +110,8 @@ This locator is also very useful for GitHub Actions `setup-python` actions, wher ```csharp ... -var pythonBuilder = services.WithPython(); -pythonBuilder.FromEnvironmentVariable("Python3_ROOT_DIR", "3.12") +var pythonBuilder = services.WithPython() + .FromEnvironmentVariable("Python3_ROOT_DIR", "3.12"); ``` ### Folder Locator @@ -120,8 +120,8 @@ The `.FromFolder()` method allows you to specify a folder that contains the Pyth ```csharp ... -var pythonBuilder = services.WithPython(); -pythonBuilder.FromFolder(@"C:\path\to\python\3.12", "3.12") +var pythonBuilder = services.WithPython() + .FromFolder(@"C:\path\to\python\3.12", "3.12"); ``` ### Source Locator @@ -132,8 +132,8 @@ It optionally takes a `bool` parameter to specify that the binary is debug mode ```csharp ... -var pythonBuilder = services.WithPython(); -pythonBuilder.FromSource(@"C:\path\to\cpython\", "3.13", debug: true, freeThreaded: true) +var pythonBuilder = services.WithPython() + .FromSource(@"C:\path\to\cpython\", "3.13", debug: true, freeThreaded: true); ``` ### MacOS Installer Locator @@ -142,8 +142,8 @@ The MacOS Installer Locator is used to find the Python runtime on MacOS. This is ```csharp ... -var pythonBuilder = services.WithPython(); -pythonBuilder.FromMacOSInstaller("3.12") +var pythonBuilder = services.WithPython() + .FromMacOSInstaller("3.12"); ``` ### Windows Installer Locator @@ -152,8 +152,8 @@ The Windows Installer Locator is used to find the Python runtime on Windows. Thi ```csharp ... -var pythonBuilder = services.WithPython(); -pythonBuilder.FromWindowsInstaller("3.12") +var pythonBuilder = services.WithPython() + .FromWindowsInstaller("3.12"); ``` ### Windows Store Locator @@ -162,8 +162,8 @@ The Windows Store Locator is used to find the Python runtime on Windows from the ```csharp ... -var pythonBuilder = services.WithPython(); -pythonBuilder.FromWindowsStore("3.12") +var pythonBuilder = services.WithPython() + .FromWindowsStore("3.12") ``` ### Nuget Locator @@ -174,10 +174,24 @@ These packages only bundle the Python runtime for Windows. You also need to spec ```csharp ... -var pythonBuilder = services.WithPython(); -pythonBuilder.FromNuGet("3.12.4") +var pythonBuilder = services.WithPython() + .FromNuGet("3.12.4"); ``` +### Conda Locator + +The Conda Locator is used to find the Python runtime from a Conda environment. This is useful for scenarios where you have installed Python from the Anaconda or miniconda distribution of Python. Upon environment creation, CSnakes will run `conda info --json` to get the path to the Python runtime. + +This Locator should be called with the path to the Conda executable: + +```csharp +... +var pythonBuilder = services.WithPython() + .FromConda(@"C:\path\to\conda"); +``` + +The Conda Locator should be combined with the `WithCondaEnvironment` method to specify the name of the Conda environment you want to use. See [Environment and Package Management](environments.md) for more information on managing Python environments and dependencies. + ## Parallelism and concurrency CSnakes is designed to be thread-safe and can be used in parallel execution scenarios. diff --git a/mkdocs.yml b/mkdocs.yml index e7007379..a0e5341b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,6 +3,7 @@ nav: - Home: index.md - Getting Started: getting-started.md - Reference: reference.md + - Environment and Package Management: environments.md - Buffer Protocol and NumPy Arrays: buffers.md - Advanced Usage: advanced.md - Limitations: limitations.md diff --git a/src/CSnakes.Runtime.Tests/Locators/PythonLocatorTests.cs b/src/CSnakes.Runtime.Tests/Locators/PythonLocatorTests.cs index f2fefc73..624d4122 100644 --- a/src/CSnakes.Runtime.Tests/Locators/PythonLocatorTests.cs +++ b/src/CSnakes.Runtime.Tests/Locators/PythonLocatorTests.cs @@ -1,5 +1,6 @@ using CSnakes.Runtime.Locators; using Microsoft.TestUtilities; +using System; using System.Runtime.InteropServices; namespace CSnakes.Runtime.Tests.Locators; @@ -174,12 +175,9 @@ public void LocatePythonInternal_Linux_returns_expected() Assert.Equal(folder, result.Folder); } - private class MockPythonLocator : PythonLocator + private class MockPythonLocator(Version version) : PythonLocator { - public MockPythonLocator(Version version) - : base(version) - { - } + protected override Version Version { get; } = version; public string GetPythonExecutablePathReal(string folder) => base.GetPythonExecutablePath(folder); diff --git a/src/CSnakes.Runtime/EnvironmentManagement/CondaEnvironmentManagement.cs b/src/CSnakes.Runtime/EnvironmentManagement/CondaEnvironmentManagement.cs new file mode 100644 index 00000000..0873708f --- /dev/null +++ b/src/CSnakes.Runtime/EnvironmentManagement/CondaEnvironmentManagement.cs @@ -0,0 +1,41 @@ +using CSnakes.Runtime.Locators; +using Microsoft.Extensions.Logging; + +namespace CSnakes.Runtime.EnvironmentManagement; +#pragma warning disable CS9113 // Parameter is unread. There for future use. +internal class CondaEnvironmentManagement(ILogger logger, string name, bool ensureExists, CondaLocator conda, string? environmentSpecPath) : IEnvironmentManagement +#pragma warning restore CS9113 // Parameter is unread. +{ + ILogger IEnvironmentManagement.Logger => logger; + + public void EnsureEnvironment(PythonLocationMetadata pythonLocation) + { + if (!ensureExists) + return; + + + var fullPath = Path.GetFullPath(GetPath()); + if (!Directory.Exists(fullPath)) + { + logger.LogError("Cannot find conda environment at {fullPath}.", fullPath); + // TODO: Automate the creation of the conda environments. + //var result = conda.ExecuteCondaShellCommand($"env create -n {name} -f {environmentSpecPath}"); + //if (!result) + //{ + // logger.LogError("Failed to create conda environment."); + // throw new InvalidOperationException("Could not create conda environment"); + //} + } + else + { + logger.LogDebug("Conda environment already exists at {fullPath}", fullPath); + // TODO: Check if the environment is up to date + } + } + + public string GetPath() + { + // TODO: Conda environments are not always in the same location. Resolve the path correctly. + return Path.Combine(conda.CondaHome, "envs", name); + } +} diff --git a/src/CSnakes.Runtime/EnvironmentManagement/IEnvironmentManagement.cs b/src/CSnakes.Runtime/EnvironmentManagement/IEnvironmentManagement.cs new file mode 100644 index 00000000..fc9886e0 --- /dev/null +++ b/src/CSnakes.Runtime/EnvironmentManagement/IEnvironmentManagement.cs @@ -0,0 +1,25 @@ +using CSnakes.Runtime.Locators; +using Microsoft.Extensions.Logging; +using System.Runtime.InteropServices; + +namespace CSnakes.Runtime.EnvironmentManagement; +public interface IEnvironmentManagement +{ + ILogger Logger { get; } + + public string GetPath(); + public virtual string GetExtraPackagePath(PythonLocationMetadata location) { + var envLibPath = string.Empty; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + envLibPath = Path.Combine(GetPath(), "Lib", "site-packages"); + else + { + string suffix = location.FreeThreaded ? "t" : ""; + envLibPath = Path.Combine(GetPath(), "lib", $"python{location.Version.Major}.{location.Version.Minor}{suffix}", "site-packages"); + } + Logger.LogDebug("Adding environment site-packages to extra paths: {VenvLibPath}", envLibPath); + return envLibPath; + } + public void EnsureEnvironment(PythonLocationMetadata pythonLocation); + +} diff --git a/src/CSnakes.Runtime/EnvironmentManagement/VenvEnvironmentManagement.cs b/src/CSnakes.Runtime/EnvironmentManagement/VenvEnvironmentManagement.cs new file mode 100644 index 00000000..f0ded7a4 --- /dev/null +++ b/src/CSnakes.Runtime/EnvironmentManagement/VenvEnvironmentManagement.cs @@ -0,0 +1,46 @@ +using CSnakes.Runtime.Locators; +using Microsoft.Extensions.Logging; + +namespace CSnakes.Runtime.EnvironmentManagement; +internal class VenvEnvironmentManagement(ILogger logger, string path, bool ensureExists) : IEnvironmentManagement +{ + ILogger IEnvironmentManagement.Logger => logger; + + public void EnsureEnvironment(PythonLocationMetadata pythonLocation) + { + if (!ensureExists) + return; + + if (string.IsNullOrEmpty(path)) + { + logger.LogError("Virtual environment location is not set but it was requested to be created."); + throw new ArgumentNullException(nameof(path), "Virtual environment location is not set."); + } + var fullPath = Path.GetFullPath(path); + if (!Directory.Exists(path)) + { + logger.LogInformation("Creating virtual environment at {VirtualEnvPath} using {PythonBinaryPath}", fullPath, pythonLocation.PythonBinaryPath); + var (process1, _, _) = ProcessUtils.ExecutePythonCommand(logger, pythonLocation, $"-VV"); + var (process2, _, error) = ProcessUtils.ExecutePythonCommand(logger, pythonLocation, $"-m venv {fullPath}"); + + if (process1.ExitCode != 0 || process2.ExitCode != 0) + { + logger.LogError("Failed to create virtual environment."); + process1.Dispose(); + process2.Dispose(); + throw new InvalidOperationException($"Could not create virtual environment. {error}"); + } + process1.Dispose(); + process2.Dispose(); + } + else + { + logger.LogDebug("Virtual environment already exists at {VirtualEnvPath}", fullPath); + } + } + + public string GetPath() + { + return path; + } +} diff --git a/src/CSnakes.Runtime/IPythonEnvironmentBuilder.cs b/src/CSnakes.Runtime/IPythonEnvironmentBuilder.cs index 97286e26..d818be93 100644 --- a/src/CSnakes.Runtime/IPythonEnvironmentBuilder.cs +++ b/src/CSnakes.Runtime/IPythonEnvironmentBuilder.cs @@ -13,9 +13,20 @@ public interface IPythonEnvironmentBuilder /// Sets the virtual environment path for the Python environment being built. /// </summary> /// <param name="path">The path to the virtual environment.</param> - /// <param name="ensureVirtualEnvironment">Indicates whether to ensure the virtual environment exists.</param> + /// <param name="ensureEnvironment">Indicates whether to ensure the virtual environment exists.</param> /// <returns>The current instance of the <see cref="IPythonEnvironmentBuilder"/>.</returns> - IPythonEnvironmentBuilder WithVirtualEnvironment(string path, bool ensureVirtualEnvironment = true); + IPythonEnvironmentBuilder WithVirtualEnvironment(string path, bool ensureEnvironment = true); + + + /// <summary> + /// Sets the virtual environment path for the Python environment to a named conda environment. + /// This requires Python to be installed via Conda and usage of the <see cref="ServiceCollectionExtensions.FromConda(IPythonEnvironmentBuilder, string)"/> locator. + /// </summary> + /// <param name="name">The name of the conda environment to use.</param> + /// <param name="environmentSpecPath">The path to the conda environment specification file (environment.yml), used if ensureEnvironment = true.</param> + /// <param name="ensureEnvironment">Indicates whether to create the conda environment if it doesn't exist (not yet supported).</param> + /// <returns>The current instance of the <see cref="IPythonEnvironmentBuilder"/>.</returns> + IPythonEnvironmentBuilder WithCondaEnvironment(string name, string? environmentSpecPath = null, bool ensureEnvironment = false); /// <summary> /// Sets the home directory for the Python environment being built. diff --git a/src/CSnakes.Runtime/Locators/CondaLocator.cs b/src/CSnakes.Runtime/Locators/CondaLocator.cs new file mode 100644 index 00000000..7133efcc --- /dev/null +++ b/src/CSnakes.Runtime/Locators/CondaLocator.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Logging; +using System.Text.Json.Nodes; +using System.Diagnostics; + +namespace CSnakes.Runtime.Locators; + +internal class CondaLocator : PythonLocator +{ + private readonly string folder; + private readonly Version version; + private readonly ILogger logger; + private readonly string condaBinaryPath; + + protected override Version Version { get { return version; } } + + internal CondaLocator(ILogger logger, string condaBinaryPath) + { + this.logger = logger; + this.condaBinaryPath = condaBinaryPath; + var (process, result, errors) = ExecuteCondaCommand($"info --json"); + if (process.ExitCode != 0) + { + logger.LogError("Failed to determine Python version from Conda {Error}.", errors); + throw new InvalidOperationException("Could not determine Python version from Conda."); + } + process.Dispose(); + // Parse JSON output to get the version + var json = JsonNode.Parse(result ?? "")!; + var versionAttribute = json["python_version"]?.GetValue<string>() ?? string.Empty; + + if (string.IsNullOrEmpty(versionAttribute)) + { + throw new InvalidOperationException("Could not determine Python version from Conda."); + } + + var basePrefix = json["root_prefix"]?.GetValue<string>() ?? string.Empty; + if (string.IsNullOrEmpty(basePrefix)) + { + throw new InvalidOperationException("Could not determine Conda home."); + } + + version = ServiceCollectionExtensions.ParsePythonVersion(versionAttribute); + folder = basePrefix; + } + + internal (Process process, string? output, string? errors) ExecuteCondaCommand(string arguments) => ProcessUtils.ExecuteCommand(logger, condaBinaryPath, arguments); + + internal bool ExecuteCondaShellCommand(string arguments) => ProcessUtils.ExecuteShellCommand(logger, condaBinaryPath, arguments); + + public override PythonLocationMetadata LocatePython() => + LocatePythonInternal(folder); + + public string CondaHome { get { return folder; } } +} diff --git a/src/CSnakes.Runtime/Locators/EnvironmentVariableLocator.cs b/src/CSnakes.Runtime/Locators/EnvironmentVariableLocator.cs index 5b7c6dcb..6ad0f2c7 100644 --- a/src/CSnakes.Runtime/Locators/EnvironmentVariableLocator.cs +++ b/src/CSnakes.Runtime/Locators/EnvironmentVariableLocator.cs @@ -1,7 +1,9 @@ namespace CSnakes.Runtime.Locators; -internal class EnvironmentVariableLocator(string variable, Version version) : PythonLocator(version) +internal class EnvironmentVariableLocator(string variable, Version version) : PythonLocator { + protected override Version Version { get; } = version; + public override PythonLocationMetadata LocatePython() { var envValue = Environment.GetEnvironmentVariable(variable); diff --git a/src/CSnakes.Runtime/Locators/FolderLocator.cs b/src/CSnakes.Runtime/Locators/FolderLocator.cs index 4eea9608..4e693d81 100644 --- a/src/CSnakes.Runtime/Locators/FolderLocator.cs +++ b/src/CSnakes.Runtime/Locators/FolderLocator.cs @@ -1,6 +1,8 @@ namespace CSnakes.Runtime.Locators; -internal class FolderLocator(string folder, Version version) : PythonLocator(version) +internal class FolderLocator(string folder, Version version) : PythonLocator { + protected override Version Version { get; } = version; + public override PythonLocationMetadata LocatePython() => LocatePythonInternal(folder); -} \ No newline at end of file +} diff --git a/src/CSnakes.Runtime/Locators/MacOSInstallerLocator.cs b/src/CSnakes.Runtime/Locators/MacOSInstallerLocator.cs index c85e7588..c336aeb1 100644 --- a/src/CSnakes.Runtime/Locators/MacOSInstallerLocator.cs +++ b/src/CSnakes.Runtime/Locators/MacOSInstallerLocator.cs @@ -1,8 +1,10 @@ using System.Runtime.InteropServices; namespace CSnakes.Runtime.Locators; -internal class MacOSInstallerLocator(Version version, bool freeThreaded = false) : PythonLocator(version) +internal class MacOSInstallerLocator(Version version, bool freeThreaded = false) : PythonLocator { + protected override Version Version { get; } = version; + public override PythonLocationMetadata LocatePython() { string framework = freeThreaded ? "PythonT.framework" : "Python.framework"; diff --git a/src/CSnakes.Runtime/Locators/NuGetLocator.cs b/src/CSnakes.Runtime/Locators/NuGetLocator.cs index 8b83f201..f4dc64a6 100644 --- a/src/CSnakes.Runtime/Locators/NuGetLocator.cs +++ b/src/CSnakes.Runtime/Locators/NuGetLocator.cs @@ -2,8 +2,10 @@ namespace CSnakes.Runtime.Locators; -internal class NuGetLocator(string nugetVersion, Version version) : PythonLocator(version) +internal class NuGetLocator(string nugetVersion, Version version) : PythonLocator { + protected override Version Version { get; } = version; + public override PythonLocationMetadata LocatePython() { var globalNugetPackagesPath = (NuGetPackages: Environment.GetEnvironmentVariable("NUGET_PACKAGES"), diff --git a/src/CSnakes.Runtime/Locators/PythonLocator.cs b/src/CSnakes.Runtime/Locators/PythonLocator.cs index 6dbdab76..f001b74b 100644 --- a/src/CSnakes.Runtime/Locators/PythonLocator.cs +++ b/src/CSnakes.Runtime/Locators/PythonLocator.cs @@ -9,12 +9,12 @@ namespace CSnakes.Runtime.Locators; /// Initializes a new instance of the <see cref="PythonLocator"/> class. /// </remarks> /// <param name="version">The version of Python.</param> -public abstract class PythonLocator(Version version) +public abstract class PythonLocator { /// <summary> /// Gets the version of Python. /// </summary> - protected Version Version { get ; } = version; + protected abstract Version Version { get; } /// <summary> /// Locates the Python installation. diff --git a/src/CSnakes.Runtime/Locators/SourceLocator.cs b/src/CSnakes.Runtime/Locators/SourceLocator.cs index abbfaa95..134e29e6 100644 --- a/src/CSnakes.Runtime/Locators/SourceLocator.cs +++ b/src/CSnakes.Runtime/Locators/SourceLocator.cs @@ -2,8 +2,10 @@ namespace CSnakes.Runtime.Locators; -internal class SourceLocator(string folder, Version version, bool debug = true, bool freeThreaded = false) : PythonLocator(version) +internal class SourceLocator(string folder, Version version, bool debug = true, bool freeThreaded = false) : PythonLocator { + protected override Version Version { get; } = version; + protected bool Debug => debug; protected override string GetLibPythonPath(string folder, bool freeThreaded = false) diff --git a/src/CSnakes.Runtime/Locators/WindowsInstallerLocator.cs b/src/CSnakes.Runtime/Locators/WindowsInstallerLocator.cs index 36738ada..a1bacbfb 100644 --- a/src/CSnakes.Runtime/Locators/WindowsInstallerLocator.cs +++ b/src/CSnakes.Runtime/Locators/WindowsInstallerLocator.cs @@ -1,8 +1,10 @@ using System.Runtime.InteropServices; namespace CSnakes.Runtime.Locators; -internal class WindowsInstallerLocator(Version version) : PythonLocator(version) +internal class WindowsInstallerLocator(Version version) : PythonLocator { + protected override Version Version { get; } = version; + readonly string programFilesPath = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); public override PythonLocationMetadata LocatePython() diff --git a/src/CSnakes.Runtime/Locators/WindowsStoreLocator.cs b/src/CSnakes.Runtime/Locators/WindowsStoreLocator.cs index 66d77ad4..292fcc62 100644 --- a/src/CSnakes.Runtime/Locators/WindowsStoreLocator.cs +++ b/src/CSnakes.Runtime/Locators/WindowsStoreLocator.cs @@ -1,8 +1,10 @@ using System.Runtime.InteropServices; namespace CSnakes.Runtime.Locators; -internal class WindowsStoreLocator(Version version) : PythonLocator(version) +internal class WindowsStoreLocator(Version version) : PythonLocator { + protected override Version Version { get; } = version; + public override PythonLocationMetadata LocatePython() { var windowsStorePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Python", $"Python{Version.Major}.{Version.Minor}"); diff --git a/src/CSnakes.Runtime/PackageManagement/IPythonPackageInstaller.cs b/src/CSnakes.Runtime/PackageManagement/IPythonPackageInstaller.cs index 8e263f88..c080c7d5 100644 --- a/src/CSnakes.Runtime/PackageManagement/IPythonPackageInstaller.cs +++ b/src/CSnakes.Runtime/PackageManagement/IPythonPackageInstaller.cs @@ -1,4 +1,6 @@ -namespace CSnakes.Runtime.PackageManagement; +using CSnakes.Runtime.EnvironmentManagement; + +namespace CSnakes.Runtime.PackageManagement; /// <summary> /// Represents an interface for installing Python packages. @@ -11,5 +13,5 @@ public interface IPythonPackageInstaller /// <param name="home">The home directory.</param> /// <param name="virtualEnvironmentLocation">The location of the virtual environment (optional).</param> /// <returns>A task representing the asynchronous package installation operation.</returns> - Task InstallPackages(string home, string? virtualEnvironmentLocation); + Task InstallPackages(string home, IEnvironmentManagement? environmentManager); } diff --git a/src/CSnakes.Runtime/PackageManagement/PipInstaller.cs b/src/CSnakes.Runtime/PackageManagement/PipInstaller.cs index 6edc3b8d..b5c2c046 100644 --- a/src/CSnakes.Runtime/PackageManagement/PipInstaller.cs +++ b/src/CSnakes.Runtime/PackageManagement/PipInstaller.cs @@ -1,22 +1,22 @@ -using Microsoft.Extensions.Logging; +using CSnakes.Runtime.EnvironmentManagement; +using Microsoft.Extensions.Logging; using System.Diagnostics; using System.Runtime.InteropServices; namespace CSnakes.Runtime.PackageManagement; -internal class PipInstaller(ILogger<PipInstaller> logger) : IPythonPackageInstaller +internal class PipInstaller(ILogger<PipInstaller> logger, string requirementsFileName) : IPythonPackageInstaller { static readonly string pipBinaryName = $"pip{(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : "")}"; - static readonly string requirementsFileName = "requirements.txt"; - public Task InstallPackages(string home, string? virtualEnvironmentLocation) + public Task InstallPackages(string home, IEnvironmentManagement? environmentManager) { // TODO:Allow overriding of the requirements file name. string requirementsPath = Path.GetFullPath(Path.Combine(home, requirementsFileName)); if (File.Exists(requirementsPath)) { logger.LogInformation("File {Requirements} was found.", requirementsPath); - InstallPackagesWithPip(home, virtualEnvironmentLocation); + InstallPackagesWithPip(home, environmentManager); } else { @@ -26,7 +26,7 @@ public Task InstallPackages(string home, string? virtualEnvironmentLocation) return Task.CompletedTask; } - private void InstallPackagesWithPip(string home, string? virtualEnvironmentLocation) + private void InstallPackagesWithPip(string home, IEnvironmentManagement? environmentManager) { ProcessStartInfo startInfo = new() { @@ -35,9 +35,9 @@ private void InstallPackagesWithPip(string home, string? virtualEnvironmentLocat Arguments = $"install -r {requirementsFileName} --disable-pip-version-check" }; - if (virtualEnvironmentLocation is not null) + if (environmentManager is not null) { - virtualEnvironmentLocation = Path.GetFullPath(virtualEnvironmentLocation); + string virtualEnvironmentLocation = Path.GetFullPath(environmentManager.GetPath()); logger.LogInformation("Using virtual environment at {VirtualEnvironmentLocation} to install packages with pip.", virtualEnvironmentLocation); string venvScriptPath = Path.Combine(virtualEnvironmentLocation, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "Scripts" : "bin"); // TODO: Check that the pip executable exists, and if not, raise an exception with actionable steps. diff --git a/src/CSnakes.Runtime/ProcessUtils.cs b/src/CSnakes.Runtime/ProcessUtils.cs new file mode 100644 index 00000000..054fd1cf --- /dev/null +++ b/src/CSnakes.Runtime/ProcessUtils.cs @@ -0,0 +1,78 @@ +using CSnakes.Runtime.Locators; +using Microsoft.Extensions.Logging; +using System.Diagnostics; + +namespace CSnakes.Runtime; + +internal static class ProcessUtils +{ + internal static (Process proc, string? result, string? errors) ExecutePythonCommand(ILogger logger, PythonLocationMetadata pythonLocation, string arguments) + { + ProcessStartInfo startInfo = new() + { + WorkingDirectory = pythonLocation.Folder, + FileName = pythonLocation.PythonBinaryPath, + Arguments = arguments, + RedirectStandardError = true, + RedirectStandardOutput = true + }; + return ExecuteCommand(logger, startInfo); + } + + internal static (Process proc, string? result, string? errors) ExecuteCommand(ILogger logger, string fileName, string arguments) + { + ProcessStartInfo startInfo = new() + { + FileName = fileName, + Arguments = arguments, + RedirectStandardError = true, + RedirectStandardOutput = true + }; + return ExecuteCommand(logger, startInfo); + } + + internal static bool ExecuteShellCommand(ILogger logger, string fileName, string arguments) + { + logger.LogInformation("Executing shell command {FileName} {Arguments}", fileName, arguments); + ProcessStartInfo startInfo = new() + { + FileName = fileName, + Arguments = arguments, + UseShellExecute = true, + }; + Process process = new() { StartInfo = startInfo }; + process.Start(); + process.WaitForExit(); + return process.ExitCode == 0; + } + + + private static (Process proc, string? result, string? errors) ExecuteCommand(ILogger logger, ProcessStartInfo startInfo) { + Process process = new() { StartInfo = startInfo }; + string? result = null; + string? errors = null; + process.OutputDataReceived += (sender, e) => + { + if (!string.IsNullOrEmpty(e.Data)) + { + result += e.Data; + logger.LogInformation("{Data}", e.Data); + } + }; + + process.ErrorDataReceived += (sender, e) => + { + if (!string.IsNullOrEmpty(e.Data)) + { + errors += e.Data; + logger.LogError("{Data}", e.Data); + } + }; + + process.Start(); + process.BeginErrorReadLine(); + process.BeginOutputReadLine(); + process.WaitForExit(); + return (process, result, errors); + } +} diff --git a/src/CSnakes.Runtime/PythonEnvironment.cs b/src/CSnakes.Runtime/PythonEnvironment.cs index 39a82183..c4023df1 100644 --- a/src/CSnakes.Runtime/PythonEnvironment.cs +++ b/src/CSnakes.Runtime/PythonEnvironment.cs @@ -1,9 +1,8 @@ using CSnakes.Runtime.CPython; +using CSnakes.Runtime.EnvironmentManagement; using CSnakes.Runtime.Locators; using CSnakes.Runtime.PackageManagement; using Microsoft.Extensions.Logging; -using System.Diagnostics; -using System.Runtime.InteropServices; namespace CSnakes.Runtime; @@ -17,13 +16,13 @@ internal class PythonEnvironment : IPythonEnvironment private static IPythonEnvironment? pythonEnvironment; private readonly static object locker = new(); - public static IPythonEnvironment GetPythonEnvironment(IEnumerable<PythonLocator> locators, IEnumerable<IPythonPackageInstaller> packageInstallers, PythonEnvironmentOptions options, Microsoft.Extensions.Logging.ILogger<IPythonEnvironment> logger) + public static IPythonEnvironment GetPythonEnvironment(IEnumerable<PythonLocator> locators, IEnumerable<IPythonPackageInstaller> packageInstallers, PythonEnvironmentOptions options, ILogger<IPythonEnvironment> logger, IEnvironmentManagement? environmentManager = null) { if (pythonEnvironment is null) { lock (locker) { - pythonEnvironment ??= new PythonEnvironment(locators, packageInstallers, options, logger); + pythonEnvironment ??= new PythonEnvironment(locators, packageInstallers, options, logger, environmentManager); } } return pythonEnvironment; @@ -33,7 +32,8 @@ private PythonEnvironment( IEnumerable<PythonLocator> locators, IEnumerable<IPythonPackageInstaller> packageInstallers, PythonEnvironmentOptions options, - ILogger<IPythonEnvironment> logger) + ILogger<IPythonEnvironment> logger, + IEnvironmentManagement? environmentManager = null) { Logger = logger; @@ -58,29 +58,18 @@ private PythonEnvironment( throw new DirectoryNotFoundException("Python home directory does not exist."); } - if (!string.IsNullOrEmpty(options.VirtualEnvironmentPath)) { - string venvLibPath = string.Empty; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - venvLibPath = Path.Combine(options.VirtualEnvironmentPath, "Lib", "site-packages"); - else - { - string suffix = location.FreeThreaded ? "t" : ""; - venvLibPath = Path.Combine(options.VirtualEnvironmentPath, "lib", $"python{location.Version.Major}.{location.Version.Minor}{suffix}", "site-packages"); - } - logger.LogDebug("Adding virtual environment site-packages to extra paths: {VenvLibPath}", venvLibPath); - extraPaths = [.. options.ExtraPaths, venvLibPath]; + if (environmentManager is not null) { + + extraPaths = [.. options.ExtraPaths, environmentManager.GetExtraPackagePath(location!)]; - if (options.EnsureVirtualEnvironment) - { - EnsureVirtualEnvironment(location, options.VirtualEnvironmentPath); - } + environmentManager.EnsureEnvironment(location); } logger.LogInformation("Setting up Python environment from {PythonLocation} using home of {Home}", location.Folder, home); foreach (var installer in packageInstallers) { - installer.InstallPackages(home, options.VirtualEnvironmentPath); + installer.InstallPackages(home, environmentManager); } char sep = Path.PathSeparator; @@ -100,61 +89,6 @@ private PythonEnvironment( api.Initialize(); } - private void EnsureVirtualEnvironment(PythonLocationMetadata pythonLocation, string? virtualEnvironmentLocation) - { - if (virtualEnvironmentLocation is null) - { - Logger.LogError("Virtual environment location is not set but it was requested to be created."); - throw new ArgumentNullException(nameof(virtualEnvironmentLocation), "Virtual environment location is not set."); - } - - virtualEnvironmentLocation = Path.GetFullPath(virtualEnvironmentLocation); - if (!Directory.Exists(virtualEnvironmentLocation)) - { - Logger.LogInformation("Creating virtual environment at {VirtualEnvPath} using {PythonBinaryPath}", virtualEnvironmentLocation, pythonLocation.PythonBinaryPath); - using Process process1 = ExecutePythonCommand(pythonLocation, virtualEnvironmentLocation, $"-VV"); - using Process process2 = ExecutePythonCommand(pythonLocation, virtualEnvironmentLocation, $"-m venv {virtualEnvironmentLocation}"); - } - else - { - Logger.LogDebug("Virtual environment already exists at {VirtualEnvPath}", virtualEnvironmentLocation); - } - - Process ExecutePythonCommand(PythonLocationMetadata pythonLocation, string? venvPath, string arguments) - { - ProcessStartInfo startInfo = new() - { - WorkingDirectory = pythonLocation.Folder, - FileName = pythonLocation.PythonBinaryPath, - Arguments = arguments, - RedirectStandardError = true, - RedirectStandardOutput = true - }; - Process process = new() { StartInfo = startInfo }; - process.OutputDataReceived += (sender, e) => - { - if (!string.IsNullOrEmpty(e.Data)) - { - Logger.LogInformation("{Data}", e.Data); - } - }; - - process.ErrorDataReceived += (sender, e) => - { - if (!string.IsNullOrEmpty(e.Data)) - { - Logger.LogError("{Data}", e.Data); - } - }; - - process.Start(); - process.BeginErrorReadLine(); - process.BeginOutputReadLine(); - process.WaitForExit(); - return process; - } - } - private CPythonAPI SetupStandardLibrary(PythonLocationMetadata pythonLocationMetadata) { string pythonDll = pythonLocationMetadata.LibPythonPath; diff --git a/src/CSnakes.Runtime/PythonEnvironmentBuilder.cs b/src/CSnakes.Runtime/PythonEnvironmentBuilder.cs index 66812239..70090bd2 100644 --- a/src/CSnakes.Runtime/PythonEnvironmentBuilder.cs +++ b/src/CSnakes.Runtime/PythonEnvironmentBuilder.cs @@ -1,20 +1,47 @@ -using Microsoft.Extensions.DependencyInjection; +using CSnakes.Runtime.EnvironmentManagement; +using CSnakes.Runtime.Locators; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace CSnakes.Runtime; internal partial class PythonEnvironmentBuilder(IServiceCollection services) : IPythonEnvironmentBuilder { - private bool ensureVirtualEnvironment = false; - private string? virtualEnvironmentLocation; private readonly string[] extraPaths = []; private string home = Environment.CurrentDirectory; public IServiceCollection Services { get; } = services; - public IPythonEnvironmentBuilder WithVirtualEnvironment(string path, bool ensureVirtualEnvironment = true) + public IPythonEnvironmentBuilder WithVirtualEnvironment(string path, bool ensureExists = true) { - this.ensureVirtualEnvironment = ensureVirtualEnvironment; - virtualEnvironmentLocation = path; + Services.AddSingleton<IEnvironmentManagement>( + sp => + { + var logger = sp.GetRequiredService<ILogger<VenvEnvironmentManagement>>(); + return new VenvEnvironmentManagement(logger, path, ensureExists); + }); + return this; + } + + public IPythonEnvironmentBuilder WithCondaEnvironment(string name, string? environmentSpecPath = null, bool ensureEnvironment = false) + { + if (ensureEnvironment) + throw new InvalidOperationException("Automated Conda environment creation not yet supported. Conda environments must be created manually."); + + Services.AddSingleton<IEnvironmentManagement>( + sp => { + try + { + var condaLocator = sp.GetRequiredService<CondaLocator>(); + var logger = sp.GetRequiredService<ILogger<CondaEnvironmentManagement>>(); + var condaEnvManager = new CondaEnvironmentManagement(logger, name, ensureEnvironment, condaLocator, environmentSpecPath); + return condaEnvManager; + } + catch (InvalidOperationException) + { + throw new InvalidOperationException("Conda environments much be used with Conda Locator."); + } + }); return this; } @@ -25,5 +52,5 @@ public IPythonEnvironmentBuilder WithHome(string home) } public PythonEnvironmentOptions GetOptions() => - new(home, virtualEnvironmentLocation, ensureVirtualEnvironment, extraPaths); + new(home, extraPaths); } diff --git a/src/CSnakes.Runtime/PythonEnvironmentOptions.cs b/src/CSnakes.Runtime/PythonEnvironmentOptions.cs index d2e6164a..2802a334 100644 --- a/src/CSnakes.Runtime/PythonEnvironmentOptions.cs +++ b/src/CSnakes.Runtime/PythonEnvironmentOptions.cs @@ -1,2 +1,2 @@ namespace CSnakes.Runtime; -public record PythonEnvironmentOptions(string Home, string? VirtualEnvironmentPath, bool EnsureVirtualEnvironment, string[] ExtraPaths); +public record PythonEnvironmentOptions(string Home, string[] ExtraPaths); diff --git a/src/CSnakes.Runtime/ServiceCollectionExtensions.cs b/src/CSnakes.Runtime/ServiceCollectionExtensions.cs index 7aa1e4dd..9858a8c1 100644 --- a/src/CSnakes.Runtime/ServiceCollectionExtensions.cs +++ b/src/CSnakes.Runtime/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ -using CSnakes.Runtime.Locators; +using CSnakes.Runtime.EnvironmentManagement; +using CSnakes.Runtime.Locators; using CSnakes.Runtime.PackageManagement; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -8,7 +9,7 @@ namespace CSnakes.Runtime; /// <summary> /// Extension methods for <see cref="IServiceCollection"/> to configure Python-related services. /// </summary> -public static class ServiceCollectionExtensions +public static partial class ServiceCollectionExtensions { /// <summary> /// Adds Python-related services to the service collection with the specified Python home directory. @@ -27,10 +28,11 @@ public static IPythonEnvironmentBuilder WithPython(this IServiceCollection servi var locators = sp.GetServices<PythonLocator>(); var installers = sp.GetServices<IPythonPackageInstaller>(); var logger = sp.GetRequiredService<ILogger<IPythonEnvironment>>(); + var environmentManager = sp.GetService<IEnvironmentManagement>(); var options = envBuilder.GetOptions(); - return PythonEnvironment.GetPythonEnvironment(locators, installers, options, logger); + return PythonEnvironment.GetPythonEnvironment(locators, installers, options, logger, environmentManager); }); return pythonBuilder; @@ -39,7 +41,7 @@ public static IPythonEnvironmentBuilder WithPython(this IServiceCollection servi public static Version ParsePythonVersion(string version) { // Remove non -numeric characters except . - Match versionMatch = Regex.Match(version, "^(\\d+(\\.\\d+)*)"); + Match versionMatch = VersionParseExpr().Match(version); if (!versionMatch.Success) { throw new InvalidOperationException($"Invalid Python version: '{version}'"); @@ -152,14 +154,49 @@ public static IPythonEnvironmentBuilder FromFolder(this IPythonEnvironmentBuilde return builder; } + /// <summary> + /// Adds a Python locator using Python from a conda environment + /// </summary> + /// <param name="builder">The <see cref="IPythonEnvironmentBuilder"/> to add the locator to.</param> + /// <param name="condaBinaryPath">The path to the conda binary.</param> + /// <returns>The modified <see cref="IPythonEnvironmentBuilder"/>.</returns> + public static IPythonEnvironmentBuilder FromConda(this IPythonEnvironmentBuilder builder, string condaBinaryPath) + { + builder.Services.AddSingleton<CondaLocator>( + sp => + { + var logger = sp.GetRequiredService<ILogger<IPythonEnvironment>>(); + return new CondaLocator(logger, condaBinaryPath); + } + ); + builder.Services.AddSingleton<PythonLocator>( + sp => + { + var condaLocator = sp.GetRequiredService<CondaLocator>(); + return condaLocator; + } + ); + return builder; + } + /// <summary> /// Adds a pip package installer to the service collection. /// </summary> /// <param name="builder">The <see cref="IPythonEnvironmentBuilder"/> to add the installer to.</param> + /// <param name="requirementsPath">The path to the requirements file.</param> /// <returns>The modified <see cref="IPythonEnvironmentBuilder"/>.</returns> - public static IPythonEnvironmentBuilder WithPipInstaller(this IPythonEnvironmentBuilder builder) + public static IPythonEnvironmentBuilder WithPipInstaller(this IPythonEnvironmentBuilder builder, string requirementsPath = "requirements.txt") { - builder.Services.AddSingleton<IPythonPackageInstaller, PipInstaller>(); + builder.Services.AddSingleton<IPythonPackageInstaller>( + sp => + { + var logger = sp.GetRequiredService<ILogger<PipInstaller>>(); + return new PipInstaller(logger, requirementsPath); + } + ); return builder; } + + [GeneratedRegex("^(\\d+(\\.\\d+)*)")] + private static partial Regex VersionParseExpr(); } diff --git a/src/CSnakes.sln b/src/CSnakes.sln index 31698159..db134f82 100644 --- a/src/CSnakes.sln +++ b/src/CSnakes.sln @@ -28,6 +28,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{E52DC71C EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestUtilities", "TestUtilities\TestUtilities.csproj", "{641C9CD0-8529-4666-8F27-ECEB7F72043C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Conda.Tests", "Conda.Tests\Conda.Tests.csproj", "{38604D9B-2C01-4B82-AFA1-A00E184BAE03}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -62,6 +64,10 @@ Global {641C9CD0-8529-4666-8F27-ECEB7F72043C}.Debug|Any CPU.Build.0 = Debug|Any CPU {641C9CD0-8529-4666-8F27-ECEB7F72043C}.Release|Any CPU.ActiveCfg = Release|Any CPU {641C9CD0-8529-4666-8F27-ECEB7F72043C}.Release|Any CPU.Build.0 = Release|Any CPU + {38604D9B-2C01-4B82-AFA1-A00E184BAE03}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {38604D9B-2C01-4B82-AFA1-A00E184BAE03}.Debug|Any CPU.Build.0 = Debug|Any CPU + {38604D9B-2C01-4B82-AFA1-A00E184BAE03}.Release|Any CPU.ActiveCfg = Release|Any CPU + {38604D9B-2C01-4B82-AFA1-A00E184BAE03}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -74,6 +80,7 @@ Global {CB00C1F5-8C01-4FE7-82AD-7F9B4398E3F8} = {E52DC71C-FB58-4E57-9CCA-8A78EAA49123} {93264FC1-2880-4959-9576-50D260039BC2} = {E52DC71C-FB58-4E57-9CCA-8A78EAA49123} {641C9CD0-8529-4666-8F27-ECEB7F72043C} = {E52DC71C-FB58-4E57-9CCA-8A78EAA49123} + {38604D9B-2C01-4B82-AFA1-A00E184BAE03} = {E52DC71C-FB58-4E57-9CCA-8A78EAA49123} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {4ACC77F9-1BB8-42DE-B647-01C458922F49} diff --git a/src/Conda.Tests/BasicTests.cs b/src/Conda.Tests/BasicTests.cs new file mode 100644 index 00000000..440f0c38 --- /dev/null +++ b/src/Conda.Tests/BasicTests.cs @@ -0,0 +1,12 @@ +namespace Conda.Tests; + +public class BasicTests : CondaTestBase +{ + [Fact] + public void TestSimpleImport() + { + var testModule = Env.TestSimple(); + Assert.NotNull(testModule); + testModule.TestNothing(); + } +} diff --git a/src/Conda.Tests/Conda.Tests.csproj b/src/Conda.Tests/Conda.Tests.csproj new file mode 100644 index 00000000..c503f8d7 --- /dev/null +++ b/src/Conda.Tests/Conda.Tests.csproj @@ -0,0 +1,40 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + + <PropertyGroup> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="coverlet.collector" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="System.Net.Http" /> + <PackageReference Include="System.Text.RegularExpressions" /> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio" /> + <PackageReference Include="python" /> + <PackageReference Include="MartinCostello.Logging.XUnit" /> + </ItemGroup> + + <ItemGroup> + <Using Include="Xunit" /> + <Using Include="CSnakes.Runtime" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\CSnakes.Runtime\CSnakes.Runtime.csproj" /> + <ProjectReference Include="..\CSnakes.SourceGeneration\CSnakes.SourceGeneration.csproj" ReferenceOutputAssembly="false" OutputItemType="Analyzer" /> + </ItemGroup> + + <ItemGroup> + <AdditionalFiles Include="python\*.py"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </AdditionalFiles> + <None Remove="python\test_simple.py" /> + <Content Include="python\environment.yml"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </Content> + </ItemGroup> +</Project> diff --git a/src/Conda.Tests/CondaTestBase.cs b/src/Conda.Tests/CondaTestBase.cs new file mode 100644 index 00000000..55bf4ffa --- /dev/null +++ b/src/Conda.Tests/CondaTestBase.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Conda.Tests; +public class CondaTestBase : IDisposable +{ + private readonly IPythonEnvironment env; + private readonly IHost app; + + public CondaTestBase() + { + string condaEnv = Environment.GetEnvironmentVariable("CONDA") ?? string.Empty; + + if (string.IsNullOrEmpty(condaEnv)) + { + if (OperatingSystem.IsWindows()) + condaEnv = Environment.GetEnvironmentVariable("LOCALAPPDATA") ?? ""; + condaEnv = Path.Join(condaEnv, "anaconda3"); + + } + var condaBinPath = OperatingSystem.IsWindows() ? Path.Join(condaEnv, "Scripts", "conda.exe") : Path.Join(condaEnv, "bin", "conda"); + var environmentSpecPath = Path.Join(Environment.CurrentDirectory, "python", "environment.yml"); + app = Host.CreateDefaultBuilder() + .ConfigureServices((context, services) => + { + var pb = services.WithPython(); + pb.WithHome(Path.Join(Environment.CurrentDirectory, "python")); + + pb.FromConda(condaBinPath) + .WithCondaEnvironment("csnakes_test", environmentSpecPath); + + services.AddLogging(builder => builder.AddXUnit()); + }) + .Build(); + + env = app.Services.GetRequiredService<IPythonEnvironment>(); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + GC.Collect(); + } + + public IPythonEnvironment Env => env; +} diff --git a/src/Conda.Tests/python/environment.yml b/src/Conda.Tests/python/environment.yml new file mode 100644 index 00000000..7e91f632 --- /dev/null +++ b/src/Conda.Tests/python/environment.yml @@ -0,0 +1,3 @@ +name: csnakes_test +dependencies: + - httpx diff --git a/src/Conda.Tests/python/test_simple.py b/src/Conda.Tests/python/test_simple.py new file mode 100644 index 00000000..29414662 --- /dev/null +++ b/src/Conda.Tests/python/test_simple.py @@ -0,0 +1,5 @@ +import httpx + + +def test_nothing() -> None: + pass