diff --git a/.gitignore b/.gitignore index 8d4a6c0..d0a1f14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ bin -obj \ No newline at end of file +obj +.vs +*.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 ef4eb65..afee0dd 100644 --- a/LiloStitcher.cs +++ b/LiloStitcher.cs @@ -1,148 +1,137 @@ -using Microsoft.Extensions.Caching.Memory; -using SkiaSharp; - -namespace LiloStitcher; - -public record GenerateRequest( - string CanvasRect, - double[] CropOffset, - double[] CropSize, - double OutputScale -); - -public 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 * 4; - - public SKBitmap? Get( string key ) => cache.TryGetValue( key, out SKBitmap? bmp ) ? bmp : null; - - public void Set( string key, SKBitmap bmp ) => - cache.Set( key, bmp, 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" ); - - var bytes = await File.ReadAllBytesAsync( path, ct ).ConfigureAwait( false ); - - using var data = SKData.CreateCopy( bytes ); - var bmp = SKBitmap.Decode( data ); - cache.Set( name, bmp ); - return bmp; - } -} - -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 fullInfo = new SKImageInfo( cols * TileSize, rows * TileSize ); - using var surface = SKSurface.Create( fullInfo ); - var canvas = surface.Canvas; - - int idx = 0; - for( int row = 0; row < rows; row++ ) - for( int column = 0; column < cols; column++, idx++ ) - canvas.DrawBitmap( bitmaps[idx], column * TileSize, row * TileSize ); - - using var full = new SKBitmap( fullInfo ); - surface.ReadPixels( fullInfo, full.GetPixels(), full.RowBytes, 0, 0 ); - - ValidateFraction( req.CropOffset, 2, nameof( req.CropOffset ), inclusiveUpper: true ); - ValidateFraction( req.CropSize, 2, nameof( req.CropSize ), inclusiveUpper: true ); - - int offsetX = (int)( req.CropOffset[0] * fullInfo.Width ); - int offsetY = (int)( req.CropOffset[1] * fullInfo.Height ); - int cropWidth = (int)( req.CropSize[0] * fullInfo.Width ); - int cropHeight = (int)( req.CropSize[1] * fullInfo.Height ); - - if( offsetX + cropWidth > fullInfo.Width ) cropWidth = fullInfo.Width - offsetX; - if( offsetY + cropHeight > fullInfo.Height ) cropHeight = fullInfo.Height - offsetY; - - var cropRect = new SKRectI( offsetX, offsetY, offsetX + cropWidth, offsetY + cropHeight ); - using var crop = new SKBitmap( cropWidth, cropHeight ); - full.ExtractSubset( crop, cropRect ); - - double scale = Math.Clamp( req.OutputScale, 0.0, 1.0 ); - int truncatedWidth = Math.Max( 1, (int)Math.Round( cropWidth * scale ) ); - int truncatedHeight = Math.Max( 1, (int)Math.Round( cropHeight * scale ) ); - - using var finalBmp = scale < 1.0 - ? crop.Resize( new SKSizeI( truncatedWidth, truncatedHeight ), new SKSamplingOptions( SKFilterMode.Linear ) ) - : crop; - - using var image = SKImage.FromBitmap( finalBmp ); - using var data = image.Encode( SKEncodedImageFormat.Png, 100 ); - return data.ToArray(); - - static void ValidateFraction( double[] arr, int len, string name, bool inclusiveUpper ) - { - for( int i = 0; i < len; i++ ) - { - double v = arr[i]; - double upper = inclusiveUpper ? 1.0 : 1.0 - double.Epsilon; - } - } - - 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 71da835..5b60d67 100644 --- a/Program.cs +++ b/Program.cs @@ -1,55 +1,20 @@ - -using Microsoft.Extensions.Caching.Memory; +using lilos_stitcher; -namespace LiloStitcher; -public static class Program -{ - public static async Task Main( string[] args ) - { - try - { - var opt = new Options(); - opt.CanvasRect = "A1:H12"; - opt.CropOffset = [0.25, 0.25]; - opt.CropSize = [0.75, 0.75]; - opt.OutputScale = 0.5; - opt.OutputPath = "stitched.png"; +var builder = WebApplication.CreateBuilder(args); - string tileFilePath = "../stitch-a-ton/tiles1705"; - 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 png = await stitcher.CreateImageAsync( req, CancellationToken.None ); +string assetDir = Environment.GetEnvironmentVariable("ASSET_PATH_RO") ?? throw new InvalidOperationException("dir not found"); - File.WriteAllBytes( opt.OutputPath!, png ); - Console.WriteLine( $"Done. Wrote {opt.OutputPath} ({png.Length / 1024.0:F1} KB)" ); - return 0; - } - catch( Exception ex ) - { - Console.Error.WriteLine( "ERROR: " + ex.Message ); - return 1; - } - } +builder.Services.AddSingleton(); +builder.Services.AddSingleton(provider => new TileLoader(provider.GetRequiredService(), assetDir)); +builder.Services.AddSingleton(); - 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 09af0f4..0000000 --- a/lilo-stitcher-console.csproj +++ /dev/null @@ -1,17 +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 +'''