File: PythonLanguageSupport.cs
Web Access
Project: src\src\Aspire.Hosting.CodeGeneration.Python\Aspire.Hosting.CodeGeneration.Python.csproj (Aspire.Hosting.CodeGeneration.Python)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices;
using Aspire.TypeSystem;
 
namespace Aspire.Hosting.CodeGeneration.Python;
 
/// <summary>
/// Provides language support for Python AppHosts, implementing scaffolding, detection, and runtime configuration.
/// </summary>
/// <remarks>
/// This implementation generates the files required for a Python-based Aspire AppHost and configures
/// the runtime to create a virtual environment and install dependencies. When <c>uv</c> is available
/// on PATH it is preferred; otherwise the standard <c>python -m venv</c> / <c>pip</c> toolchain is used.
/// </remarks>
internal sealed class PythonLanguageSupport : ILanguageSupport
{
    /// <summary>
    /// The language/runtime identifier for Python.
    /// </summary>
    private const string LanguageId = "python";
 
    /// <summary>
    /// The code generation target language. This maps to the ICodeGenerator.Language property.
    /// </summary>
    private const string CodeGenTarget = "Python";
 
    private const string LanguageDisplayName = "Python";
    private static readonly string[] s_detectionPatterns = ["apphost.py"];
 
    /// <summary>
    /// Relative path from the project directory to the venv Python executable (platform-dependent).
    /// </summary>
    private static readonly string s_venvPython = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
        ? @".venv\Scripts\python.exe"
        : ".venv/bin/python";
 
    /// <summary>
    /// Lazily loaded Python script that creates a venv with a microvenv fallback.
    /// </summary>
    private static readonly Lazy<string> s_microvenvScript = new(LoadMicrovenvScript);
 
    /// <summary>
    /// Gets the language identifier for Python AppHosts.
    /// </summary>
    /// <value>The string <c>"python"</c>.</value>
    public string Language => LanguageId;
 
    /// <summary>
    /// Generates the initial scaffold files for a new Python AppHost project.
    /// </summary>
    /// <param name="request">The scaffold request containing project details such as the project name and an optional port seed.</param>
    /// <returns>
    /// A dictionary mapping relative file paths to their contents. The generated files include
    /// <c>apphost.py</c>, <c>pylock.toml</c>, and <c>apphost.run.json</c>.
    /// </returns>
    /// <remarks>
    /// The <c>apphost.run.json</c> file is generated with randomly assigned port numbers unless
    /// <see cref="ScaffoldRequest.PortSeed"/> is provided, in which case ports are deterministically assigned.
    /// </remarks>
    public Dictionary<string, string> Scaffold(ScaffoldRequest request)
    {
        var files = new Dictionary<string, string>();
 
        // Create apphost.py
        files["apphost.py"] = """
            # Aspire Python AppHost
            # For more information, see: https://aspire.dev
 
            from aspire_app import create_builder
 
            with create_builder() as builder:
                # Add your resources here, for example:
                # redis = builder.add_redis("cache")
                # postgres = builder.add_postgres("db")
                builder.run()
            """;
 
        // Create pylock.toml
        files["pylock.apphost.toml"] = $$"""
            created-by = 'Aspire'
            lock-version = '1.0'
            requires-python = '>=3.11'
 
            [[packages]]
            name = "aspire_app"
            editable = true
 
            [packages.directory]
            path = ".aspire/modules"
            """;
 
        // Create requirements.txt as a fallback for pip (which doesn't support pylock.toml)
        files["apphost_requirements.txt"] = """
            # Aspire Python AppHost requirements
            # This file is used when uv is not available and pip is used instead.
            -e .aspire/modules
            """;
 
        // Create apphost.run.json with random ports
        // Use PortSeed if provided (for testing), otherwise use random
        var random = request.PortSeed.HasValue
            ? new Random(request.PortSeed.Value)
            : Random.Shared;
 
        var httpsPort = random.Next(10000, 65000);
        var httpPort = random.Next(10000, 65000);
        var otlpPort = random.Next(10000, 65000);
        var resourceServicePort = random.Next(10000, 65000);
 
        files["apphost.run.json"] = $$"""
            {
              "profiles": {
                "https": {
                  "applicationUrl": "https://localhost:{{httpsPort}};http://localhost:{{httpPort}}",
                  "environmentVariables": {
                    "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:{{otlpPort}}",
                    "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:{{resourceServicePort}}"
                  }
                }
              }
            }
            """;
 
        return files;
    }
 
