Skip to content

Commit ec32342

Browse files
github-actions[bot]Copilotdavidfowleerhardt
authored
[release/13.0] Generate fallback Dockerfile for Python apps without UV (#12640)
* Initial plan * Add fallback Dockerfile generation for Python apps without UV support - Modified PythonAppResourceBuilderExtensions to generate Dockerfiles for all Python apps - Split Dockerfile generation into two methods: GenerateUvDockerfile and GenerateFallbackDockerfile - Fallback Dockerfiles detect requirements.txt and run pip install if present - Uses same runtime image (python:X.Y-slim-bookworm) as UV workflow for consistency - Updated PythonVersionDetector to accept nullable VirtualEnvironment parameter - Added comprehensive tests for fallback Dockerfile generation Co-authored-by: davidfowl <[email protected]> * Address code review feedback: simplify version detection logic and remove empty line Co-authored-by: davidfowl <[email protected]> * PR feedback * Use a default Python version if we can't detect it. --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: davidfowl <[email protected]> Co-authored-by: Eric Erhardt <[email protected]>
1 parent c63ccff commit ec32342

8 files changed

+475
-113
lines changed

src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs

Lines changed: 191 additions & 111 deletions
Large diffs are not rendered by default.

src/Aspire.Hosting.Python/PythonVersionDetector.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ internal static partial class PythonVersionDetector
1414
/// <param name="appDirectory">The directory containing the Python application.</param>
1515
/// <param name="virtualEnvironment">The virtual environment to check as a fallback.</param>
1616
/// <returns>The detected Python version in major.minor format (e.g., "3.13"), or null if not found.</returns>
17-
public static string? DetectVersion(string appDirectory, VirtualEnvironment virtualEnvironment)
17+
public static string? DetectVersion(string appDirectory, VirtualEnvironment? virtualEnvironment)
1818
{
1919
// First, try .python-version file (most specific)
2020
var pythonVersionFile = Path.Combine(appDirectory, ".python-version");
@@ -41,7 +41,12 @@ internal static partial class PythonVersionDetector
4141
}
4242

4343
// Third, try detecting from virtual environment as ultimate fallback
44-
return DetectVersionFromVirtualEnvironment(virtualEnvironment);
44+
if (virtualEnvironment != null)
45+
{
46+
return DetectVersionFromVirtualEnvironment(virtualEnvironment);
47+
}
48+
49+
return null;
4550
}
4651

4752
/// <summary>

tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1372,5 +1372,157 @@ public async Task WithUvEnvironment_CustomBaseImages_GeneratesDockerfileWithCust
13721372
// Verify the custom runtime image is used
13731373
Assert.Contains("FROM python:3.13-slim AS app", dockerfileContent);
13741374
}
1375+
1376+
[Fact]
1377+
public async Task FallbackDockerfile_GeneratesDockerfileWithoutUv_WithRequirementsTxt()
1378+
{
1379+
using var sourceDir = new TempDirectory();
1380+
using var outputDir = new TempDirectory();
1381+
var projectDirectory = sourceDir.Path;
1382+
1383+
// Create a Python project without UV but with requirements.txt
1384+
var requirementsContent = """
1385+
flask==3.0.0
1386+
requests==2.31.0
1387+
""";
1388+
1389+
var scriptContent = """
1390+
print("Hello from non-UV project!")
1391+
""";
1392+
1393+
File.WriteAllText(Path.Combine(projectDirectory, "requirements.txt"), requirementsContent);
1394+
File.WriteAllText(Path.Combine(projectDirectory, "main.py"), scriptContent);
1395+
1396+
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest");
1397+
1398+
// Add Python resources without UV environment
1399+
builder.AddPythonScript("script-app", projectDirectory, "main.py");
1400+
1401+
var app = builder.Build();
1402+
app.Run();
1403+
1404+
// Verify that Dockerfile was generated
1405+
var dockerfilePath = Path.Combine(outputDir.Path, "script-app.Dockerfile");
1406+
Assert.True(File.Exists(dockerfilePath), "Dockerfile should be generated for non-UV Python app");
1407+
1408+
var dockerfileContent = File.ReadAllText(dockerfilePath);
1409+
1410+
// Verify it's a fallback Dockerfile (single stage, no UV)
1411+
Assert.DoesNotContain("uv sync", dockerfileContent);
1412+
Assert.DoesNotContain("ghcr.io/astral-sh/uv", dockerfileContent);
1413+
1414+
// Verify it uses pip install for requirements.txt
1415+
Assert.Contains("pip install --no-cache-dir -r requirements.txt", dockerfileContent);
1416+
1417+
// Verify it uses the same runtime image as UV workflow
1418+
Assert.Contains("FROM python:3.13-slim-bookworm", dockerfileContent);
1419+
1420+
await Verify(dockerfileContent);
1421+
}
1422+
1423+
[Fact]
1424+
public async Task FallbackDockerfile_GeneratesDockerfileWithoutUv_WithoutRequirementsTxt()
1425+
{
1426+
using var sourceDir = new TempDirectory();
1427+
using var outputDir = new TempDirectory();
1428+
var projectDirectory = sourceDir.Path;
1429+
1430+
// Create a Python project without UV and without requirements.txt
1431+
var scriptContent = """
1432+
print("Hello from non-UV project with no dependencies!")
1433+
""";
1434+
1435+
var pyprojectContent = """
1436+
[project]
1437+
name = "test-app"
1438+
version = "0.1.0"
1439+
requires-python = ">=3.11"
1440+
""";
1441+
1442+
File.WriteAllText(Path.Combine(projectDirectory, "main.py"), scriptContent);
1443+
File.WriteAllText(Path.Combine(projectDirectory, "pyproject.toml"), pyprojectContent);
1444+
1445+
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest");
1446+
1447+
// Add Python resources without UV environment
1448+
builder.AddPythonScript("script-app", projectDirectory, "main.py");
1449+
1450+
var app = builder.Build();
1451+
app.Run();
1452+
1453+
// Verify that Dockerfile was generated
1454+
var dockerfilePath = Path.Combine(outputDir.Path, "script-app.Dockerfile");
1455+
Assert.True(File.Exists(dockerfilePath), "Dockerfile should be generated for non-UV Python app");
1456+
1457+
var dockerfileContent = File.ReadAllText(dockerfilePath);
1458+
1459+
// Verify it's a fallback Dockerfile (single stage, no UV)
1460+
Assert.DoesNotContain("uv sync", dockerfileContent);
1461+
Assert.DoesNotContain("ghcr.io/astral-sh/uv", dockerfileContent);
1462+
1463+
// Verify it doesn't have pip install since there's no requirements.txt
1464+
Assert.DoesNotContain("pip install", dockerfileContent);
1465+
1466+
// Verify it uses the same runtime image as UV workflow
1467+
Assert.Contains("FROM python:3.11-slim-bookworm", dockerfileContent);
1468+
1469+
await Verify(dockerfileContent);
1470+
}
1471+
1472+
[Fact]
1473+
public async Task FallbackDockerfile_GeneratesDockerfileForAllEntrypointTypes()
1474+
{
1475+
using var sourceDir = new TempDirectory();
1476+
using var outputDir = new TempDirectory();
1477+
var projectDirectory = sourceDir.Path;
1478+
1479+
// Create a Python project without UV
1480+
var scriptContent = """
1481+
print("Hello!")
1482+
""";
1483+
1484+
var pythonVersionContent = "3.12";
1485+
1486+
File.WriteAllText(Path.Combine(projectDirectory, "main.py"), scriptContent);
1487+
File.WriteAllText(Path.Combine(projectDirectory, ".python-version"), pythonVersionContent);
1488+
1489+
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest");
1490+
1491+
// Add Python resources with different entrypoint types, none using UV
1492+
builder.AddPythonScript("script-app", projectDirectory, "main.py");
1493+
builder.AddPythonModule("module-app", projectDirectory, "mymodule");
1494+
builder.AddPythonExecutable("executable-app", projectDirectory, "pytest");
1495+
1496+
var app = builder.Build();
1497+
app.Run();
1498+
1499+
// Verify that Dockerfiles were generated for each entrypoint type
1500+
var scriptDockerfilePath = Path.Combine(outputDir.Path, "script-app.Dockerfile");
1501+
Assert.True(File.Exists(scriptDockerfilePath), "Dockerfile should be generated for script entrypoint");
1502+
1503+
var moduleDockerfilePath = Path.Combine(outputDir.Path, "module-app.Dockerfile");
1504+
Assert.True(File.Exists(moduleDockerfilePath), "Dockerfile should be generated for module entrypoint");
1505+
1506+
var executableDockerfilePath = Path.Combine(outputDir.Path, "executable-app.Dockerfile");
1507+
Assert.True(File.Exists(executableDockerfilePath), "Dockerfile should be generated for executable entrypoint");
1508+
1509+
var scriptDockerfileContent = File.ReadAllText(scriptDockerfilePath);
1510+
var moduleDockerfileContent = File.ReadAllText(moduleDockerfilePath);
1511+
var executableDockerfileContent = File.ReadAllText(executableDockerfilePath);
1512+
1513+
// Verify none use UV
1514+
Assert.DoesNotContain("uv sync", scriptDockerfileContent);
1515+
Assert.DoesNotContain("uv sync", moduleDockerfileContent);
1516+
Assert.DoesNotContain("uv sync", executableDockerfileContent);
1517+
1518+
// Verify correct entrypoints
1519+
Assert.Contains("ENTRYPOINT [\"python\",\"main.py\"]", scriptDockerfileContent);
1520+
Assert.Contains("ENTRYPOINT [\"python\",\"-m\",\"mymodule\"]", moduleDockerfileContent);
1521+
Assert.Contains("ENTRYPOINT [\"pytest\"]", executableDockerfileContent);
1522+
1523+
await Verify(scriptDockerfileContent)
1524+
.AppendContentAsFile(moduleDockerfileContent)
1525+
.AppendContentAsFile(executableDockerfileContent);
1526+
}
13751527
}
13761528

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
FROM python:3.12-slim-bookworm
2+
3+
# ------------------------------
4+
# 🚀 Python Application
5+
# ------------------------------
6+
# Create non-root user for security
7+
RUN groupadd --system --gid 999 appuser && useradd --system --gid 999 --uid 999 --create-home appuser
8+
9+
# Set working directory
10+
WORKDIR /app
11+
12+
# Copy application files
13+
COPY --chown=appuser:appuser . /app
14+
15+
# Set environment variables
16+
ENV PYTHONDONTWRITEBYTECODE=1
17+
ENV PYTHONUNBUFFERED=1
18+
19+
# Use the non-root user to run the application
20+
USER appuser
21+
22+
# Run the application
23+
ENTRYPOINT ["python","main.py"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
FROM python:3.12-slim-bookworm
2+
3+
# ------------------------------
4+
# 🚀 Python Application
5+
# ------------------------------
6+
# Create non-root user for security
7+
RUN groupadd --system --gid 999 appuser && useradd --system --gid 999 --uid 999 --create-home appuser
8+
9+
# Set working directory
10+
WORKDIR /app
11+
12+
# Copy application files
13+
COPY --chown=appuser:appuser . /app
14+
15+
# Set environment variables
16+
ENV PYTHONDONTWRITEBYTECODE=1
17+
ENV PYTHONUNBUFFERED=1
18+
19+
# Use the non-root user to run the application
20+
USER appuser
21+
22+
# Run the application
23+
ENTRYPOINT ["python","-m","mymodule"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
FROM python:3.12-slim-bookworm
2+
3+
# ------------------------------
4+
# 🚀 Python Application
5+
# ------------------------------
6+
# Create non-root user for security
7+
RUN groupadd --system --gid 999 appuser && useradd --system --gid 999 --uid 999 --create-home appuser
8+
9+
# Set working directory
10+
WORKDIR /app
11+
12+
# Copy application files
13+
COPY --chown=appuser:appuser . /app
14+
15+
# Set environment variables
16+
ENV PYTHONDONTWRITEBYTECODE=1
17+
ENV PYTHONUNBUFFERED=1
18+
19+
# Use the non-root user to run the application
20+
USER appuser
21+
22+
# Run the application
23+
ENTRYPOINT ["pytest"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
FROM python:3.13-slim-bookworm
2+
3+
# ------------------------------
4+
# 🚀 Python Application
5+
# ------------------------------
6+
# Create non-root user for security
7+
RUN groupadd --system --gid 999 appuser && useradd --system --gid 999 --uid 999 --create-home appuser
8+
9+
# Set working directory
10+
WORKDIR /app
11+
12+
# Copy requirements.txt for dependency installation
13+
COPY requirements.txt /app/requirements.txt
14+
15+
# Install dependencies using pip
16+
RUN apt-get update \
17+
&& apt-get install -y --no-install-recommends build-essential \
18+
&& pip install --no-cache-dir -r requirements.txt \
19+
&& apt-get purge -y --auto-remove build-essential \
20+
&& rm -rf /var/lib/apt/lists/*
21+
22+
# Copy application files
23+
COPY --chown=appuser:appuser . /app
24+
25+
# Set environment variables
26+
ENV PYTHONDONTWRITEBYTECODE=1
27+
ENV PYTHONUNBUFFERED=1
28+
29+
# Use the non-root user to run the application
30+
USER appuser
31+
32+
# Run the application
33+
ENTRYPOINT ["python","main.py"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
FROM python:3.11-slim-bookworm
2+
3+
# ------------------------------
4+
# 🚀 Python Application
5+
# ------------------------------
6+
# Create non-root user for security
7+
RUN groupadd --system --gid 999 appuser && useradd --system --gid 999 --uid 999 --create-home appuser
8+
9+
# Set working directory
10+
WORKDIR /app
11+
12+
# Copy application files
13+
COPY --chown=appuser:appuser . /app
14+
15+
# Set environment variables
16+
ENV PYTHONDONTWRITEBYTECODE=1
17+
ENV PYTHONUNBUFFERED=1
18+
19+
# Use the non-root user to run the application
20+
USER appuser
21+
22+
# Run the application
23+
ENTRYPOINT ["python","main.py"]

0 commit comments

Comments
 (0)