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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
302 changes: 191 additions & 111 deletions src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions src/Aspire.Hosting.Python/PythonVersionDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ internal static partial class PythonVersionDetector
/// <param name="appDirectory">The directory containing the Python application.</param>
/// <param name="virtualEnvironment">The virtual environment to check as a fallback.</param>
/// <returns>The detected Python version in major.minor format (e.g., "3.13"), or null if not found.</returns>
public static string? DetectVersion(string appDirectory, VirtualEnvironment virtualEnvironment)
public static string? DetectVersion(string appDirectory, VirtualEnvironment? virtualEnvironment)
{
// First, try .python-version file (most specific)
var pythonVersionFile = Path.Combine(appDirectory, ".python-version");
Expand All @@ -41,7 +41,12 @@ internal static partial class PythonVersionDetector
}

// Third, try detecting from virtual environment as ultimate fallback
return DetectVersionFromVirtualEnvironment(virtualEnvironment);
if (virtualEnvironment != null)
{
return DetectVersionFromVirtualEnvironment(virtualEnvironment);
}

return null;
}

/// <summary>
Expand Down
152 changes: 152 additions & 0 deletions tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1372,5 +1372,157 @@ public async Task WithUvEnvironment_CustomBaseImages_GeneratesDockerfileWithCust
// Verify the custom runtime image is used
Assert.Contains("FROM python:3.13-slim AS app", dockerfileContent);
}

[Fact]
public async Task FallbackDockerfile_GeneratesDockerfileWithoutUv_WithRequirementsTxt()
{
using var sourceDir = new TempDirectory();
using var outputDir = new TempDirectory();
var projectDirectory = sourceDir.Path;

// Create a Python project without UV but with requirements.txt
var requirementsContent = """
flask==3.0.0
requests==2.31.0
""";

var scriptContent = """
print("Hello from non-UV project!")
""";

File.WriteAllText(Path.Combine(projectDirectory, "requirements.txt"), requirementsContent);
File.WriteAllText(Path.Combine(projectDirectory, "main.py"), scriptContent);

using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest");

// Add Python resources without UV environment
builder.AddPythonScript("script-app", projectDirectory, "main.py");

var app = builder.Build();
app.Run();

// Verify that Dockerfile was generated
var dockerfilePath = Path.Combine(outputDir.Path, "script-app.Dockerfile");
Assert.True(File.Exists(dockerfilePath), "Dockerfile should be generated for non-UV Python app");

var dockerfileContent = File.ReadAllText(dockerfilePath);

// Verify it's a fallback Dockerfile (single stage, no UV)
Assert.DoesNotContain("uv sync", dockerfileContent);
Assert.DoesNotContain("ghcr.io/astral-sh/uv", dockerfileContent);

// Verify it uses pip install for requirements.txt
Assert.Contains("pip install --no-cache-dir -r requirements.txt", dockerfileContent);

// Verify it uses the same runtime image as UV workflow
Assert.Contains("FROM python:3.13-slim-bookworm", dockerfileContent);

await Verify(dockerfileContent);
}

[Fact]
public async Task FallbackDockerfile_GeneratesDockerfileWithoutUv_WithoutRequirementsTxt()
{
using var sourceDir = new TempDirectory();
using var outputDir = new TempDirectory();
var projectDirectory = sourceDir.Path;

// Create a Python project without UV and without requirements.txt
var scriptContent = """
print("Hello from non-UV project with no dependencies!")
""";

var pyprojectContent = """
[project]
name = "test-app"
version = "0.1.0"
requires-python = ">=3.11"
""";

File.WriteAllText(Path.Combine(projectDirectory, "main.py"), scriptContent);
File.WriteAllText(Path.Combine(projectDirectory, "pyproject.toml"), pyprojectContent);

using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest");

// Add Python resources without UV environment
builder.AddPythonScript("script-app", projectDirectory, "main.py");

var app = builder.Build();
app.Run();

// Verify that Dockerfile was generated
var dockerfilePath = Path.Combine(outputDir.Path, "script-app.Dockerfile");
Assert.True(File.Exists(dockerfilePath), "Dockerfile should be generated for non-UV Python app");

var dockerfileContent = File.ReadAllText(dockerfilePath);

// Verify it's a fallback Dockerfile (single stage, no UV)
Assert.DoesNotContain("uv sync", dockerfileContent);
Assert.DoesNotContain("ghcr.io/astral-sh/uv", dockerfileContent);

// Verify it doesn't have pip install since there's no requirements.txt
Assert.DoesNotContain("pip install", dockerfileContent);

// Verify it uses the same runtime image as UV workflow
Assert.Contains("FROM python:3.11-slim-bookworm", dockerfileContent);

await Verify(dockerfileContent);
}

