feat: implement generate command in console app

This commit is contained in:
Stevan Freeborn
2025-02-12 16:10:49 -06:00
parent 8e0a6933f7
commit ec9651e5e9
12 changed files with 296 additions and 161 deletions
@@ -12,6 +12,8 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" /> <PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="RichardSzalay.MockHttp" Version="7.0.0" />
<PackageReference Include="FluentAssertions" Version="7.1.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -0,0 +1,87 @@
using System.Net;
using FluentAssertions;
namespace AltGen.Console.Tests.Unit;
public class AltGenServiceTests : IDisposable
{
readonly MockHttpMessageHandler _mockHttpMessageHandler = new();
readonly AltGenService _altGenService;
public AltGenServiceTests()
{
_altGenService = new AltGenService(_mockHttpMessageHandler.ToHttpClient());
}
[Fact]
public async Task GenerateAltTextAsync_WhenCalledAndRequestSucceeds_ItShouldReturnAltText()
{
var req = new GenerateAltTextRequest(
"provider",
"key",
"file.jpg",
[1, 2, 3],
"image/jpeg"
);
var altText = "alt text";
_mockHttpMessageHandler
.When(HttpMethod.Post, "*/generate")
.Respond(
"application/json",
/*lang=json,strict*/
$@"{{""AltText"":""{altText}""}}"
);
var result = await _altGenService.GenerateAltTextAsync(req);
result.Should().Be(altText);
}
[Fact]
public async Task GenerateAltTextAsync_WhenCalledAndRequestFails_ItShouldThrowException()
{
var req = new GenerateAltTextRequest(
"provider",
"key",
"file.jpg",
[1, 2, 3],
"image/jpeg"
);
_mockHttpMessageHandler
.When(HttpMethod.Post, "*/generate")
.Respond(HttpStatusCode.InternalServerError);
var action = async () => await _altGenService.GenerateAltTextAsync(req);
await action.Should().ThrowAsync<AltTextException>();
}
[Fact]
public async Task GenerateAltTextAsync_WhenCalledAndResponseIsNull_ItShouldThrowException()
{
var req = new GenerateAltTextRequest(
"provider",
"key",
"file.jpg",
[1, 2, 3],
"image/jpeg"
);
_mockHttpMessageHandler
.When(HttpMethod.Post, "*/generate")
.Respond("application/json", "null");
var action = async () => await _altGenService.GenerateAltTextAsync(req);
await action.Should().ThrowAsync<AltTextException>();
}
public void Dispose()
{
_mockHttpMessageHandler.Dispose();
GC.SuppressFinalize(this);
}
}
-10
View File
@@ -1,10 +0,0 @@
namespace AltGen.Console.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
+3
View File
@@ -0,0 +1,3 @@
global using RichardSzalay.MockHttp;
global using AltGen.Console.Generate;
+30
View File
@@ -16,4 +16,34 @@
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.3.1" /> <PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.3.1" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
</ItemGroup>
<PropertyGroup>
<GeneratedCodeDirectory>Generated</GeneratedCodeDirectory>
<GeneratedConstantsFile>$(GeneratedCodeDirectory)\Constants.cs</GeneratedConstantsFile>
<AltGenApiUri>http://localhost:7297</AltGenApiUri>
</PropertyGroup>
<Target Name="GenerateBuildConstants" BeforeTargets="BeforeBuild;BeforeRebuild">
<MakeDir Directories="$(GeneratedCodeDirectory)" />
<WriteLinesToFile
File="$(GeneratedConstantsFile)" Lines="
namespace $(AssemblyName).$(GeneratedCodeDirectory)%3b
static class Constants
{
public const string AltGenApiUri = &quot;$(AltGenApiUri)&quot;%3b
}
"
Overwrite="true" />
<ItemGroup>
<Compile Include="$(GeneratedConstantsFile)" Exclude="@(Compile)" />
</ItemGroup>
</Target>
</Project> </Project>
@@ -0,0 +1,44 @@
namespace AltGen.Console.Generate;
sealed class AltGenService(HttpClient client) : IAltGenService
{
static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
readonly HttpClient _client = client;
public async Task<string> GenerateAltTextAsync(GenerateAltTextRequest req)
{
var byteContent = new ByteArrayContent(req.Image);
byteContent.Headers.ContentType = new MediaTypeHeaderValue(req.ContentType);
var request = new HttpRequestMessage(HttpMethod.Post, $"{Constants.AltGenApiUri}/generate")
{
Content = new MultipartFormDataContent
{
{ new StringContent(req.Provider), "provider" },
{ new StringContent(req.ProviderKey), "providerKey" },
{ new ByteArrayContent(req.Image), "file", req.FileName }
}
};
var response = await _client.SendAsync(request);
var content = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode is false)
{
throw new AltTextException("Failed to generate alt text.");
}
var altTextResponse = JsonSerializer.Deserialize<AltTextResponse>(content, JsonOptions) ?? throw new AltTextException("Failed to deserialize response.");
return altTextResponse.AltText;
}
}
record AltTextResponse(string AltText);
class AltTextException(string message) : Exception(message)
{
}
@@ -0,0 +1,9 @@
namespace AltGen.Console.Generate;
record GenerateAltTextRequest(
string Provider,
string ProviderKey,
string FileName,
byte[] Image,
string ContentType
);
@@ -0,0 +1,88 @@
namespace AltGen.Console.Generate;
sealed class GenerateCommand(
IAnsiConsole console,
IFileSystem fileSystem,
IAltGenService altGenService
) : AsyncCommand<GenerateCommand.Settings>
{
readonly IAnsiConsole _console = console;
readonly IFileSystem _fileSystem = fileSystem;
readonly IAltGenService _altGenService = altGenService;
public class Settings(IFileSystem fileSystem) : CommandSettings
{
readonly Dictionary<string, string> _imageTypes = new()
{
[".jpeg"] = "image/jpeg",
[".jpg"] = "image/jpeg",
[".png"] = "image/png"
};
readonly List<string> _providers = [
"gemini",
];
readonly IFileSystem _fileSystem = fileSystem;
[CommandArgument(1, "<provider>")]
[Description("The provider to use for generating alt text.")]
public string Provider { get; init; } = string.Empty;
[CommandArgument(2, "<key>")]
[Description("The key for the provider.")]
public string Key { get; init; } = string.Empty;
[CommandArgument(3, "<path>")]
[Description("The path to the image to generate alt text for.")]
public string Path { get; init; } = string.Empty;
public string ContentType => _imageTypes[_fileSystem.Path.GetExtension(Path)];
public override ValidationResult Validate()
{
// TODO: We should allow provider and key to be optional
// if they are not passed on the command line then
// we should try to resolve them from configuration
if (_providers.Contains(Provider) is false)
{
return ValidationResult.Error($"The provider '{Provider}' is not supported.");
}
var pathExists = _fileSystem.File.Exists(Path);
if (pathExists is false)
{
return ValidationResult.Error($"The path '{Path}' does not exist.");
}
var extension = _fileSystem.Path.GetExtension(Path);
if (_imageTypes.ContainsKey(extension) is false)
{
return ValidationResult.Error($"The file '{Path}' is not a valid image file.");
}
return ValidationResult.Success();
}
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
{
var fileName = _fileSystem.Path.GetFileName(settings.Path);
var image = await _fileSystem.File.ReadAllBytesAsync(settings.Path);
var altText = await _altGenService.GenerateAltTextAsync(new(
settings.Provider,
settings.Key,
fileName,
image,
settings.ContentType
));
_console.MarkupLine($"[bold]{altText}[/]");
return 0;
}
}
@@ -0,0 +1,4 @@
interface IAltGenService
{
Task<string> GenerateAltTextAsync(GenerateAltTextRequest req);
}
@@ -0,0 +1,6 @@
namespace AltGen.Console.Generated;
static class Constants
{
public const string AltGenApiUri = "http://localhost:7297";
}
+12 -151
View File
@@ -1,18 +1,17 @@
// TODO: We want to be able // TODO: Need to implement config
// to provide a path to an image // command. This should allow
// read the image // the user to set default values
// and then post the image to our API // for provider keys and a default
// get the response and display the alt text // provider.
//
using System.ComponentModel; // For example setting a providers key
using System.IO.Abstractions; // i.e. altgen config gemini mykey
using System.Net.Http.Headers; //
// Or setting provider as default
using AltGen.Console.Common; // i.e. altgen config gemini mykey --default
using Spectre.Console;
await Host.CreateDefaultBuilder(args) await Host.CreateDefaultBuilder(args)
.ConfigureLogging(static l => l.ClearProviders())
.ConfigureServices(static (_, services) => .ConfigureServices(static (_, services) =>
{ {
services.AddSingleton<IFileSystem, FileSystem>(); services.AddSingleton<IFileSystem, FileSystem>();
@@ -22,141 +21,3 @@ await Host.CreateDefaultBuilder(args)
}) })
.BuildApp() .BuildApp()
.RunAsync(args); .RunAsync(args);
sealed class GenerateCommand(
IAnsiConsole console,
IFileSystem fileSystem,
IAltGenService altGenService
) : AsyncCommand<GenerateCommand.Settings>
{
readonly IAnsiConsole _console = console;
readonly IFileSystem _fileSystem = fileSystem;
readonly IAltGenService _altGenService = altGenService;
public class Settings(IFileSystem fileSystem) : CommandSettings
{
readonly Dictionary<string, string> _imageTypes = new()
{
[".jpeg"] = "image/jpeg",
[".jpg"] = "image/jpeg",
[".png"] = "image/png"
};
readonly List<string> _providers = [
"gemini",
];
readonly IFileSystem _fileSystem = fileSystem;
[CommandArgument(1, "<provider>")]
[Description("The provider to use for generating alt text.")]
public string Provider { get; init; } = string.Empty;
[CommandArgument(2, "<key>")]
[Description("The key for the provider.")]
public string Key { get; init; } = string.Empty;
[CommandArgument(3, "<path>")]
[Description("The path to the image to generate alt text for.")]
public string Path { get; init; } = string.Empty;
public string ContentType => _imageTypes[_fileSystem.Path.GetExtension(Path)];
public override ValidationResult Validate()
{
if (_providers.Contains(Provider) is false)
{
return ValidationResult.Error($"The provider '{Provider}' is not supported.");
}
var pathExists = _fileSystem.File.Exists(Path);
if (pathExists is false)
{
return ValidationResult.Error($"The path '{Path}' does not exist.");
}
var extension = _fileSystem.Path.GetExtension(Path);
if (_imageTypes.ContainsKey(extension) is false)
{
return ValidationResult.Error($"The file '{Path}' is not a valid image file.");
}
return ValidationResult.Success();
}
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
{
var fileName = _fileSystem.Path.GetFileName(settings.Path);
var image = await _fileSystem.File.ReadAllBytesAsync(settings.Path);
var altText = await _altGenService.GenerateAltTextAsync(
settings.Provider,
settings.Key,
fileName,
image,
settings.ContentType
);
_console.MarkupLine($"[bold]Alt Text:[/] {altText}");
return 0;
}
}
interface IAltGenService
{
Task<string> GenerateAltTextAsync(
string provider,
string key,
string fileName,
byte[] image,
string contentType
);
}
sealed class AltGenService(HttpClient client) : IAltGenService
{
readonly HttpClient _client = client;
public async Task<string> GenerateAltTextAsync(
string provider,
string key,
string fileName,
byte[] image,
string contentType
)
{
var byteContent = new ByteArrayContent(image);
byteContent.Headers.ContentType = new MediaTypeHeaderValue(contentType);
// TODO: Need to have proper URL
// have constants generated at build time
// that point to proper API URL
var request = new HttpRequestMessage(HttpMethod.Post, "altgen")
{
Content = new MultipartFormDataContent
{
{ new StringContent(provider), "provider" },
{ new StringContent(key), "providerKey" },
{ new ByteArrayContent(image), "file", fileName }
}
};
var response = await _client.SendAsync(request);
if (response.IsSuccessStatusCode is false)
{
// TODO: Use accurate exception
throw new InvalidDataException("Failed to generate alt text.");
}
// TODO: Deserialize the response
var altText = await response.Content.ReadAsStringAsync();
return altText;
}
}
+11
View File
@@ -1,4 +1,15 @@
global using System.ComponentModel;
global using System.IO.Abstractions;
global using System.Net.Http.Headers;
global using System.Text.Json;
global using AltGen.Console.Common;
global using AltGen.Console.Generated;
global using AltGen.Console.Generate;
global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Hosting; global using Microsoft.Extensions.Hosting;
global using Microsoft.Extensions.Logging;
global using Spectre.Console;
global using Spectre.Console.Cli; global using Spectre.Console.Cli;