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