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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ private string MapTypeRefToPython(AtsTypeRef? typeRef)
return wrapperClassName;
}

return typeRef.Category switch
var mappedType = typeRef.Category switch
{
AtsTypeCategory.Primitive => MapPrimitiveType(typeRef.TypeId),
AtsTypeCategory.Enum => MapEnumType(typeRef.TypeId),
Expand All @@ -225,6 +225,19 @@ private string MapTypeRefToPython(AtsTypeRef? 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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ private string MapTypeRefToTypeScript(AtsTypeRef? typeRef)
return GetInterfaceName(wrapperClassName);
}

return typeRef.Category switch
var mappedType = typeRef.Category switch
{
AtsTypeCategory.Primitive => MapPrimitiveType(typeRef.TypeId),
AtsTypeCategory.Enum => MapEnumType(typeRef.TypeId),
Expand All @@ -220,6 +220,19 @@ private string MapTypeRefToTypeScript(AtsTypeRef? typeRef)
AtsTypeCategory.Unknown => "any", // Unknown types use 'any' since they're not in the ATS universe
_ => "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} | null";
}

/// <summary>
Expand Down
48 changes: 40 additions & 8 deletions src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,7 @@ public static List<AtsCapabilityInfo> ScanCapabilities(

// Collect public properties for the DTO interface
var properties = new List<AtsDtoPropertyInfo>();
var nullabilityContext = new NullabilityInfoContext();

foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
Expand All @@ -1074,6 +1075,7 @@ public static List<AtsCapabilityInfo> ScanCapabilities(
{
continue;
}
propTypeRef = WithNullability(propTypeRef, prop.PropertyType, nullabilityContext.Create(prop).ReadState);

IReadOnlyList<AtsCallbackParameterInfo>? callbackParameters = null;
AtsTypeRef? callbackReturnType = null;
Expand All @@ -1083,7 +1085,7 @@ public static List<AtsCapabilityInfo> ScanCapabilities(
}

var propDescription = GetXmlDocSummary(xmlDoc, $"P:{type.FullName}.{prop.Name}");
var isOptional = !prop.CanWrite || Nullable.GetUnderlyingType(prop.PropertyType) is not null;
var isOptional = !prop.CanWrite || propTypeRef.IsNullable == true;

properties.Add(new AtsDtoPropertyInfo
{
Expand Down Expand Up @@ -1492,6 +1494,7 @@ private static ContextTypeCapabilitiesResult CreateContextTypeCapabilities(
var exposeAllProperties = HasExposePropertiesAttribute(contextType);
var exposeAllMethods = HasExposeMethodsAttribute(contextType);
var typeExportAttr = GetAspireExportAttribute(contextType);
var nullabilityContext = new NullabilityInfoContext();

// Scan properties
foreach (var property in contextType.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static))
Expand Down Expand Up @@ -1591,6 +1594,9 @@ private static ContextTypeCapabilitiesResult CreateContextTypeCapabilities(
// Skip properties with unmapped types
continue;
}
var propertyNullability = nullabilityContext.Create(property);
var getterTypeRef = WithNullability(propertyTypeRef!, propType, propertyNullability.ReadState);
var setterTypeRef = WithNullability(propertyTypeRef!, propType, propertyNullability.WriteState);

// Create type ref for the context type with full inheritance info
var contextTypeRef = CreateHandleTypeRef(contextType);
Expand All @@ -1616,7 +1622,7 @@ private static ContextTypeCapabilitiesResult CreateContextTypeCapabilities(
OwningTypeName = typeName,
Description = propertyDescription,
Parameters = [
new AtsParameterInfo
new AtsParameterInfo
{
Name = "context",
Type = contextTypeRef,
Expand All @@ -1625,8 +1631,8 @@ private static ContextTypeCapabilitiesResult CreateContextTypeCapabilities(
IsCallback = false,
DefaultValue = null
}
],
ReturnType = propertyTypeRef!,
],
ReturnType = getterTypeRef,
TargetTypeId = typeId,
TargetType = contextTypeRef,
TargetParameterName = "context",
Expand Down Expand Up @@ -1657,7 +1663,7 @@ private static ContextTypeCapabilitiesResult CreateContextTypeCapabilities(
OwningTypeName = typeName,
Description = $"Sets the {property.Name} property",
Parameters = [
new AtsParameterInfo
new AtsParameterInfo
{
Name = "context",
Type = contextTypeRef,
Expand All @@ -1669,13 +1675,13 @@ private static ContextTypeCapabilitiesResult CreateContextTypeCapabilities(
new AtsParameterInfo
{
Name = "value",
Type = propertyTypeRef!,
Type = setterTypeRef,
IsOptional = false,
IsNullable = false,
IsNullable = setterTypeRef.IsNullable == true,
IsCallback = false,
DefaultValue = null
}
],
],
ReturnType = contextTypeRef,
TargetTypeId = typeId,
TargetType = contextTypeRef,
Expand Down Expand Up @@ -2506,6 +2512,32 @@ private static bool IsResourceBuilderType(Type type)
Category = AtsTypeCategory.Primitive
};

private static AtsTypeRef WithNullability(AtsTypeRef typeRef, Type declaredType, NullabilityState nullabilityState)
{
var isNullable = nullabilityState == NullabilityState.Nullable ||
Nullable.GetUnderlyingType(declaredType) is not null;
if (!isNullable)
{
return typeRef;
}

return new AtsTypeRef
{
TypeId = typeRef.TypeId,
ClrType = typeRef.ClrType,
Category = typeRef.Category,
IsInterface = typeRef.IsInterface,
IsNullable = true,
ElementType = typeRef.ElementType,
KeyType = typeRef.KeyType,
ValueType = typeRef.ValueType,
IsReadOnly = typeRef.IsReadOnly,
UnionTypes = typeRef.UnionTypes,
ImplementedInterfaces = typeRef.ImplementedInterfaces,
BaseType = typeRef.BaseType
};
}

/// <summary>
/// Creates an AtsTypeRef from a CLR type, optionally collecting enum types.
/// </summary>
Expand Down
15 changes: 15 additions & 0 deletions src/Aspire.TypeSystem/AtsCapabilityInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;

namespace Aspire.TypeSystem;

Expand Down Expand Up @@ -34,6 +35,19 @@ public sealed class AtsTypeRef
/// </summary>
public bool IsInterface { get; init; }

/// <summary>
/// Gets or sets whether this type reference accepts a JSON null value at its current use site.
/// </summary>
/// <remarks>
/// Nullability is attached to the type reference as it appears in a capability or DTO property.
/// Nested element, key, and value type nullability is only represented when those nested
/// references were scanned from member metadata that exposes nullability information.
/// For example, a DTO property declared as <code>string?</code> produces a nullable string
/// type reference, while the same CLR <see cref="string" /> type on a non-nullable property does not.
/// </remarks>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool? IsNullable { get; init; }

Comment on lines +38 to +50
/// <summary>
/// Gets or sets the element type reference for Array/List types.
/// </summary>
Expand Down Expand Up @@ -307,6 +321,7 @@ public sealed class AtsParameterInfo
/// <summary>
/// Gets or sets whether this parameter is nullable.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] to this pre-existing bool property changes the wire format: "isNullable": false was previously emitted and will now be omitted. Consumers that treat a missing field as its default (false) are unaffected, but any consumer doing explicit presence-checking on the JSON key could break. Worth confirming no downstream consumer relies on the explicit presence of this field.

public bool IsNullable { get; init; }

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,22 @@ public void GeneratedCode_UsesTypeHints()
Assert.Contains(": str", aspirePy);
}

[Fact]
public void GeneratedCode_NullableScalarProperties_GenerateNullableTypes()
{
var atsContext = CreateContextFromBothAssemblies();

var files = _generator.GenerateDistributedApplication(atsContext);
var aspirePy = files["aspire_app.py"];

Assert.Contains("OptionalField: str | None", aspirePy);
Assert.Contains("def description(self) -> str | None:", aspirePy);
Assert.Contains("def description(self, value: str | None) -> None:", aspirePy);
Assert.Contains("Name: str", aspirePy);
Assert.Contains("def name(self) -> str:", aspirePy);
Assert.Contains("def name(self, value: str) -> None:", aspirePy);
Comment on lines +304 to +309
}

[Fact]
public void GeneratedCode_SanitizesPythonKeywordIdentifiers()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# -------------------------------------------------------------
# -------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See LICENSE in project root for information.
#
Expand Down Expand Up @@ -1543,7 +1543,7 @@ class TestConfigDto(typing.TypedDict, total=False):
Name: str
Port: int
Enabled: bool
OptionalField: str
OptionalField: str | None

class TestDeeplyNestedDto(typing.TypedDict, total=False):
NestedData: AspireDict[str, AspireList[TestConfigDto]]
Expand Down Expand Up @@ -1655,16 +1655,16 @@ def handle(self) -> Handle:
return self._handle

@_uncached_property
def name(self) -> str:
def name(self) -> str | None:
"""Gets the Name property"""
result = self._client.invoke_capability(
'Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes/TestCallbackContext.name',
{'context': self._handle}
)
return typing.cast(str, result)
return typing.cast(str | None, result)

@name.setter
def name(self, value: str) -> None:
def name(self, value: str | None) -> None:
"""Sets the Name property"""
self._client.invoke_capability(
'Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes/TestCallbackContext.setName',
Expand Down Expand Up @@ -1764,16 +1764,16 @@ def name(self, value: str) -> None:
)

@_uncached_property
def description(self) -> str:
def description(self) -> str | None:
"""Gets the Description property"""
result = self._client.invoke_capability(
'Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes/TestEnvironmentContext.description',
{'context': self._handle}
)
return typing.cast(str, result)
return typing.cast(str | None, result)

@description.setter
def description(self, value: str) -> None:
def description(self, value: str | None) -> None:
"""Sets the Description property"""
self._client.invoke_capability(
'Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes/TestEnvironmentContext.setDescription',
Expand Down
Loading
Loading