diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index e12bae7..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index d18070c..d0a1f14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ bin obj .vs -*.png \ No newline at end of file +*.png +.idea +/.contest diff --git a/LiloController.cs b/LiloController.cs new file mode 100644 index 0000000..989b972 --- /dev/null +++ b/LiloController.cs @@ -0,0 +1,18 @@ +using lilos_stitcher; +using Microsoft.AspNetCore.Mvc; + +namespace LiloStitcher; + +[ApiController] +[Route( "api/image" )] +public sealed class LiloController( lilos_stitcher.LiloStitcher stitcher ) : ControllerBase +{ + [HttpPost] + [Route( "generate" )] + [Produces( "image/png" )] + public async Task Generate( [FromBody] GenerateRequest payload, CancellationToken ct ) + { + string path = await stitcher.CreateImageAsync( payload, ct ); + return PhysicalFile(path, "image/png", enableRangeProcessing: false); + } +} \ No newline at end of file diff --git a/LiloStitcher.cs b/LiloStitcher.cs index 7ae74cc..afee0dd 100644 --- a/LiloStitcher.cs +++ b/LiloStitcher.cs @@ -1,155 +1,137 @@ -using Microsoft.Extensions.Caching.Memory; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; -using SixLabors.ImageSharp.Processing; -using SixLabors.ImageSharp.Formats.Png; - -namespace LiloStitcher; - -public record GenerateRequest( - string CanvasRect, - double[] CropOffset, - double[] CropSize, - double OutputScale -); - -public readonly record struct PlateCoordinate(int Row, int Col) -{ - public static PlateCoordinate Parse(string token) - { - var rowPart = new string(token.TakeWhile(char.IsLetter).ToArray()).ToUpperInvariant(); - var colPart = new string(token.SkipWhile(char.IsLetter).ToArray()); - - int row = 0; - foreach (var c in rowPart) - row = row * 26 + (c - 'A' + 1); - - int.TryParse(colPart, out int col); - return new PlateCoordinate(row, col); - } -} - -public class TileCache(IMemoryCache cache) -{ - private const long TileBytes = 720L * 720 * 3; - - public Image? Get(string key) => cache.TryGetValue(key, out Image? img) ? img : null; - - public void Set(string key, Image img) => - cache.Set(key, img, new MemoryCacheEntryOptions - { - Size = TileBytes, - SlidingExpiration = TimeSpan.FromMinutes(20) - }); -} - -public class TileLoader(TileCache cache, string assetDir) -{ - public async Task> LoadAsync(string name, CancellationToken ct) - { - if (cache.Get(name) is { } hit) - return hit; - - var path = Path.Combine(assetDir, $"{name}.png"); - await using var fs = File.OpenRead(path); - var image = await Image.LoadAsync(fs, ct).ConfigureAwait(false); - cache.Set(name, image); - return image; - } -} - -public class LiloStitcher(TileLoader loader) -{ - private const int TileSize = 720; - - public async Task CreateImageAsync(GenerateRequest req, CancellationToken ct) - { - var parts = req.CanvasRect.ToUpperInvariant() - .Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - var part1 = PlateCoordinate.Parse(parts[0]); - var part2 = PlateCoordinate.Parse(parts[1]); - - int rowMin = Math.Min(part1.Row, part2.Row); - int rowMax = Math.Max(part1.Row, part2.Row); - int colMin = Math.Min(part1.Col, part2.Col); - int colMax = Math.Max(part1.Col, part2.Col); - - int rows = rowMax - rowMin + 1; - int cols = colMax - colMin + 1; - - var names = Enumerable.Range(rowMin, rows) - .SelectMany(r => Enumerable.Range(colMin, cols).Select(c => $"{RowName(r)}{c}")) - .ToArray(); - - var bitmaps = await Task.WhenAll(names.Select(n => loader.LoadAsync(n, ct))).ConfigureAwait(false); - var stitched = new Image(cols * TileSize, rows * TileSize); - - int idx = 0; - for (int r = 0; r < rows; r++) - { - for (int c = 0; c < cols; c++, idx++) - { - stitched.Mutate(ctx => ctx.DrawImage(bitmaps[idx], new Point(c * TileSize, r * TileSize), 1f)); - } - } - - Validate(req.CropOffset, 2, nameof(req.CropOffset), inclusiveUpper: true); - Validate(req.CropSize, 2, nameof(req.CropSize), inclusiveUpper: true); - - int offsetX = (int)(req.CropOffset[0] * stitched.Width); - int offsetY = (int)(req.CropOffset[1] * stitched.Height); - int cropWidth = (int)(req.CropSize[0] * stitched.Width); - int cropHeight = (int)(req.CropSize[1] * stitched.Height); - - if (offsetX + cropWidth > stitched.Width) cropWidth = stitched.Width - offsetX; - if (offsetY + cropHeight > stitched.Height) cropHeight = stitched.Height - offsetY; - - var cropRect = new Rectangle(offsetX, offsetY, cropWidth, cropHeight); - - using var cropped = stitched.Clone(ctx => ctx.Crop(cropRect)); - - double scale = Math.Clamp(req.OutputScale, 0.0, 1.0); - Image output; - if (scale < 1.0) - { - int width = Math.Max(1, (int)Math.Truncate(cropWidth * scale)); - int height = Math.Max(1, (int)Math.Truncate(cropHeight * scale)); - output = cropped.Clone(ctx => ctx.Resize(width, height)); - } - else - { - output = cropped.Clone(); - } - - using var memStream = new MemoryStream(); - await output.SaveAsync(memStream, new PngEncoder - { - }, ct).ConfigureAwait(false); - return memStream.ToArray(); - - static void Validate(double[] arr, int len, string name, bool inclusiveUpper) - { - if (arr is null || arr.Length < len) - throw new ArgumentException($"{name} must have length {len}"); - double upper = inclusiveUpper ? 1.0 : 1.0 - double.Epsilon; - for (int i = 0; i < len; i++) - { - var v = arr[i]; - if (v < 0 || v > upper) - throw new ArgumentOutOfRangeException($"{name}[{i}]= {v} outside [0,{upper}]"); - } - } - - static string RowName(int row) - { - var stringBuilder = new System.Text.StringBuilder(); - while (row > 0) - { - row--; - stringBuilder.Insert(0, (char)('A' + row % 26)); - row /= 26; - } - return stringBuilder.ToString(); - } - } -} \ No newline at end of file +using Microsoft.Extensions.Caching.Memory; +using NetVips; + +namespace lilos_stitcher; + +public sealed record GenerateRequest( + string Canvas_Rect, + double[] Crop_Offset, + double[] Crop_Size, + double Output_Scale +); + +public readonly record struct PlateCoordinate( int Row, int Col ) +{ + public static PlateCoordinate Parse( string token ) + { + if( string.IsNullOrWhiteSpace( token ) ) + throw new ArgumentException( "Empty coordinate." ); + + var rowPart = new string( token.TakeWhile( char.IsLetter ).ToArray() ).ToUpperInvariant(); + var colPart = new string( token.SkipWhile( char.IsLetter ).ToArray() ); + + int row = 0; + foreach( char c in rowPart ) + row = row * 26 + ( c - 'A' + 1 ); + + return new PlateCoordinate( row, int.Parse( colPart ) ); + } +} + + +public class TileCache( IMemoryCache cache ) +{ + private const long TileBytes = 720L * 720 * 3; + + public Image? Get( string key ) => cache.TryGetValue( key, out Image? img ) ? img : null; + + public void Set( string key, Image img ) => + cache.Set( key, img, new MemoryCacheEntryOptions + { + Size = TileBytes, + SlidingExpiration = TimeSpan.FromMinutes( 20 ) + } ); +} + +public class TileLoader( TileCache cache, string assetDir ) +{ + public async Task LoadAsync( string name, CancellationToken ct ) + { + if( cache.Get( name ) is { } hit ) + return hit; + + var image = await Task.Run( () => + Image.NewFromFile( Path.Combine( assetDir, $"{name}.png" ), access: Enums.Access.Sequential ), ct ); + + cache.Set( name, image ); + return image; + } +} + +public class LiloStitcher( TileLoader loader ) +{ + public async Task CreateImageAsync( GenerateRequest req, CancellationToken ct ) + { + (int rowMin, int colMin, int rows, int cols) = ParseCanvas( req.Canvas_Rect ); + + Validate(req.Crop_Offset, nameof(req.Crop_Offset)); + Validate(req.Crop_Size, nameof(req.Crop_Size)); + double scale = req.Output_Scale; + if( scale <= 0 || scale > 1 ) + throw new ArgumentOutOfRangeException( nameof( req.Output_Scale ) ); + + var tiles = new List( rows * cols ); + for( int row = 0; row < rows; row++ ) + { + for( int col = 0; col < cols; col++ ) + { + string id = $"{RowName( rowMin + row )}{colMin + col}"; + var tile = await loader.LoadAsync( id, ct ); + if( scale < 1 ) tile = tile.Resize( scale ); + tiles.Add( tile ); + } + } + + var mosaic = Image.Arrayjoin( tiles.ToArray(), across: cols ); + + int offsetX = (int)Math.Truncate( req.Crop_Offset[0] * mosaic.Width ); + int offsetY = (int)Math.Truncate( req.Crop_Offset[1] * mosaic.Height ); + + int restWidth = mosaic.Width - offsetX; + int restHeight = mosaic.Height - offsetY; + + int cropWidth = Math.Max( 1, (int)Math.Truncate( req.Crop_Size[0] * restWidth ) ); + int cropHeight = Math.Max( 1, (int)Math.Truncate( req.Crop_Size[1] * restHeight ) ); + + int cropX = (int)Math.Truncate( offsetX / 2.0 + ( restWidth - cropWidth ) / 2.0 ); + int cropY = (int)Math.Truncate( offsetY / 2.0 + ( restHeight - cropHeight ) / 2.0 ); + + var cropped = mosaic.Crop( cropX, cropY, cropWidth, cropHeight ); + + string path = Path.Combine(Path.GetTempPath(), $"mosaic-{Guid.NewGuid():N}.png"); + cropped.WriteToFile(path); + return path; + } + + private static (int rowMin, int colMin, int rows, int cols) ParseCanvas( string rect ) + { + var parts = rect.ToUpperInvariant().Split( ':', StringSplitOptions.RemoveEmptyEntries ); + var part1 = PlateCoordinate.Parse( parts[0] ); + var part2 = PlateCoordinate.Parse( parts[1] ); + int rowMin = Math.Min( part1.Row, part2.Row ); + int rowMax = Math.Max( part1.Row, part2.Row ); + int colMin = Math.Min( part1.Col, part2.Col ); + int colMax = Math.Max( part1.Col, part2.Col ); + return (rowMin, colMin, rowMax - rowMin + 1, colMax - colMin + 1); + } + + private static void Validate( double[] arr, string name ) + { + if( arr is null || arr.Length < 2 ) + throw new ArgumentException( $"{name} length" ); + if( arr.Any( x => x < 0 || x > 1 ) ) + throw new ArgumentOutOfRangeException( name ); + } + + static string RowName( int row ) + { + var stringBuilder = new System.Text.StringBuilder(); + while( row > 0 ) + { + row--; + stringBuilder.Insert( 0, (char)( 'A' + row % 26 ) ); + row /= 26; + } + return stringBuilder.ToString(); + } +} diff --git a/Program.cs b/Program.cs index 3f0c08d..5b60d67 100644 --- a/Program.cs +++ b/Program.cs @@ -1,61 +1,20 @@ -using System.Diagnostics; -using Microsoft.Extensions.Caching.Memory; +using lilos_stitcher; -namespace LiloStitcher; -public static class Program -{ - public static async Task Main(string[] args) - { - try - { - Stopwatch sw = Stopwatch.StartNew(); - var begin = sw.ElapsedMilliseconds; - var opt = new Options - { - CanvasRect = "A1:AE55", - CropOffset = [0.0, 0.0], - CropSize = [1.0, 1.0], - OutputScale = 1.0, - OutputPath = "stitched.png" - }; +var builder = WebApplication.CreateBuilder(args); - string tileFilePath = "../tiles1705"; // should later be directed to the read-only `ASSET_PATH_RO` environment variable - string assetDir = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), tileFilePath)); +NetVips.NetVips.Init(); +NetVips.NetVips.Concurrency = 3; - using var memCache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 256L * 1024 * 1024 }); - var tileCache = new TileCache(memCache); - var loader = new TileLoader(tileCache, assetDir); - var stitcher = new LiloStitcher(loader); +builder.Services.AddControllers().AddJsonOptions(o => o.JsonSerializerOptions.PropertyNameCaseInsensitive = true); - var req = new GenerateRequest( - opt.CanvasRect!, - opt.CropOffset!, - opt.CropSize!, - opt.OutputScale - ); +builder.Services.AddMemoryCache(o => o.SizeLimit = 128L * 1024 * 1024); - Console.WriteLine("Stitching..."); - var pngBytes = await stitcher.CreateImageAsync(req, CancellationToken.None); +string assetDir = Environment.GetEnvironmentVariable("ASSET_PATH_RO") ?? throw new InvalidOperationException("dir not found"); - File.WriteAllBytes(opt.OutputPath!, pngBytes); - Console.WriteLine($"Done. Wrote {opt.OutputPath} ({pngBytes.Length / 1024.0:F1} KB)"); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(provider => new TileLoader(provider.GetRequiredService(), assetDir)); +builder.Services.AddSingleton(); - Console.WriteLine(begin - sw.ElapsedMilliseconds); - return 0; - } - catch (Exception ex) - { - Console.Error.WriteLine("ERROR: " + ex.Message); - return 1; - } - } - - private struct Options - { - public string? CanvasRect { get; set; } - public double[]? CropOffset { get; set; } - public double[]? CropSize { get; set; } - public double OutputScale { get; set; } - public string? OutputPath { get; set; } - } -} \ No newline at end of file +var app = builder.Build(); +app.MapControllers(); +app.Run(); \ No newline at end of file diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..23d5fa7 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5243", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7121;http://localhost:5243", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..1980fc5 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Lilo Stitcher API + +An ASP.NET Core Web API for stitching, cropping, and scaling tiled images (720×720). + +## Prerequisites + +* .NET 9.0 SDK +* `ASSET_PATH_RO` environment variable containing the path to tiles directory (1,705 PNGs named A1.png…AE55.png) + +## Build & Run + +```bash +cd lilo-stitcher +dotnet clean +dotnet run +``` + +By default, the API listens on `http://localhost:5243` and `https://localhost:7121` (see [`launchSettings.json`](https://null.formulatrix.dev/fikribahru/lilo-stitcher/src/branch/main/Properties/launchSettings.json)). + +## Usage + +**Endpoint:** `POST /api/image/generate` + +**Request Body (JSON):** + +```json +{ + "canvas_rect": "A1:H12", + "crop_offset": [0.25, 0.25], + "crop_size": [0.5, 0.5], + "output_scale": 1.0 +} +``` + +**Example (using `curl`):** + +```bash +curl -X POST http://localhost:5243/api/image/generate \ + -H "Content-Type: application/json" \ + -o output.png \ + -d '{"canvas_rect":"A1:H12","crop_offset":[0.25,0.25],"crop_size":[0.5,0.5],"output_scale":1.0}' +``` + +The API will return a `image/png` containing the stitched, cropped, and scaled result. diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/lilo-stitcher-console.csproj b/lilo-stitcher-console.csproj deleted file mode 100644 index eb79597..0000000 --- a/lilo-stitcher-console.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - Exe - net9.0 - lilo_stitcher_console - enable - enable - - - - - - - - diff --git a/lilos-stitcher.csproj b/lilos-stitcher.csproj new file mode 100644 index 0000000..2ced848 --- /dev/null +++ b/lilos-stitcher.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + lilos_stitcher + preview + + + + + + + + + + + \ No newline at end of file diff --git a/lilos-stitcher.http b/lilos-stitcher.http new file mode 100644 index 0000000..8e967ed --- /dev/null +++ b/lilos-stitcher.http @@ -0,0 +1,6 @@ +@lilos_stitcher_HostAddress = http://localhost:5243 + +GET {{lilos_stitcher_HostAddress}}/weatherforecast/ +Accept: application/json + +### \ No newline at end of file diff --git a/lilo-stitcher-console.sln b/lilos-stitcher.sln similarity index 58% rename from lilo-stitcher-console.sln rename to lilos-stitcher.sln index 2e5c9ca..b15f5b6 100644 --- a/lilo-stitcher-console.sln +++ b/lilos-stitcher.sln @@ -2,7 +2,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.2.0 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "lilo-stitcher-console", "lilo-stitcher-console.csproj", "{2A1F81C9-D10F-1AE9-CA5C-0714270F48C1}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "lilos-stitcher", "lilos-stitcher.csproj", "{8FDFFCBC-9C8E-94D5-A96D-606027D71B44}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -10,15 +10,15 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {2A1F81C9-D10F-1AE9-CA5C-0714270F48C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2A1F81C9-D10F-1AE9-CA5C-0714270F48C1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2A1F81C9-D10F-1AE9-CA5C-0714270F48C1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2A1F81C9-D10F-1AE9-CA5C-0714270F48C1}.Release|Any CPU.Build.0 = Release|Any CPU + {8FDFFCBC-9C8E-94D5-A96D-606027D71B44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8FDFFCBC-9C8E-94D5-A96D-606027D71B44}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8FDFFCBC-9C8E-94D5-A96D-606027D71B44}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8FDFFCBC-9C8E-94D5-A96D-606027D71B44}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {C7CD65D7-8035-4FC2-A584-06B21F732FF7} + SolutionGuid = {8DD20971-C4B3-41BF-8263-D31D5D81631F} EndGlobalSection EndGlobal diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..dca4d7a --- /dev/null +++ b/mise.toml @@ -0,0 +1,92 @@ +# Quick Guide +# - install mise +# - download the asset and extract them to ASSET_PATH_RO +# - mise trust mise.toml +# - mise run verify-asset +# - require hashdeep +# `hashdeep` package in debian +# or `md5deep` package in fedora +# or uncomment `tools."http:hashdeep"` below in windows +# - mise run serve +# - mise run arrange +# - mise run action +# - mise run assert +# - mise run bench + +[env] +ASSET_PATH_RO = "{{ [xdg_cache_home, 'stitch-a-ton', 'asset'] | join_path }}" +CONTEST_HOST = "http://localhost:7007" +CONTEST_API = "/api/image/generate" +CONTEST_OUTPUT = "{{ [cwd, '.contest'] | join_path }}" +DOTNET_ENVIRONMENT = "Production" +ANSWER_COMMIT_HASH = "89a07b40bf0414212c96945671a012035d375a25" + +[tools] +dotnet = "9" +xh = "latest" +uv = "latest" +k6 = "latest" + +# uncomment these if you're on windows +#[tools."http:hashdeep"] +#version = "4.4" + +#[tools."http:hashdeep".platforms] +#windows-x64 = {url = "https://github.com/jessek/hashdeep/releases/download/v4.4/md5deep-4.4.zip"} + +[tasks.setup] +run = ''' +{% if env.CONTEST_OUTPUT is not exists %} +mkdir .contest +{% endif %} +''' + +[tasks.verify-asset] +dir = "{{ env.ASSET_PATH_RO }}" +run = ''' +xh get https://null.formulatrix.dev/Contest/stitch-a-ton-answer/raw/commit/{{ env.ANSWER_COMMIT_HASH }}/asset.txt -o ../asset.txt +hashdeep -arbvk ../asset.txt . +''' + +[tasks.arrange] +depends = ['setup'] +dir = "{{ env.CONTEST_OUTPUT }}" +outputs = ['answer.json', 'action.py', 'assert.py', 'bench.js', 'fuzzy.json'] +run = ''' +xh get https://null.formulatrix.dev/Contest/stitch-a-ton-answer/raw/commit/{{ env.ANSWER_COMMIT_HASH }}/answer.json -o answer.json +xh get https://null.formulatrix.dev/Contest/stitch-a-ton-answer/raw/commit/{{ env.ANSWER_COMMIT_HASH }}/action.py -o action.py +xh get https://null.formulatrix.dev/Contest/stitch-a-ton-answer/raw/commit/{{ env.ANSWER_COMMIT_HASH }}/assert.py -o assert.py +xh get https://null.formulatrix.dev/Contest/stitch-a-ton-answer/raw/commit/{{ env.ANSWER_COMMIT_HASH }}/bench.js -o bench.js +xh get https://null.formulatrix.dev/Contest/stitch-a-ton-answer/raw/commit/{{ env.ANSWER_COMMIT_HASH }}/fuzzy.json -o fuzzy.json +''' + +[tasks.serve] +run = "dotnet run -c Release --no-launch-profile --urls {{env.CONTEST_HOST}}" + +[tasks.quick] +depends = ['arrange'] +dir = "{{ env.CONTEST_OUTPUT }}" +run = ''' +xh post {{env.CONTEST_HOST}}{{env.CONTEST_ENDPOINT}} canvas_rect=A1:H12 crop_offset:=[0,0] crop_size:=[1,1] output_scale:=0.25 -o quick.png +''' + +[tasks.action] +depends = ['arrange'] +dir = "{{ env.CONTEST_OUTPUT }}" +run = ''' +uv run --no-config --script {{ [env.CONTEST_OUTPUT, 'action.py'] | join_path }} +''' + +[tasks.assert] +depends = ['arrange'] +dir = "{{ env.CONTEST_OUTPUT }}" +run = ''' +uvx --no-config --with-requirements assert.py pytest assert.py +''' + +[tasks.bench] +depends = ['arrange'] +dir = "{{ env.CONTEST_OUTPUT }}" +run = ''' +k6 run -e TARGET_URL="{{ env.CONTEST_HOST }}{{ env.CONTEST_API }}" bench.js +'''