diff --git a/runtime/promptycs/Prompty.Core.Tests/LoadTests.cs b/runtime/promptycs/Prompty.Core.Tests/LoadTests.cs
index e5fdd14e..e296a7a2 100644
--- a/runtime/promptycs/Prompty.Core.Tests/LoadTests.cs
+++ b/runtime/promptycs/Prompty.Core.Tests/LoadTests.cs
@@ -1,3 +1,4 @@
+using System.Reflection;
using System.Transactions;
namespace Prompty.Core.Tests;
@@ -36,6 +37,76 @@ public void LoadRawWithConfig(string path)
Assert.Equal("FAKE_TYPE", prompty.Model?.Configuration.Type);
}
+ ///
+ /// Test the Loading from a Stream
+ ///
+ [Fact]
+ public void LoadStream()
+ {
+ var path = "prompty/basic.prompty";
+ using var stream = File.OpenRead(path);
+ var prompty = Prompty.Load(stream);
+
+ Assert.NotNull(prompty);
+ Assert.NotNull(prompty.Content);
+ }
+
+ ///
+ /// Test the Loading from an embedded resource
+ ///
+ [Fact]
+ public void LoadEmbeddedResource()
+ {
+ // Get the fully qualified name of the embedded resource
+ var assembly = Assembly.GetExecutingAssembly();
+ var resourceNames = assembly.GetManifestResourceNames();
+ var resourceName = resourceNames.FirstOrDefault(r => r.EndsWith("basic.prompty"));
+
+ Assert.NotNull(resourceName); // Ensure we found the resource
+
+ using var stream = assembly.GetManifestResourceStream(resourceName);
+ Assert.NotNull(stream);
+ var prompty = Prompty.Load(stream!);
+
+ Assert.NotNull(prompty);
+ Assert.NotNull(prompty.Content);
+ }
+
+ ///
+ /// Test the Loading from an embedded resource with config
+ ///
+ [Fact]
+ public void LoadEmbeddedResourceWithConfig()
+ {
+ // Get the fully qualified name of the embedded resource
+ var assembly = Assembly.GetExecutingAssembly();
+ var resourceNames = assembly.GetManifestResourceNames();
+ var resourceName = resourceNames.FirstOrDefault(r => r.EndsWith("basic.prompty"));
+
+ Assert.NotNull(resourceName); // Ensure we found the resource
+
+ using var stream = assembly.GetManifestResourceStream(resourceName);
+ Assert.NotNull(stream);
+ var prompty = Prompty.Load(stream!, "fake");
+
+ Assert.Equal("FAKE_TYPE", prompty.Model?.Configuration.Type);
+ }
+
+
+ ///
+ /// Test the Loading from a Stream Async
+ ///
+ [Fact]
+ public async Task LoadStreamAsync()
+ {
+ var path = "prompty/basic.prompty";
+ using var stream = File.OpenRead(path);
+ var prompty = await Prompty.LoadAsync(stream);
+
+ Assert.NotNull(prompty);
+ Assert.NotNull(prompty.Content);
+ }
+
[Fact]
public void BasicSampleParameters()
{
diff --git a/runtime/promptycs/Prompty.Core.Tests/Prompty.Core.Tests.csproj b/runtime/promptycs/Prompty.Core.Tests/Prompty.Core.Tests.csproj
index bb5a5b8b..e6a0505d 100644
--- a/runtime/promptycs/Prompty.Core.Tests/Prompty.Core.Tests.csproj
+++ b/runtime/promptycs/Prompty.Core.Tests/Prompty.Core.Tests.csproj
@@ -69,6 +69,7 @@
Always
+
Always
diff --git a/runtime/promptycs/Prompty.Core.Tests/PromptyAttributeTests.cs b/runtime/promptycs/Prompty.Core.Tests/PromptyAttributeTests.cs
new file mode 100644
index 00000000..4286bd9c
--- /dev/null
+++ b/runtime/promptycs/Prompty.Core.Tests/PromptyAttributeTests.cs
@@ -0,0 +1,200 @@
+using Microsoft.Extensions.AI;
+
+namespace Prompty.Core.Tests;
+
+///
+/// Test class with a single Prompty attribute
+///
+[Prompty("prompty/basic.prompty")]
+public class ClassWithAttribute { }
+
+///
+/// Test class with a Prompty attribute that loads from an embedded resource
+///
+[Prompty("prompty/basic.prompty", IsResource = true)]
+public class ClassWithResourceAttribute { }
+
+///
+/// Test class with a Prompty attribute that loads from an embedded resource
+/// and has configuration and parameters
+///
+[Prompty("prompty/basic.prompty", IsResource = true, Configuration = "FAKE_TYPE", Params = new string[] { "firstName", "Caspar", "lastName", "Haglund", "question", "What is your name?" })]
+public class ClassWithResourceAttributeAndCofigAndParams { }
+
+///
+/// Test class with multiple Prompty attributes
+///
+[Prompty("prompty/basic.prompty")]
+[Prompty("prompty/context.prompty")]
+public class ClassWithMultipleAttributes { }
+
+///
+/// Test class with multiple Prompty attributes with mixed configurations
+///
+[Prompty("prompty/basic.prompty", IsResource = true)]
+[Prompty("prompty/context.prompty", Configuration = "FAKE_TYPE")]
+public class ClassWithMultipleMixedAttributes { }
+
+///
+/// Prompty Attribute Tests
+///
+public class PromptyAttributeTests
+{
+ public PromptyAttributeTests()
+ {
+ Environment.SetEnvironmentVariable("AZURE_OPENAI_ENDPOINT", "ENDPOINT_VALUE");
+ }
+
+ ///
+ /// Test Loading from a File path
+ ///
+ [Fact]
+ public void LoadFromFile()
+ {
+ var attr = (PromptyAttribute)Attribute.GetCustomAttribute(
+ typeof(ClassWithAttribute),
+ typeof(PromptyAttribute))!;
+ Assert.NotNull(attr.Messages);
+ Assert.NotNull(attr);
+ Assert.Equal("prompty/basic.prompty", attr.File);
+ Assert.False(attr.IsResource);
+ Assert.NotNull(attr.Prompt);
+ }
+
+ ///
+ /// Test Loading from an embedded Resource path
+ ///
+ [Fact]
+ public void LoadFromResource()
+ {
+ var attr = (PromptyAttribute)Attribute.GetCustomAttribute(
+ typeof(ClassWithResourceAttribute),
+ typeof(PromptyAttribute))!;
+ Assert.NotNull(attr.Messages);
+ Assert.NotNull(attr);
+ Assert.Equal("prompty/basic.prompty", attr.File);
+ Assert.True(attr.IsResource);
+ Assert.NotNull(attr.Prompt);
+ }
+
+ ///
+ /// Test that inalid file paths result in exception
+ ///
+ [Fact]
+ public void ThrowsOnInvalidFile()
+ {
+
+ Assert.Throws(() =>
+ {
+ var fail = new PromptyAttribute("nonexistent.prompty", false);
+ var _ = fail.Prompt;
+ });
+ }
+
+ ///
+ /// Test that invalid resource paths result in exception
+ ///
+ [Fact]
+ public void ThrowsOnInvalidResource()
+ {
+ Assert.Throws(() =>
+ {
+ var fail = new PromptyAttribute("nonexistent.prompty", true);
+ var _ = fail.Prompt;
+ });
+ }
+
+ ///
+ /// Test Loading from a Resource path with configuration and parameters
+ ///
+ [Fact]
+ public void LoadFromResourceWithConfigAndParams()
+ {
+
+ var attr = (PromptyAttribute)Attribute.GetCustomAttribute(
+ typeof(ClassWithResourceAttributeAndCofigAndParams),
+ typeof(PromptyAttribute))!;
+
+ var messages = attr.Messages;
+
+ Assert.NotNull(attr);
+ Assert.Equal("prompty/basic.prompty", attr.File);
+ Assert.True(attr.IsResource);
+ Assert.Equal("FAKE_TYPE", attr.Configuration);
+ Assert.NotNull(attr.Params);
+ Assert.Equal(6, attr.Params.Length);
+ Assert.Equal("firstName", attr.Params[0]);
+ Assert.Equal("Caspar", attr.Params[1]);
+ Assert.Equal("lastName", attr.Params[2]);
+ Assert.Equal("Haglund", attr.Params[3]);
+ Assert.Equal("question", attr.Params[4]);
+ Assert.Equal("What is your name?", attr.Params[5]);
+ Assert.NotNull(attr.Prompt);
+ Assert.IsType(messages);
+ Assert.NotNull(messages);
+ Assert.Equal(2, messages.Length);
+ Assert.Contains("Caspar", messages[0].Text);
+ Assert.Contains("Haglund", messages[0].Text);
+ Assert.Contains("What is your name?", messages[1].Text);
+ }
+
+ ///
+ /// Test retrieving multiple Prompty attributes from a class
+ ///
+ [Fact]
+ public void LoadMultipleAttributes()
+ {
+ var attrs = Attribute.GetCustomAttributes(
+ typeof(ClassWithMultipleAttributes),
+ typeof(PromptyAttribute));
+
+ Assert.NotNull(attrs);
+ Assert.Equal(2, attrs.Length);
+
+ var basicAttr = attrs[0] as PromptyAttribute;
+ var contextAttr = attrs[1] as PromptyAttribute;
+
+ Assert.NotNull(basicAttr);
+ Assert.NotNull(contextAttr);
+ Assert.Equal("prompty/basic.prompty", basicAttr!.File);
+ Assert.Equal("prompty/context.prompty", contextAttr!.File);
+
+ Assert.NotNull(basicAttr.Prompt);
+ Assert.NotNull(contextAttr.Prompt);
+
+ Assert.NotNull(basicAttr.Messages);
+ Assert.NotNull(contextAttr.Messages);
+ }
+
+ ///
+ /// Test retrieving multiple Prompty attributes with different configurations
+ ///
+ [Fact]
+ public void LoadMultipleMixedAttributes()
+ {
+ var attrs = Attribute.GetCustomAttributes(
+ typeof(ClassWithMultipleMixedAttributes),
+ typeof(PromptyAttribute));
+
+ Assert.NotNull(attrs);
+ Assert.Equal(2, attrs.Length);
+
+ var basicAttr = attrs[0] as PromptyAttribute;
+ var contextAttr = attrs[1] as PromptyAttribute;
+
+ Assert.NotNull(basicAttr);
+ Assert.NotNull(contextAttr);
+
+ // First attribute with IsResource = true
+ Assert.Equal("prompty/basic.prompty", basicAttr!.File);
+ Assert.True(basicAttr.IsResource);
+ Assert.NotNull(basicAttr.Prompt);
+ Assert.NotNull(basicAttr.Messages);
+
+ // Second attribute with specific configuration
+ Assert.Equal("prompty/context.prompty", contextAttr!.File);
+ Assert.Equal("FAKE_TYPE", contextAttr.Configuration);
+ Assert.NotNull(contextAttr.Prompt);
+ Assert.NotNull(contextAttr.Messages);
+ }
+}
diff --git a/runtime/promptycs/Prompty.Core/Prompty.cs b/runtime/promptycs/Prompty.Core/Prompty.cs
index eccd0912..7170f375 100644
--- a/runtime/promptycs/Prompty.Core/Prompty.cs
+++ b/runtime/promptycs/Prompty.Core/Prompty.cs
@@ -181,6 +181,44 @@ public static async Task LoadAsync(string path, string configuration =
return prompty;
}
+ ///
+ /// Load a prompty file from a Stream
+ ///
+ /// Stream to read the prompty file from.
+ /// Id of the configuration to use.
+ public static Prompty Load(Stream stream, string configuration = "default")
+ {
+ using var reader = new StreamReader(stream);
+ string text = reader.ReadToEnd();
+
+ var global_config = GlobalConfig.Load(System.IO.Path.GetDirectoryName(stream.ToString()) ?? string.Empty, configuration) ?? [];
+ var streamPath = stream.ToString() ?? string.Empty;
+ global_config = Normalizer.Normalize(global_config, streamPath);
+
+ var frontmatter = LoadRaw(text, global_config, stream.ToString());
+ var prompty = Convert(frontmatter, stream.ToString());
+ return prompty;
+ }
+
+ ///
+ /// Load a prompty file from a Stream Asynchronously
+ ///
+ /// Stream to read the prompty file from.
+ /// Id of the configuration to use.
+ public static async Task LoadAsync(Stream stream, string configuration = "default")
+ {
+ using var reader = new StreamReader(stream);
+ string text = await reader.ReadToEndAsync();
+
+ var global_config = await GlobalConfig.LoadAsync(System.IO.Path.GetDirectoryName(stream.ToString()) ?? string.Empty, configuration) ?? [];
+ var streamPath = stream.ToString() ?? string.Empty;
+ global_config = Normalizer.Normalize(global_config, streamPath);
+
+ var frontmatter = LoadRaw(text, global_config, stream.ToString());
+ var prompty = Convert(frontmatter, stream.ToString());
+ return prompty;
+ }
+
///
/// Load a prompty file using the provided text content.
///
diff --git a/runtime/promptycs/Prompty.Core/PromptyAttribute.cs b/runtime/promptycs/Prompty.Core/PromptyAttribute.cs
new file mode 100644
index 00000000..13d0a712
--- /dev/null
+++ b/runtime/promptycs/Prompty.Core/PromptyAttribute.cs
@@ -0,0 +1,174 @@
+using Microsoft.Extensions.AI;
+using System.Reflection;
+
+namespace Prompty.Core;
+
+///
+/// Prompty Attribute - used to load a prompty file or resource from an attribute
+///
+///
+/// [Prompty("prompty/basic.prompty"]
+/// [Prompty("prompty/embedded-resource-path.prompty", IsResource = true, Configuration = "default", Params = new string[] { "question", "answer" })]
+/// public class MyClass
+/// {...}
+/// in a class or method then use the attribute to load the prompty
+/// ...
+/// var prompty = (PromptyAttribute)Attribute.GetCustomAttribute(typeof(MyClass), typeof(PromptyAttribute));
+/// var messages = prompty.Messages;
+/// ...
+///
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
+public class PromptyAttribute(string File, bool IsResource = false, string Configuration = "default", string[] Params = null!) : Attribute
+{
+ ///
+ /// The file name of the prompty file
+ public string File { get; set; } = File;
+
+ ///
+ /// Is the file a resource
+ ///
+ public bool IsResource { get; set; } = IsResource;
+
+ ///
+ /// The configuration id to use
+ ///
+ public string? Configuration { get; set; } = Configuration;
+
+ ///
+ /// The parameters for input
+ ///
+ public string[]? Params { get; set; } = Params;
+
+ ///
+ /// the loaded prompty
+ ///
+ public Prompty? Prompt => GetPrompt();
+
+ ///
+ /// The prepared messages
+ ///
+ public ChatMessage[] Messages => GetMessages();
+
+ ///
+ /// Attempts to find a resource in multiple assemblies
+ ///
+ /// The resource name to find
+ /// A Stream for the resource if found, null otherwise
+ private Stream? FindResourceInAssemblies(string resourceName)
+ {
+ // Normalize resource name to handle different path formats
+ var normalizedName = resourceName.Replace('\\', '.').Replace('/', '.');
+
+ // Helper function to check for resource in an assembly
+ Stream? TryGetResourceStream(Assembly assembly, string name)
+ {
+ // Try direct match
+ var stream = assembly.GetManifestResourceStream(name);
+ if (stream != null)
+ return stream;
+
+ // Try assembly qualified name
+ stream = assembly.GetManifestResourceStream($"{assembly.GetName().Name}.{name}");
+ if (stream != null)
+ return stream;
+
+ // Try suffix match with all manifest resources
+ var resourceNames = assembly.GetManifestResourceNames();
+ var matchingResource = resourceNames.FirstOrDefault(r =>
+ r.EndsWith(normalizedName, StringComparison.OrdinalIgnoreCase) ||
+ r.EndsWith(name, StringComparison.OrdinalIgnoreCase));
+
+ if (!string.IsNullOrEmpty(matchingResource))
+ return assembly.GetManifestResourceStream(matchingResource);
+
+ return null;
+ }
+
+ // Try executing assembly (the assembly containing this code)
+ var executingAssembly = Assembly.GetExecutingAssembly();
+ var stream = TryGetResourceStream(executingAssembly, resourceName);
+ if (stream != null)
+ return stream;
+
+ // Try entry assembly (the main application assembly)
+ var entryAssembly = Assembly.GetEntryAssembly();
+ if (entryAssembly != null && entryAssembly != executingAssembly)
+ {
+ stream = TryGetResourceStream(entryAssembly, resourceName);
+ if (stream != null)
+ return stream;
+ }
+
+ // Try all other loaded assemblies
+ return AppDomain.CurrentDomain.GetAssemblies()
+ .Where(a => a != executingAssembly && a != entryAssembly)
+ .Select(a => TryGetResourceStream(a, resourceName))
+ .FirstOrDefault(s => s != null);
+ }
+
+ ///
+ /// convert the params to a dictionary
+ ///
+ /// Dictionary
+ private Dictionary GetParams()
+ {
+ var dict = new Dictionary();
+ if (Params != null)
+ {
+ for (int i = 0; i < Params.Length; i += 2)
+ {
+ if (i + 1 < Params.Length)
+ dict.Add(Params[i], Params[i + 1]);
+ }
+ }
+ return dict;
+ }
+
+ ///
+ /// Get the prompt from the file or resource
+ ///
+ /// Prompty
+ ///
+ private Prompty GetPrompt()
+ {
+ Prompty? prompt = null;
+ if (IsResource == true)
+ {
+ // Try to get the resource from various assemblies
+ Stream? stream = FindResourceInAssemblies(File);
+
+ if (stream == null)
+ {
+ throw new FileNotFoundException($"Resource {File} not found");
+ }
+
+ using (stream)
+ {
+ prompt = Prompty.Load(stream, Configuration ?? "default");
+ }
+ }
+ else
+ {
+ if (!System.IO.File.Exists(File))
+ {
+ throw new FileNotFoundException($"File {File} not found");
+ }
+ // load the file
+ prompt = Prompty.Load(File, Configuration ?? "default");
+ }
+ return prompt;
+ }
+
+ ///
+ /// Get the messages from the prompt
+ ///
+ /// ChatMessage[]
+ ///
+ private ChatMessage[] GetMessages()
+ {
+ InvokerFactory.AutoDiscovery();
+ if (Prompt == null)
+ throw new InvalidOperationException("Prompt is null");
+ return (ChatMessage[])Prompt.Prepare(GetParams(), mergeSample: true);
+ }
+}