// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Globalization;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Shared.Json;
using Aspire.TypeSystem;
namespace Aspire.Hosting.CodeGeneration.Python;
/// <summary>
/// Represents a builder class to be generated with its capabilities.
/// Internal type replacing BuilderModel - used only within the generator.
/// </summary>
internal sealed class BuilderModel
{
public required string TypeId { get; init; }
public required string BuilderClassName { get; init; }
public required List<AtsCapabilityInfo> Capabilities { get; init; }
public bool IsInterface { get; init; }
public AtsTypeRef? TargetType { get; init; }
}
internal sealed class PythonExportedValueTreeNode
{
public Dictionary<string, PythonExportedValueTreeNode> Children { get; } = new(StringComparer.Ordinal);
public AtsExportedValueInfo? Value { get; set; }
}
/// <summary>
/// Generates a Python SDK using the ATS (Aspire Type System) capability-based API.
/// Produces typed wrapper classes with fluent methods that use invoke_capability().
/// </summary>
/// <remarks>
/// <para>
/// <b>ATS to Python Type Mapping</b>
/// </para>
/// <para>
/// The generator maps ATS types to Python types according to the following rules:
/// </para>
/// <para>
/// <b>Primitive Types:</b>
/// <list type="table">
/// <listheader>
/// <term>ATS Type</term>
/// <description>Python Type</description>
/// </listheader>
/// <item><term><c>string</c></term><description><c>str</c></description></item>
/// <item><term><c>number</c></term><description><c>float</c></description></item>
/// <item><term><c>boolean</c></term><description><c>bool</c></description></item>
/// <item><term><c>any</c></term><description><c>Any</c></description></item>
/// </list>
/// </para>
/// <para>
/// <b>Handle Types:</b>
/// Type IDs use the format <c>{AssemblyName}/{TypeName}</c>.
/// Handle types are wrapped in Python classes that provide typed access to capabilities.
/// </para>
/// <para>
/// <b>Method Naming:</b>
/// <list type="bullet">
/// <item><description>Derived from capability ID using snake_case conversion</description></item>
/// <item><description><c>addRedis</c> → <c>add_redis</c></description></item>
/// <item><description><c>withEnvironment</c> → <c>with_environment</c></description></item>
/// </list>
/// </para>
/// </remarks>
internal sealed class AtsPythonCodeGenerator : ICodeGenerator
{
private static readonly HashSet<string> s_pythonKeywords = new(StringComparer.Ordinal)
{
"False",
"None",
"True",
"and",
"as",
"assert",
"async",
"await",
"break",
"class",
"continue",
"def",
"del",
"elif",
"else",
"except",
"finally",
"for",
"from",
"global",
"if",
"import",
"in",
"is",
"lambda",
"match",
"nonlocal",
"not",
"or",
"pass",
"raise",
"return",
"try",
"while",
"with",
"yield"
};
private sealed record OptionVariation(
string OptionType,
List<AtsParameterInfo> RequiredParameters,
List<AtsParameterInfo> OptionalParameters,
string? Experimental);
/// <summary>
/// Tracks the alternate capability ID for merged capabilities.
/// Key: the merged capability ID (the "short" one without the extra param).
/// Value: (alternateCapabilityId, discriminatingParamName) - the capability to invoke when the extra param is provided.
/// </summary>
private sealed record MergedCapabilityDispatch(string AlternateCapabilityId, string DiscriminatingParamName);
private readonly Dictionary<string, MergedCapabilityDispatch> _mergedCapabilityDispatches = new(StringComparer.Ordinal);
// Type ID of InteractionInputCollection. The by-name accessors below are hand-authored on top of the
// generated to_array capability so Python matches the .NET indexer and TypeScript get/value helpers.
private const string InteractionInputCollectionTypeId = "Aspire.Hosting/Aspire.Hosting.InteractionInputCollection";
private PythonModuleBuilder _moduleBuilder = null!;
// Mapping of typeId -> wrapper class name for all generated wrapper types
// Used to resolve parameter types to wrapper classes instead of handle types
private readonly Dictionary<string, string> _wrapperClassNames = new(StringComparer.Ordinal);
// Mapping of enum type IDs to Python enum names
private readonly Dictionary<string, string> _enumTypeNames = new(StringComparer.Ordinal);
// List of type IDs to ignore when generating handle type aliases
private readonly List<string> _ignoreTypes = new()
{
AtsConstants.ReferenceExpressionTypeId,
"System.Private.CoreLib/System.IAsyncDisposable",
"System.Private.CoreLib/System.IDisposable",
"Microsoft.Extensions.Hosting.Abstractions/Microsoft.Extensions.Hosting.IHost"
};
/// <summary>
/// Checks if an AtsTypeRef represents a handle type.
/// </summary>
private static bool IsHandleType(AtsTypeRef? typeRef) =>
typeRef != null && typeRef.Category == AtsTypeCategory.Handle;
/// <summary>
/// Checks if the capability's target type is already covered by the builder's base class hierarchy.
/// Returns true if the target type is a base class of the builder's type, or an interface
/// that's implemented by any class in the builder's base class hierarchy.
/// </summary>
private static bool IsTargetTypeCoveredByBaseHierarchy(AtsTypeRef? capabilityTargetType, AtsTypeRef? builderTargetType)
{
if (capabilityTargetType == null || builderTargetType == null)
{
return false;
}
// If the capability targets the builder's own type, it's not covered by base
if (capabilityTargetType.TypeId == builderTargetType.TypeId)
{
return false;
}
// Check if the capability's target type is in the base class hierarchy
var currentBase = builderTargetType.BaseType;
while (currentBase != null)
{
// Check if capability targets this base class
if (capabilityTargetType.TypeId == currentBase.TypeId)
{
return true;
}
// Check if capability targets an interface implemented by this base class
if (currentBase.ImplementedInterfaces != null)
{
if (currentBase.ImplementedInterfaces.Any(i => i.TypeId == capabilityTargetType.TypeId))
{
return true;
}
}
currentBase = currentBase.BaseType;
}
return false;
}
/// <summary>
/// Maps an AtsTypeRef to a Python type using category-based dispatch.
/// This is the preferred method - uses type metadata rather than string parsing.
/// </summary>
private string MapTypeRefToPython(AtsTypeRef? typeRef)
{
if (typeRef is null)
{
return "typing.Any";
}
// Check for wrapper class first (handles custom types like ReferenceExpression)
if (_wrapperClassNames.TryGetValue(typeRef.TypeId, out var wrapperClassName))
{
return wrapperClassName;
}
var mappedType = typeRef.Category switch
{
AtsTypeCategory.Primitive => MapPrimitiveType(typeRef.TypeId),
AtsTypeCategory.Enum => MapEnumType(typeRef.TypeId),
AtsTypeCategory.Handle => GetWrapperOrHandleName(typeRef.TypeId),
AtsTypeCategory.Dto => GetDtoClassName(typeRef.TypeId),
AtsTypeCategory.Callback => "typing.Callable", // Callbacks handled separately with full signature
AtsTypeCategory.Array => $"typing.Iterable[{MapTypeRefToPython(typeRef.ElementType)}]",
AtsTypeCategory.List => $"AspireList[{MapTypeRefToPython(typeRef.ElementType)}]",
AtsTypeCategory.Dict => typeRef.IsReadOnly
? $"typing.Mapping[{MapTypeRefToPython(typeRef.KeyType)}, {MapTypeRefToPython(typeRef.ValueType)}]"
: $"AspireDict[{MapTypeRefToPython(typeRef.KeyType)}, {MapTypeRefToPython(typeRef.ValueType)}]",
AtsTypeCategory.Union => MapUnionTypeToPython(typeRef),
AtsTypeCategory.Unknown => "typing.Any", // Unknown types use 'Any' since they're not in the ATS universe
_ => "typing.Any" // Fallback for any unhandled categories
};
return ApplyNullableType(typeRef, mappedType);
}
private static string ApplyNullableType(AtsTypeRef typeRef, string mappedType)
{
if (typeRef.IsNullable != true || typeRef.Category is not (AtsTypeCategory.Primitive or AtsTypeCategory.Enum))
{
return mappedType;
}
return typeRef.TypeId is AtsConstants.Void or AtsConstants.Any or AtsConstants.CancellationToken
? mappedType
: $"{mappedType} | None";
}
/// <summary>
/// Maps primitive type IDs to Python types.
/// </summary>
private static string MapPrimitiveType(string typeId) => typeId switch
{
AtsConstants.String or AtsConstants.Char => "str",
AtsConstants.Number => "int",
AtsConstants.Boolean => "bool",
AtsConstants.Void => "None",
AtsConstants.Any => "typing.Any",
AtsConstants.DateTime => "datetime.datetime",
AtsConstants.DateTimeOffset => "datetime.datetime",
AtsConstants.DateOnly => "datetime.date",
AtsConstants.TimeOnly => "datetime.time",
AtsConstants.TimeSpan => "float",
AtsConstants.Guid or AtsConstants.Uri => "str",
AtsConstants.CancellationToken => "CancellationToken",
_ => typeId
};
private static string GetParamHandler(AtsParameterInfo param, string paramName)
{
if (param.IsCallback)
{
return $"self._client.register_callback({paramName})";
}
if (param.Type?.TypeId == AtsConstants.CancellationToken)
{
return $"self._client.register_cancellation_token({paramName})";
}
return paramName;
}
private static string GetConstructorParamHandler(AtsParameterInfo param, string paramName)
{
if (param.IsCallback)
{
return $"client.register_callback({paramName})";
}
if (param.Type?.TypeId == AtsConstants.CancellationToken)
{
return $"client.register_cancellation_token({paramName})";
}
return paramName;
}
/// <summary>
/// Merges capabilities that share the same <see cref="AtsCapabilityInfo.SourceLocation"/> and differ
/// by exactly one required parameter. The merged capability uses the shortest method name and makes
/// the differing parameter optional.
/// </summary>
private List<AtsCapabilityInfo> MergeCapabilitiesBySourceLocation(List<AtsCapabilityInfo> capabilities)
{
var result = new List<AtsCapabilityInfo>();
var groups = capabilities.GroupBy(c => c.SourceLocation ?? string.Empty);
foreach (var group in groups)
{
if (string.IsNullOrEmpty(group.Key) || group.Count() <= 1)
{
result.AddRange(group);
continue;
}
var items = group.ToList();
// Find parameter names common to all capabilities in this group
var commonParamNames = new HashSet<string>(
items[0].Parameters.Select(p => p.Name), StringComparer.Ordinal);
foreach (var item in items.Skip(1))
{
commonParamNames.IntersectWith(item.Parameters.Select(p => p.Name));
}
// Find the union of all parameter names
var allParamNames = items
.SelectMany(c => c.Parameters.Select(p => p.Name))
.ToHashSet(StringComparer.Ordinal);
var extraParamNames = allParamNames.Except(commonParamNames).ToHashSet(StringComparer.Ordinal);
// Only merge when exactly one parameter differs
if (extraParamNames.Count != 1)
{
result.AddRange(items);
continue;
}
var extraParamName = extraParamNames.Single();
// Get the extra parameter info from whichever capability has it
var capWithExtra = items.First(c => c.Parameters.Any(p => string.Equals(p.Name, extraParamName, StringComparison.Ordinal)));
var extraParam = capWithExtra.Parameters.First(p => string.Equals(p.Name, extraParamName, StringComparison.Ordinal));
// Only merge if the extra param is required (not already optional).
// Never merge when the differing parameter is a callback: a callback cannot be represented as a
// positional tuple element, so merging would (a) change the option shape from the published
// `str | tuple[...]` shorthand to a TypedDict (a breaking change for the non-callback overload)
// and (b) require conditional capability dispatch that always registers the callback argument even
// when routing to the non-callback capability. Keeping the callback overload as its own separate
// option avoids both problems.
if (extraParam.IsOptional || extraParam.IsNullable || extraParam.IsCallback)
{
result.AddRange(items);
continue;
}
// Use the capability with the shortest MethodName as the base
var shortest = items.OrderBy(c => c.MethodName.Length).ThenBy(c => c.MethodName, StringComparer.Ordinal).First();
// Build merged params from the capability with the most parameters
var fullCap = items.OrderByDescending(c => c.Parameters.Count).First();
var mergedParams = new List<AtsParameterInfo>();
foreach (var p in fullCap.Parameters)
{
if (string.Equals(p.Name, extraParamName, StringComparison.Ordinal))
{
mergedParams.Add(new AtsParameterInfo
{
Name = p.Name,
Type = p.Type,
IsOptional = true,
IsNullable = true,
IsCallback = p.IsCallback,
CallbackParameters = p.CallbackParameters,
CallbackReturnType = p.CallbackReturnType,
Documentation = p.Documentation,
DefaultValue = p.DefaultValue
});
}
else
{
mergedParams.Add(p);
}
}
// Determine which capability ID to use when the extra param IS vs IS NOT provided.
// The "short" capability (without the extra param) is the default.
// The "long" capability (with the extra param) is the alternate.
var capWithoutExtra = items.FirstOrDefault(c => !c.Parameters.Any(p => string.Equals(p.Name, extraParamName, StringComparison.Ordinal)));
var alternateCapabilityId = capWithExtra.CapabilityId;
var baseCapabilityId = capWithoutExtra?.CapabilityId ?? shortest.CapabilityId;
var merged = new AtsCapabilityInfo
{
CapabilityId = baseCapabilityId,
MethodName = shortest.MethodName,
OwningTypeName = shortest.OwningTypeName,
Description = shortest.Description ?? items.FirstOrDefault(c => c.Description is not null)?.Description,
Documentation = shortest.Documentation ?? items.FirstOrDefault(c => c.Documentation is not null)?.Documentation,
Parameters = mergedParams,
ReturnType = shortest.ReturnType,
TargetTypeId = shortest.TargetTypeId,
TargetType = shortest.TargetType,
TargetParameterName = shortest.TargetParameterName,
ReturnsBuilder = shortest.ReturnsBuilder,
CapabilityKind = shortest.CapabilityKind,
SourceLocation = shortest.SourceLocation,
RunSyncOnBackgroundThread = shortest.RunSyncOnBackgroundThread,
ExpandedTargetTypes = shortest.ExpandedTargetTypes
};
// Track the alternate dispatch so GenerateBuilderMethod can emit conditional logic
if (!string.Equals(baseCapabilityId, alternateCapabilityId, StringComparison.Ordinal))
{
_mergedCapabilityDispatches[baseCapabilityId] = new MergedCapabilityDispatch(alternateCapabilityId, extraParamName);
}
result.Add(merged);
}
return result;
}
/// <summary>
/// Filters capability parameters for Python code generation.
/// Removes the target parameter (e.g., "builder", "context") if specified,
/// and removes cancellationToken when a separate timeout parameter already exists,
/// since cancellationToken maps to "timeout" in Python and would cause a naming conflict.
/// </summary>
private static List<AtsParameterInfo> FilterMethodParameters(IReadOnlyList<AtsParameterInfo> parameters, string? targetParameterName = null)
{
var filtered = targetParameterName is not null
? parameters.Where(p => p.Name != targetParameterName)
: parameters.AsEnumerable();
if (parameters.Any(p => string.Equals(p.Name, "timeout", StringComparison.Ordinal)))
{
filtered = filtered.Where(p => p.Type?.TypeId != AtsConstants.CancellationToken);
}
return filtered.ToList();
}
/// <summary>
/// Gets the Python parameter name for a capability parameter.
/// Converts camelCase to snake_case, and renames cancellationToken to timeout.
/// </summary>
private static string GetParamName(AtsParameterInfo param)
{
if (param.Type?.TypeId == AtsConstants.CancellationToken)
{
return "timeout";
}
return ToSnakeCase(param.Name);
}
/// <summary>
/// Gets the Python representation of a parameter's default value.
/// Returns "None" if the default value is null or not set.
/// </summary>
private static string GetPythonDefaultValue(AtsParameterInfo param)
{
var defaultValue = param.DefaultValue;
if (defaultValue is null)
{
return "None";
}
return defaultValue switch
{
bool b => b ? "True" : "False",
string s => $"\"{s.Replace("\\", "\\\\").Replace("\"", "\\\"")}\"",
char c => $"\"{c}\"",
int i => i.ToString(CultureInfo.InvariantCulture),
long l => l.ToString(CultureInfo.InvariantCulture),
float f => float.IsPositiveInfinity(f) ? "float('inf')" : float.IsNegativeInfinity(f) ? "float('-inf')" : f.ToString(CultureInfo.InvariantCulture),
double d => double.IsPositiveInfinity(d) ? "float('inf')" : double.IsNegativeInfinity(d) ? "float('-inf')" : d.ToString(CultureInfo.InvariantCulture),
Enum e => $"\"{e}\"",
_ => "None"
};
}
/// <summary>
/// Gets the Python type annotation suffix and default value for an optional parameter.
/// Uses the actual default value when available instead of always defaulting to None.
/// </summary>
private string GetOptionalParamSuffix(AtsParameterInfo param)
{
var pythonDefault = GetPythonDefaultValue(param);
if (pythonDefault == "None")
{
var paramType = MapParameterToPython(param);
return $"{paramType} | None = None";
}
var type = MapParameterToPython(param);
// When we have a real default, the type doesn't need "| None" unless the param is also nullable
if (param.IsNullable)
{
return $"{type} | None = {pythonDefault}";
}
return $"{type} = {pythonDefault}";
}
/// <summary>
/// Maps an enum type ID to the generated Python enum name.
/// Throws if the enum type wasn't collected during scanning.
/// </summary>
private string MapEnumType(string typeId)
{
if (!_enumTypeNames.TryGetValue(typeId, out var enumName))
{
throw new InvalidOperationException(
$"Enum type '{typeId}' was not found in the scanned enum types. " +
$"This indicates the enum type was not discovered during assembly scanning.");
}
return enumName;
}
/// <summary>
/// Maps a union type to Python union syntax (T1 | T2 | ...).
/// </summary>
private string MapUnionTypeToPython(AtsTypeRef typeRef)
{
if (typeRef.UnionTypes is null || typeRef.UnionTypes.Count == 0)
{
return "typing.Any";
}
var memberTypes = typeRef.UnionTypes
.Select(MapTypeRefToPython)
.Distinct();
return string.Join(" | ", memberTypes);
}
private string MapDtoPropertyTypeToPython(AtsTypeRef? typeRef)
{
if (typeRef is null)
{
return "typing.Any";
}
return typeRef.Category switch
{
AtsTypeCategory.Array or AtsTypeCategory.List => $"typing.Iterable[{MapDtoPropertyTypeToPython(typeRef.ElementType)}]",
AtsTypeCategory.Dict => $"typing.Mapping[{MapDtoPropertyTypeToPython(typeRef.KeyType)}, {MapDtoPropertyTypeToPython(typeRef.ValueType)}]",
AtsTypeCategory.Union => MapDtoUnionTypeToPython(typeRef),
_ => MapTypeRefToPython(typeRef)
};
}
private string MapDtoUnionTypeToPython(AtsTypeRef typeRef)
{
if (typeRef.UnionTypes is null || typeRef.UnionTypes.Count == 0)
{
return "typing.Any";
}
var memberTypes = typeRef.UnionTypes
.Select(MapDtoPropertyTypeToPython)
.Distinct();
return string.Join(" | ", memberTypes);
}
/// <summary>
/// Gets the wrapper class name or handle type name for a handle type ID.
/// Prefers wrapper class if one exists, otherwise generates a handle type name.
/// </summary>
private string GetWrapperOrHandleName(string typeId)
{
if (_wrapperClassNames.TryGetValue(typeId, out var wrapperClassName))
{
return wrapperClassName;
}
if (ExtractSimpleTypeName(typeId) == "IDistributedApplicationBuilder")
{
return "DistributedApplicationBuilder";
}
return GetHandleTypeName(typeId);
}
/// <summary>
/// Gets a Python class name for a DTO type.
/// </summary>
private static string GetDtoClassName(string typeId)
{
// Extract simple type name and use as class name
var simpleTypeName = ExtractSimpleTypeName(typeId);
return simpleTypeName;
}
/// <summary>
/// Maps a parameter to its Python type, handling callbacks specially.
/// For interface handle types, uses ResourceBuilderBase as the parameter type.
/// </summary>
private string MapParameterToPython(AtsParameterInfo param)
{
if (param.IsCallback)
{
return GenerateCallbackTypeSignature(param.CallbackParameters, param.CallbackReturnType);
}
if (param.Type?.TypeId == AtsConstants.CancellationToken)
{
return "int";
}
var baseType = MapTypeRefToPython(param.Type);
return baseType;
}
// /// <summary>
// /// Checks if a type reference is an interface handle type.
// /// Interface handles need base class types to accept wrapper classes.
// /// </summary>
// private static bool IsInterfaceHandleType(AtsTypeRef? typeRef)
// {
// if (typeRef == null)
// {
// return false;
// }
// return typeRef.Category == AtsTypeCategory.Handle && typeRef.IsInterface;
// }
/// <summary>
/// Gets the TypeId from a capability's return type.
/// </summary>
private static string? GetReturnTypeId(AtsCapabilityInfo capability) => capability.ReturnType?.TypeId;
/// <inheritdoc />
public string Language => "Python";
/// <inheritdoc />
public Dictionary<string, string> GenerateDistributedApplication(AtsContext context)
{
var files = new Dictionary<string, string>();
// Generate the capability-based aspire.py SDK
files["aspire_app.py"] = GenerateAspireSdk(context);
files["pyproject.toml"] = GetEmbeddedResource("pyproject.toml");
return files;
}
private static string GetEmbeddedResource(string name)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"Aspire.Hosting.CodeGeneration.Python.Resources.{name}";
using var stream = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Embedded resource '{name}' not found.");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
/// <summary>
/// Gets a valid Python method name from a capability method name.
/// Converts camelCase to snake_case.
/// Handles dotted names like "EnvironmentContext.resource" by extracting just the final part.
/// </summary>
private static string GetPythonMethodName(string methodName)
{
// Extract last component if dotted (e.g., "Type.method" -> "method")
var lastDot = methodName.LastIndexOf('.');
if (lastDot >= 0)
{
methodName = methodName[(lastDot + 1)..];
}
// Convert camelCase to snake_case
var snakeName = ToSnakeCase(methodName);
if (snakeName.EndsWith("_async", StringComparison.Ordinal))
{
snakeName = snakeName[..^6];
}
return snakeName;
}
private static string GetMethodAsOptionName(string methodName)
{
if (methodName.StartsWith("with_", StringComparison.Ordinal))
{
return methodName[5..];
}
return methodName;
}
private static string GetMethodParametersName(string methodName)
{
methodName = char.ToUpper(methodName[0]) + methodName.Substring(1);
if (methodName.StartsWith("With", StringComparison.Ordinal))
{
methodName = methodName[4..];
}
return methodName + "Parameters";
}
/// <summary>
/// Generates the aspire.py SDK file with capability-based API.
/// </summary>
private string GenerateAspireSdk(AtsContext context)
{
_moduleBuilder = new PythonModuleBuilder();
var capabilities = context.Capabilities;
var dtoTypes = context.DtoTypes;
var enumTypes = context.EnumTypes;
var exportedValues = context.ExportedValues;
// Get builder models (flattened - each builder has all its applicable capabilities)
var allBuilders = CreateBuilderModels(capabilities);
var entryPoints = GetEntryPointCapabilities(capabilities);
// All builders (no special filtering)
var builders = allBuilders;
// Collect all unique type IDs for handle type aliases
// Exclude DTO types - they have their own interfaces, not handle aliases
var dtoTypeIds = new HashSet<string>(dtoTypes.Select(d => d.TypeId));
var typeIds = new HashSet<string>();
foreach (var cap in capabilities)
{
if (!string.IsNullOrEmpty(cap.TargetTypeId) && !dtoTypeIds.Contains(cap.TargetTypeId))
{
typeIds.Add(cap.TargetTypeId);
}
if (IsHandleType(cap.ReturnType) && !dtoTypeIds.Contains(cap.ReturnType!.TypeId))
{
typeIds.Add(GetReturnTypeId(cap)!);
}
// Add parameter type IDs (for types like IResourceBuilder<IResource>)
foreach (var param in cap.Parameters)
{
if (IsHandleType(param.Type) && !dtoTypeIds.Contains(param.Type!.TypeId))
{
typeIds.Add(param.Type!.TypeId);
}
// Also collect callback parameter types
if (param.IsCallback && param.CallbackParameters != null)
{
foreach (var cbParam in param.CallbackParameters)
{
if (IsHandleType(cbParam.Type) && !dtoTypeIds.Contains(cbParam.Type.TypeId))
{
typeIds.Add(cbParam.Type.TypeId);
}
}
}
}
}
// Collect enum type names
foreach (var enumType in enumTypes)
{
if (!_enumTypeNames.ContainsKey(enumType.TypeId))
{
_enumTypeNames[enumType.TypeId] = ExtractSimpleTypeName(enumType.TypeId);
}
}
// Separate builders into categories:
// 1. Resource builders: IResource*, ContainerResource, etc.
// 2. Type classes: everything else (context types, wrapper types)
var resources = builders.Where(b => b.TargetType?.IsResourceBuilder == true).ToList();
var typeClasses = builders.Where(b => b.TargetType?.IsResourceBuilder != true).ToList();
var interfaceClasses = resources.Where(b => b.IsInterface).ToList();
// Build wrapper class name mapping for type resolution BEFORE generating code
// This allows parameter types to use wrapper class names instead of handle types
_wrapperClassNames.Clear();
foreach (var resource in resources)
{
_wrapperClassNames[resource.TypeId] = resource.BuilderClassName;
}
// Add ReferenceExpression (defined in base.py, not generated)
//_wrapperClassNames[AtsConstants.ReferenceExpressionTypeId] = "ReferenceExpression";
// Generate enum types
GenerateEnumTypes(enumTypes);
// Generate DTO classes
GenerateDtoClasses(dtoTypes);
// Generate exported immutable values
GenerateExportedValues(exportedValues, dtoTypes.ToDictionary(dto => dto.TypeId, StringComparer.Ordinal));
// Generate type classes (context types and wrapper types)
foreach (var typeClass in typeClasses.Where(t => !_ignoreTypes.Contains(t.TypeId)))
{
GenerateTypeClass(typeClass);
}
// Generate interface ABC classes
foreach (var interfaceClass in interfaceClasses)
{
GenerateInterfaceClass(interfaceClass);
}
// Generate resource builder classes
foreach (var resource in resources.Where(b => !b.IsInterface))
{
GenerateBuilderClass(resource);
}
// Generate entry point functions
GenerateEntryPointFunctions(_moduleBuilder.EntryPoints, entryPoints);
return _moduleBuilder.Write();
}
/// <summary>
/// Generates Python enums from discovered enum types.
/// </summary>
private void GenerateEnumTypes(IReadOnlyList<AtsEnumTypeInfo> enumTypes)
{
var sb = _moduleBuilder.Enums;
if (enumTypes.Count == 0)
{
return;
}
foreach (var enumType in enumTypes.OrderBy(e => e.Name))
{
var enumName = _enumTypeNames[enumType.TypeId];
sb.AppendLine(CultureInfo.InvariantCulture, $"{enumName} = typing.Literal[{string.Join(", ", enumType.Values.Select(v => $"\"{v}\""))}]");
sb.AppendLine();
}
}
/// <summary>
/// Generates Python classes for DTO types marked with [AspireDto].
/// </summary>
private void GenerateDtoClasses(IReadOnlyList<AtsDtoTypeInfo> dtoTypes)
{
var sb = _moduleBuilder.DtoClasses;
if (dtoTypes.Count == 0)
{
return;
}
foreach (var dtoType in dtoTypes.OrderBy(d => d.Name))
{
var className = GetDtoClassName(dtoType.TypeId);
// All DTO properties are optional in Python to allow partial objects
sb.AppendLine(CultureInfo.InvariantCulture, $"class {className}(typing.TypedDict, total=False):");
foreach (var prop in dtoType.Properties)
{
// Callback-typed DTO properties carry the same CallbackParameters/CallbackReturnType
// metadata as method parameters, so render the strongly-typed Callable signature
// (e.g. typing.Callable[[InputsDialogValidationContext], None]) instead of a bare
// typing.Callable. The runtime marshaller already registers callables embedded in
// DTO dicts, so no serialization change is needed.
var propType = prop.IsCallback
? GenerateCallbackTypeSignature(prop.CallbackParameters, prop.CallbackReturnType)
: MapDtoPropertyTypeToPython(prop.Type);
sb.AppendLine(CultureInfo.InvariantCulture, $" {prop.Name}: {propType}");
}
sb.AppendLine();
}
}
private void GenerateExportedValues(
IReadOnlyList<AtsExportedValueInfo> exportedValues,
IReadOnlyDictionary<string, AtsDtoTypeInfo> dtoTypesById)
{
var sb = _moduleBuilder.ExportedValues;
if (exportedValues.Count == 0)
{
return;
}
var root = BuildExportedValueTree(exportedValues);
foreach (var (name, node) in root.Children.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
sb.AppendLine(CultureInfo.InvariantCulture, $"{name} = types.SimpleNamespace()");
WritePythonExportedValueChildren(sb, name, node, dtoTypesById);
sb.AppendLine();
}
}
private void WritePythonExportedValueChildren(
StringBuilder sb,
string path,
PythonExportedValueTreeNode node,
IReadOnlyDictionary<string, AtsDtoTypeInfo> dtoTypesById)
{
foreach (var (name, child) in node.Children.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
var childPath = $"{path}.{name}";
if (child.Value is { } valueInfo)
{
if (!string.IsNullOrWhiteSpace(valueInfo.Description))
{
sb.AppendLine(CultureInfo.InvariantCulture, $"# {valueInfo.Description}");
}
sb.AppendLine(CultureInfo.InvariantCulture, $"{childPath} = {RenderPythonExportedValue(valueInfo.Value, valueInfo.Type, dtoTypesById, topLevel: true)}");
}
else
{
sb.AppendLine(CultureInfo.InvariantCulture, $"{childPath} = types.SimpleNamespace()");
WritePythonExportedValueChildren(sb, childPath, child, dtoTypesById);
}
}
}
private string RenderPythonExportedValue(
JsonNode? value,
AtsTypeRef typeRef,
IReadOnlyDictionary<string, AtsDtoTypeInfo> dtoTypesById,
bool topLevel)
{
string expression;
if (value is null)
{
expression = "None";
}
else
{
expression = typeRef.Category switch
{
AtsTypeCategory.Dto when value is JsonObject obj && dtoTypesById.TryGetValue(typeRef.TypeId, out var dtoInfo)
=> RenderPythonDtoValue(obj, dtoInfo, dtoTypesById),
AtsTypeCategory.Array or AtsTypeCategory.List when value is JsonArray arr
=> "[" + string.Join(", ", arr.Select(item => RenderPythonExportedValue(item, typeRef.ElementType!, dtoTypesById, topLevel: false))) + "]",
AtsTypeCategory.Dict when value is JsonObject obj
=> "{" + string.Join(", ", obj.Select(pair => $"{AtsJsonCodeWriter.ToRelaxedJsonString(pair.Key)}: {RenderPythonExportedValue(pair.Value, typeRef.ValueType!, dtoTypesById, topLevel: false)}")) + "}",
_ => RenderPythonPrimitiveValue(value)
};
}
if (!topLevel || typeRef.Category is AtsTypeCategory.Primitive)
{
return expression;
}
return $"typing.cast({MapTypeRefToPython(typeRef)}, {expression})";
}
private string RenderPythonDtoValue(
JsonObject value,
AtsDtoTypeInfo dtoInfo,
IReadOnlyDictionary<string, AtsDtoTypeInfo> dtoTypesById)
{
var members = new List<string>();
foreach (var property in dtoInfo.Properties)
{
if (!value.TryGetPropertyValue(property.Name, out var propertyValue))
{
continue;
}
members.Add($"{AtsJsonCodeWriter.ToRelaxedJsonString(property.Name)}: {RenderPythonExportedValue(propertyValue, property.Type, dtoTypesById, topLevel: false)}");
}
return "{ " + string.Join(", ", members) + " }";
}
private static string RenderPythonPrimitiveValue(JsonNode value)
{
return value switch
{
JsonValue jsonValue when jsonValue.TryGetValue<bool>(out var boolValue) => boolValue ? "True" : "False",
JsonValue jsonValue => jsonValue.ToRelaxedJsonString(),
_ => value.ToRelaxedJsonString()
};
}
private static PythonExportedValueTreeNode BuildExportedValueTree(IReadOnlyList<AtsExportedValueInfo> exportedValues)
{
var root = new PythonExportedValueTreeNode();
foreach (var exportedValue in exportedValues)
{
var current = root;
foreach (var segment in exportedValue.PathSegments)
{
if (!current.Children.TryGetValue(segment, out var child))
{
child = new PythonExportedValueTreeNode();
current.Children[segment] = child;
}
current = child;
}
current.Value = exportedValue;
}
return root;
}
/// <summary>
/// Converts a camelCase name to snake_case.
/// </summary>
private static string ToSnakeCase(string name)
{
if (string.IsNullOrEmpty(name))
{
return name;
}
var resultStr = JsonNamingPolicy.SnakeCaseLower.ConvertName(name);
resultStr = resultStr.Replace("environment", "env");
resultStr = resultStr.Replace("configuration", "config");
resultStr = resultStr.Replace("application", "app");
resultStr = resultStr.Replace("variable", "var");
resultStr = resultStr.Replace("directory", "dir");
return SanitizePythonIdentifier(resultStr);
}
/// <summary>
/// Generates a type class (context type or wrapper type).
/// Uses property-like pattern for exposed properties.
/// </summary>
private void GenerateTypeClass(BuilderModel model)
{
var className = DeriveClassName(model.TypeId);
var sb = new System.Text.StringBuilder();
_moduleBuilder.TypeClasses[className] = sb;
// Separate capabilities by type using CapabilityKind enum
var getters = model.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertyGetter).ToList();
var setters = model.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertySetter).ToList();
var contextMethods = model.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.InstanceMethod).ToList();
var otherMethods = model.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.Method).ToList();
// Combine methods
var allMethods = contextMethods.Concat(otherMethods).ToList();
if (className == "AbstractDistributedApplicationBuilder")
{
sb.Append(PythonModuleBuilder.DistributedApplicationBuilder);
sb.AppendLine();
}
else
{
if (model.IsInterface && model.Capabilities.Count == 0)
{
sb.AppendLine(CultureInfo.InvariantCulture, $"class {className}(abc.ABC):");
sb.AppendLine(CultureInfo.InvariantCulture, $" \"\"\"Abstract base class for {className}.\"\"\"");
}
else
{
_moduleBuilder.HandleRegistrations[model.TypeId] = className;
sb.AppendLine(CultureInfo.InvariantCulture, $"class {className}:");
sb.AppendLine(CultureInfo.InvariantCulture, $" \"\"\"Type class for {className}.\"\"\"");
sb.AppendLine();
sb.AppendLine(" def __init__(self, handle: Handle, client: AspireClient) -> None:");
sb.AppendLine(" self._handle = handle");
sb.AppendLine(" self._client = client");
sb.AppendLine();
sb.AppendLine(" def __repr__(self) -> str:");
sb.AppendLine(CultureInfo.InvariantCulture, $" return f\"{className}(handle={{self._handle.handle_id}})\"");
sb.AppendLine();
sb.AppendLine(" @_uncached_property");
sb.AppendLine(" def handle(self) -> Handle:");
sb.AppendLine(" \"\"\"The underlying object reference handle.\"\"\"");
sb.AppendLine(" return self._handle");
sb.AppendLine();
}
}
// Group getters and setters by property name to create properties
var properties = GroupPropertiesByName(getters, setters);
// Generate properties
foreach (var prop in properties)
{
GeneratePropertyMethods(sb, prop.PropertyName, prop.Getter, prop.Setter);
}
// Generate methods
foreach (var method in allMethods)
{
GenerateTypeClassMethod(sb, method, className == "AbstractDistributedApplicationBuilder");
}
if (string.Equals(model.TypeId, InteractionInputCollectionTypeId, StringComparison.Ordinal))
{
EmitInteractionInputCollectionAccessors(sb);
}
}
private static void EmitInteractionInputCollectionAccessors(System.Text.StringBuilder sb)
{
sb.AppendLine(" def get(self, name: str) -> InteractionInput | None:");
sb.AppendLine(" \"\"\"Get the input with the specified name, or None if no input matches.\"\"\"");
sb.AppendLine(" lookup_name = name.lower()");
sb.AppendLine(" for interaction_input in self.to_array():");
sb.AppendLine(" input_name = interaction_input.get(\"Name\")");
sb.AppendLine(" if input_name is not None and input_name.lower() == lookup_name:");
sb.AppendLine(" return interaction_input");
sb.AppendLine(" return None");
sb.AppendLine();
sb.AppendLine(" def required(self, name: str) -> InteractionInput:");
sb.AppendLine(" \"\"\"Get the input with the specified name, or raise ValueError if no input matches.\"\"\"");
sb.AppendLine(" interaction_input = self.get(name)");
sb.AppendLine(" if interaction_input is None:");
sb.AppendLine(" raise ValueError(f\"no input with name '{name}' was found\")");
sb.AppendLine(" return interaction_input");
sb.AppendLine();
sb.AppendLine(" def value(self, name: str) -> str:");
sb.AppendLine(" \"\"\"Get the input value with the specified name, or an empty string if no input matches.\"\"\"");
sb.AppendLine(" interaction_input = self.get(name)");
sb.AppendLine(" if interaction_input is None:");
sb.AppendLine(" return \"\"");
sb.AppendLine(" return interaction_input.get(\"Value\") or \"\"");
sb.AppendLine();
sb.AppendLine(" def required_value(self, name: str) -> str:");
sb.AppendLine(" \"\"\"Get the input value with the specified name, or raise ValueError if no input matches.\"\"\"");
sb.AppendLine(" return self.required(name).get(\"Value\") or \"\"");
sb.AppendLine();
}
/// <summary>
/// Groups getters and setters by property name.
/// </summary>
private static List<(string PropertyName, AtsCapabilityInfo? Getter, AtsCapabilityInfo? Setter)> GroupPropertiesByName(
List<AtsCapabilityInfo> getters, List<AtsCapabilityInfo> setters)
{
var result = new List<(string PropertyName, AtsCapabilityInfo? Getter, AtsCapabilityInfo? Setter)>();
var processedNames = new HashSet<string>();
// Process getters
foreach (var getter in getters)
{
var propName = ExtractPropertyName(getter.MethodName);
if (processedNames.Contains(propName))
{
continue;
}
processedNames.Add(propName);
// Find matching setter (setPropertyName for propertyName)
var setterName = "set" + char.ToUpperInvariant(propName[0]) + propName[1..];
var setter = setters.FirstOrDefault(s => ExtractPropertyName(s.MethodName).Equals(setterName, StringComparison.OrdinalIgnoreCase));
result.Add((propName, getter, setter));
}
// Process any setters without matching getters
foreach (var setter in setters)
{
var setterMethodName = ExtractPropertyName(setter.MethodName);
// setPropertyName -> propertyName
if (setterMethodName.StartsWith("set", StringComparison.OrdinalIgnoreCase) && setterMethodName.Length > 3)
{
var propName = char.ToLowerInvariant(setterMethodName[3]) + setterMethodName[4..];
if (!processedNames.Contains(propName))
{
processedNames.Add(propName);
result.Add((propName, null, setter));
}
}
}
return result;
}
/// <summary>
/// Extracts the property name from a method name like "ClassName.propertyName" or "setPropertyName".
/// </summary>
private static string ExtractPropertyName(string methodName)
{
// Handle "ClassName.propertyName" format
if (methodName.Contains('.'))
{
return methodName[(methodName.LastIndexOf('.') + 1)..];
}
return methodName;
}
/// <summary>
/// Generates getter and setter methods for a property.
/// </summary>
private void GeneratePropertyMethods(System.Text.StringBuilder sb, string propertyName, AtsCapabilityInfo? getter, AtsCapabilityInfo? setter, bool isInterface = false)
{
var snakeName = ToSnakeCase(propertyName);
// Generate getter
if (getter != null)
{
if (propertyName == "cancellationToken")
{
// TODO: Replace this with handling for a CancelCallback exception.
// or maybe a cancel() method.
sb.AppendLine(CultureInfo.InvariantCulture, $" def cancel(self) -> None:");
sb.AppendLine(CultureInfo.InvariantCulture, $" \"\"\"Cancel the operation.\"\"\"");
if (isInterface)
{
return;
}
sb.AppendLine(CultureInfo.InvariantCulture, $" token: CancellationToken = self._client.invoke_capability(");
sb.AppendLine(CultureInfo.InvariantCulture, $" '{getter.CapabilityId}',");
sb.AppendLine(CultureInfo.InvariantCulture, $" {{'context': self._handle}}");
sb.AppendLine(CultureInfo.InvariantCulture, $" )");
sb.AppendLine(CultureInfo.InvariantCulture, $" token.cancel()");
sb.AppendLine();
return;
}
var returnType = MapTypeRefToPython(getter.ReturnType);
var propertyType = setter != null ? "@_uncached_property" : "@_cached_property";
if (!string.IsNullOrEmpty(getter.Description))
{
sb.AppendLine(CultureInfo.InvariantCulture, $" {propertyType}");
sb.AppendLine(CultureInfo.InvariantCulture, $" def {snakeName}(self) -> {returnType}:");
sb.AppendLine(CultureInfo.InvariantCulture, $" \"\"\"{getter.Description}\"\"\"");
}
else
{
sb.AppendLine(CultureInfo.InvariantCulture, $" {propertyType}");
sb.AppendLine(CultureInfo.InvariantCulture, $" def {snakeName}(self) -> {returnType}:");
sb.AppendLine(CultureInfo.InvariantCulture, $" \"\"\"{propertyName}\"\"\"");
}
if (!isInterface)
{
sb.AppendLine(CultureInfo.InvariantCulture, $" result = self._client.invoke_capability(");
sb.AppendLine(CultureInfo.InvariantCulture, $" '{getter.CapabilityId}',");
sb.AppendLine(CultureInfo.InvariantCulture, $" {{'context': self._handle}}");
sb.AppendLine(CultureInfo.InvariantCulture, $" )");
sb.AppendLine(CultureInfo.InvariantCulture, $" return typing.cast({returnType}, result)");
}
sb.AppendLine();
}
// Generate setter
if (setter != null)
{
var valueParam = setter.Parameters.FirstOrDefault(p => p.Name == "value");
if (valueParam != null)
{
var valueType = MapTypeRefToPython(valueParam.Type);
if (!string.IsNullOrEmpty(setter.Description))
{
sb.AppendLine(CultureInfo.InvariantCulture, $" @{snakeName}.setter");
sb.AppendLine(CultureInfo.InvariantCulture, $" def {snakeName}(self, value: {valueType}) -> None:");
sb.AppendLine(CultureInfo.InvariantCulture, $" \"\"\"{setter.Description}\"\"\"");
}
else
{
sb.AppendLine(CultureInfo.InvariantCulture, $" @{snakeName}.setter");
sb.AppendLine(CultureInfo.InvariantCulture, $" def {snakeName}(self, value: {valueType}) -> None:");
sb.AppendLine(CultureInfo.InvariantCulture, $" \"\"\"Set {propertyName}\"\"\"");
}
if (!isInterface)
{
sb.AppendLine(CultureInfo.InvariantCulture, $" self._client.invoke_capability(");
sb.AppendLine(CultureInfo.InvariantCulture, $" '{setter.CapabilityId}',");
sb.AppendLine(CultureInfo.InvariantCulture, $" {{'context': self._handle, 'value': value}}");
sb.AppendLine(CultureInfo.InvariantCulture, $" )");
}
sb.AppendLine();
}
}
}
/// <summary>
/// Generates a method on a type class.
/// </summary>
private void GenerateTypeClassMethod(
System.Text.StringBuilder sb,
AtsCapabilityInfo capability,
bool isDistributedApplicationBuilder)
{
// Use OwningTypeName if available to extract method name, otherwise parse from MethodName
var methodName = !string.IsNullOrEmpty(capability.OwningTypeName) && capability.MethodName.Contains('.')
? capability.MethodName[(capability.MethodName.LastIndexOf('.') + 1)..]
: capability.MethodName;
var pythonMethodName = GetPythonMethodName(methodName);
var targetParamName = capability.TargetParameterName ?? "context";
var userParams = FilterMethodParameters(capability.Parameters, targetParamName);
var requiredParams = userParams.Where(p => !p.IsOptional && !p.IsNullable).ToList();
var optionalParams = userParams.Where(p => !requiredParams.Contains(p)).ToList();
// Determine return type
var returnsSelf = isDistributedApplicationBuilder && capability.ReturnType?.TypeId == capability.TargetTypeId;
var returnType = returnsSelf
? "typing.Self"
: GetReturnTypeId(capability) != null
? MapTypeRefToPython(capability.ReturnType)
: "None";
var isResourceBuilder = capability.ReturnType != null && capability.ReturnType.Category == AtsTypeCategory.Handle &&
capability.ReturnType.IsResourceBuilder && !capability.ReturnType.IsInterface;
// Generate method signature
sb.Append(CultureInfo.InvariantCulture, $" def {pythonMethodName}(self");
foreach (var param in requiredParams)
{
var paramName = GetParamName(param);
var paramType = MapParameterToPython(param);
sb.Append(CultureInfo.InvariantCulture, $", {paramName}: {paramType}");
}
if (optionalParams.Count > 0)
{
sb.Append(", *");
}
foreach (var param in optionalParams)
{
var paramName = GetParamName(param);
var suffix = GetOptionalParamSuffix(param);
sb.Append(CultureInfo.InvariantCulture, $", {paramName}: {suffix}");
}
if (isResourceBuilder)
{
sb.Append(CultureInfo.InvariantCulture, $", **kwargs: typing.Unpack[\"{returnType}Kwargs\"]");
}
sb.AppendLine(CultureInfo.InvariantCulture, $") -> {returnType}:{(isResourceBuilder ? " # type: ignore" : string.Empty)}");
// Generate docstring
if (!string.IsNullOrEmpty(capability.Description))
{
sb.AppendLine(CultureInfo.InvariantCulture, $" \"\"\"{capability.Description}\"\"\"");
}
// Build args dict
sb.AppendLine(CultureInfo.InvariantCulture, $" rpc_args: dict[str, typing.Any] = {{'{targetParamName}': self._handle}}");
foreach (var param in userParams)
{
var paramName = GetParamName(param);
var paramHandler = GetParamHandler(param, paramName);
if (param.IsOptional || param.IsNullable)
{
sb.AppendLine(CultureInfo.InvariantCulture, $" if {paramName} is not None:");
sb.AppendLine(CultureInfo.InvariantCulture, $" rpc_args['{param.Name}'] = {paramHandler}");
}
else
{
sb.AppendLine(CultureInfo.InvariantCulture, $" rpc_args['{param.Name}'] = {paramHandler}");
}
}
// Invoke capability
if (returnType == "None" || returnsSelf)
{
sb.AppendLine(CultureInfo.InvariantCulture, $" self._client.invoke_capability(");
sb.AppendLine(CultureInfo.InvariantCulture, $" '{capability.CapabilityId}',");
sb.AppendLine(CultureInfo.InvariantCulture, $" rpc_args");
sb.AppendLine(CultureInfo.InvariantCulture, $" )");
if (returnsSelf)
{
sb.AppendLine(" return self");
}
}
else
{
sb.AppendLine(CultureInfo.InvariantCulture, $" result = self._client.invoke_capability(");
sb.AppendLine(CultureInfo.InvariantCulture, $" '{capability.CapabilityId}',");
sb.AppendLine(CultureInfo.InvariantCulture, $" rpc_args,");
if (isResourceBuilder)
{
sb.AppendLine(CultureInfo.InvariantCulture, $" kwargs,");
}
sb.AppendLine(CultureInfo.InvariantCulture, $" )");
if (capability.ReturnType != null && (capability.ReturnType.Category == AtsTypeCategory.Handle || capability.ReturnType.Category == AtsTypeCategory.Dto))
{
sb.AppendLine(CultureInfo.InvariantCulture, $" return typing.cast({returnType}, result)");
}
else
{
sb.AppendLine(CultureInfo.InvariantCulture, $" return result");
}
}
sb.AppendLine();
}
private void GenerateInterfaceClass(BuilderModel builder)
{
var sb = new System.Text.StringBuilder();
_moduleBuilder.InterfaceClasses[builder.BuilderClassName] = sb;
var baseClass = "abc.ABC";
var implementedInterfaces = builder.TargetType?.ImplementedInterfaces.ToList();
if (implementedInterfaces is { Count: > 0 })
{
// Remove interfaces that are already implemented by another interface in the list
var transitivelyImplemented = new HashSet<string>(
implementedInterfaces
.SelectMany(i => i.ImplementedInterfaces ?? [])
.Select(i => i.TypeId),
StringComparer.Ordinal);
implementedInterfaces = implementedInterfaces
.Where(i => !transitivelyImplemented.Contains(i.TypeId))
.ToList();
baseClass = string.Join(", ", implementedInterfaces.Select(i => DeriveClassName(i.TypeId)));
}
if (builder.TargetType?.ClrType!.IsGenericType == true)
{
sb.AppendLine(CultureInfo.InvariantCulture, $"T_{builder.BuilderClassName} = typing.TypeVar('T_{builder.BuilderClassName}')");
sb.AppendLine(CultureInfo.InvariantCulture, $"class {builder.BuilderClassName}({baseClass}, typing.Generic[T_{builder.BuilderClassName}]):");
}
else
{
sb.AppendLine(CultureInfo.InvariantCulture, $"class {builder.BuilderClassName}({baseClass}):");
}
sb.AppendLine(CultureInfo.InvariantCulture, $" \"\"\"Abstract base class for {builder.BuilderClassName} interface.\"\"\"");
sb.AppendLine();
// Group getters and setters by property name to create properties
var getters = builder.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertyGetter).ToList();
var setters = builder.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertySetter).ToList();
var properties = GroupPropertiesByName(getters, setters);
// Generate properties
foreach (var prop in properties)
{
GeneratePropertyMethods(sb, prop.PropertyName, prop.Getter, prop.Setter, true);
}
// Generate methods for each capability
// Filter out property getters and setters - they are not methods
var methods = builder.Capabilities.Where(c =>
c.CapabilityKind != AtsCapabilityKind.PropertyGetter &&
c.CapabilityKind != AtsCapabilityKind.PropertySetter).ToList();
methods = MergeCapabilitiesBySourceLocation(methods);
foreach (var capability in methods)
{
GenerateBuilderMethod(sb, capability, builder.TargetType!.IsResourceBuilder);
sb.AppendLine();
}
}
private void GenerateBuilderClass(BuilderModel builder)
{
var sb = new System.Text.StringBuilder();
var sbOptions = new System.Text.StringBuilder();
var sbConstructor = new System.Text.StringBuilder();
_moduleBuilder.ResourceClasses[builder.BuilderClassName] = sb;
_moduleBuilder.ResourceOptions[builder.BuilderClassName] = sbOptions;
_moduleBuilder.HandleRegistrations[builder.TypeId] = builder.BuilderClassName;
var optionsBaseClass = "_BaseResourceKwargs";
var baseClass = "_BaseResource";
var isBaseResource = builder.BuilderClassName == baseClass;
var baseBuilderClassName = baseClass;
if (!isBaseResource)
{
var baseType = builder.TargetType?.BaseType;
var baseTypeInterfaces = new List<string>();
if (baseType != null)
{
baseTypeInterfaces = baseType.ImplementedInterfaces.Select(i => i.TypeId).ToList();
var baseTypeName = DeriveClassName(baseType);
if (baseTypeName != "AbstractResource")
{
baseClass = baseTypeName;
optionsBaseClass = $"{baseTypeName}Kwargs";
}
}
baseBuilderClassName = baseClass;
var implementedInterfaces = builder.TargetType?.ImplementedInterfaces.Where(i => !baseTypeInterfaces.Contains(i.TypeId)).ToList();
if (implementedInterfaces is { Count: > 0 })
{
// Remove interfaces that are already implemented by another interface in the list
var transitivelyImplemented = new HashSet<string>(
implementedInterfaces
.SelectMany(i => i.ImplementedInterfaces ?? [])
.Select(i => i.TypeId),
StringComparer.Ordinal);
implementedInterfaces = implementedInterfaces
.Where(i => !transitivelyImplemented.Contains(i.TypeId))
.ToList();
foreach (var i in implementedInterfaces)
{
if (i.ClrType!.IsGenericType)
{
if (i.ClrType.GenericTypeArguments == null || i.ClrType.GenericTypeArguments.Length != 1)
{
throw new InvalidOperationException("Cannot support a generic interface that doesn't have exactly 1 argument.");
}
var genericSubType = i.ClrType.GenericTypeArguments[0];
baseClass += $", {DeriveClassName(i)}[\"{DeriveClassName(genericSubType)}\"]";
}
else
{
baseClass += ", " + DeriveClassName(i);
}
}
}
sb.AppendLine(CultureInfo.InvariantCulture, $"class {builder.BuilderClassName}({baseClass}):");
sb.AppendLine(CultureInfo.InvariantCulture, $" \"\"\"{builder.BuilderClassName} resource.\"\"\"");
sb.AppendLine();
sb.AppendLine(" def __repr__(self) -> str:");
sb.AppendLine(CultureInfo.InvariantCulture, $" return \"{builder.BuilderClassName}(handle={{self._handle.handle_id}})\"");
sb.AppendLine();
sbOptions.AppendLine(CultureInfo.InvariantCulture, $"class {builder.BuilderClassName}Kwargs({optionsBaseClass}, total=False):");
sbOptions.AppendLine(CultureInfo.InvariantCulture, $" \"\"\"{builder.BuilderClassName} options.\"\"\"");
sbOptions.AppendLine();
sbConstructor.AppendLine(CultureInfo.InvariantCulture, $" def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack[{builder.BuilderClassName}Kwargs]) -> None:");
}
else
{
sb.AppendLine(CultureInfo.InvariantCulture, $"class {builder.BuilderClassName}(AbstractResource):");
sb.AppendLine(CultureInfo.InvariantCulture, $" \"\"\"Base resource class.\"\"\"");
sb.AppendLine();
sb.AppendLine(" def _wrap_builder(self, builder: typing.Any) -> Handle:");
sb.AppendLine(" if isinstance(builder, Handle):");
sb.AppendLine(" return builder");
sb.AppendLine(" return typing.cast(typing.Self, builder).handle");
sb.AppendLine();
sb.AppendLine(" @_uncached_property");
sb.AppendLine(" def handle(self) -> Handle:");
sb.AppendLine(" \"\"\"The underlying object reference handle.\"\"\"");
sb.AppendLine(" return self._handle");
sb.AppendLine();
sbOptions.AppendLine(CultureInfo.InvariantCulture, $"class {optionsBaseClass}(typing.TypedDict, total=False):");
sbOptions.AppendLine(" \"\"\"Base resource options.\"\"\"");
sbOptions.AppendLine();
sbConstructor.AppendLine(CultureInfo.InvariantCulture, $" def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack[{optionsBaseClass}]) -> None:");
}
// Initialize option names, inheriting from base class
var optionNames = new List<string>();
if (!isBaseResource && _moduleBuilder.ResourceOptionNames.TryGetValue(baseBuilderClassName, out var baseOptionNames))
{
optionNames.AddRange(baseOptionNames);
}
_moduleBuilder.ResourceOptionNames[builder.BuilderClassName] = optionNames;
// Group getters and setters by property name to create properties
// Only include properties that are not covered by base class hierarchy
var getters = builder.Capabilities.Where(c =>
c.CapabilityKind == AtsCapabilityKind.PropertyGetter &&
!IsTargetTypeCoveredByBaseHierarchy(c.TargetType, builder.TargetType)).ToList();
var setters = builder.Capabilities.Where(c =>
c.CapabilityKind == AtsCapabilityKind.PropertySetter &&
!IsTargetTypeCoveredByBaseHierarchy(c.TargetType, builder.TargetType)).ToList();
var properties = GroupPropertiesByName(getters, setters);
// Generate properties
foreach (var prop in properties)
{
GeneratePropertyMethods(sb, prop.PropertyName, prop.Getter, prop.Setter);
}
// Generate methods for each capability
// Filter out property getters and setters - they are not methods
// Also filter out capabilities whose TargetType is already covered by a base class
var methods = builder.Capabilities.Where(c =>
c.CapabilityKind != AtsCapabilityKind.PropertyGetter &&
c.CapabilityKind != AtsCapabilityKind.PropertySetter &&
!IsTargetTypeCoveredByBaseHierarchy(c.TargetType, builder.TargetType)).ToList();
methods = MergeCapabilitiesBySourceLocation(methods);
foreach (var capability in methods)
{
GenerateBuilderMethod(sb, capability, false, sbOptions, sbConstructor, builder.BuilderClassName);
sb.AppendLine();
}
if (isBaseResource)
{
sbConstructor.AppendLine(" self._handle = handle");
sbConstructor.AppendLine(" self._client = client");
sbConstructor.AppendLine(" if kwargs:");
sbConstructor.AppendLine(" raise TypeError(f\"Unexpected keyword arguments: {list(kwargs.keys())}\")");
}
else
{
sbConstructor.AppendLine(" super().__init__(handle, client, **kwargs)");
}
sb.AppendLine(sbConstructor.ToString());
}
private void GenerateBuilderMethod(System.Text.StringBuilder sb, AtsCapabilityInfo capability, bool isInterface,
System.Text.StringBuilder? options = null, System.Text.StringBuilder? constructor = null, string? builderClassName = null)
{
var methodName = GetPythonMethodName(capability.MethodName);
// Use the actual target parameter name from the capability
var targetParamName = capability.TargetParameterName ?? "builder";
var userParams = FilterMethodParameters(capability.Parameters, targetParamName);
// Determine return type - use the builder's own type for fluent methods
var returnsBuilder = capability.ReturnsBuilder && capability.ReturnType!.TypeId == capability.TargetTypeId;
var returnsChildBuilder = capability.ReturnsBuilder && capability.ReturnType != null && IsHandleType(capability.ReturnType) && capability.ReturnType.TypeId != capability.TargetTypeId;
var returnType = returnsBuilder ? "typing.Self" : MapTypeRefToPython(capability.ReturnType);
var requiredParams = userParams.Where(p => !p.IsOptional && !p.IsNullable).ToList();
var optionalParams = userParams.Where(p => !requiredParams.Contains(p)).ToList();
if (isInterface)
{
sb.AppendLine(" @abc.abstractmethod");
}
else if (returnType == "typing.Self" && options != null && constructor != null)
{
var optionName = GetMethodAsOptionName(methodName);
var optionTypeVariations = CreateOptionVariations(capability, userParams, requiredParams, optionalParams);
var formattedOtions = string.Join(" | ", optionTypeVariations.Select(v => v.OptionType.Replace("(", "tuple[").Replace(")", "]")));
if (optionTypeVariations[0].Experimental != null)
{
formattedOtions = $"Annotated[{options}, Warnings(experimental=\"{optionTypeVariations[0].Experimental}\")]";
}
options.AppendLine(CultureInfo.InvariantCulture, $" {optionName}: {formattedOtions}");
_mergedCapabilityDispatches.TryGetValue(capability.CapabilityId, out var optionMergedDispatch);
BuildOptionConstructor(constructor, capability, optionName, optionTypeVariations, optionMergedDispatch);
// Track option name for conflict detection
if (builderClassName != null && _moduleBuilder.ResourceOptionNames.TryGetValue(builderClassName, out var optionNamesList))
{
optionNamesList.Add(optionName);
}
}
// Generate method signature
sb.Append(CultureInfo.InvariantCulture, $" def {methodName}(self");
foreach (var param in requiredParams)
{
var paramName = GetParamName(param);
var paramType = MapParameterToPython(param);
sb.Append(CultureInfo.InvariantCulture, $", {paramName}: {paramType}");
}
if (optionalParams.Count > 0)
{
sb.Append(", *");
}
foreach (var param in optionalParams)
{
var paramName = GetParamName(param);
var suffix = GetOptionalParamSuffix(param);
sb.Append(CultureInfo.InvariantCulture, $", {paramName}: {suffix}");
}
if (returnType == "None")
{
sb.AppendLine(CultureInfo.InvariantCulture, $") -> None:");
}
else
{
if (returnsChildBuilder)
{
sb.Append(CultureInfo.InvariantCulture, $", **kwargs: typing.Unpack[{returnType}Kwargs]");
}
sb.AppendLine(CultureInfo.InvariantCulture, $") -> {returnType}:{(returnsChildBuilder ? " # type: ignore" : string.Empty)}");
}
// Generate docstring
if (!string.IsNullOrEmpty(capability.Description))
{
sb.AppendLine(CultureInfo.InvariantCulture, $" \"\"\"{capability.Description}\"\"\"");
}
if (isInterface)
{
return;
}
// Build args dict
sb.AppendLine(CultureInfo.InvariantCulture, $" rpc_args: dict[str, typing.Any] = {{'{targetParamName}': self._handle}}");
foreach (var param in userParams)
{
var paramName = GetParamName(param);
var paramHandler = GetParamHandler(param, paramName);
if (param.IsOptional || param.IsNullable)
{
sb.AppendLine(CultureInfo.InvariantCulture, $" if {paramName} is not None:");
sb.AppendLine(CultureInfo.InvariantCulture, $" rpc_args['{param.Name}'] = {paramHandler}");
}
else
{
sb.AppendLine(CultureInfo.InvariantCulture, $" rpc_args['{param.Name}'] = {paramHandler}");
}
}
// Check if this is a merged capability that needs conditional dispatch
_mergedCapabilityDispatches.TryGetValue(capability.CapabilityId, out var mergedDispatch);
var discriminatingPythonParam = mergedDispatch is not null ? GetParamName(
userParams.First(p => string.Equals(p.Name, mergedDispatch.DiscriminatingParamName, StringComparison.Ordinal))) : null;
if (mergedDispatch is not null)
{
sb.AppendLine(CultureInfo.InvariantCulture, $" capability_id = '{mergedDispatch.AlternateCapabilityId}' if {discriminatingPythonParam} is not None else '{capability.CapabilityId}'");
}
var capabilityIdExpr = mergedDispatch is not null ? "capability_id" : $"'{capability.CapabilityId}'";
if (returnType == "None")
{
sb.AppendLine(CultureInfo.InvariantCulture, $" self._client.invoke_capability(");
sb.AppendLine(CultureInfo.InvariantCulture, $" {capabilityIdExpr},");
sb.AppendLine(CultureInfo.InvariantCulture, $" rpc_args");
sb.AppendLine(CultureInfo.InvariantCulture, $" )");
}
else
{
sb.AppendLine(CultureInfo.InvariantCulture, $" result = self._client.invoke_capability(");
sb.AppendLine(CultureInfo.InvariantCulture, $" {capabilityIdExpr},");
sb.AppendLine(CultureInfo.InvariantCulture, $" rpc_args,");
if (returnsChildBuilder)
{
sb.AppendLine(CultureInfo.InvariantCulture, $" kwargs,");
}
sb.AppendLine(CultureInfo.InvariantCulture, $" )");
if (returnsBuilder)
{
sb.AppendLine(CultureInfo.InvariantCulture, $" self._handle = self._wrap_builder(result)");
sb.AppendLine(CultureInfo.InvariantCulture, $" return self");
}
else
{
sb.AppendLine(CultureInfo.InvariantCulture, $" return typing.cast({returnType}, result)");
}
}
}
/// <summary>
/// Generates entry point functions.
/// </summary>
private void GenerateEntryPointFunctions(System.Text.StringBuilder sb, List<AtsCapabilityInfo> entryPoints)
{
if (entryPoints.Count == 0)
{
return;
}
foreach (var capability in entryPoints)
{
GenerateEntryPointFunction(sb, capability);
}
}
private void GenerateEntryPointFunction(System.Text.StringBuilder sb, AtsCapabilityInfo capability)
{
var methodName = GetPythonMethodName(capability.MethodName);
// Build parameter list
var paramDefs = new List<string> { "client: AspireClient" };
var paramArgs = new List<string>();
var userParams = FilterMethodParameters(capability.Parameters);
foreach (var param in userParams)
{
var paramName = GetParamName(param);
if (param.IsOptional || param.IsNullable)
{
paramDefs.Add($"{paramName}: {GetOptionalParamSuffix(param)}");
}
else
{
var paramType = MapParameterToPython(param);
paramDefs.Add($"{paramName}: {paramType}");
}
paramArgs.Add(param.Name);
}
var paramsString = string.Join(", ", paramDefs);
// Determine return type
var capReturnTypeId = GetReturnTypeId(capability);
var returnType = !string.IsNullOrEmpty(capReturnTypeId)
? MapTypeRefToPython(capability.ReturnType)
: "None";
// Generate JSDoc equivalent
if (!string.IsNullOrEmpty(capability.Description))
{
sb.AppendLine(CultureInfo.InvariantCulture, $"def {methodName}({paramsString}) -> {returnType}:");
sb.AppendLine(CultureInfo.InvariantCulture, $" \"\"\"{capability.Description}\"\"\"");
}
else
{
sb.AppendLine(CultureInfo.InvariantCulture, $"def {methodName}({paramsString}) -> {returnType}:");
}
// Build args dict
sb.AppendLine(CultureInfo.InvariantCulture, $" rpc_args: dict[str, typing.Any] = {{}}");
foreach (var param in userParams)
{
var paramName = GetParamName(param);
if (param.IsOptional || param.IsNullable)
{
sb.AppendLine(CultureInfo.InvariantCulture, $" if {paramName} is not None:");
sb.AppendLine(CultureInfo.InvariantCulture, $" rpc_args['{param.Name}'] = {paramName}");
}
else
{
sb.AppendLine(CultureInfo.InvariantCulture, $" rpc_args['{param.Name}'] = {paramName}");
}
}
// Invoke capability
if (returnType == "None")
{
sb.AppendLine(CultureInfo.InvariantCulture, $" client.invoke_capability(");
sb.AppendLine(CultureInfo.InvariantCulture, $" '{capability.CapabilityId}',");
sb.AppendLine(CultureInfo.InvariantCulture, $" rpc_args");
sb.AppendLine(CultureInfo.InvariantCulture, $" )");
}
else
{
sb.AppendLine(CultureInfo.InvariantCulture, $" result = client.invoke_capability(");
sb.AppendLine(CultureInfo.InvariantCulture, $" '{capability.CapabilityId}',");
sb.AppendLine(CultureInfo.InvariantCulture, $" rpc_args");
sb.AppendLine(CultureInfo.InvariantCulture, $" )");
sb.AppendLine(CultureInfo.InvariantCulture, $" return result");
}
sb.AppendLine();
}
// ============================================================================
// Builder Model Helpers
// ============================================================================
/// <summary>
/// Groups capabilities by ExpandedTargetTypes to create builder models.
/// Uses expansion to map interface targets to their concrete implementations.
/// Also creates builders for interface types (for use as return type wrappers).
/// </summary>
private List<BuilderModel> CreateBuilderModels(IReadOnlyList<AtsCapabilityInfo> capabilities)
{
// Group capabilities by expanded target type IDs
var capabilitiesByTypeId = new Dictionary<string, List<AtsCapabilityInfo>>();
// Track the AtsTypeRef for each typeId
var typeRefsByTypeId = new Dictionary<string, AtsTypeRef>();
// Also track interface types and their capabilities
var interfaceCapabilities = new Dictionary<string, List<AtsCapabilityInfo>>();
foreach (var cap in capabilities)
{
var targetTypeRef = cap.TargetType;
var targetTypeId = cap.TargetTypeId;
if (targetTypeRef == null || string.IsNullOrEmpty(targetTypeId))
{
// Entry point methods - handled separately
continue;
}
if (targetTypeRef.Category != AtsTypeCategory.Handle)
{
continue;
}
if (targetTypeRef.IsInterface)
{
if (!interfaceCapabilities.TryGetValue(targetTypeId, out var interfaceList))
{
interfaceList = [];
interfaceCapabilities[targetTypeId] = interfaceList;
typeRefsByTypeId[targetTypeId] = targetTypeRef;
}
interfaceList.Add(cap);
// Use expanded types if available, otherwise fall back to the original target
var expandedTypes = cap.ExpandedTargetTypes;
if (expandedTypes is { Count: > 0 })
{
// Flatten to concrete types
foreach (var expandedType in expandedTypes)
{
if (!capabilitiesByTypeId.TryGetValue(expandedType.TypeId, out var list))
{
list = [];
capabilitiesByTypeId[expandedType.TypeId] = list;
typeRefsByTypeId[expandedType.TypeId] = expandedType;
}
list.Add(cap);
}
}
}
else
{
// No expansion - use original target (concrete type)
if (!capabilitiesByTypeId.TryGetValue(targetTypeId, out var list))
{
list = [];
capabilitiesByTypeId[targetTypeId] = list;
typeRefsByTypeId[targetTypeId] = targetTypeRef;
}
list.Add(cap);
}
}
// Create a builder for each concrete type with its specific capabilities
var builders = new List<BuilderModel>();
foreach (var (typeId, typeCapabilities) in capabilitiesByTypeId)
{
var typeRef = typeRefsByTypeId.GetValueOrDefault(typeId);
var builderClassName = typeRef is not null ? DeriveClassName(typeRef) : DeriveClassName(typeId);
// Deduplicate capabilities by CapabilityId
var uniqueCapabilities = typeCapabilities
.GroupBy(c => c.CapabilityId)
.Select(g => g.First())
.ToList();
var builder = new BuilderModel
{
TypeId = typeId,
BuilderClassName = builderClassName,
Capabilities = uniqueCapabilities,
IsInterface = typeRef?.IsInterface ?? false,
TargetType = typeRef
};
builders.Add(builder);
}
// Also create builders for interface types
foreach (var (interfaceTypeId, caps) in interfaceCapabilities)
{
if (capabilitiesByTypeId.ContainsKey(interfaceTypeId))
{
continue;
}
var typeRef = typeRefsByTypeId.GetValueOrDefault(interfaceTypeId);
var builderClassName = typeRef is not null ? DeriveClassName(typeRef) : DeriveClassName(interfaceTypeId);
var uniqueCapabilities = caps
.GroupBy(c => c.CapabilityId)
.Select(g => g.First())
.ToList();
var builder = new BuilderModel
{
TypeId = interfaceTypeId,
BuilderClassName = builderClassName,
Capabilities = uniqueCapabilities,
IsInterface = true,
TargetType = typeRef
};
builders.Add(builder);
}
// Also create builders for resource types referenced anywhere in capabilities
var allReferencedTypeRefs = CollectAllReferencedTypes(capabilities);
var existingBuilderTypeIds = new HashSet<string>(capabilitiesByTypeId.Keys);
foreach (var (interfaceTypeId, _) in interfaceCapabilities)
{
existingBuilderTypeIds.Add(interfaceTypeId);
}
foreach (var (typeId, typeRef) in allReferencedTypeRefs)
{
var typeIdReference = typeRef.ClrType!.IsGenericType ? typeId.Split('`')[0] + "T" : typeId;
if (existingBuilderTypeIds.Contains(typeIdReference))
{
continue;
}
var builderClassName = DeriveClassName(typeRef);
// For non-interface resource builder types, find capabilities that target this type or an interface it implements
// This is essentially here to make sure we can move common capabilities onto the _ResourceBase class.
var applicableCapabilities = new List<AtsCapabilityInfo>();
if (typeRef.IsResourceBuilder && !typeRef.IsInterface)
{
var implementedInterfaceIds = typeRef.ImplementedInterfaces?
.Select(i => i.TypeId)
.ToHashSet(StringComparer.Ordinal) ?? [];
applicableCapabilities = capabilities
.Where(c => c.TargetTypeId == typeId ||
(c.TargetType?.IsInterface == true && implementedInterfaceIds.Contains(c.TargetTypeId!)))
.GroupBy(c => c.CapabilityId)
.Select(g => g.First())
.ToList();
}
var builder = new BuilderModel
{
TypeId = typeIdReference,
BuilderClassName = builderClassName,
Capabilities = applicableCapabilities,
IsInterface = typeRef.IsInterface,
TargetType = typeRef
};
builders.Add(builder);
existingBuilderTypeIds.Add(typeIdReference);
}
// Topological sort: base types and interfaces must come before types that depend on them
return TopologicalSortBuilders(builders);
}
/// <summary>
/// Performs a topological sort on builders to ensure base types and interfaces
/// come before types that extend or implement them.
/// </summary>
private static List<BuilderModel> TopologicalSortBuilders(List<BuilderModel> builders)
{
builders = builders
.GroupBy(b => b.TypeId, StringComparer.Ordinal)
.Select(g =>
{
if (g.Count() == 1)
{
return g.First();
}
var first = g.First();
return new BuilderModel
{
TypeId = g.Key,
BuilderClassName = first.BuilderClassName,
Capabilities = g.SelectMany(b => b.Capabilities)
.GroupBy(c => c.CapabilityId, StringComparer.Ordinal)
.Select(cg => cg.First())
.ToList(),
IsInterface = first.IsInterface,
TargetType = g.Select(b => b.TargetType).FirstOrDefault(t => t is not null)
};
})
.ToList();
var buildersByTypeId = builders.ToDictionary(b => b.TypeId, StringComparer.Ordinal);
var result = new List<BuilderModel>();
var visited = new HashSet<string>(StringComparer.Ordinal);
var visiting = new HashSet<string>(StringComparer.Ordinal); // For cycle detection
void Visit(BuilderModel builder)
{
if (visited.Contains(builder.TypeId))
{
return;
}
if (visiting.Contains(builder.TypeId))
{
// Cycle detected - skip to avoid infinite recursion
return;
}
visiting.Add(builder.TypeId);
// Visit base type first
if (builder.TargetType?.BaseType != null)
{
var baseTypeId = GetBuilderTypeId(builder.TargetType.BaseType);
if (buildersByTypeId.TryGetValue(baseTypeId, out var baseBuilder))
{
Visit(baseBuilder);
}
}
// Visit implemented interfaces
if (builder.TargetType?.ImplementedInterfaces != null)
{
foreach (var iface in builder.TargetType.ImplementedInterfaces)
{
if (buildersByTypeId.TryGetValue(GetBuilderTypeId(iface), out var ifaceBuilder))
{
Visit(ifaceBuilder);
}
}
}
visiting.Remove(builder.TypeId);
visited.Add(builder.TypeId);
result.Add(builder);
}
// Visit all builders, sorting by name for deterministic output
foreach (var builder in builders.OrderBy(b => b.BuilderClassName))
{
Visit(builder);
}
return result;
}
/// <summary>
/// Collects all type refs referenced in capabilities.
/// </summary>
private Dictionary<string, AtsTypeRef> CollectAllReferencedTypes(IReadOnlyList<AtsCapabilityInfo> capabilities)
{
var typeRefs = new Dictionary<string, AtsTypeRef>();
void CollectFromTypeRef(AtsTypeRef? typeRef)
{
if (typeRef == null)
{
return;
}
if (!string.IsNullOrEmpty(typeRef.TypeId) && typeRef.Category == AtsTypeCategory.Handle)
{
typeRefs.TryAdd(typeRef.TypeId, typeRef);
}
if (!string.IsNullOrEmpty(typeRef.TypeId) && typeRef.Category == AtsTypeCategory.Dict)
{
_moduleBuilder.HandleRegistrations.TryAdd(typeRef.TypeId, "AspireDict");
}
if (!string.IsNullOrEmpty(typeRef.TypeId) && typeRef.Category == AtsTypeCategory.List)
{
_moduleBuilder.HandleRegistrations.TryAdd(typeRef.TypeId, "AspireList");
}
CollectFromTypeRef(typeRef.BaseType);
CollectFromTypeRef(typeRef.ElementType);
CollectFromTypeRef(typeRef.KeyType);
CollectFromTypeRef(typeRef.ValueType);
if (typeRef.UnionTypes != null)
{
foreach (var unionType in typeRef.UnionTypes)
{
CollectFromTypeRef(unionType);
}
}
if (typeRef.ImplementedInterfaces != null)
{
foreach (var iface in typeRef.ImplementedInterfaces)
{
CollectFromTypeRef(iface);
}
}
}
foreach (var cap in capabilities)
{
CollectFromTypeRef(cap.ReturnType);
foreach (var param in cap.Parameters)
{
CollectFromTypeRef(param.Type);
if (param.IsCallback)
{
if (param.CallbackParameters != null)
{
foreach (var cbParam in param.CallbackParameters)
{
CollectFromTypeRef(cbParam.Type);
}
}
CollectFromTypeRef(param.CallbackReturnType);
}
}
}
return typeRefs;
}
/// <summary>
/// Gets entry point capabilities (those without TargetTypeId).
/// </summary>
private static List<AtsCapabilityInfo> GetEntryPointCapabilities(IReadOnlyList<AtsCapabilityInfo> capabilities)
{
return capabilities.Where(c => string.IsNullOrEmpty(c.TargetTypeId)).ToList();
}
/// <summary>
/// Derives the class name from an ATS type ID.
/// For interfaces like IResource, strips the leading 'I'.
/// </summary>
private static string DeriveClassName(string typeId)
{
var typeName = ExtractSimpleTypeName(typeId);
return DeriveClassNameFromTypeName(typeName);
}
private static string DeriveClassName(AtsTypeRef typeRef)
{
if (typeRef.ClrType is { } clrType)
{
if (clrType.IsGenericType)
{
return DeriveClassName(clrType.GetGenericTypeDefinition()) + "T";
}
return DeriveClassName(clrType);
}
return DeriveClassName(typeRef.TypeId);
}
private static string DeriveClassName(Type clrType)
{
var typeName = GetClrTypeName(clrType);
return DeriveClassNameFromTypeName(typeName);
}
private static string DeriveClassNameFromTypeName(string typeName)
{
if (typeName == "Resource")
{
return "_BaseResource";
}
// Strip leading 'I' from interface types
if (typeName.StartsWith('I') && typeName.Length > 1 && char.IsUpper(typeName[1]))
{
return "Abstract" + typeName[1..];
}
return typeName;
}
/// <summary>
/// Gets the handle type alias name for a type ID.
/// </summary>
private static string GetHandleTypeName(string typeId)
{
var typeName = ExtractSimpleTypeName(typeId);
// Strip leading 'I' from interface types
if (typeName.StartsWith('I') && typeName.Length > 1 && char.IsUpper(typeName[1]))
{
return "Abstract" + typeName[1..];
}
return $"{typeName}";
}
/// <summary>
/// Extracts the simple type name from a type ID (e.g., "Aspire.Hosting/RedisResource" -> "RedisResource").
/// </summary>
private static string ExtractSimpleTypeName(string typeId)
{
var slashIndex = typeId.LastIndexOf('/');
var fullTypeName = slashIndex >= 0 ? typeId[(slashIndex + 1)..] : typeId;
var bracketIndex = fullTypeName.IndexOf('[');
if (bracketIndex >= 0)
{
fullTypeName = fullTypeName[..bracketIndex];
}
var commaIndex = fullTypeName.IndexOf(',');
if (commaIndex >= 0)
{
fullTypeName = fullTypeName[..commaIndex];
}
var dotIndex = fullTypeName.LastIndexOf('.');
var simpleTypeName = dotIndex >= 0 ? fullTypeName[(dotIndex + 1)..] : fullTypeName;
return SanitizePythonTypeName(StripGenericArity(simpleTypeName));
}
private static string GetClrTypeName(Type clrType)
{
if (clrType.IsArray)
{
return GetClrTypeName(clrType.GetElementType()!) + "Array";
}
var typeName = StripGenericArity(clrType.Name);
if (clrType.IsGenericType)
{
return SanitizePythonTypeName(typeName + string.Concat(clrType.GetGenericArguments().Select(GetClrTypeName)));
}
return SanitizePythonTypeName(typeName);
}
private static string StripGenericArity(string typeName)
{
var backtickIndex = typeName.IndexOf('`');
return backtickIndex >= 0 ? typeName[..backtickIndex] : typeName;
}
private static string SanitizePythonIdentifier(string identifier)
{
if (string.IsNullOrEmpty(identifier))
{
return identifier;
}
var sb = new System.Text.StringBuilder(identifier.Length + 1);
var previousWasSeparator = false;
foreach (var c in identifier)
{
if (char.IsLetterOrDigit(c) || c == '_')
{
if (sb.Length == 0 && char.IsDigit(c))
{
sb.Append('_');
}
sb.Append(c);
previousWasSeparator = false;
}
else if (!previousWasSeparator && sb.Length > 0)
{
sb.Append('_');
previousWasSeparator = true;
}
}
var sanitized = sb.ToString().TrimEnd('_');
if (string.IsNullOrEmpty(sanitized))
{
sanitized = "_";
}
if (s_pythonKeywords.Contains(sanitized))
{
sanitized += "_";
}
return sanitized;
}
private static string SanitizePythonTypeName(string typeName)
{
if (string.IsNullOrEmpty(typeName))
{
return typeName;
}
var sb = new System.Text.StringBuilder(typeName.Length + 1);
foreach (var c in typeName)
{
if (char.IsLetterOrDigit(c) || c == '_')
{
if (sb.Length == 0 && char.IsDigit(c))
{
sb.Append('_');
}
sb.Append(c);
}
}
var sanitized = sb.Length > 0 ? sb.ToString() : "_";
if (s_pythonKeywords.Contains(sanitized))
{
sanitized += "_";
}
return sanitized;
}
private static string GetBuilderTypeId(AtsTypeRef typeRef)
{
if (typeRef.ClrType?.IsGenericType == true)
{
var tickIndex = typeRef.TypeId.IndexOf('`');
if (tickIndex >= 0)
{
return typeRef.TypeId[..tickIndex] + "T";
}
return typeRef.TypeId + "T";
}
return typeRef.TypeId;
}
private List<OptionVariation> CreateOptionVariations(
AtsCapabilityInfo capability,
List<AtsParameterInfo> parameters,
List<AtsParameterInfo> requiredParameters,
List<AtsParameterInfo> optionalParameters)
{
var requiredParamsTypes = string.Join(", ", requiredParameters.Select(MapParameterToPython));
var optionalParamsTypes = string.Join(", ", optionalParameters.Select(MapParameterToPython));
var parameterMappingName = GetMethodParametersName(capability.MethodName);
string? experimental = null; // TODO: get experimental tag
var variations = new List<OptionVariation>();
if (parameters.Count == 0)
{
variations.Add(new OptionVariation("typing.Literal[True]", requiredParameters, optionalParameters, experimental));
}
else if (parameters.Count == 1)
{
if (requiredParameters.Count == 1)
{
variations.Add(new OptionVariation(requiredParamsTypes, requiredParameters, optionalParameters, experimental));
}
else
{
variations.Add(new OptionVariation(optionalParamsTypes, requiredParameters, optionalParameters, experimental));
variations.Add(new OptionVariation("typing.Literal[True]", requiredParameters, optionalParameters, experimental));
}
}
else if (requiredParameters.Count == 1)
{
if (optionalParameters.Count == 1)
{
variations.Add(new OptionVariation(requiredParamsTypes, requiredParameters, optionalParameters, experimental));
variations.Add(new OptionVariation($"({requiredParamsTypes}, {optionalParamsTypes})", requiredParameters, optionalParameters, experimental));
}
else
{
AddParameterMapping(parameterMappingName, requiredParameters, optionalParameters);
variations.Add(new OptionVariation(requiredParamsTypes, requiredParameters, optionalParameters, experimental));
variations.Add(new OptionVariation(parameterMappingName, requiredParameters, optionalParameters, experimental));
}
}
else if (requiredParameters.Count > 0 && requiredParameters.Count <= 3)
{
if (optionalParameters.Count > 0)
{
AddParameterMapping(parameterMappingName, requiredParameters, optionalParameters);
variations.Add(new OptionVariation("(" + requiredParamsTypes + ")", requiredParameters, optionalParameters, experimental));
variations.Add(new OptionVariation(parameterMappingName, requiredParameters, optionalParameters, experimental));
}
else
{
variations.Add(new OptionVariation("(" + requiredParamsTypes + ")", requiredParameters, optionalParameters, experimental));
}
}
else
{
AddParameterMapping(parameterMappingName, requiredParameters, optionalParameters);
if (requiredParameters.Count == 0)
{
variations.Add(new OptionVariation(parameterMappingName, requiredParameters, optionalParameters, experimental));
variations.Add(new OptionVariation("typing.Literal[True]", requiredParameters, optionalParameters, experimental));
}
else
{
variations.Add(new OptionVariation(parameterMappingName, requiredParameters, optionalParameters, experimental));
}
}
return variations;
}
private void AddParameterMapping(string methodName, List<AtsParameterInfo> requiredParameters, List<AtsParameterInfo> optionalParameters)
{
if (_moduleBuilder.MethodParameters.ContainsKey(methodName))
{
return;
}
var parameters = new System.Text.StringBuilder();
parameters.AppendLine();
parameters.AppendLine(CultureInfo.InvariantCulture, $"class {methodName}(typing.TypedDict, total=False):");
foreach (var requiredParam in requiredParameters)
{
parameters.AppendLine(CultureInfo.InvariantCulture, $" {ToSnakeCase(requiredParam.Name!)}: typing.Required[{MapParameterToPython(requiredParam)}]");
}
foreach (var optionalParam in optionalParameters)
{
parameters.AppendLine(CultureInfo.InvariantCulture, $" {ToSnakeCase(optionalParam.Name!)}: {MapParameterToPython(optionalParam)}");
}
_moduleBuilder.MethodParameters[methodName] = parameters;
}
/// <summary>
/// Determines whether a specific option variation format will actually include
/// the named parameter in the generated rpc_args dictionary.
/// </summary>
/// <remarks>
/// All variations share the same requiredParameters/optionalParameters lists,
/// but each format only populates a subset into rpc_args:
/// <list type="bullet">
/// <item><c>typing.Literal[True]</c> — no params sent</item>
/// <item>Single type — only 1 param (first required, or first optional if none required)</item>
/// <item>Tuple — required params + optional if tuple has an extra slot</item>
/// <item>Parameters dict — all required and optional params</item>
/// </list>
/// </remarks>
private static bool VariationIncludesParam(
string optionType,
List<AtsParameterInfo> requiredParams,
List<AtsParameterInfo> optionalParams,
string paramName)
{
if (optionType == "typing.Literal[True]")
{
return false;
}
// Required params are always included regardless of variation format
if (requiredParams.Any(p => string.Equals(p.Name, paramName, StringComparison.Ordinal)))
{
return true;
}
// Dict/Parameters format includes all params (required + optional)
if (optionType.EndsWith("Parameters", StringComparison.Ordinal))
{
return optionalParams.Any(p => string.Equals(p.Name, paramName, StringComparison.Ordinal));
}
// Tuple format includes optional params only when there's exactly 1 optional
// and the tuple has an extra slot for it
if (optionType.StartsWith("(", StringComparison.Ordinal))
{
var tupleSlots = SplitTupleTypes(optionType.Trim('(', ')'));
if (tupleSlots.Count == requiredParams.Count + 1 && optionalParams.Count == 1)
{
return optionalParams.Any(p => string.Equals(p.Name, paramName, StringComparison.Ordinal));
}
return false;
}
// Single param format: only 1 param is sent
var singleParam = requiredParams.Count > 0 ? requiredParams[0] : optionalParams[0];
return string.Equals(singleParam.Name, paramName, StringComparison.Ordinal);
}
private static void BuildOptionConstructor(
System.Text.StringBuilder builder,
AtsCapabilityInfo capability,
string optionName,
List<OptionVariation> variations,
MergedCapabilityDispatch? mergedDispatch,
int optionIndex = 0)
{
var variation = variations[optionIndex];
var targetParamName = capability.TargetParameterName ?? "builder";
var currentOption = variation.OptionType;
var requiredParameters = variation.RequiredParameters;
var optionalParameters = variation.OptionalParameters;
var clause = "elif";
var last = optionIndex == variations.Count - 1;
if (optionIndex == 0)
{
// This will be a big if/elif/else clause, so we start with if on the first type variation.
clause = "if";
builder.AppendLine(CultureInfo.InvariantCulture, $" if _{optionName} := kwargs.pop(\"{optionName}\", None):");
}
// Resolve the correct capability ID for this variation.
// We must check whether this specific variation format will actually include the
// discriminating parameter in rpc_args. Since the discriminating param is always
// optional (made so by merging), we need to check whether the variation format
// carries optional parameters.
var variationCapabilityId = capability.CapabilityId;
if (mergedDispatch is not null)
{
variationCapabilityId = VariationIncludesParam(currentOption, requiredParameters, optionalParameters, mergedDispatch.DiscriminatingParamName)
? mergedDispatch.AlternateCapabilityId
: capability.CapabilityId;
}
if (currentOption == "typing.Literal[True]")
{
builder.AppendLine(CultureInfo.InvariantCulture, $" {clause} _{optionName} is True:");
builder.AppendLine(CultureInfo.InvariantCulture, $" rpc_args: dict[str, typing.Any] = {{\"{targetParamName}\": handle}}");
builder.AppendLine(CultureInfo.InvariantCulture, $" handle = self._wrap_builder(client.invoke_capability('{variationCapabilityId}', rpc_args))");
}
else if (currentOption.EndsWith("Parameters", StringComparison.Ordinal))
{
builder.AppendLine(CultureInfo.InvariantCulture, $" {clause} _validate_dict_types(_{optionName}, {currentOption}):");
builder.AppendLine(CultureInfo.InvariantCulture, $" rpc_args: dict[str, typing.Any] = {{\"{targetParamName}\": handle}}");
foreach (var param in requiredParameters)
{
var paramHandler = GetConstructorParamHandler(param, $"typing.cast({currentOption}, _{optionName})[\"{ToSnakeCase(param.Name!)}\"]");
builder.AppendLine(CultureInfo.InvariantCulture, $" rpc_args[\"{param.Name}\"] = {paramHandler}");
}
foreach (var param in optionalParameters)
{
var paramHandler = GetConstructorParamHandler(param, $"typing.cast({currentOption}, _{optionName}).get(\"{ToSnakeCase(param.Name!)}\")");
builder.AppendLine(CultureInfo.InvariantCulture, $" rpc_args[\"{param.Name}\"] = {paramHandler}");
}
// For dict variations with merged dispatch, emit a runtime check for the
// discriminating parameter since the dict may or may not contain it.
if (mergedDispatch is not null)
{
var snakeDiscrimParam = ToSnakeCase(mergedDispatch.DiscriminatingParamName);
builder.AppendLine(CultureInfo.InvariantCulture, $" capability_id = '{mergedDispatch.AlternateCapabilityId}' if \"{snakeDiscrimParam}\" in _{optionName} else '{capability.CapabilityId}'");
builder.AppendLine(CultureInfo.InvariantCulture, $" handle = self._wrap_builder(client.invoke_capability(capability_id, rpc_args))");
}
else
{
builder.AppendLine(CultureInfo.InvariantCulture, $" handle = self._wrap_builder(client.invoke_capability('{variationCapabilityId}', rpc_args))");
}
}
else if (currentOption.StartsWith("(", StringComparison.Ordinal)) // Tuple of parameters
{
var paramTypes = SplitTupleTypes(currentOption.Trim('(', ')'));
builder.AppendLine(CultureInfo.InvariantCulture, $" {clause} _validate_tuple_types(_{optionName}, {currentOption}):");
builder.AppendLine(CultureInfo.InvariantCulture, $" rpc_args: dict[str, typing.Any] = {{\"{targetParamName}\": handle}}");
foreach (var (param, index) in requiredParameters.Select((item, index) => (item, index)))
{
var paramHandler = GetConstructorParamHandler(param, $"typing.cast(tuple[{currentOption.Trim('(', ')')}], _{optionName})[{index}]");
builder.AppendLine(CultureInfo.InvariantCulture, $" rpc_args[\"{param.Name}\"] = {paramHandler}");
}
if (paramTypes.Count == requiredParameters.Count + 1 && optionalParameters.Count == 1)
{
var param = optionalParameters[0];
var paramHandler = GetConstructorParamHandler(param, $"typing.cast(tuple[{currentOption.Trim('(', ')')}], _{optionName})[{paramTypes.Count - 1}]");
builder.AppendLine(CultureInfo.InvariantCulture, $" rpc_args[\"{param.Name}\"] = {paramHandler}");
}
builder.AppendLine(CultureInfo.InvariantCulture, $" handle = self._wrap_builder(client.invoke_capability('{variationCapabilityId}', rpc_args))");
}
else // Single parameter
{
builder.AppendLine(CultureInfo.InvariantCulture, $" {clause} _validate_type(_{optionName}, {currentOption}):");
builder.AppendLine(CultureInfo.InvariantCulture, $" rpc_args: dict[str, typing.Any] = {{\"{targetParamName}\": handle}}");
var singleParam = requiredParameters.Count > 0
? requiredParameters[0]
: optionalParameters[0];
var paramHandler = GetConstructorParamHandler(singleParam, $"typing.cast({currentOption}, _{optionName})");
builder.AppendLine(CultureInfo.InvariantCulture, $" rpc_args[\"{singleParam.Name}\"] = {paramHandler}");
builder.AppendLine(CultureInfo.InvariantCulture, $" handle = self._wrap_builder(client.invoke_capability('{variationCapabilityId}', rpc_args))");
}
if (last)
{
builder.AppendLine(CultureInfo.InvariantCulture, $" else:");
builder.AppendLine(CultureInfo.InvariantCulture, $" raise TypeError(\"Invalid type for option '{optionName}'. Expected: {String.Join(" or ", variations.Select(v => v.OptionType.Replace("typing.", "")))}\")");
}
else
{
BuildOptionConstructor(builder, capability, optionName, variations, mergedDispatch, optionIndex + 1);
}
}
/// <summary>
/// Generates a callback type signature for Python.
/// </summary>
private string GenerateCallbackTypeSignature(IReadOnlyList<AtsCallbackParameterInfo>? parameters, AtsTypeRef? returnType)
{
var paramTypes = parameters?.Select(p => MapTypeRefToPython(p.Type)).ToList() ?? [];
var returnTypeStr = returnType != null ? MapTypeRefToPython(returnType) : "None";
if (paramTypes.Count == 0)
{
return $"typing.Callable[[], {returnTypeStr}]";
}
return $"typing.Callable[[{string.Join(", ", paramTypes)}], {returnTypeStr}]";
}
/// <summary>
/// Splits a tuple type string on ", " but respects brackets [ ] to avoid splitting inside nested types.
/// For example: "(str, Callable[[], str])" splits into ["str", "Callable[[], str]"]
/// </summary>
private static List<string> SplitTupleTypes(string tupleContent)
{
var result = new List<string>();
var current = new System.Text.StringBuilder();
int bracketDepth = 0;
for (int i = 0; i < tupleContent.Length; i++)
{
char c = tupleContent[i];
if (c == '[')
{
bracketDepth++;
current.Append(c);
}
else if (c == ']')
{
bracketDepth--;
current.Append(c);
}
else if (c == ',' && bracketDepth == 0)
{
// Found a comma at the top level, check if followed by space
if (i + 1 < tupleContent.Length && tupleContent[i + 1] == ' ')
{
// This is our delimiter
result.Add(current.ToString());
current.Clear();
i++; // Skip the space after comma
}
else
{
current.Append(c);
}
}
else
{
current.Append(c);
}
}
// Add the last segment
if (current.Length > 0)
{
result.Add(current.ToString());
}
return result;
}
}