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); + } +}