diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..e12bae7 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index d0a1f14..d18070c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,4 @@ bin obj .vs -*.png -.idea -/.contest +*.png \ No newline at end of file diff --git a/.idea/.idea.lilo-stitcher-console/.idea/.gitignore b/.idea/.idea.lilo-stitcher-console/.idea/.gitignore new file mode 100644 index 0000000..4be9211 --- /dev/null +++ b/.idea/.idea.lilo-stitcher-console/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/contentModel.xml +/projectSettingsUpdater.xml +/.idea.lilo-stitcher-console.iml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.lilo-stitcher-console/.idea/encodings.xml b/.idea/.idea.lilo-stitcher-console/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/.idea.lilo-stitcher-console/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/LiloController.cs b/LiloController.cs deleted file mode 100644 index 989b972..0000000 --- a/LiloController.cs +++ /dev/null @@ -1,18 +0,0 @@ -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 afee0dd..a2ed464 100644 --- a/LiloStitcher.cs +++ b/LiloStitcher.cs @@ -1,137 +1,136 @@ using Microsoft.Extensions.Caching.Memory; using NetVips; -namespace lilos_stitcher; +namespace lilo_stitcher_console; -public sealed record GenerateRequest( - string Canvas_Rect, - double[] Crop_Offset, - double[] Crop_Size, - double Output_Scale +public record GenerateRequest( + string CanvasRect, + double[] CropOffset, + double[] CropSize, + double OutputScale ); -public readonly record struct PlateCoordinate( int Row, int Col ) +public readonly record struct PlateCoordinate(int Row, int Col) { - public static PlateCoordinate Parse( string token ) - { - if( string.IsNullOrWhiteSpace( token ) ) - throw new ArgumentException( "Empty coordinate." ); + 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()); - var rowPart = new string( token.TakeWhile( char.IsLetter ).ToArray() ).ToUpperInvariant(); - var colPart = new string( token.SkipWhile( char.IsLetter ).ToArray() ); + int row = 0; + foreach (var character in rowPart) + row = row * 26 + (character - 'A' + 1); - int row = 0; - foreach( char c in rowPart ) - row = row * 26 + ( c - 'A' + 1 ); - - return new PlateCoordinate( row, int.Parse( colPart ) ); - } + int.TryParse(colPart, out int col); + return new PlateCoordinate(row, col); + } } - -public class TileCache( IMemoryCache cache ) +public class TileCache(IMemoryCache cache) { - private const long TileBytes = 720L * 720 * 3; + private const long TileBytes = 720L * 720 * 3; - public Image? Get( string key ) => cache.TryGetValue( key, out Image? img ) ? img : null; + 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 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 class TileLoader(TileCache cache, string assetDir) { - public async Task LoadAsync( string name, CancellationToken ct ) - { - if( cache.Get( name ) is { } hit ) - return hit; + 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); - var image = await Task.Run( () => - Image.NewFromFile( Path.Combine( assetDir, $"{name}.png" ), access: Enums.Access.Sequential ), ct ); - - cache.Set( name, image ); - return image; - } + cache.Set(name, image); + return image; + } } -public class LiloStitcher( TileLoader loader ) +public class LiloStitcher(TileLoader loader) { - public async Task CreateImageAsync( GenerateRequest req, CancellationToken ct ) + public async Task CreateImageAsync(GenerateRequest req, CancellationToken ct) { - (int rowMin, int colMin, int rows, int cols) = ParseCanvas( req.Canvas_Rect ); + (int rowMin, int colMin, int rows, int cols) = ParseCanvas(req.CanvasRect); - 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 ) ); + Validate(req.CropOffset, nameof(req.CropOffset)); + Validate(req.CropSize, nameof(req.CropSize)); - var tiles = new List( rows * cols ); + double scale = req.OutputScale; + if (scale <= 0 || scale > 1) + throw new ArgumentOutOfRangeException(nameof(req.OutputScale)); + + 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 ); - } + 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 ); + 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 offsetX = (int)Math.Truncate(req.CropOffset[0] * mosaic.Width); + int offsetY = (int)Math.Truncate(req.CropOffset[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 cropWidth = Math.Max(1, (int)Math.Truncate(req.CropSize[0] * restWidth)); + int cropHeight = Math.Max(1, (int)Math.Truncate(req.CropSize[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 ); + 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 ); + var cropRect = mosaic.Crop(cropX, cropY, cropWidth, cropHeight); - string path = Path.Combine(Path.GetTempPath(), $"mosaic-{Guid.NewGuid():N}.png"); - cropped.WriteToFile(path); - return path; + string tmpPath = Path.Combine(Path.GetTempPath(), $"mosaic-{Guid.NewGuid():N}.png"); + cropRect.WriteToFile(tmpPath); + + return tmpPath; } - private static (int rowMin, int colMin, int rows, int cols) ParseCanvas( string rect ) + 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 ); + 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 ) + 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 ); + 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 ) + 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(); + 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 5b60d67..7019819 100644 --- a/Program.cs +++ b/Program.cs @@ -1,20 +1,68 @@ -using lilos_stitcher; +using lilo_stitcher_console; +using System.Diagnostics; +using Microsoft.Extensions.Caching.Memory; -var builder = WebApplication.CreateBuilder(args); +namespace LiloStitcher; -NetVips.NetVips.Init(); -NetVips.NetVips.Concurrency = 3; +public static class Program +{ + public static async Task Main( string[] args ) + { + try + { + NetVips.NetVips.Init(); + NetVips.NetVips.Concurrency = 3; + + Stopwatch sw = Stopwatch.StartNew(); + var begin = sw.ElapsedMilliseconds; + var opt = new Options + { + CanvasRect = "A1:AE55", + CropOffset = new[] { 0.4, 0.4 }, + CropSize = new[] { 0.8, 0.8 }, + OutputScale = 0.5, + OutputPath = "stitched.png" + }; -builder.Services.AddControllers().AddJsonOptions(o => o.JsonSerializerOptions.PropertyNameCaseInsensitive = true); + 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)); -builder.Services.AddMemoryCache(o => o.SizeLimit = 128L * 1024 * 1024); + using var memCache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 128L * 1024 * 1024 }); + var tileCache = new TileCache(memCache); + var loader = new TileLoader(tileCache, assetDir); + var stitcher = new lilo_stitcher_console.LiloStitcher(loader); -string assetDir = Environment.GetEnvironmentVariable("ASSET_PATH_RO") ?? throw new InvalidOperationException("dir not found"); + var req = new GenerateRequest( + opt.CanvasRect!, + opt.CropOffset!, + opt.CropSize!, + opt.OutputScale + ); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(provider => new TileLoader(provider.GetRequiredService(), assetDir)); -builder.Services.AddSingleton(); + Console.WriteLine("Stitching..."); + var png = await stitcher.CreateImageAsync(req, CancellationToken.None); + File.Move( png, opt.OutputPath!, overwrite: true ); + + long bytes = new FileInfo( opt.OutputPath! ).Length; + Console.WriteLine($"Done. Wrote {opt.OutputPath} ({bytes / 1024.0:F1} KB)"); -var app = builder.Build(); -app.MapControllers(); -app.Run(); \ No newline at end of file + Console.WriteLine(sw.ElapsedMilliseconds - begin); + + 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 diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json deleted file mode 100644 index 23d5fa7..0000000 --- a/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$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 deleted file mode 100644 index 1980fc5..0000000 --- a/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# 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 deleted file mode 100644 index 0c208ae..0000000 --- a/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/appsettings.json b/appsettings.json deleted file mode 100644 index 10f68b8..0000000 --- a/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} diff --git a/lilos-stitcher.csproj b/lilo-stitcher-console.csproj similarity index 53% rename from lilos-stitcher.csproj rename to lilo-stitcher-console.csproj index 2ced848..cd56a0f 100644 --- a/lilos-stitcher.csproj +++ b/lilo-stitcher-console.csproj @@ -1,19 +1,18 @@ - + + Exe net9.0 - enable + lilo_stitcher_console enable - lilos_stitcher + enable preview - + - - - + - \ No newline at end of file + diff --git a/lilos-stitcher.sln b/lilo-stitcher-console.sln similarity index 58% rename from lilos-stitcher.sln rename to lilo-stitcher-console.sln index b15f5b6..2e5c9ca 100644 --- a/lilos-stitcher.sln +++ b/lilo-stitcher-console.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}") = "lilos-stitcher", "lilos-stitcher.csproj", "{8FDFFCBC-9C8E-94D5-A96D-606027D71B44}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "lilo-stitcher-console", "lilo-stitcher-console.csproj", "{2A1F81C9-D10F-1AE9-CA5C-0714270F48C1}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -10,15 +10,15 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {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 + {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {8DD20971-C4B3-41BF-8263-D31D5D81631F} + SolutionGuid = {C7CD65D7-8035-4FC2-A584-06B21F732FF7} EndGlobalSection EndGlobal diff --git a/lilos-stitcher.http b/lilos-stitcher.http deleted file mode 100644 index 8e967ed..0000000 --- a/lilos-stitcher.http +++ /dev/null @@ -1,6 +0,0 @@ -@lilos_stitcher_HostAddress = http://localhost:5243 - -GET {{lilos_stitcher_HostAddress}}/weatherforecast/ -Accept: application/json - -### \ No newline at end of file diff --git a/mise.toml b/mise.toml deleted file mode 100644 index dca4d7a..0000000 --- a/mise.toml +++ /dev/null @@ -1,92 +0,0 @@ -# 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 -'''