-
Notifications
You must be signed in to change notification settings - Fork 861
Expand file tree
/
Copy pathPythonAppResourceBuilderExtensions.cs
More file actions
1545 lines (1387 loc) · 71.8 KB
/
PythonAppResourceBuilderExtensions.cs
File metadata and controls
1545 lines (1387 loc) · 71.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.ApplicationModel.Docker;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Python;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
#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.
#pragma warning disable ASPIREEXTENSION001
#pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPUBLISHERS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIRECERTIFICATES001
namespace Aspire.Hosting;
/// <summary>
/// Provides extension methods for adding Python applications to an <see cref="IDistributedApplicationBuilder"/>.
/// </summary>
public static class PythonAppResourceBuilderExtensions
{
private const string DefaultVirtualEnvFolder = ".venv";
private const string DefaultPythonVersion = "3.13";
/// <summary>
/// Adds a Python application to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="appDirectory">The path to the directory containing the python application.</param>
/// <param name="scriptPath">The path to the script relative to the app directory to run.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// This method executes a Python script directly using <c>python script.py</c>.
/// By default, the virtual environment is resolved using the following priority:
/// <list type="number">
/// <item>If <c>.venv</c> exists in the app directory, use it.</item>
/// <item>If <c>.venv</c> exists in the AppHost directory, use it.</item>
/// <item>Otherwise, default to <c>.venv</c> in the app directory.</item>
/// </list>
/// Use <see cref="WithVirtualEnvironment{T}(IResourceBuilder{T}, string, bool)"/> to specify a different virtual environment path.
/// Use <c>WithArgs</c> to pass arguments to the script.
/// </para>
/// <para>
/// Python applications automatically have debugging support enabled.
/// </para>
/// </remarks>
/// <example>
/// Add a FastAPI Python application to the application model:
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddPythonApp("fastapi-app", "../api", "main.py")
/// .WithArgs("arg1", "arg2");
///
/// builder.Build().Run();
/// </code>
/// </example>
[OverloadResolutionPriority(1)]
[AspireExport(Description = "Adds a Python script application resource")]
public static IResourceBuilder<PythonAppResource> AddPythonApp(
this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string scriptPath)
=> AddPythonAppCore(builder, name, appDirectory, EntrypointType.Script, scriptPath, DefaultVirtualEnvFolder)
.WithDebugging();
/// <summary>
/// Adds a Python module to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="appDirectory">The path to the directory containing the python application.</param>
/// <param name="moduleName">The name of the Python module to run (e.g., "flask", "uvicorn").</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// This method runs a Python module using <c>python -m <module></c>.
/// By default, the virtual environment folder is expected to be named <c>.venv</c> and located in the app directory.
/// Use <see cref="WithVirtualEnvironment{T}(IResourceBuilder{T}, string, bool)"/> to specify a different virtual environment path.
/// Use <c>WithArgs</c> to pass arguments to the module.
/// </para>
/// <para>
/// Python modules automatically have debugging support enabled.
/// </para>
/// </remarks>
/// <example>
/// Add a Flask module to the application model:
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddPythonModule("flask-dev", "../flaskapp", "flask")
/// .WithArgs("run", "--debug", "--host=0.0.0.0");
///
/// builder.Build().Run();
/// </code>
/// </example>
[AspireExport(Description = "Adds a Python module application resource")]
public static IResourceBuilder<PythonAppResource> AddPythonModule(
this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string moduleName)
=> AddPythonAppCore(builder, name, appDirectory, EntrypointType.Module, moduleName, DefaultVirtualEnvFolder)
.WithDebugging();
/// <summary>
/// Adds a Python executable to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="appDirectory">The path to the directory containing the python application.</param>
/// <param name="executableName">The name of the executable in the virtual environment (e.g., "pytest", "uvicorn", "flask").</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// This method runs an executable from the virtual environment's bin directory.
/// By default, the virtual environment folder is expected to be named <c>.venv</c> and located in the app directory.
/// Use <see cref="WithVirtualEnvironment{T}(IResourceBuilder{T}, string, bool)"/> to specify a different virtual environment path.
/// Use <c>WithArgs</c> to pass arguments to the executable.
/// </para>
/// <para>
/// Unlike scripts and modules, Python executables do not have debugging support enabled by default.
/// Use <see cref="WithDebugging"/> to explicitly enable debugging support if the executable is a Python-based
/// tool that can be debugged.
/// </para>
/// </remarks>
/// <example>
/// Add a pytest executable to the application model:
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddPythonExecutable("pytest", "../api", "pytest")
/// .WithArgs("-q")
/// .WithDebugging();
///
/// builder.Build().Run();
/// </code>
/// </example>
[AspireExport(Description = "Adds a Python executable application resource")]
public static IResourceBuilder<PythonAppResource> AddPythonExecutable(
this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string executableName)
=> AddPythonAppCore(builder, name, appDirectory, EntrypointType.Executable, executableName, DefaultVirtualEnvFolder);
/// <summary>
/// Adds a python application with a virtual environment to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="appDirectory">The path to the directory containing the python app files.</param>
/// <param name="scriptPath">The path to the script relative to the app directory to run.</param>
/// <param name="scriptArgs">The arguments for the script.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// This overload is obsolete. Use one of the more specific methods instead:
/// </para>
/// <list type="bullet">
/// <item><description><see cref="AddPythonApp(IDistributedApplicationBuilder, string, string, string)"/> - To run a Python script file</description></item>
/// <item><description><see cref="AddPythonModule"/> - To run a Python module via <c>python -m</c></description></item>
/// <item><description><see cref="AddPythonExecutable"/> - To run an executable from the virtual environment</description></item>
/// </list>
/// <para>
/// Chain with <c>WithArgs</c> to pass arguments:
/// </para>
/// <example>
/// <code lang="csharp">
/// builder.AddPythonScript("name", "dir", "script.py")
/// .WithArgs("arg1", "arg2");
/// </code>
/// </example>
/// </remarks>
[Obsolete("Use AddPythonScript, AddPythonModule, or AddPythonExecutable and chain with .WithArgs(...) instead.")]
[EditorBrowsable(EditorBrowsableState.Never)]
public static IResourceBuilder<PythonAppResource> AddPythonApp(
this IDistributedApplicationBuilder builder, string name, string appDirectory, string scriptPath, params string[] scriptArgs)
{
ArgumentException.ThrowIfNullOrEmpty(scriptPath);
ThrowIfNullOrContainsIsNullOrEmpty(scriptArgs);
return AddPythonAppCore(builder, name, appDirectory, EntrypointType.Script, scriptPath, DefaultVirtualEnvFolder)
.WithDebugging()
.WithArgs(scriptArgs);
}
/// <summary>
/// Adds a python application with a virtual environment to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="appDirectory">The path to the directory containing the python app files.</param>
/// <param name="scriptPath">The path to the script to run, relative to the app directory.</param>
/// <param name="virtualEnvironmentPath">Path to the virtual environment.</param>
/// <param name="scriptArgs">The arguments for the script.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// This overload is obsolete. Use one of the more specific methods instead:
/// </para>
/// <list type="bullet">
/// <item><description><see cref="AddPythonApp(IDistributedApplicationBuilder, string, string, string)"/> - To run a Python script file</description></item>
/// <item><description><see cref="AddPythonModule"/> - To run a Python module via <c>python -m</c></description></item>
/// <item><description><see cref="AddPythonExecutable"/> - To run an executable from the virtual environment</description></item>
/// </list>
/// <para>
/// Chain with <see cref="WithVirtualEnvironment"/> and <c>WithArgs</c>:
/// </para>
/// <example>
/// <code lang="csharp">
/// builder.AddPythonScript("name", "dir", "script.py")
/// .WithVirtualEnvironment("myenv")
/// .WithArgs("arg1", "arg2");
/// </code>
/// </example>
/// </remarks>
[Obsolete("Use AddPythonScript, AddPythonModule, or AddPythonExecutable and chain with .WithVirtualEnvironment(...).WithArgs(...) instead.")]
[EditorBrowsable(EditorBrowsableState.Never)]
public static IResourceBuilder<PythonAppResource> AddPythonApp(
this IDistributedApplicationBuilder builder, string name, string appDirectory, string scriptPath,
string virtualEnvironmentPath, params string[] scriptArgs)
{
ThrowIfNullOrContainsIsNullOrEmpty(scriptArgs);
ArgumentException.ThrowIfNullOrEmpty(scriptPath);
return AddPythonAppCore(builder, name, appDirectory, EntrypointType.Script, scriptPath, virtualEnvironmentPath)
.WithDebugging()
.WithArgs(scriptArgs);
}
/// <summary>
/// Adds a Uvicorn-based Python application to the distributed application builder with HTTP endpoint configuration.
/// </summary>
/// <param name="builder">The distributed application builder to which the Uvicorn application resource will be added.</param>
/// <param name="name">The unique name of the Uvicorn application resource.</param>
/// <param name="appDirectory">The directory containing the Python application files.</param>
/// <param name="app">The ASGI app import path which informs Uvicorn which module and variable to load as your web application.
/// For example, "main:app" means "main.py" file and variable named "app".</param>
/// <returns>A resource builder for further configuration of the Uvicorn Python application resource.</returns>
/// <remarks>
/// <para>
/// This method configures the application to use Uvicorn as the ASGI server and exposes an HTTP
/// endpoint. When publishing, it sets the entry point to use the Uvicorn executable with appropriate arguments for
/// host and port.
/// </para>
/// <para>
/// By default, the virtual environment folder is expected to be named <c>.venv</c> and located in the app directory.
/// Use <see cref="WithVirtualEnvironment"/> to specify a different virtual environment path.
/// </para>
/// <para>
/// In non-publish mode, the <c>--reload</c> flag is automatically added to enable hot reload during development.
/// </para>
/// </remarks>
/// <example>
/// Add a FastAPI application using Uvicorn:
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// var api = builder.AddUvicornApp("api", "../fastapi-app", "main:app")
/// .WithUv()
/// .WithExternalHttpEndpoints();
///
/// builder.Build().Run();
/// </code>
/// </example>
[AspireExport(Description = "Adds a Uvicorn-based Python application resource")]
public static IResourceBuilder<UvicornAppResource> AddUvicornApp(
this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string app)
{
var resourceBuilder =
AddPythonAppCore(
builder,
name,
appDirectory,
EntrypointType.Executable,
"uvicorn",
DefaultVirtualEnvFolder,
(n, e, d) => new UvicornAppResource(n, e, d))
.WithDebugging()
.WithHttpEndpoint(env: "PORT")
.WithArgs(c =>
{
c.Args.Add(app);
c.Args.Add("--host");
var endpoint = ((IResourceWithEndpoints)c.Resource).GetEndpoint("http");
if (builder.ExecutionContext.IsPublishMode)
{
c.Args.Add("0.0.0.0");
}
else
{
c.Args.Add(endpoint.EndpointAnnotation.TargetHost);
}
c.Args.Add("--port");
c.Args.Add(endpoint.Property(EndpointProperty.TargetPort));
// Add hot reload in non-publish mode
if (!builder.ExecutionContext.IsPublishMode)
{
c.Args.Add("--reload");
}
})
.WithHttpsCertificateConfiguration(ctx =>
{
ctx.Arguments.Add("--ssl-keyfile");
ctx.Arguments.Add(ctx.KeyPath);
ctx.Arguments.Add("--ssl-certfile");
ctx.Arguments.Add(ctx.CertificatePath);
if (ctx.Password is not null)
{
ctx.Arguments.Add("--ssl-keyfile-password");
ctx.Arguments.Add(ctx.Password);
}
return Task.CompletedTask;
});
if (builder.ExecutionContext.IsRunMode)
{
// If a TLS certificate is configured, override the endpoint to use HTTPS instead of HTTP.
// Uvicorn only supports binding to a single port.
resourceBuilder.SubscribeHttpsEndpointsUpdate(ctx =>
{
resourceBuilder.WithEndpoint("http", ep => ep.UriScheme = "https");
});
}
return resourceBuilder;
}
private static IResourceBuilder<PythonAppResource> AddPythonAppCore(
IDistributedApplicationBuilder builder, string name, string appDirectory, EntrypointType entrypointType,
string entrypoint, string virtualEnvironmentPath)
{
return AddPythonAppCore(builder, name, appDirectory, entrypointType, entrypoint,
virtualEnvironmentPath, (n, e, d) => new PythonAppResource(n, e, d));
}
private static IResourceBuilder<T> AddPythonAppCore<T>(
IDistributedApplicationBuilder builder, string name, string appDirectory, EntrypointType entrypointType,
string entrypoint, string virtualEnvironmentPath, Func<string, string, string, T> createResource) where T : PythonAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentNullException.ThrowIfNull(appDirectory);
ArgumentException.ThrowIfNullOrEmpty(entrypoint);
ArgumentNullException.ThrowIfNull(virtualEnvironmentPath);
// When using the default virtual environment path, look for existing virtual environments
// in multiple locations: app directory first, then AppHost directory as fallback
var resolvedVenvPath = virtualEnvironmentPath;
if (virtualEnvironmentPath == DefaultVirtualEnvFolder)
{
resolvedVenvPath = ResolveDefaultVirtualEnvironmentPath(builder, appDirectory, virtualEnvironmentPath);
}
// python will be replaced with the resolved entrypoint based on the virtualEnvironmentPath
var resource = createResource(name, "python", Path.GetFullPath(appDirectory, builder.AppHostDirectory));
var resourceBuilder = builder
.AddResource(resource)
// Order matters, we need to bootstrap the entrypoint before setting the entrypoint
.WithAnnotation(new PythonEntrypointAnnotation
{
Type = entrypointType,
Entrypoint = entrypoint
})
// This will resolve the correct python executable based on the virtual environment
.WithVirtualEnvironment(resolvedVenvPath)
// This will set up the the entrypoint based on the PythonEntrypointAnnotation
.WithEntrypoint(entrypointType, entrypoint);
resourceBuilder.WithIconName("CodePyRectangle");
resourceBuilder.WithOtlpExporter();
// Configure OpenTelemetry exporters using environment variables
// https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#exporter-selection
resourceBuilder.WithEnvironment(context =>
{
context.EnvironmentVariables["OTEL_TRACES_EXPORTER"] = "otlp";
context.EnvironmentVariables["OTEL_LOGS_EXPORTER"] = "otlp";
context.EnvironmentVariables["OTEL_METRICS_EXPORTER"] = "otlp";
// Make sure to attach the logging instrumentation setting, so we can capture logs.
// Without this you'll need to configure logging yourself. Which is kind of a pain.
context.EnvironmentVariables["OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED"] = "true";
// Set PYTHONUTF8=1 on Windows in run mode to enable UTF-8 mode
// See: https://docs.python.org/3/using/cmdline.html#envvar-PYTHONUTF8
if (OperatingSystem.IsWindows() && context.ExecutionContext.IsRunMode)
{
context.EnvironmentVariables["PYTHONUTF8"] = "1";
}
});
// Configure required environment variables for custom certificate trust when running as an executable.
resourceBuilder
.WithCertificateTrustScope(CertificateTrustScope.System)
.WithCertificateTrustConfiguration(ctx =>
{
if (ctx.Scope == CertificateTrustScope.Append)
{
var resourceLogger = ctx.ExecutionContext.ServiceProvider.GetRequiredService<ResourceLoggerService>();
var logger = resourceLogger.GetLogger(ctx.Resource);
logger.LogInformation("Certificate trust scope is set to 'Append', but Python resources do not support appending to the default certificate authorities; only OTLP certificate trust will be applied.");
}
else
{
// Override default certificates path for the requests module.
// See: https://docs.python-requests.org/en/latest/user/advanced/#ssl-cert-verification
ctx.EnvironmentVariables["REQUESTS_CA_BUNDLE"] = ctx.CertificateBundlePath;
// Requests also supports CURL_CA_BUNDLE as an alternative config (lower priority than REQUESTS_CA_BUNDLE).
// Setting it to be as complete as possible and avoid potential issues with conflicting configurations.
ctx.EnvironmentVariables["CURL_CA_BUNDLE"] = ctx.CertificateBundlePath;
}
// Override default opentelemetry-python certificate bundle path
// See: https://opentelemetry-python.readthedocs.io/en/latest/exporter/otlp/otlp.html#module-opentelemetry.exporter.otlp
ctx.EnvironmentVariables["OTEL_EXPORTER_OTLP_CERTIFICATE"] = ctx.CertificateBundlePath;
return Task.CompletedTask;
});
resourceBuilder.PublishAsDockerFile(c =>
{
// Only generate a Dockerfile if one doesn't already exist in the app directory
if (File.Exists(Path.Combine(resource.WorkingDirectory, "Dockerfile")))
{
return;
}
c.WithDockerfileBuilder(resource.WorkingDirectory,
context =>
{
if (!context.Resource.TryGetLastAnnotation<PythonEntrypointAnnotation>(out var entrypointAnnotation))
{
// No entrypoint annotation found, cannot generate Dockerfile
return;
}
// Try to get Python environment annotation
context.Resource.TryGetLastAnnotation<PythonEnvironmentAnnotation>(out var pythonEnvironmentAnnotation);
// Detect Python version
var pythonVersion = pythonEnvironmentAnnotation?.Version;
if (pythonVersion is null)
{
var virtualEnvironment = pythonEnvironmentAnnotation?.VirtualEnvironment;
pythonVersion = PythonVersionDetector.DetectVersion(appDirectory, virtualEnvironment);
}
// if we could not detect Python version, use the default
pythonVersion ??= DefaultPythonVersion;
var entrypointType = entrypointAnnotation.Type;
var entrypoint = entrypointAnnotation.Entrypoint;
// Check if using UV by looking at the package manager annotation
var isUsingUv = context.Resource.TryGetLastAnnotation<PythonPackageManagerAnnotation>(out var pkgMgr) &&
pkgMgr.ExecutableName == "uv";
if (isUsingUv)
{
GenerateUvDockerfile(context, resource, pythonVersion, entrypointType, entrypoint);
}
else
{
GenerateFallbackDockerfile(context, resource, pythonVersion, entrypointType, entrypoint);
}
});
});
resourceBuilder.WithPipelineConfiguration(context =>
{
if (resourceBuilder.Resource.TryGetAnnotationsOfType<ContainerFilesDestinationAnnotation>(out var containerFilesAnnotations))
{
var buildSteps = context.GetSteps(resourceBuilder.Resource, WellKnownPipelineTags.BuildCompute);
foreach (var containerFile in containerFilesAnnotations)
{
buildSteps.DependsOn(context.GetSteps(containerFile.Source, WellKnownPipelineTags.BuildCompute));
}
}
});
if (builder.ExecutionContext.IsRunMode)
{
// Subscribe to BeforeStartEvent for this specific resource to wire up dependencies dynamically
// This allows methods like WithPip, WithUv, and WithVirtualEnvironment to add/remove resources
// and the dependencies will be established based on which resources actually exist
// Only do this in run mode since the installer and venv creator only run in run mode
var resourceToSetup = resourceBuilder.Resource;
builder.OnBeforeStart((evt, ct) =>
{
// Wire up wait dependencies for this resource based on which child resources exist
SetupDependencies(builder, resourceToSetup);
return Task.CompletedTask;
});
// Automatically add pip as the package manager if pyproject.toml or requirements.txt exists
// Only do this in run mode since the installer resource only runs in run mode
// Note: pip supports both pyproject.toml and requirements.txt
var appDirectoryFullPath = Path.GetFullPath(appDirectory, builder.AppHostDirectory);
if (File.Exists(Path.Combine(appDirectoryFullPath, "pyproject.toml")) ||
File.Exists(Path.Combine(appDirectoryFullPath, "requirements.txt")))
{
resourceBuilder.WithPip();
}
else
{
// No package files found, but we should still create venv if it doesn't exist
// and createIfNotExists is true (which is the default)
CreateVenvCreatorIfNeeded(resourceBuilder);
}
}
return resourceBuilder;
}
private static void GenerateUvDockerfile(DockerfileBuilderCallbackContext context, PythonAppResource resource,
string pythonVersion, EntrypointType entrypointType, string entrypoint)
{
// Check if uv.lock exists in the working directory
var uvLockPath = Path.Combine(resource.WorkingDirectory, "uv.lock");
var hasUvLock = File.Exists(uvLockPath);
// Get custom base images from annotation, if present
context.Resource.TryGetLastAnnotation<DockerfileBaseImageAnnotation>(out var baseImageAnnotation);
var buildImage = baseImageAnnotation?.BuildImage ?? $"ghcr.io/astral-sh/uv:python{pythonVersion}-bookworm-slim";
var runtimeImage = baseImageAnnotation?.RuntimeImage ?? $"python:{pythonVersion}-slim-bookworm";
var builderStage = context.Builder
.From(buildImage, "builder")
.EmptyLine()
.Comment("Enable bytecode compilation and copy mode for the virtual environment")
.Env("UV_COMPILE_BYTECODE", "1")
.Env("UV_LINK_MODE", "copy")
.EmptyLine()
.WorkDir("/app")
.EmptyLine();
if (hasUvLock)
{
// If uv.lock exists, use locked mode for reproducible builds
builderStage
.Comment("Install dependencies first for better layer caching")
.Comment("Uses BuildKit cache mounts to speed up repeated builds")
.RunWithMounts(
"uv sync --locked --no-install-project --no-dev",
"type=cache,target=/root/.cache/uv",
"type=bind,source=uv.lock,target=uv.lock",
"type=bind,source=pyproject.toml,target=pyproject.toml")
.EmptyLine()
.Comment("Copy the rest of the application source and install the project")
.Copy(".", "/app")
.RunWithMounts(
"uv sync --locked --no-dev",
"type=cache,target=/root/.cache/uv");
}
else
{
// If uv.lock doesn't exist, copy pyproject.toml and generate lock file
builderStage
.Comment("Copy pyproject.toml to install dependencies")
.Copy("pyproject.toml", "/app/")
.EmptyLine()
.Comment("Install dependencies and generate lock file")
.Comment("Uses BuildKit cache mount to speed up repeated builds")
.RunWithMounts(
"uv sync --no-install-project --no-dev",
"type=cache,target=/root/.cache/uv")
.EmptyLine()
.Comment("Copy the rest of the application source and install the project")
.Copy(".", "/app")
.RunWithMounts(
"uv sync --no-dev",
"type=cache,target=/root/.cache/uv");
}
var logger = context.Services.GetService<ILogger<PythonAppResource>>();
context.Builder.AddContainerFilesStages(context.Resource, logger);
var runtimeBuilder = context.Builder
.From(runtimeImage, "app")
.EmptyLine()
.AddContainerFiles(context.Resource, "/app", logger)
.Comment("------------------------------")
.Comment("🚀 Runtime stage")
.Comment("------------------------------")
.Comment("Create non-root user for security")
.Run("groupadd --system --gid 999 appuser && useradd --system --gid 999 --uid 999 --create-home appuser")
.EmptyLine()
.Comment("Copy the application and virtual environment from builder")
.CopyFrom(builderStage.StageName!, "/app", "/app", "appuser:appuser")
.EmptyLine()
.Comment("Add virtual environment to PATH and set VIRTUAL_ENV")
.Env("PATH", "/app/.venv/bin:${PATH}")
.Env("VIRTUAL_ENV", "/app/.venv")
.Env("PYTHONDONTWRITEBYTECODE", "1")
.Env("PYTHONUNBUFFERED", "1")
.EmptyLine()
.Comment("Use the non-root user to run the application")
.User("appuser")
.EmptyLine()
.Comment("Set working directory")
.WorkDir("/app")
.EmptyLine()
.Comment("Run the application");
// Set the appropriate entrypoint and command based on entrypoint type
switch (entrypointType)
{
case EntrypointType.Script:
runtimeBuilder.Entrypoint(["python", entrypoint]);
break;
case EntrypointType.Module:
runtimeBuilder.Entrypoint(["python", "-m", entrypoint]);
break;
case EntrypointType.Executable:
runtimeBuilder.Entrypoint([entrypoint]);
break;
}
}
private static void GenerateFallbackDockerfile(DockerfileBuilderCallbackContext context, PythonAppResource resource,
string pythonVersion, EntrypointType entrypointType, string entrypoint)
{
// Use the same runtime image as UV workflow for consistency
context.Resource.TryGetLastAnnotation<DockerfileBaseImageAnnotation>(out var baseImageAnnotation);
var runtimeImage = baseImageAnnotation?.RuntimeImage ?? $"python:{pythonVersion}-slim-bookworm";
// Check if requirements.txt or pyproject.toml exists
var requirementsTxtPath = Path.Combine(resource.WorkingDirectory, "requirements.txt");
var hasRequirementsTxt = File.Exists(requirementsTxtPath);
var logger = context.Services.GetService<ILogger<PythonAppResource>>();
context.Builder.AddContainerFilesStages(context.Resource, logger);
var stage = context.Builder
.From(runtimeImage)
.EmptyLine()
.AddContainerFiles(context.Resource, "/app", logger)
.Comment("------------------------------")
.Comment("🚀 Python Application")
.Comment("------------------------------")
.Comment("Create non-root user for security")
.Run("groupadd --system --gid 999 appuser && useradd --system --gid 999 --uid 999 --create-home appuser")
.EmptyLine()
.Comment("Set working directory")
.WorkDir("/app")
.EmptyLine();
if (hasRequirementsTxt)
{
// Copy requirements.txt first for better layer caching
stage
.Comment("Copy requirements.txt for dependency installation")
.Copy("requirements.txt", "/app/requirements.txt")
.EmptyLine()
.Comment("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/*
""")
.EmptyLine();
}
else
{
var pyprojectTomlPath = Path.Combine(resource.WorkingDirectory, "pyproject.toml");
var hasPyprojectToml = File.Exists(pyprojectTomlPath);
if (hasPyprojectToml)
{
// Copy pyproject.toml first for better layer caching
stage
.Comment("Copy pyproject.toml for dependency installation")
.Copy("pyproject.toml", "/app/pyproject.toml")
.EmptyLine()
.Comment("Install dependencies using pip")
.Run(
"""
apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& pip install --no-cache-dir . \
&& apt-get purge -y --auto-remove build-essential \
&& rm -rf /var/lib/apt/lists/*
""")
.EmptyLine();
}
}
// Copy the rest of the application
stage
.Comment("Copy application files")
.Copy(".", "/app", "appuser:appuser")
.EmptyLine()
.Comment("Set environment variables")
.Env("PYTHONDONTWRITEBYTECODE", "1")
.Env("PYTHONUNBUFFERED", "1")
.EmptyLine()
.Comment("Use the non-root user to run the application")
.User("appuser")
.EmptyLine()
.Comment("Run the application");
// Set the appropriate entrypoint based on entrypoint type
switch (entrypointType)
{
case EntrypointType.Script:
stage.Entrypoint(["python", entrypoint]);
break;
case EntrypointType.Module:
stage.Entrypoint(["python", "-m", entrypoint]);
break;
case EntrypointType.Executable:
stage.Entrypoint([entrypoint]);
break;
}
}
private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] scriptArgs)
{
ArgumentNullException.ThrowIfNull(scriptArgs);
foreach (var scriptArg in scriptArgs)
{
if (string.IsNullOrEmpty(scriptArg))
{
var values = string.Join(", ", scriptArgs);
if (scriptArg is null)
{
throw new ArgumentNullException(nameof(scriptArgs), $"Array params contains null item: [{values}]");
}
throw new ArgumentException($"Array params contains empty item: [{values}]", nameof(scriptArgs));
}
}
}
/// <summary>
/// Resolves the default virtual environment path by checking multiple candidate locations.
/// </summary>
/// <param name="builder">The distributed application builder.</param>
/// <param name="appDirectory">The Python app directory (relative to AppHost).</param>
/// <param name="virtualEnvironmentPath">The relative virtual environment path (e.g., ".venv").</param>
/// <returns>The resolved virtual environment path.</returns>
private static string ResolveDefaultVirtualEnvironmentPath(IDistributedApplicationBuilder builder, string appDirectory, string virtualEnvironmentPath)
{
var appDirectoryFullPath = Path.GetFullPath(appDirectory, builder.AppHostDirectory);
// Walk up from the Python app directory looking for the virtual environment
// Stop at the AppHost's parent directory to avoid picking up unrelated venvs
var appHostParentDirectory = Path.GetDirectoryName(builder.AppHostDirectory);
// Check if the app directory is under the AppHost's parent directory
// If not, only look in the app directory itself
if (appHostParentDirectory != null)
{
var relativePath = Path.GetRelativePath(appHostParentDirectory, appDirectoryFullPath);
var isUnderAppHostParent = !relativePath.StartsWith("..", StringComparison.Ordinal) &&
!Path.IsPathRooted(relativePath);
if (!isUnderAppHostParent)
{
// App is not under AppHost's parent, only use the app directory
return Path.Combine(appDirectoryFullPath, virtualEnvironmentPath);
}
}
var currentDirectory = appDirectoryFullPath;
while (currentDirectory != null)
{
var venvPath = Path.Combine(currentDirectory, virtualEnvironmentPath);
if (Directory.Exists(venvPath))
{
return venvPath;
}
// Stop if we've reached the AppHost's parent directory
// Use case-insensitive comparison on Windows, case-sensitive on Unix
var reachedBoundary = OperatingSystem.IsWindows()
? string.Equals(currentDirectory, appHostParentDirectory, StringComparison.OrdinalIgnoreCase)
: string.Equals(currentDirectory, appHostParentDirectory, StringComparison.Ordinal);
if (reachedBoundary)
{
break;
}
// Move up to the parent directory
var parentDirectory = Path.GetDirectoryName(currentDirectory);
// Stop if we can't go up anymore or if we've gone beyond the AppHost's parent
if (parentDirectory == null || parentDirectory == currentDirectory)
{
break;
}
currentDirectory = parentDirectory;
}
// Default: Return app directory path (for cases where the venv will be created later)
return Path.Combine(appDirectoryFullPath, virtualEnvironmentPath);
}
/// <summary>
/// Configures a custom virtual environment path for the Python application.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="virtualEnvironmentPath">
/// The path to the virtual environment. Can be absolute or relative to the app directory.
/// When relative, it is resolved from the working directory of the Python application.
/// Common values include ".venv", "venv", or "myenv".
/// </param>
/// <param name="createIfNotExists">
/// Whether to automatically create the virtual environment if it doesn't exist. Defaults to <c>true</c>.
/// Set to <c>false</c> to disable automatic venv creation (the venv must already exist).
/// </param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for method chaining.</returns>
/// <remarks>
/// <para>
/// This method updates the Python executable path to use the specified virtual environment.
/// </para>
/// <para>
/// By default (<paramref name="createIfNotExists"/> = <c>true</c>), if the virtual environment doesn't exist,
/// it will be automatically created before running the application (when using pip package manager, not uv).
/// Set <paramref name="createIfNotExists"/> to <c>false</c> to disable this behavior and require the venv to already exist.
/// </para>
/// <para>
/// Virtual environments allow Python applications to have isolated dependencies separate from
/// the system Python installation. This is the recommended approach for Python applications.
/// </para>
/// <para>
/// When you explicitly specify a virtual environment path using this method, the path is used verbatim.
/// The automatic multi-location lookup (checking both app and AppHost directories) only applies when
/// using the default ".venv" path during initial app creation via AddPythonScript, AddPythonModule, or AddPythonExecutable.
/// </para>
/// </remarks>
/// <example>
/// Configure a Python app to use a custom virtual environment:
/// <code lang="csharp">
/// var python = builder.AddPythonApp("api", "../python-api", "main.py")
/// .WithVirtualEnvironment("myenv");
///
/// // Disable automatic venv creation (require venv to exist)
/// var python2 = builder.AddPythonApp("api2", "../python-api2", "main.py")
/// .WithVirtualEnvironment("myenv", createIfNotExists: false);
/// </code>
/// </example>
[AspireExport(Description = "Configures the virtual environment for a Python application")]
public static IResourceBuilder<T> WithVirtualEnvironment<T>(
this IResourceBuilder<T> builder, string virtualEnvironmentPath, bool createIfNotExists = true) where T : PythonAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(virtualEnvironmentPath);
// Use the provided path verbatim - resolve relative paths against the app working directory
var resolvedPath = Path.IsPathRooted(virtualEnvironmentPath)
? virtualEnvironmentPath
: Path.GetFullPath(virtualEnvironmentPath, builder.Resource.WorkingDirectory);
var virtualEnvironment = new VirtualEnvironment(resolvedPath);
// Get the entrypoint annotation to determine how to update the command
if (!builder.Resource.TryGetLastAnnotation<PythonEntrypointAnnotation>(out var entrypointAnnotation))
{
throw new InvalidOperationException("Cannot update virtual environment: Python entrypoint annotation not found.");
}
// Update the command based on entrypoint type
string command = entrypointAnnotation.Type switch
{
EntrypointType.Executable => virtualEnvironment.GetExecutable(entrypointAnnotation.Entrypoint),
EntrypointType.Script or EntrypointType.Module => virtualEnvironment.GetExecutable("python"),
_ => throw new InvalidOperationException($"Unsupported entrypoint type: {entrypointAnnotation.Type}")
};
builder.WithCommand(command);
builder.WithPythonEnvironment(env =>
{
env.VirtualEnvironment = virtualEnvironment;
env.CreateVenvIfNotExists = createIfNotExists;
});
// If createIfNotExists is false, remove venv creator
if (!createIfNotExists)
{
RemoveVenvCreator(builder);
}
return builder;
}
/// <summary>
/// Enables debugging support for the Python application.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for method chaining.</returns>
/// <remarks>
/// <para>
/// This method adds the <see cref="PythonExecutableDebuggableAnnotation"/> to the resource, which enables
/// debugging support. The debugging configuration is automatically set up based on the
/// entrypoint type (Script, Module, or Executable).
/// </para>
/// <para>
/// The debug configuration includes the Python interpreter path from the virtual environment,
/// the program or module to debug, and appropriate launch settings.
/// </para>
/// </remarks>
[AspireExport(Description = "Enables debugging support for a Python application")]
public static IResourceBuilder<T> WithDebugging<T>(
this IResourceBuilder<T> builder) where T : PythonAppResource
{
ArgumentNullException.ThrowIfNull(builder);
// Add the annotation that marks this resource as debuggable
builder.WithAnnotation(new PythonExecutableDebuggableAnnotation());
// Get the entrypoint annotation to determine how to configure debugging
if (!builder.Resource.TryGetLastAnnotation<PythonEntrypointAnnotation>(out var entrypointAnnotation))
{
throw new InvalidOperationException("Cannot configure debugging: Python entrypoint annotation not found.");
}
var entrypointType = entrypointAnnotation.Type;
var entrypoint = entrypointAnnotation.Entrypoint;
string programPath;
string module;
if (entrypointType == EntrypointType.Script)
{
programPath = Path.GetFullPath(entrypoint, builder.Resource.WorkingDirectory);
module = string.Empty;
}
else
{
programPath = builder.Resource.WorkingDirectory;
module = entrypoint;
}
builder.WithDebugSupport(
mode =>
{
string interpreterPath;
if (!builder.Resource.TryGetLastAnnotation<PythonEnvironmentAnnotation>(out var annotation) || annotation.VirtualEnvironment is null)
{
interpreterPath = string.Empty;
}
else
{
var venvPath = Path.IsPathRooted(annotation.VirtualEnvironment.VirtualEnvironmentPath)
? annotation.VirtualEnvironment.VirtualEnvironmentPath
: Path.GetFullPath(annotation.VirtualEnvironment.VirtualEnvironmentPath, builder.Resource.WorkingDirectory);
if (OperatingSystem.IsWindows())
{
interpreterPath = Path.Join(venvPath, "Scripts", "python.exe");
}
else
{
interpreterPath = Path.Join(venvPath, "bin", "python");
}
}
return new PythonLaunchConfiguration
{
ProgramPath = programPath,
Module = module,
Mode = mode,
InterpreterPath = interpreterPath
};
},
"python",
static ctx =>
{
// Remove entrypoint-specific arguments that VS Code will handle.
// We need to verify the annotation to ensure we remove the correct args.
if (!ctx.Resource.TryGetLastAnnotation<PythonEntrypointAnnotation>(out var annotation))
{
return;
}
// For Module type: remove "-m" and module name (2 args)
if (annotation.Type == EntrypointType.Module)
{
if (ctx.Args is [string arg0, string arg1, ..] &&
arg0 == "-m" &&
arg1 == annotation.Entrypoint)
{
ctx.Args.RemoveAt(0); // Remove "-m"
ctx.Args.RemoveAt(0); // Remove module name
}
}
// For Script type: remove script path (1 arg)