Skip to content

Commit 8e2e80c

Browse files
Copilotdavidfowleerhardt
authored
Add Python and uv installation validation with user notifications (#12787)
* Initial plan * Add Python and uv installation validation with user notifications Co-authored-by: davidfowl <[email protected]> * Make RequiredCommandValidator and CoalescingAsyncOperation public, remove venv check and logging Co-authored-by: davidfowl <[email protected]> * Fix OnResolvedAsync visibility to protected virtual Co-authored-by: davidfowl <[email protected]> * Move RequiredCommandValidator and CoalescingAsyncOperation to Shared as internal, parameterize message strings Co-authored-by: davidfowl <[email protected]> * Make OnResolvedAsync protected internal to allow test access Co-authored-by: davidfowl <[email protected]> * Remove unused resource strings from MessageStrings files Co-authored-by: davidfowl <[email protected]> * Remove shared code from Aspire.Hosting, remove Python Resources, add RunMode check Co-authored-by: davidfowl <[email protected]> * Revert Aspire.Hosting changes and remove unused properties Co-authored-by: eerhardt <[email protected]> * Move validation to child resources: PythonVenvCreatorResource and PythonInstallerResource Co-authored-by: davidfowl <[email protected]> * Add throwOnFailure parameter and remove RunMode checks from child resource validation Co-authored-by: davidfowl <[email protected]> * Add UvInstallationManager registration in WithUv method Co-authored-by: davidfowl <[email protected]> * Remove unused resource strings from Aspire.Hosting/Resources Co-authored-by: davidfowl <[email protected]> * Update src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs * Update src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: davidfowl <[email protected]> Co-authored-by: eerhardt <[email protected]> Co-authored-by: David Fowler <[email protected]>
1 parent 3d530d7 commit 8e2e80c

10 files changed

+210
-10
lines changed

src/Aspire.Hosting.DevTunnels/Aspire.Hosting.DevTunnels.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
</PropertyGroup>
1010

1111
<ItemGroup>
12+
<Compile Include="$(SharedDir)CoalescingAsyncOperation.cs" LinkBase="Shared" />
13+
<Compile Include="$(SharedDir)RequiredCommandValidator.cs" LinkBase="Shared" />
1214
<Compile Include="$(SharedDir)StringComparers.cs" LinkBase="Shared" />
1315
<Compile Include="$(SharedDir)ResourceNameComparer.cs" LinkBase="Shared" />
1416
<Compile Include="$(SharedDir)PortAllocator.cs" LinkBase="Shared" />

src/Aspire.Hosting.DevTunnels/DevTunnelCliInstallationManager.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Globalization;
5+
using Aspire.Hosting.Utils;
56
using Microsoft.Extensions.Configuration;
67
using Microsoft.Extensions.Logging;
78

src/Aspire.Hosting.DevTunnels/DevTunnelLoginManager.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using Aspire.Hosting.Utils;
45
using Microsoft.Extensions.Configuration;
56
using Microsoft.Extensions.Logging;
67

src/Aspire.Hosting.DevTunnels/LoggedOutNotificationManager.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using Aspire.Hosting.Utils;
5+
46
namespace Aspire.Hosting.DevTunnels;
57

68
#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.

src/Aspire.Hosting.Python/Aspire.Hosting.Python.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
</PropertyGroup>
99

1010
<ItemGroup>
11+
<Compile Include="$(SharedDir)CoalescingAsyncOperation.cs" Link="Utils\CoalescingAsyncOperation.cs" />
1112
<Compile Include="$(SharedDir)PathNormalizer.cs" Link="Utils\PathNormalizer.cs" />
13+
<Compile Include="$(SharedDir)RequiredCommandValidator.cs" Link="Utils\RequiredCommandValidator.cs" />
1214
<Compile Include="$(SharedDir)OverloadResolutionPriorityAttribute.cs" Link="Utils\OverloadResolutionPriorityAttribute.cs" />
1315
<Compile Include="$(RepoRoot)src\Aspire.Hosting\Dcp\Model\ExecutableLaunchConfiguration.cs" />
1416
</ItemGroup>

src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Aspire.Hosting.Pipelines;
99
using Aspire.Hosting.Python;
1010
using Microsoft.Extensions.DependencyInjection;
11+
using Microsoft.Extensions.DependencyInjection.Extensions;
1112
using Microsoft.Extensions.Logging;
1213

1314
#pragma warning disable ASPIREDOCKERFILEBUILDER001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
@@ -316,6 +317,8 @@ private static IResourceBuilder<T> AddPythonAppCore<T>(
316317
ArgumentException.ThrowIfNullOrEmpty(entrypoint);
317318
ArgumentNullException.ThrowIfNull(virtualEnvironmentPath);
318319

320+
// Register Python environment validation services (once per builder)
321+
builder.Services.TryAddSingleton<PythonInstallationManager>();
319322
// When using the default virtual environment path, look for existing virtual environments
320323
// in multiple locations: app directory first, then AppHost directory as fallback
321324
var resolvedVenvPath = virtualEnvironmentPath;
@@ -1183,6 +1186,9 @@ public static IResourceBuilder<T> WithUv<T>(this IResourceBuilder<T> builder, bo
11831186
{
11841187
ArgumentNullException.ThrowIfNull(builder);
11851188

1189+
// Register UV validation service
1190+
builder.ApplicationBuilder.Services.TryAddSingleton<UvInstallationManager>();
1191+
11861192
// Default args: sync only (uv will auto-detect Python and dependencies from pyproject.toml)
11871193
args ??= ["sync"];
11881194

@@ -1271,6 +1277,20 @@ private static void AddInstaller<T>(IResourceBuilder<T> builder, bool install) w
12711277
.WithParentRelationship(builder.Resource)
12721278
.ExcludeFromManifest();
12731279

1280+
// Add validation for the installer command (uv or python)
1281+
installerBuilder.OnBeforeResourceStarted(static async (installerResource, e, ct) =>
1282+
{
1283+
// Check which command this installer is using (set by BeforeStartEvent)
1284+
if (installerResource.TryGetLastAnnotation<ExecutableAnnotation>(out var executable) &&
1285+
executable.Command == "uv")
1286+
{
1287+
// Validate that uv is installed - don't throw so the app fails as it normally would
1288+
var uvInstallationManager = e.Services.GetRequiredService<UvInstallationManager>();
1289+
await uvInstallationManager.EnsureInstalledAsync(throwOnFailure: false, ct).ConfigureAwait(false);
1290+
}
1291+
// For other package managers (pip, etc.), Python validation happens via PythonVenvCreatorResource
1292+
});
1293+
12741294
builder.ApplicationBuilder.Eventing.Subscribe<BeforeStartEvent>((_, _) =>
12751295
{
12761296
// Set the installer's working directory to match the resource's working directory
@@ -1344,7 +1364,13 @@ private static void CreateVenvCreatorIfNeeded<T>(IResourceBuilder<T> builder) wh
13441364
.WithArgs(["-m", "venv", venvPath])
13451365
.WithWorkingDirectory(builder.Resource.WorkingDirectory)
13461366
.WithParentRelationship(builder.Resource)
1347-
.ExcludeFromManifest();
1367+
.ExcludeFromManifest()
1368+
.OnBeforeResourceStarted(static async (venvCreatorResource, e, ct) =>
1369+
{
1370+
// Validate that Python is installed before creating venv - don't throw so the app fails as it normally would
1371+
var pythonInstallationManager = e.Services.GetRequiredService<PythonInstallationManager>();
1372+
await pythonInstallationManager.EnsureInstalledAsync(throwOnFailure: false, ct).ConfigureAwait(false);
1373+
});
13481374

13491375
// Wait relationships will be set up dynamically in SetupDependencies
13501376
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Aspire.Hosting.Utils;
5+
using Microsoft.Extensions.Logging;
6+
7+
namespace Aspire.Hosting.Python;
8+
9+
/// <summary>
10+
/// Validates that the Python executable is available on the system.
11+
/// </summary>
12+
#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
13+
internal sealed class PythonInstallationManager : RequiredCommandValidator
14+
{
15+
private string? _resolvedCommandPath;
16+
17+
public PythonInstallationManager(
18+
IInteractionService interactionService,
19+
ILogger<PythonInstallationManager> logger)
20+
: base(interactionService, logger)
21+
{
22+
}
23+
24+
/// <summary>
25+
/// Ensures Python is installed/available. This method is safe for concurrent callers;
26+
/// only one validation will run at a time.
27+
/// </summary>
28+
/// <param name="throwOnFailure">Whether to throw an exception if Python is not found. Default is true.</param>
29+
/// <param name="cancellationToken">Cancellation token.</param>
30+
public Task EnsureInstalledAsync(bool throwOnFailure = true, CancellationToken cancellationToken = default)
31+
{
32+
SetThrowOnFailure(throwOnFailure);
33+
return RunAsync(cancellationToken);
34+
}
35+
36+
protected override string GetCommandPath()
37+
{
38+
// Try common Python command names based on platform
39+
// On Windows: python, py
40+
// On Linux/macOS: python3, python
41+
if (OperatingSystem.IsWindows())
42+
{
43+
// Try 'python' first, then 'py' (Python launcher)
44+
var pythonPath = ResolveCommand("python");
45+
if (pythonPath is not null)
46+
{
47+
return "python";
48+
}
49+
return "py";
50+
}
51+
else
52+
{
53+
// Try 'python3' first (more specific), then 'python'
54+
var python3Path = ResolveCommand("python3");
55+
if (python3Path is not null)
56+
{
57+
return "python3";
58+
}
59+
return "python";
60+
}
61+
}
62+
63+
protected override Task OnValidatedAsync(string resolvedCommandPath, CancellationToken cancellationToken)
64+
{
65+
_resolvedCommandPath = resolvedCommandPath;
66+
return Task.CompletedTask;
67+
}
68+
69+
protected override string? GetHelpLink() => "https://www.python.org/downloads/";
70+
}
71+
#pragma warning restore ASPIREINTERACTION001
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Aspire.Hosting.Utils;
5+
using Microsoft.Extensions.Logging;
6+
7+
namespace Aspire.Hosting.Python;
8+
9+
/// <summary>
10+
/// Validates that the uv command is available on the system.
11+
/// </summary>
12+
#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
13+
internal sealed class UvInstallationManager : RequiredCommandValidator
14+
{
15+
private string? _resolvedCommandPath;
16+
17+
public UvInstallationManager(
18+
IInteractionService interactionService,
19+
ILogger<UvInstallationManager> logger)
20+
: base(interactionService, logger)
21+
{
22+
}
23+
24+
/// <summary>
25+
/// Ensures uv is installed/available. This method is safe for concurrent callers;
26+
/// only one validation will run at a time.
27+
/// </summary>
28+
/// <param name="throwOnFailure">Whether to throw an exception if uv is not found. Default is true.</param>
29+
/// <param name="cancellationToken">Cancellation token.</param>
30+
public Task EnsureInstalledAsync(bool throwOnFailure = true, CancellationToken cancellationToken = default)
31+
{
32+
SetThrowOnFailure(throwOnFailure);
33+
return RunAsync(cancellationToken);
34+
}
35+
36+
protected override string GetCommandPath() => "uv";
37+
38+
protected override Task OnValidatedAsync(string resolvedCommandPath, CancellationToken cancellationToken)
39+
{
40+
_resolvedCommandPath = resolvedCommandPath;
41+
return Task.CompletedTask;
42+
}
43+
44+
protected override string? GetHelpLink() => "https://docs.astral.sh/uv/getting-started/installation/";
45+
}
46+
#pragma warning restore ASPIREINTERACTION001

src/Aspire.Hosting.DevTunnels/CoalescingAsyncOperation.cs renamed to src/Shared/CoalescingAsyncOperation.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
namespace Aspire.Hosting.DevTunnels;
4+
namespace Aspire.Hosting.Utils;
55

66
/// <summary>
77
/// Provides a reusable pattern for running an asynchronous operation ensuring that only one
@@ -83,6 +83,9 @@ private async Task ClearCompletedAsync(Task completed)
8383
}
8484
}
8585

86+
/// <summary>
87+
/// Disposes the coalescing operation, canceling any in-progress execution and releasing resources.
88+
/// </summary>
8689
public virtual void Dispose()
8790
{
8891
_gate.Wait();

src/Aspire.Hosting.DevTunnels/RequiredCommandValidator.cs renamed to src/Shared/RequiredCommandValidator.cs

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
using System.Runtime.InteropServices;
66
using Microsoft.Extensions.Logging;
77

8-
namespace Aspire.Hosting.DevTunnels;
8+
namespace Aspire.Hosting.Utils;
99

1010
/// <summary>
1111
/// Base class that extends <see cref="CoalescingAsyncOperation"/> with validation logic
@@ -28,12 +28,40 @@ internal abstract class RequiredCommandValidator(IInteractionService interaction
2828

2929
private Task? _notificationTask;
3030
private string? _notificationMessage;
31+
private bool _throwOnFailure = true;
3132

3233
/// <summary>
3334
/// Returns the command string (file name or path) that should be validated.
3435
/// </summary>
3536
protected abstract string GetCommandPath();
3637

38+
/// <summary>
39+
/// Gets the message to display when the command is not found and there is no help link.
40+
/// Default: "Required command '{0}' was not found on PATH or at a specified location."
41+
/// </summary>
42+
/// <param name="command">The command name.</param>
43+
/// <returns>The formatted message.</returns>
44+
protected virtual string GetCommandNotFoundMessage(string command) =>
45+
string.Format(CultureInfo.CurrentCulture, "Required command '{0}' was not found on PATH or at a specified location.", command);
46+
47+
/// <summary>
48+
/// Gets the message to display when the command is not found and there is a help link.
49+
/// Default: "Required command '{0}' was not found. See installation instructions for more details."
50+
/// </summary>
51+
/// <param name="command">The command name.</param>
52+
/// <returns>The formatted message.</returns>
53+
protected virtual string GetCommandNotFoundWithLinkMessage(string command) =>
54+
string.Format(CultureInfo.CurrentCulture, "Required command '{0}' was not found. See installation instructions for more details.", command);
55+
56+
/// <summary>
57+
/// Gets the message to display when the command is found but validation failed.
58+
/// Default: "{0} See installation instructions for more details."
59+
/// </summary>
60+
/// <param name="validationMessage">The validation failure message.</param>
61+
/// <returns>The formatted message.</returns>
62+
protected virtual string GetValidationFailedMessage(string validationMessage) =>
63+
string.Format(CultureInfo.CurrentCulture, "{0} See installation instructions for more details.", validationMessage);
64+
3765
/// <summary>
3866
/// Called after the command has been successfully resolved to a full path.
3967
/// Default implementation does nothing.
@@ -59,8 +87,12 @@ protected sealed override async Task ExecuteCoreAsync(CancellationToken cancella
5987
var notificationTask = _notificationTask;
6088
if (notificationTask is { IsCompleted: false })
6189
{
62-
// Failure notification is still being shown so just throw again.
63-
throw new DistributedApplicationException(_notificationMessage ?? $"Required command '{command}' was not found on PATH, at the specified location, or failed validation.");
90+
// Failure notification is still being shown so just throw again if configured to throw.
91+
if (_throwOnFailure)
92+
{
93+
throw new DistributedApplicationException(_notificationMessage ?? $"Required command '{command}' was not found on PATH, at the specified location, or failed validation.");
94+
}
95+
return;
6496
}
6597

6698
if (string.IsNullOrWhiteSpace(command))
@@ -80,9 +112,9 @@ protected sealed override async Task ExecuteCoreAsync(CancellationToken cancella
80112
var message = (link, validationMessage) switch
81113
{
82114
(null, not null) => validationMessage,
83-
(not null, not null) => string.Format(CultureInfo.CurrentCulture, Resources.MessageStrings.RequiredCommandNotificationWithValidation, validationMessage),
84-
(not null, null) => string.Format(CultureInfo.CurrentCulture, Resources.MessageStrings.RequiredCommandNotificationWithLink, command),
85-
_ => string.Format(CultureInfo.CurrentCulture, Resources.MessageStrings.RequiredCommandNotification, command)
115+
(not null, not null) => GetValidationFailedMessage(validationMessage),
116+
(not null, null) => GetCommandNotFoundWithLinkMessage(command),
117+
_ => GetCommandNotFoundMessage(command)
86118
};
87119

88120
_logger.LogWarning("{Message}", message);
@@ -113,12 +145,26 @@ protected sealed override async Task ExecuteCoreAsync(CancellationToken cancella
113145
_logger.LogDebug(ex, "Failed to show missing command notification");
114146
}
115147
}
116-
throw new DistributedApplicationException(message);
148+
149+
if (_throwOnFailure)
150+
{
151+
throw new DistributedApplicationException(message);
152+
}
153+
return;
117154
}
118155

119156
_notificationMessage = null;
120157
await OnValidatedAsync(resolved, cancellationToken).ConfigureAwait(false);
121158
}
159+
160+
/// <summary>
161+
/// Sets whether to throw an exception when validation fails.
162+
/// </summary>
163+
/// <param name="throwOnFailure">True to throw on failure, false to just show notification and log.</param>
164+
protected void SetThrowOnFailure(bool throwOnFailure)
165+
{
166+
_throwOnFailure = throwOnFailure;
167+
}
122168

123169
/// <summary>
124170
/// Optional link returned to guide users when the command is missing. Return null for no link.
@@ -130,7 +176,7 @@ protected sealed override async Task ExecuteCoreAsync(CancellationToken cancella
130176
/// </summary>
131177
/// <param name="command">The command string.</param>
132178
/// <returns>Full path if resolved; otherwise null.</returns>
133-
protected static string? ResolveCommand(string command)
179+
protected internal static string? ResolveCommand(string command)
134180
{
135181
// If the command includes any directory separator, treat it as a path (relative or absolute)
136182
if (command.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]) >= 0)

0 commit comments

Comments
 (0)