[Fact]
public async Task FallbackDockerfile_GeneratesDockerfileForAllEntrypointTypes()
{
using var sourceDir = new TempDirectory();
using var outputDir = new TempDirectory();
var projectDirectory = sourceDir.Path;

// Create a Python project without UV
var scriptContent = """
print("Hello!")
""";

var pythonVersionContent = "3.12";

File.WriteAllText(Path.Combine(projectDirectory, "main.py"), scriptContent);
File.WriteAllText(Path.Combine(projectDirectory, ".python-version"), pythonVersionContent);

using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest");

// Add Python resources with different entrypoint types, none using UV
builder.AddPythonScript("script-app", projectDirectory, "main.py");
builder.AddPythonModule("module-app", projectDirectory, "mymodule");
builder.AddPythonExecutable("executable-app", projectDirectory, "pytest");

var app = builder.Build();
app.Run();

// Verify that Dockerfiles were generated for each entrypoint type
var scriptDockerfilePath = Path.Combine(outputDir.Path, "script-app.Dockerfile");
Assert.True(File.Exists(scriptDockerfilePath), "Dockerfile should be generated for script entrypoint");

var moduleDockerfilePath = Path.Combine(outputDir.Path, "module-app.Dockerfile");
Assert.True(File.Exists(moduleDockerfilePath), "Dockerfile should be generated for module entrypoint");

var executableDockerfilePath = Path.Combine(outputDir.Path, "executable-app.Dockerfile");
Assert.True(File.Exists(executableDockerfilePath), "Dockerfile should be generated for executable entrypoint");

var scriptDockerfileContent = File.ReadAllText(scriptDockerfilePath);
var moduleDockerfileContent = File.ReadAllText(moduleDockerfilePath);
var executableDockerfileContent = File.ReadAllText(executableDockerfilePath);

// Verify none use UV
Assert.DoesNotContain("uv sync", scriptDockerfileContent);
Assert.DoesNotContain("uv sync", moduleDockerfileContent);
Assert.DoesNotContain("uv sync", executableDockerfileContent);

// Verify correct entrypoints
Assert.Contains("ENTRYPOINT [\"python\",\"main.py\"]", scriptDockerfileContent);
Assert.Contains("ENTRYPOINT [\"python\",\"-m\",\"mymodule\"]", moduleDockerfileContent);
Assert.Contains("ENTRYPOINT [\"pytest\"]", executableDockerfileContent);

await Verify(scriptDockerfileContent)
.AppendContentAsFile(moduleDockerfileContent)
.AppendContentAsFile(executableDockerfileContent);
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM python:3.12-slim-bookworm

# ------------------------------
# 🚀 Python Application
# ------------------------------
# Create non-root user for security
RUN groupadd --system --gid 999 appuser && useradd --system --gid 999 --uid 999 --create-home appuser

# Set working directory
WORKDIR /app

# Copy application files
COPY --chown=appuser:appuser . /app

# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# Use the non-root user to run the application
USER appuser

# Run the application
ENTRYPOINT ["python","main.py"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM python:3.12-slim-bookworm

# ------------------------------
# 🚀 Python Application
# ------------------------------
# Create non-root user for security
RUN groupadd --system --gid 999 appuser && useradd --system --gid 999 --uid 999 --create-home appuser

# Set working directory
WORKDIR /app

# Copy application files
COPY --chown=appuser:appuser . /app

# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# Use the non-root user to run the application
USER appuser

# Run the application
ENTRYPOINT ["python","-m","mymodule"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM python:3.12-slim-bookworm

# ------------------------------
# 🚀 Python Application
# ------------------------------
# Create non-root user for security
RUN groupadd --system --gid 999 appuser && useradd --system --gid 999 --uid 999 --create-home appuser

# Set working directory
WORKDIR /app

# Copy application files
COPY --chown=appuser:appuser . /app

# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# Use the non-root user to run the application
USER appuser

# Run the application
ENTRYPOINT ["pytest"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
FROM python:3.13-slim-bookworm

# ------------------------------
# 🚀 Python Application
# ------------------------------
# Create non-root user for security
RUN groupadd --system --gid 999 appuser && useradd --system --gid 999 --uid 999 --create-home appuser

# Set working directory
WORKDIR /app

# Copy requirements.txt for dependency installation
COPY requirements.txt /app/requirements.txt

# Install dependencies using pip
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& pip install --no-cache-dir -r requirements.txt \
&& apt-get purge -y --auto-remove build-essential \
&& rm -rf /var/lib/apt/lists/*

# Copy application files
COPY --chown=appuser:appuser . /app

# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# Use the non-root user to run the application
USER appuser

# Run the application
ENTRYPOINT ["python","main.py"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM python:3.11-slim-bookworm

# ------------------------------
# 🚀 Python Application
# ------------------------------
# Create non-root user for security
RUN groupadd --system --gid 999 appuser && useradd --system --gid 999 --uid 999 --create-home appuser

# Set working directory
WORKDIR /app

# Copy application files
COPY --chown=appuser:appuser . /app

# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# Use the non-root user to run the application
USER appuser

# Run the application
ENTRYPOINT ["python","main.py"]