Skip to content

Commit

Permalink
Add Conda Environment Management and Locator Support (#232)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* use file-scoped namespace

* Fix merge foo

* Fix logger resolver.

---------

Co-authored-by: Aaron Powell <[email protected]>
  • Loading branch information
tonybaloney and aaronpowell authored Oct 11, 2024
1 parent b871b33 commit 0754331
Show file tree
Hide file tree
Showing 32 changed files with 597 additions and 133 deletions.
10 changes: 9 additions & 1 deletion .github/workflows/dotnet-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions docs/environments.md
Original file line number Diff line number Diff line change
@@ -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.

4 changes: 3 additions & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down
42 changes: 28 additions & 14 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 3 additions & 5 deletions src/CSnakes.Runtime.Tests/Locators/PythonLocatorTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using CSnakes.Runtime.Locators;
using Microsoft.TestUtilities;
using System;
using System.Runtime.InteropServices;

namespace CSnakes.Runtime.Tests.Locators;
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);

}
Original file line number Diff line number Diff line change
@@ -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;
}
}
15 changes: 13 additions & 2 deletions src/CSnakes.Runtime/IPythonEnvironmentBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading

0 comments on commit 0754331

Please sign in to comment.