    /// <summary>
    /// Detects whether the specified directory contains a Python AppHost project.
    /// </summary>
    /// <param name="directoryPath">The full path to the directory to inspect.</param>
    /// <returns>
    /// A <see cref="DetectionResult"/> with <see cref="DetectionResult.Found"/> if <c>apphost.py</c>
    /// and either <c>pylock.toml</c> or <c>requirements.txt</c> exist in <paramref name="directoryPath"/>;
    /// otherwise <see cref="DetectionResult.NotFound"/>.
    /// </returns>
    public DetectionResult Detect(string directoryPath)
    {
        var appHostPath = Path.Combine(directoryPath, "apphost.py");
        if (!File.Exists(appHostPath))
        {
            return DetectionResult.NotFound;
        }
 
        var hasPylock = File.Exists(Path.Combine(directoryPath, "pylock.apphost.toml"));
        var hasRequirements = File.Exists(Path.Combine(directoryPath, "apphost_requirements.txt"));
 
        if (!hasPylock && !hasRequirements)
        {
            return DetectionResult.NotFound;
        }
 
        return DetectionResult.Found(LanguageId, "apphost.py");
    }
 
    /// <summary>
    /// Gets the runtime execution specification for Python AppHosts.
    /// </summary>
    /// <returns>
    /// A <see cref="RuntimeSpec"/> configured for <c>uv</c> when available, otherwise falling back
    /// to the standard <c>python -m venv</c> / <c>pip install</c> toolchain.
    /// </returns>
    public RuntimeSpec GetRuntimeSpec()
    {
        return IsCommandAvailable("uv")
            ? GetUvRuntimeSpec()
            : GetVenvRuntimeSpec();
    }
 
    private static RuntimeSpec GetUvRuntimeSpec()
    {
        return new RuntimeSpec
        {
            Language = LanguageId,
            DisplayName = LanguageDisplayName,
            CodeGenLanguage = CodeGenTarget,
            DetectionPatterns = s_detectionPatterns,
            Initialize =
            [
                new CommandSpec
                {
                    Command = "uv",
                    Args = ["venv", ".venv", "--allow-existing"]
                }
            ],
            InstallDependencies = new CommandSpec
            {
                Command = "uv",
                Args = ["pip", "sync", "pylock.apphost.toml"]
            },
            Execute = new CommandSpec
            {
                Command = "uv",
                Args = ["run", "python", "{appHostFile}", "{args}"]
            }
        };
    }
 
    private static RuntimeSpec GetVenvRuntimeSpec()
    {
        var python = FindPythonCommand();
        var hasVenv = IsCommandAvailable(python, "-m", "venv", "--help");
 
        var initializeArgs = hasVenv
            ? (string[])["-m", "venv", ".venv"]
            : ["-c", s_microvenvScript.Value, ".venv"];
 
        return new RuntimeSpec
        {
            Language = LanguageId,
            DisplayName = LanguageDisplayName,
            CodeGenLanguage = CodeGenTarget,
            DetectionPatterns = s_detectionPatterns,
            Initialize =
            [
                new CommandSpec
                {
                    Command = python,
                    Args = initializeArgs
                },
                new CommandSpec
                {
                    Command = s_venvPython,
                    Args = ["-m", "ensurepip", "--upgrade"]
                }
            ],
            InstallDependencies = new CommandSpec
            {
                Command = s_venvPython,
                Args = ["-m", "pip", "install", "-r", "apphost_requirements.txt"]
            },
            Execute = new CommandSpec
            {
                Command = s_venvPython,
                Args = ["{appHostFile}", "{args}"]
            }
        };
    }
 
    /// <summary>
    /// Returns the Python command name available on PATH (<c>python3</c> preferred, falls back to <c>python</c>).
    /// </summary>
    private static string FindPythonCommand()
    {
        return IsCommandAvailable("python3") ? "python3" : "python";
    }
 
    /// <summary>
    /// Checks whether a command is available by running it with the given arguments
    /// (defaults to <c>--version</c>) and checking for a zero exit code.
    /// </summary>
    private static bool IsCommandAvailable(string command, params string[] args)
    {
        try
        {
            var startInfo = new ProcessStartInfo
            {
                FileName = command,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                CreateNoWindow = true
            };
 
            if (args.Length == 0)
            {
                startInfo.ArgumentList.Add("--version");
            }
            else
            {
                foreach (var arg in args)
                {
                    startInfo.ArgumentList.Add(arg);
                }
            }
 
            using var process = Process.Start(startInfo);
 
            if (process is null)
            {
                return false;
            }
 
            if (!process.WaitForExit(5000))
            {
                return false;
            }
 
            return process.ExitCode == 0;
        }
        catch
        {
            return false;
        }
    }
 
    private static string LoadMicrovenvScript()
    {
        using var stream = Assembly.GetExecutingAssembly()
            .GetManifestResourceStream("Aspire.Hosting.CodeGeneration.Python.Resources.microvenv.py")
            ?? throw new InvalidOperationException("Embedded resource 'microvenv.py' not found.");
        using var reader = new StreamReader(stream);
        return reader.ReadToEnd();
    }
}