diff --git a/cloud/main.tf b/cloud/main.tf index d4caacfe..e421504d 100644 --- a/cloud/main.tf +++ b/cloud/main.tf @@ -1,133 +1,138 @@ terraform { - required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 4.16" - } + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 4.16" } + } - required_version = ">= 1.2.0" + required_version = ">= 1.2.0" } provider "aws" { - region = var.region + region = var.region } -data "aws_ami" "ubuntu" { - most_recent = true - owners = ["099720109477"] # Canonical - - filter { - name = "name" - values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] - } - - filter { - name = "virtualization-type" - values = ["hvm"] - } +locals { + normalized_name_prefix = trimsuffix(trimspace(var.name_prefix), "-") + resource_name_prefix = local.normalized_name_prefix == "" ? "" : "${local.normalized_name_prefix}-" +} - filter { - name = "architecture" - values = ["x86_64"] - } +data "aws_ami" "ubuntu" { + most_recent = true + owners = ["099720109477"] # Canonical + + filter { + name = "name" + values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } + + filter { + name = "architecture" + values = ["x86_64"] + } } resource "aws_key_pair" "aws_key" { - key_name = var.aws_key_name - public_key = file(var.ssh_public_key_path) + key_name = "${local.resource_name_prefix}${var.aws_key_name}" + public_key = file(var.ssh_public_key_path) } data "aws_vpc" "default" { - default = true + default = true } data "aws_subnets" "default" { - filter { - name = "vpc-id" - values = [data.aws_vpc.default.id] - } + filter { + name = "vpc-id" + values = [data.aws_vpc.default.id] + } } resource "aws_security_group" "ssh" { - name = "allow-ssh" - description = "Allow SSH" - vpc_id = data.aws_vpc.default.id - - ingress { - description = "SSH from specific IP" - from_port = 22 - to_port = 22 - protocol = "tcp" - cidr_blocks = [var.allowed_ssh_cidr] - } - - egress { - description = "Allow all outbound traffic" - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - } - - tags = { - Name = "ale-bench-security-group-ssh" - Project = "ale-bench" - ManagedBy = "terraform" - } + name = "${local.resource_name_prefix}allow-ssh" + description = "Allow SSH" + vpc_id = data.aws_vpc.default.id + + ingress { + description = "SSH from specific IP" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = [var.allowed_ssh_cidr] + } + + egress { + description = "Allow all outbound traffic" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${local.resource_name_prefix}ale-bench-security-group-ssh" + Project = "ale-bench" + ManagedBy = "terraform" + } } resource "aws_instance" "ale_bench_instance" { - count = var.instance_count - ami = data.aws_ami.ubuntu.id - instance_type = var.instance_type - key_name = aws_key_pair.aws_key.key_name - - vpc_security_group_ids = [aws_security_group.ssh.id] - subnet_id = tolist(data.aws_subnets.default.ids)[0] - associate_public_ip_address = true - - root_block_device { - volume_type = "gp3" - volume_size = var.instance_volume_size - iops = 3000 - throughput = 125 - delete_on_termination = true - tags = { - Name = "ale-bench-instance-${count.index}-storage" - Project = "ale-bench" - ManagedBy = "terraform" - } + count = var.instance_count + ami = data.aws_ami.ubuntu.id + instance_type = var.instance_type + key_name = aws_key_pair.aws_key.key_name + + vpc_security_group_ids = [aws_security_group.ssh.id] + subnet_id = tolist(data.aws_subnets.default.ids)[0] + associate_public_ip_address = true + + root_block_device { + volume_type = "gp3" + volume_size = var.instance_volume_size + iops = 3000 + throughput = 125 + delete_on_termination = true + tags = { + Name = "${local.resource_name_prefix}ale-bench-instance-${count.index}-storage" + Project = "ale-bench" + ManagedBy = "terraform" } + } - disable_api_stop = false - disable_api_termination = false - instance_initiated_shutdown_behavior = "stop" + disable_api_stop = false + disable_api_termination = false + instance_initiated_shutdown_behavior = "stop" - user_data = file(var.setup_file_name) + user_data = file(var.setup_file_name) - tags = { - Name = "ale-bench-instance-${count.index}" - Project = "ale-bench" - ManagedBy = "terraform" - } + tags = { + Name = "${local.resource_name_prefix}ale-bench-instance-${count.index}" + Project = "ale-bench" + ManagedBy = "terraform" + } - metadata_options { - http_endpoint = "enabled" - http_tokens = "required" - } + metadata_options { + http_endpoint = "enabled" + http_tokens = "required" + } - lifecycle { - ignore_changes = [ami] - } + lifecycle { + ignore_changes = [ami] + } } output "instance_public_ips" { - value = sort(aws_instance.ale_bench_instance[*].public_ip) - description = "Public IP addresses of the instances" + value = sort(aws_instance.ale_bench_instance[*].public_ip) + description = "Public IP addresses of the instances" } output "ssh_connection_string" { - value = sort([for i in range(var.instance_count) : "ssh -i ubuntu@${aws_instance.ale_bench_instance[i].public_ip}"]) - description = "SSH connection strings for the instances" + value = sort([for i in range(var.instance_count) : "ssh -i ubuntu@${aws_instance.ale_bench_instance[i].public_ip}"]) + description = "SSH connection strings for the instances" } diff --git a/cloud/variables.tf b/cloud/variables.tf index 03b90262..4f42bcd2 100644 --- a/cloud/variables.tf +++ b/cloud/variables.tf @@ -1,47 +1,53 @@ variable "region" { - description = "AWS region" - type = string - default = "us-east-1" + description = "AWS region" + type = string + default = "us-east-1" } variable "instance_type" { - description = "EC2 instance type" - type = string - default = "c6i.8xlarge" + description = "EC2 instance type" + type = string + default = "c6i.8xlarge" } variable "instance_count" { - description = "The number of instances to launch" - type = number - default = 1 + description = "The number of instances to launch" + type = number + default = 1 } variable "instance_volume_size" { - description = "Size of the root volume in GB" - type = number - default = 100 + description = "Size of the root volume in GB" + type = number + default = 100 +} + +variable "name_prefix" { + description = "Optional prefix for AWS resource names, useful when deploying multiple stacks in one account" + type = string + default = "" } variable "aws_key_name" { - description = "Name of the AWS key pair" - type = string - default = "ale-bench" + description = "Name of the AWS key pair" + type = string + default = "ale-bench" } variable "ssh_public_key_path" { - description = "Path to the public key used for SSH access" - type = string - default = "~/.ssh/id_rsa.pub" + description = "Path to the public key used for SSH access" + type = string + default = "~/.ssh/id_rsa.pub" } variable "allowed_ssh_cidr" { - description = "CIDR block allowed for SSH access" - type = string - default = "0.0.0.0/0" + description = "CIDR block allowed for SSH access" + type = string + default = "0.0.0.0/0" } variable "setup_file_name" { - description = "Name of the setup file to be copied to the instance" - type = string - default = "setup.sh" + description = "Name of the setup file to be copied to the instance" + type = string + default = "setup.sh" } diff --git a/docs/evaluation.md b/docs/evaluation.md index 46e5d194..03f41690 100644 --- a/docs/evaluation.md +++ b/docs/evaluation.md @@ -68,7 +68,7 @@ uv run -m ale_bench_eval --model_config_path llm_configs/gpt-5.json --num_worker bash scripts/run_eval.sh gpt-5 # Or directly run using uv -uv run -m ale_bench_eval --model_config_path llm_configs/gpt-5.json --n_repeated_sampling 15 --n_self_refine 16 --num_workers 10 --n_public_cases 50 --judge_version 202510 --code_language typescript --prompt_language en --max_parallel_problems 5 --problem_ids_type all --selection_method median +uv run -m ale_bench_eval --model_config_path llm_configs/gpt-5.json --n_repeated_sampling 15 --n_self_refine 16 --num_workers 10 --n_public_cases 50 --judge_version 202510 --code_language typescript --prompt_language en --max_parallel_problems 5 --max_concurrent_llm_calls 20 --max_repeated_sampling_workers 100 --problem_ids_type all --selection_method median ``` ### Bash Script Arguments @@ -88,6 +88,8 @@ Script options: | `--judge_version`, `-j` | `202301` | Judge toolchain version (`201907`, `202301`, `202510`) | | `--code_language`, `-c` | depends on judge version | Programming language for generation/evaluation | | `--prompt_language`, `-p` | `en` | Prompt language (`en` or `ja`) | +| `--max_concurrent_llm_calls` | Python CLI default | Maximum number of in-flight LLM calls across all problems (`none` uses the maximum possible repeated-sampling fan-out) | +| `--max_repeated_sampling_workers` | Python CLI default | Maximum repeated-sampling LLM worker threads per problem (`none` means `n_repeated_sampling`) | | `--help`, `-h` | - | Show usage | Default `code_language` by `judge_version`: @@ -105,6 +107,9 @@ bash scripts/run_eval.sh -j 202510 -c typescript -p ja -r results/gpt5-ts-ja gpt # options after config_name bash scripts/run_eval.sh gpt-5 --judge_version 202510 --code_language rust --root_path results/gpt5-rust + +# limit global LLM request concurrency while allowing repeated sampling to fan out per problem +bash scripts/run_eval.sh gpt-5 --max_concurrent_llm_calls 20 --max_repeated_sampling_workers 100 ``` ### Command Line Arguments @@ -120,6 +125,8 @@ bash scripts/run_eval.sh gpt-5 --judge_version 202510 --code_language rust --roo | `code_language` | str | `cpp20` | Target programming language (`any`, `bash`, `cpp17`, `cpp20`, `cpp23`, `csharp`, `fish`, `fortran`, `go`, `haskell`, `javascript`, `julia`, `lean`, `ocaml`, `perl`, `pypy`, `python`, `rust`, `typescript`) | | `prompt_language` | str | `en` | Prompt language (`en` for English, `ja` for Japanese) | | `max_parallel_problems` | int | `1` | Maximum number of problems to evaluate in parallel | +| `max_concurrent_llm_calls` | int \| `None` | `None` | Maximum number of in-flight LLM calls across all problems (`None`/`none` resolves to `max_parallel_problems * effective_max_repeated_sampling_workers`) | +| `max_repeated_sampling_workers` | int \| `None` | `None` | Maximum repeated-sampling LLM worker threads per problem (`None`/`none` resolves to `n_repeated_sampling`) | | `problem_ids_type` | str | `debug` | Problem ID set to evaluate (`debug`, `lite`, `all`) | | `selection_method` | str | `median` | Method to select solution from repeated sampling (`best`, `median`) | | `use_statement_image` | bool | `False` | Whether to use statement images in the evaluation process (requires a vision-capable model/provider) | @@ -128,6 +135,8 @@ bash scripts/run_eval.sh gpt-5 --judge_version 202510 --code_language rust --roo > **Note**: Ensure that `num_workers` $\times$ `max_parallel_problems` does not exceed the number of physical CPU cores available on your machine to avoid resource contention and performance degradation. +> **Note**: `max_parallel_problems` controls problem-level concurrency. `max_repeated_sampling_workers` controls only repeated-sampling LLM generation within each problem. If it is `None`, it is resolved to `n_repeated_sampling`; otherwise it is capped at `n_repeated_sampling`. `max_concurrent_llm_calls` is a global cap shared by repeated sampling and self-refinement LLM calls. If it is `None`, it is resolved to `max_parallel_problems * effective_max_repeated_sampling_workers`. Judge execution remains bounded by `num_workers` for each active problem. + > **Note**: `code_language` must be supported by the selected `judge_version`. > If `code_language=any`, available languages are: > - `201907`: `cpp17`, `python`, `rust` @@ -166,7 +175,7 @@ results/ │ └── self_refine_results_.json # Self-refinement public result (n = number of iterations) ├── conversations/ # Conversations with LLM │ ├── repeated_sampling_conversations_.json # Repeated sampling conversations (n = number of iterations) - │ └── self_refine_conversations.json # Self-refinement conversations + │ └── self_refine_conversations_.json # Self-refinement conversations (n = number of iterations) ├── results/ │ ├── final_results.json # Private evaluation results │ ├── repeated_sampling_results.json # Repeated sampling public evaluation results diff --git a/llm_configs/claude-4.8-opus-high.json b/llm_configs/claude-4.8-opus-high.json new file mode 100644 index 00000000..1a6fc4ec --- /dev/null +++ b/llm_configs/claude-4.8-opus-high.json @@ -0,0 +1,12 @@ +{ + "model_name": "claude-opus-4-8", + "provider": "anthropic", + "settings": { + "max_tokens": 128000, + "anthropic_thinking": { + "type": "adaptive", + "display": "summarized" + }, + "anthropic_effort": "high" + } +} diff --git a/llm_configs/minimax-m3.json b/llm_configs/minimax-m3.json new file mode 100644 index 00000000..9d5e071f --- /dev/null +++ b/llm_configs/minimax-m3.json @@ -0,0 +1,22 @@ +{ + "model_name": "minimax/minimax-m3", + "provider": "openrouter", + "settings": { + "temperature": 1.0, + "top_p": 0.95, + "extra_body": { + "provider": { + "allow_fallbacks": false, + "data_collection": "deny", + "order": ["minimax/fp8"], + "require_parameters": true + }, + "reasoning": { + "enabled": true + }, + "usage": { + "include": true + } + } + } +} diff --git a/llm_configs/step-3.7-flash-medium.json b/llm_configs/step-3.7-flash-medium.json new file mode 100644 index 00000000..a3c69da0 --- /dev/null +++ b/llm_configs/step-3.7-flash-medium.json @@ -0,0 +1,21 @@ +{ + "model_name": "stepfun/step-3.7-flash", + "provider": "openrouter", + "settings": { + "extra_body": { + "provider": { + "allow_fallbacks": false, + "data_collection": "deny", + "order": ["stepfun/fp8"], + "require_parameters": true + }, + "reasoning": { + "effort": "medium", + "enabled": true + }, + "usage": { + "include": true + } + } + } +} diff --git a/pyproject.toml b/pyproject.toml index 7769393e..937d47e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,10 +44,10 @@ dependencies = [ [dependency-groups] dev = [ - "pytest>=9.0.2", + "pytest>=9.0.3", "pytest-mock>=3.15.1", - "ruff>=0.15.9", - "ty>=0.0.28", + "ruff>=0.15.14", + "ty>=0.0.38", ] [project.urls] @@ -56,11 +56,11 @@ Repository = "https://github.com/SakanaAI/ALE-Bench" [project.optional-dependencies] eval = [ "fire>=0.7.0", - "genai-prices>=0.0.47", + "genai-prices>=0.0.61", "numpy>=2.0.2", "pandas>=2.3.3", "psutil>=7.0.0", - "pydantic-ai>=1.30.0", + "pydantic-ai>=1.100.0", ] [tool.uv] diff --git a/scripts/run_eval.sh b/scripts/run_eval.sh index 9960bef1..395d9352 100644 --- a/scripts/run_eval.sh +++ b/scripts/run_eval.sh @@ -8,6 +8,8 @@ usage() { echo " --judge_version, -j <201907|202301|202510>" echo " --code_language, -c " echo " --prompt_language, -p " + echo " --max_concurrent_llm_calls " + echo " --max_repeated_sampling_workers " echo " --help, -h" } @@ -26,6 +28,8 @@ root_path="" judge_version="202301" code_language="" prompt_language="en" +max_concurrent_llm_calls="" +max_repeated_sampling_workers="" while [ $# -gt 0 ]; do case "$1" in @@ -49,6 +53,16 @@ while [ $# -gt 0 ]; do prompt_language=${2:-} shift 2 ;; + --max_concurrent_llm_calls) + require_option_value "$1" "${2:-}" + max_concurrent_llm_calls=${2:-} + shift 2 + ;; + --max_repeated_sampling_workers) + require_option_value "$1" "${2:-}" + max_repeated_sampling_workers=${2:-} + shift 2 + ;; --help|-h) usage exit 0 @@ -117,6 +131,12 @@ esac echo -e "Using judge version: ${judge_version}" echo -e "Using code language: ${code_language}" echo -e "Using prompt language: ${prompt_language}" +if [ -n "${max_concurrent_llm_calls}" ]; then + echo -e "Using max concurrent LLM calls: ${max_concurrent_llm_calls}" +fi +if [ -n "${max_repeated_sampling_workers}" ]; then + echo -e "Using max repeated sampling workers: ${max_repeated_sampling_workers}" +fi dotenv_path=${project_dir}/.env if [ -f "${dotenv_path}" ]; then @@ -130,16 +150,28 @@ fi cd "$project_dir" +eval_args=( + --model_config_path "$config_path" + --n_repeated_sampling 15 + --n_self_refine 16 + --num_workers 10 + --n_public_cases 50 + --judge_version "$judge_version" + --code_language "$code_language" + --prompt_language "$prompt_language" + --max_parallel_problems 5 + --problem_ids_type all + --selection_method median +) +if [ -n "${max_concurrent_llm_calls}" ]; then + eval_args+=(--max_concurrent_llm_calls "$max_concurrent_llm_calls") +fi +if [ -n "${max_repeated_sampling_workers}" ]; then + eval_args+=(--max_repeated_sampling_workers "$max_repeated_sampling_workers") +fi if [ -n "${root_path}" ]; then echo -e "Using root path: ${root_path}" - uv run -m ale_bench_eval --model_config_path "$config_path" \ - --n_repeated_sampling 15 --n_self_refine 16 --num_workers 10 --n_public_cases 50 \ - --judge_version "$judge_version" --code_language "$code_language" --prompt_language "$prompt_language" \ - --max_parallel_problems 5 --problem_ids_type all --selection_method median \ - --root_path "$root_path" -else - uv run -m ale_bench_eval --model_config_path "$config_path" \ - --n_repeated_sampling 15 --n_self_refine 16 --num_workers 10 --n_public_cases 50 \ - --judge_version "$judge_version" --code_language "$code_language" --prompt_language "$prompt_language" \ - --max_parallel_problems 5 --problem_ids_type all --selection_method median + eval_args+=(--root_path "$root_path") fi + +uv run -m ale_bench_eval "${eval_args[@]}" diff --git a/src/ale_bench_eval/__main__.py b/src/ale_bench_eval/__main__.py index 99200218..7a40fd94 100644 --- a/src/ale_bench_eval/__main__.py +++ b/src/ale_bench_eval/__main__.py @@ -2,6 +2,7 @@ import warnings from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path +from threading import BoundedSemaphore from typing import Any, Literal, cast from fire import Fire @@ -32,6 +33,24 @@ ) +def normalize_optional_positive_int(value: int | str | None, parameter_name: str) -> int | None: + if value is None: + return None + if isinstance(value, str): + normalized_value = value.strip() + if normalized_value.lower() in {"none", "null"}: + return None + try: + value = int(normalized_value) + except ValueError as e: + msg = f"{parameter_name} must be a positive integer or None" + raise ValueError(msg) from e + if value < 1: + msg = f"{parameter_name} must be at least 1" + raise ValueError(msg) + return value + + def power_of_two_indices(n: int) -> list[int]: """Generate a list of power of two indices up to n.""" indices = [2**i for i in range(n.bit_length()) if 2**i <= n] @@ -52,6 +71,8 @@ def evaluate_contest( n_public_cases: int | None = None, selection_method: Literal["best", "median"] = "median", root_path: Path | None = None, + llm_semaphore: BoundedSemaphore | None = None, + max_repeated_sampling_workers: int | None = None, ) -> None: """Main evaluation function orchestrating the entire benchmarking process.""" start_time = get_now_utc() @@ -87,6 +108,8 @@ def evaluate_contest( user_prompt=create_initial_message(prompt_args, session.problem), system_prompt=create_system_message(prompt_args), save_info=save_info, + llm_semaphore=llm_semaphore, + max_repeated_sampling_workers=max_repeated_sampling_workers, ) expected_keys = set(range(n_repeated_sampling)) actual_keys = {int(k) for k in results_repeated_sampling if int(k) in expected_keys} @@ -120,9 +143,11 @@ def evaluate_contest( initial_conversations = save_info.load_conversations( f"repeated_sampling_conversations_{selected_index_repeated_sampling}.json" ) - initial_public_result = save_info.load_ale_bench_results( - f"repeated_sampling_results_{selected_index_repeated_sampling}.json" - ) + initial_public_result = None + if selected_code_repeated_sampling.strip() != "": + initial_public_result = save_info.load_ale_bench_results( + f"repeated_sampling_results_{selected_index_repeated_sampling}.json" + ) results_self_refine = run_self_refinement( config=config, model_config=model_config, @@ -131,6 +156,7 @@ def evaluate_contest( initial_public_result=initial_public_result, initial_result=results_repeated_sampling[selected_index_repeated_sampling], save_info=save_info, + llm_semaphore=llm_semaphore, ) # Phase 4: Private evaluation @@ -226,6 +252,8 @@ def _run_evaluation_task( n_public_cases: int | None, selection_method: Literal["best", "median"], root_path: Path, + llm_semaphore: BoundedSemaphore | None, + max_repeated_sampling_workers: int | None, ) -> tuple[str, bool, str]: """Wrapper function for parallel evaluation execution.""" try: @@ -242,6 +270,8 @@ def _run_evaluation_task( n_public_cases=n_public_cases, selection_method=selection_method, root_path=root_path, + llm_semaphore=llm_semaphore, + max_repeated_sampling_workers=max_repeated_sampling_workers, ) print(f"✅ Completed: {model_name} on {problem_id}") except Exception as e: @@ -262,6 +292,8 @@ def main( judge_version: EvalJudgeVersion = "202301", prompt_language: Literal["en", "ja"] = "en", max_parallel_problems: int = 1, + max_concurrent_llm_calls: int | str | None = None, + max_repeated_sampling_workers: int | str | None = None, problem_ids_type: Literal["all", "lite", "debug"] = "debug", selection_method: Literal["best", "median"] = "median", use_statement_image: bool = False, @@ -278,6 +310,27 @@ def main( judge_version = cast("EvalJudgeVersion", str(judge_version).strip()) prompt_language = cast('Literal["en", "ja"]', str(prompt_language).strip()) + if n_repeated_sampling < 1: + msg = "n_repeated_sampling must be at least 1" + raise ValueError(msg) + if max_parallel_problems < 1: + msg = "max_parallel_problems must be at least 1" + raise ValueError(msg) + max_concurrent_llm_calls = normalize_optional_positive_int( + max_concurrent_llm_calls, + "max_concurrent_llm_calls", + ) + max_repeated_sampling_workers = normalize_optional_positive_int( + max_repeated_sampling_workers, + "max_repeated_sampling_workers", + ) + if max_repeated_sampling_workers is None: + max_repeated_sampling_workers = n_repeated_sampling + else: + max_repeated_sampling_workers = min(max_repeated_sampling_workers, n_repeated_sampling) + if max_concurrent_llm_calls is None: + max_concurrent_llm_calls = max_parallel_problems * max_repeated_sampling_workers + physical_cores = cpu_count(logical=False) if physical_cores is None: warnings.warn( @@ -328,6 +381,8 @@ def main( raise ValueError(msg) # NOTE: skip n_self_refine check to allow resuming with different n_self_refine # NOTE: skip num_workers check to allow resuming with different num_workers + # NOTE: skip max_concurrent_llm_calls check to allow resuming with different LLM concurrency + # NOTE: skip max_repeated_sampling_workers check to allow resuming with different repeated sampling concurrency if existing_settings["n_public_cases"] != n_public_cases: msg = "Experiment settings already exist with different n_public_cases" raise ValueError(msg) @@ -361,6 +416,8 @@ def main( "judge_version": judge_version, "prompt_language": prompt_language, "max_parallel_problems": max_parallel_problems, + "max_concurrent_llm_calls": max_concurrent_llm_calls, + "max_repeated_sampling_workers": max_repeated_sampling_workers, "problem_ids_type": problem_ids_type, "selection_method": selection_method, }, @@ -374,7 +431,9 @@ def main( print(f"\n🚀 Starting parallel evaluation of {len(problem_ids)} problems...") print( f"📊 Model: {model_name}, Repeated Sampling: {n_repeated_sampling}, Self-Refine: {n_self_refine}, " - f"Code Language: {code_language}, Judge Version: {judge_version}" + f"Code Language: {code_language}, Judge Version: {judge_version}, " + f"Max Concurrent LLM Calls: {max_concurrent_llm_calls}, " + f"Max Repeated Sampling Workers: {max_repeated_sampling_workers}" ) if skip_llm_inference: @@ -386,6 +445,7 @@ def main( with result_json_path.open() as f: results = json.load(f) else: + llm_semaphore = BoundedSemaphore(max_concurrent_llm_calls) # Evaluate on problems in parallel with ThreadPoolExecutor(max_workers=max_parallel_problems) as executor: future_to_problem = { @@ -402,6 +462,8 @@ def main( n_public_cases, selection_method, exp_root, + llm_semaphore, + max_repeated_sampling_workers, ): problem_id for problem_id in problem_ids } @@ -409,6 +471,8 @@ def main( for future in as_completed(future_to_problem): problem_id, success, message = future.result() results[problem_id] = {"success": success, "message": message} + with (exp_root / "results.json").open("w") as f: + json.dump(results, f) # Summary report successful = sum(1 for r in results.values() if r["success"]) diff --git a/src/ale_bench_eval/calc_cost.py b/src/ale_bench_eval/calc_cost.py index 3b3e1ac8..57a276e4 100644 --- a/src/ale_bench_eval/calc_cost.py +++ b/src/ale_bench_eval/calc_cost.py @@ -304,6 +304,11 @@ output_mtok=Decimal(12) / Decimal(10), cache_read_mtok=Decimal(6) / Decimal(100), ), + "minimax-m3": ModelPrice( + input_mtok=Decimal(6) / Decimal(10), + output_mtok=Decimal(24) / Decimal(10), + cache_read_mtok=Decimal(12) / Decimal(100), + ), "nemotron-3-super-120b-a12b:free": ModelPrice( input_mtok=Decimal(1) / Decimal(10), output_mtok=Decimal(5) / Decimal(10), @@ -370,6 +375,11 @@ cache_read_mtok=Decimal(25) / Decimal(100), cache_write_mtok=Decimal(3125) / Decimal(1000), ), + "step-3.7-flash": ModelPrice( + input_mtok=Decimal(2) / Decimal(10), + cache_read_mtok=Decimal(4) / Decimal(100), + output_mtok=Decimal(115) / Decimal(100), + ), } diff --git a/src/ale_bench_eval/logger.py b/src/ale_bench_eval/logger.py index 8201fc90..3dc57e12 100644 --- a/src/ale_bench_eval/logger.py +++ b/src/ale_bench_eval/logger.py @@ -3,8 +3,10 @@ import datetime import json import logging +import os from pathlib import Path from typing import Any, Final +from uuid import uuid4 from pydantic import BaseModel from pydantic_ai.run import AgentRunResult @@ -92,6 +94,20 @@ class AgentRunResultWrapper(BaseModel): value: AgentRunResult +def atomic_json_dump(path: Path, data: object, encoder_cls: type[json.JSONEncoder] | None = None) -> None: + """Atomically write JSON data to a file in the same directory.""" + tmp_path = path.with_name(f".{path.name}.{uuid4().hex[:12]}.tmp") + try: + with tmp_path.open("w") as f: + json.dump(data, f, cls=encoder_cls) + f.flush() + os.fsync(f.fileno()) + tmp_path.replace(path) + finally: + if tmp_path.exists(): + tmp_path.unlink() + + class SaveInfo: def __init__(self, model_name: str, problem_id: str, root_path: Path | None = None) -> None: self.problem_id = problem_id @@ -110,8 +126,11 @@ def __init__(self, model_name: str, problem_id: str, root_path: Path | None = No def save_conversations(self, filename: str, agent_run_result: AgentRunResult) -> None: """Save conversations to JSON file.""" - with (self.conversations / filename).open("w") as f: - json.dump(dataclasses.asdict(agent_run_result), f, cls=CustomJSONEncoder) + atomic_json_dump( + self.conversations / filename, + dataclasses.asdict(agent_run_result), + encoder_cls=CustomJSONEncoder, + ) def load_conversations(self, filename: str) -> AgentRunResult: """Load conversations from JSON file.""" @@ -121,8 +140,7 @@ def load_conversations(self, filename: str) -> AgentRunResult: def save_results(self, filename: str, results: dict[str, Any]) -> None: """Save results to JSON file.""" - with (self.results / filename).open("w") as f: - json.dump(results, f) + atomic_json_dump(self.results / filename, results) def load_results(self, filename: str) -> dict[str, Any]: """Load results from JSON file.""" @@ -142,8 +160,7 @@ def save_ale_bench_results(self, filename: str, results: AleBenchResult) -> None case_results=results.case_results, ) ) - with (self.ale_bench_results / filename).open("w") as f: - json.dump(serialized_results.model_dump(), f) + atomic_json_dump(self.ale_bench_results / filename, serialized_results.model_dump()) def load_ale_bench_results(self, filename: str) -> AleBenchResult: """Load results from JSON file.""" diff --git a/src/ale_bench_eval/safe_generation.py b/src/ale_bench_eval/safe_generation.py index 3207d9bb..99a2ba4f 100644 --- a/src/ale_bench_eval/safe_generation.py +++ b/src/ale_bench_eval/safe_generation.py @@ -1,4 +1,6 @@ from collections.abc import Sequence +from contextlib import nullcontext +from threading import BoundedSemaphore from typing import TYPE_CHECKING, Any from google.genai.errors import ClientError @@ -145,6 +147,7 @@ def safe_generation( system_prompt: str | Sequence[str] = (), timeout: float = 60.0, num_retries: int = 3, + llm_semaphore: BoundedSemaphore | None = None, ) -> AgentRunResult[str]: agent = build_agent_from_config( model_config=model_config, @@ -154,9 +157,10 @@ def safe_generation( ) try: - result = shared_async_loop().run( - agent.run(user_prompt=user_prompt, message_history=message_history), - ) + with llm_semaphore if llm_semaphore is not None else nullcontext(): + result = shared_async_loop().run( + agent.run(user_prompt=user_prompt, message_history=message_history), + ) model_response = result.all_messages()[-1] validate_model_response(model_response) except ClientError as e: diff --git a/src/ale_bench_eval/scaffolds.py b/src/ale_bench_eval/scaffolds.py index abf5b73a..b702071b 100644 --- a/src/ale_bench_eval/scaffolds.py +++ b/src/ale_bench_eval/scaffolds.py @@ -1,7 +1,11 @@ -from collections.abc import Sequence -from typing import Any +from collections.abc import Callable, Sequence +from concurrent.futures import ThreadPoolExecutor, as_completed +from threading import BoundedSemaphore +from time import sleep +from typing import Any, TypeVar from pydantic_ai.messages import ModelMessage, UserContent +from pydantic_ai.run import AgentRunResult from ale_bench.result import JudgeResult, Result from ale_bench.session import Session @@ -19,6 +23,199 @@ TIMEOUT_SECONDS = 3600 MAX_RETRIES = 30 +JUDGE_MAX_RETRIES = 3 +JUDGE_RETRY_WAIT_SECONDS = 5.0 + +T = TypeVar("T") + + +def _run_with_retries( + operation_name: str, + save_info: SaveInfo, + operation: Callable[[], T], + max_attempts: int = JUDGE_MAX_RETRIES, +) -> T: + last_error: Exception | None = None + for attempt in range(1, max_attempts + 1): + try: + return operation() + except Exception as e: # noqa: PERF203 + last_error = e + if attempt == max_attempts: + save_info.logger.info("%s failed after %s attempts: %s", operation_name, max_attempts, e) + break + save_info.logger.info( + "%s failed on attempt %s/%s: %s. Retrying...", + operation_name, + attempt, + max_attempts, + e, + ) + sleep(JUDGE_RETRY_WAIT_SECONDS) + + msg = f"{operation_name} failed after {max_attempts} attempts" + raise RuntimeError(msg) from last_error + + +def _evaluate_public_result_once( + *, + config: EvaluationConfig, + session: Session, + public_cases: list[str] | str | None, + code: str, + code_language: str, +) -> Result: + if public_cases is not None: + public_result = session.case_eval( + public_cases, + code, + code_language, + judge_version=config.prompt_args.judge_version, + skip_local_visualization=True, + ) + else: + public_result = session.public_eval( + code, + code_language, + judge_version=config.prompt_args.judge_version, + ) + if public_result.overall_judge_result == JudgeResult.INTERNAL_ERROR: + msg = "Judge returned INTERNAL_ERROR." + raise RuntimeError(msg) + return public_result + + +def _evaluate_and_save_public_result( + *, + config: EvaluationConfig, + session: Session, + public_cases: list[str] | str | None, + code: str, + code_language: str, + save_info: SaveInfo, + filename: str, +) -> Result: + public_result = _run_with_retries( + "Judge evaluation", + save_info, + lambda: _evaluate_public_result_once( + config=config, + session=session, + public_cases=public_cases, + code=code, + code_language=code_language, + ), + ) + _run_with_retries( + "Judge result save", + save_info, + lambda: save_info.save_ale_bench_results(filename, public_result), + ) + return public_result + + +def _delete_previous_self_refine_conversation(save_info: SaveInfo, i: int) -> None: + if i <= 0: + return + previous_conversation_path = save_info.conversations / f"self_refine_conversations_{i - 1}.json" + try: + previous_conversation_path.unlink(missing_ok=True) + except Exception as e: + save_info.logger.info( + "Failed to delete previous self-refine conversation %s: %s", + previous_conversation_path.name, + e, + ) + + +def _generate_repeated_sampling( + i: int, + config: EvaluationConfig, + model_config: dict[str, Any], + user_prompt: str | Sequence[UserContent], + system_prompt: str | Sequence[str], + save_info: SaveInfo, + llm_semaphore: BoundedSemaphore | None, +) -> tuple[int, AgentRunResult[str], str, str]: + save_info.logger.info("Repeated sampling %s/%s", i + 1, config.n_repeated_sampling) + response = safe_generation( + model_config=model_config, + user_prompt=user_prompt, + system_prompt=system_prompt, + timeout=TIMEOUT_SECONDS, + num_retries=MAX_RETRIES, + llm_semaphore=llm_semaphore, + ) + code_language, code = get_code_from_response( + response.output, + config.prompt_args.code_language, + config.prompt_args.judge_version, + ) + return i, response, code_language, code + + +def _save_repeated_sampling_result( + *, + i: int, + response: AgentRunResult[str], + code_language: str, + code: str, + config: EvaluationConfig, + model_config: dict[str, Any], + session: Session, + public_cases: list[str] | str | None, + save_info: SaveInfo, + results_repeated_sampling: dict[int, dict[str, Any]], + results_filename: str, +) -> None: + is_context_length_overflow = False + eval_code_language = code_language + eval_code = code + if eval_code.strip() == "": + if eval_code_language == "": + eval_code_language = get_default_code_language(config.prompt_args.judge_version) + eval_code = get_ce_code(eval_code_language) + + public_result = _evaluate_and_save_public_result( + config=config, + session=session, + public_cases=public_cases, + code=eval_code, + code_language=eval_code_language, + save_info=save_info, + filename=f"repeated_sampling_results_{i}.json", + ) + overall_absolute_score = ( + public_result.overall_absolute_score + if public_result.overall_judge_result == JudgeResult.ACCEPTED + else get_worst_score(session.problem.metadata.score_type) + ) + save_info.logger.info("Overall absolute score: %s", overall_absolute_score) + + usage = response.usage + result_entry = { + "code_language": code_language, + "code": code, + "overall_absolute_score": overall_absolute_score, + "is_context_length_overflow": is_context_length_overflow, + "input_tokens": int(usage.input_tokens), + "output_tokens": int(usage.output_tokens), + "total_tokens": int(usage.total_tokens), + "cost": calc_cost(usage, model_config["model_name"]), + } + + _run_with_retries( + "Repeated sampling conversation save", + save_info, + lambda: save_info.save_conversations(f"repeated_sampling_conversations_{i}.json", response), + ) + updated_results = {**results_repeated_sampling, i: result_entry} + _run_with_retries( + "Repeated sampling summary save", + save_info, + lambda: save_info.save_results(results_filename, {str(k): v for k, v in sorted(updated_results.items())}), + ) + results_repeated_sampling[i] = result_entry def run_repeated_sampling( @@ -28,8 +225,14 @@ def run_repeated_sampling( user_prompt: str | Sequence[UserContent], system_prompt: str | Sequence[str], save_info: SaveInfo, + llm_semaphore: BoundedSemaphore | None = None, + max_repeated_sampling_workers: int | None = None, ) -> dict[int, dict[str, Any]]: """Run repeated sampling to generate multiple solutions and find the best one.""" + if max_repeated_sampling_workers is not None and max_repeated_sampling_workers < 1: + msg = "max_repeated_sampling_workers must be at least 1" + raise ValueError(msg) + public_cases = None if config.n_public_cases is not None: public_cases = session.case_gen(list(range(config.n_public_cases))) @@ -37,7 +240,7 @@ def run_repeated_sampling( results_filename = "repeated_sampling_results.json" if (save_info.results / results_filename).exists(): results_repeated_sampling_raw = save_info.load_results(results_filename) - results_repeated_sampling = {int(k): v for k, v in results_repeated_sampling_raw.items()} + results_repeated_sampling = dict(sorted((int(k), v) for k, v in results_repeated_sampling_raw.items())) save_info.logger.info("Loaded %s results from %s", len(results_repeated_sampling), results_filename) else: results_repeated_sampling = {} @@ -51,93 +254,93 @@ def run_repeated_sampling( ) return results_repeated_sampling - for i in missing_indices: - save_info.logger.info("Repeated sampling %s/%s", i + 1, config.n_repeated_sampling) - is_context_length_overflow = False - try: - response = safe_generation( - model_config=model_config, - user_prompt=user_prompt, - system_prompt=system_prompt, - timeout=TIMEOUT_SECONDS, - num_retries=MAX_RETRIES, - ) - code_language, code = get_code_from_response( - response.output, - config.prompt_args.code_language, - config.prompt_args.judge_version, - ) - # NOTE: We don't expect MaxTokenError here for repeated sampling - # NOTE: The model should be able to handle the prompt at this point - except Exception: - save_info.logger.exception("Error for repeated sampling %s", i) - continue # skip this iteration and do not save results - - overall_absolute_score = get_worst_score(session.problem.metadata.score_type) - if response is not None: + max_workers = min( + len(missing_indices), + config.n_repeated_sampling if max_repeated_sampling_workers is None else max_repeated_sampling_workers, + ) + if max_workers == 1: + for i in missing_indices: try: - # If code is empty, use a compile-error-inducing code to save the evaluation result - is_code_language_empty, is_code_empty = False, False - if code.strip() == "": - is_code_empty = True - if code_language == "": - is_code_language_empty = True - code_language = get_default_code_language(config.prompt_args.judge_version) - code = get_ce_code(code_language) - # Evaluate the code - if public_cases is not None: - public_result = session.case_eval( - public_cases, - code, - code_language, - judge_version=config.prompt_args.judge_version, - skip_local_visualization=True, - ) - else: - public_result = session.public_eval( - code, - code_language, - judge_version=config.prompt_args.judge_version, - ) - overall_absolute_score = ( - public_result.overall_absolute_score - if public_result.overall_judge_result == JudgeResult.ACCEPTED - else get_worst_score(session.problem.metadata.score_type) + sample_index, response, code_language, code = _generate_repeated_sampling( + i, + config, + model_config, + user_prompt, + system_prompt, + save_info, + llm_semaphore, ) - save_info.logger.info("Overall absolute score: %s", overall_absolute_score) - save_info.save_ale_bench_results(f"repeated_sampling_results_{i}.json", public_result) - except Exception as e: - save_info.logger.info("Code evaluation failed for sample %s: %s", i, e) - finally: - # If code or code_language is empty, set them to originally empty values - if is_code_empty: - code = "" - if is_code_language_empty: - code_language = "" - - usage = response.usage() if response is not None else None - results_repeated_sampling[i] = { - "code_language": code_language, - "code": code, - "overall_absolute_score": overall_absolute_score, - "is_context_length_overflow": is_context_length_overflow, - "input_tokens": int(usage.input_tokens) if usage is not None else None, - "output_tokens": int(usage.output_tokens) if usage is not None else None, - "total_tokens": int(usage.total_tokens) if usage is not None else None, - "cost": calc_cost(usage, model_config["model_name"]) if usage is not None else None, - } - - # Save intermediate results - if response is not None: - save_info.save_conversations(f"repeated_sampling_conversations_{i}.json", response) - save_info.save_results(results_filename, {str(k): v for k, v in results_repeated_sampling.items()}) + # NOTE: We don't expect MaxTokenError here for repeated sampling + # NOTE: The model should be able to handle the prompt at this point + except Exception: + save_info.logger.exception("Error for repeated sampling %s", i) + continue # skip this iteration and do not save results + try: + _save_repeated_sampling_result( + i=sample_index, + response=response, + code_language=code_language, + code=code, + config=config, + model_config=model_config, + session=session, + public_cases=public_cases, + save_info=save_info, + results_repeated_sampling=results_repeated_sampling, + results_filename=results_filename, + ) + except Exception: + save_info.logger.exception("Judge/save failed for repeated sampling %s", sample_index) + continue + else: + save_info.logger.info("Running repeated sampling with up to %s in-flight LLM calls", max_workers) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_to_index = { + executor.submit( + _generate_repeated_sampling, + i, + config, + model_config, + user_prompt, + system_prompt, + save_info, + llm_semaphore, + ): i + for i in missing_indices + } + for future in as_completed(future_to_index): + i = future_to_index[future] + try: + i, response, code_language, code = future.result() + # NOTE: We don't expect MaxTokenError here for repeated sampling + # NOTE: The model should be able to handle the prompt at this point + except Exception: + save_info.logger.exception("Error for repeated sampling %s", i) + continue # skip this iteration and do not save results + try: + _save_repeated_sampling_result( + i=i, + response=response, + code_language=code_language, + code=code, + config=config, + model_config=model_config, + session=session, + public_cases=public_cases, + save_info=save_info, + results_repeated_sampling=results_repeated_sampling, + results_filename=results_filename, + ) + except Exception: + save_info.logger.exception("Judge/save failed for repeated sampling %s", i) + continue # Check if we have any successful results if not results_repeated_sampling: msg = "No successful repeated sampling results generated" raise RuntimeError(msg) - return results_repeated_sampling + return dict(sorted(results_repeated_sampling.items())) def run_self_refinement( @@ -145,9 +348,10 @@ def run_self_refinement( model_config: dict[str, Any], session: Session, initial_message_history: list[ModelMessage], - initial_public_result: Result, + initial_public_result: Result | None, initial_result: dict[str, Any], save_info: SaveInfo, + llm_semaphore: BoundedSemaphore | None = None, ) -> dict[int, dict[str, Any]]: """Run self-refinement iterations to improve the best solution.""" public_cases = None @@ -156,8 +360,7 @@ def run_self_refinement( public_result: Result | None = None results_filename = "self_refine_results.json" - conversations_filename = "self_refine_conversations.json" - if (save_info.results / results_filename).exists() and (save_info.conversations / conversations_filename).exists(): + if (save_info.results / results_filename).exists(): results_self_refine_raw = save_info.load_results(results_filename) results_self_refine = {int(k): v for k, v in results_self_refine_raw.items()} max_result_index = max(results_self_refine.keys()) @@ -167,10 +370,16 @@ def run_self_refinement( max_result_index, ) return results_self_refine - message_history = save_info.load_conversations(conversations_filename).all_messages() - public_result = save_info.load_ale_bench_results( - f"self_refine_results_{len(results_self_refine) - 1}.json" # exclude the initial repeated sampling result - ) + if max_result_index == 0: + message_history = initial_message_history + public_result = initial_public_result + else: + conversations_filename = f"self_refine_conversations_{max_result_index}.json" + message_history = save_info.load_conversations(conversations_filename).all_messages() + if results_self_refine[max_result_index]["code"].strip() == "": + public_result = None + else: + public_result = save_info.load_ale_bench_results(f"self_refine_results_{max_result_index}.json") save_info.logger.info("Loaded %s results from %s", len(results_self_refine), results_filename) else: save_info.logger.info("No results found for %s, starting from scratch", results_filename) @@ -184,7 +393,11 @@ def run_self_refinement( raise ValueError(msg) if initial_index >= config.n_self_refine: if not (save_info.results / results_filename).exists(): # NOTE: n_self_refine=1 - save_info.save_results(results_filename, {str(k): v for k, v in results_self_refine.items()}) + _run_with_retries( + "Self-refine summary save", + save_info, + lambda: save_info.save_results(results_filename, {str(k): v for k, v in results_self_refine.items()}), + ) save_info.logger.info("Skipping self-refinement because already generated %s results", initial_index) return results_self_refine @@ -198,6 +411,7 @@ def run_self_refinement( message_history=message_history, # including system prompt timeout=TIMEOUT_SECONDS, num_retries=MAX_RETRIES, + llm_semaphore=llm_semaphore, ) code_language, code = get_code_from_response( response.output, @@ -217,50 +431,38 @@ def run_self_refinement( overall_absolute_score = get_worst_score(session.problem.metadata.score_type) if response is not None: + eval_code_language = code_language + eval_code = code + is_code_empty = eval_code.strip() == "" + if is_code_empty: + if eval_code_language == "": + eval_code_language = get_default_code_language(config.prompt_args.judge_version) + eval_code = get_ce_code(eval_code_language) try: - # If code is empty, use a compile-error-inducing code to save the evaluation result - is_code_language_empty, is_code_empty = False, False - if code.strip() == "": - is_code_empty = True - if code_language == "": - is_code_language_empty = True - code_language = get_default_code_language(config.prompt_args.judge_version) - code = get_ce_code(code_language) - # Evaluate the code - if public_cases is not None: - public_result = session.case_eval( - public_cases, - code, - code_language, - judge_version=config.prompt_args.judge_version, - skip_local_visualization=True, - ) - else: - public_result = session.public_eval( - code, - code_language, - judge_version=config.prompt_args.judge_version, - ) + public_result = _evaluate_and_save_public_result( + config=config, + session=session, + public_cases=public_cases, + code=eval_code, + code_language=eval_code_language, + save_info=save_info, + filename=f"self_refine_results_{i}.json", + ) overall_absolute_score = ( public_result.overall_absolute_score if public_result.overall_judge_result == JudgeResult.ACCEPTED else get_worst_score(session.problem.metadata.score_type) ) save_info.logger.info("Overall absolute score: %s", overall_absolute_score) - save_info.save_ale_bench_results(f"self_refine_results_{i}.json", public_result) except Exception as e: - save_info.logger.info("Code evaluation failed for refinement %s: %s", i, e) + save_info.logger.info("Code evaluation failed for refinement %s after retries: %s", i, e) + msg = f"Judge/save failed during self-refinement {i}: {e}" + raise ValueError(msg) from e + if is_code_empty: public_result = None - finally: - # If code or code_language is empty, set them to originally empty values - if is_code_empty: - public_result = None - code = "" - if is_code_language_empty: - code_language = "" - - usage = response.usage() if response is not None else None - results_self_refine[i] = { + + usage = response.usage if response is not None else None + result_entry = { "code_language": code_language, "code": code, "overall_absolute_score": overall_absolute_score, @@ -273,8 +475,24 @@ def run_self_refinement( # Save intermediate results if response is not None: - save_info.save_conversations(conversations_filename, response) - save_info.save_results(results_filename, {str(k): v for k, v in results_self_refine.items()}) + _run_with_retries( + "Self-refine conversation save", + save_info, + lambda i=i, response=response: save_info.save_conversations( + f"self_refine_conversations_{i}.json", response + ), + ) + updated_results = {**results_self_refine, i: result_entry} + _run_with_retries( + "Self-refine summary save", + save_info, + lambda updated_results=updated_results: save_info.save_results( + results_filename, {str(k): v for k, v in updated_results.items()} + ), + ) + results_self_refine[i] = result_entry + if response is not None: + _delete_previous_self_refine_conversation(save_info, i) # End self refine if context length overflow if response is None: diff --git a/tests/test_utils.py b/tests/test_utils.py index 4f0e28ec..1b066fc0 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,6 @@ import os from pathlib import Path -from typing import Literal, TypedDict +from typing import Literal, TypedDict, cast import pytest from PIL import Image @@ -503,9 +503,11 @@ def test_parse_statement( assert content == expected_content elif isinstance(expected_content, dict): assert isinstance(content, dict) - assert content.keys() == expected_content.keys() - for key in content: - assert content[key] == expected_content[key] + content_dict = cast("dict[str, str | dict[str, str]]", content) + expected_content_dict = cast("dict[str, str | dict[str, str]]", expected_content) + assert content_dict.keys() == expected_content_dict.keys() + for key in expected_content_dict: + assert content_dict[key] == expected_content_dict[key] elif isinstance(expected_content, Image.Image): assert isinstance(content, Image.Image) assert content.tobytes() == expected_content.tobytes() diff --git a/uv.lock b/uv.lock index 4a767a19..2d713e78 100644 --- a/uv.lock +++ b/uv.lock @@ -12,7 +12,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-04-03T04:40:22.086497Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P1W" [[package]] @@ -232,7 +232,7 @@ requires-dist = [ { name = "cairosvg" }, { name = "docker" }, { name = "fire", marker = "extra == 'eval'", specifier = ">=0.7.0" }, - { name = "genai-prices", marker = "extra == 'eval'", specifier = ">=0.0.47" }, + { name = "genai-prices", marker = "extra == 'eval'", specifier = ">=0.0.61" }, { name = "huggingface-hub" }, { name = "numpy", marker = "extra == 'eval'", specifier = ">=2.0.2" }, { name = "pandas", marker = "extra == 'eval'", specifier = ">=2.3.3" }, @@ -240,16 +240,16 @@ requires-dist = [ { name = "polars", specifier = ">=1" }, { name = "psutil", marker = "extra == 'eval'", specifier = ">=7.0.0" }, { name = "pydantic", specifier = ">=2" }, - { name = "pydantic-ai", marker = "extra == 'eval'", specifier = ">=1.30.0" }, + { name = "pydantic-ai", marker = "extra == 'eval'", specifier = ">=1.100.0" }, ] provides-extras = ["eval"] [package.metadata.requires-dev] dev = [ - { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-mock", specifier = ">=3.15.1" }, - { name = "ruff", specifier = ">=0.15.9" }, - { name = "ty", specifier = ">=0.0.28" }, + { name = "ruff", specifier = ">=0.15.14" }, + { name = "ty", specifier = ">=0.0.38" }, ] [[package]] @@ -272,7 +272,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.88.0" +version = "0.104.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -284,9 +284,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/68/565f13059c0a6a6fd5f96f306f2a0fb478a0e1174ec18a4df16b5fac9379/anthropic-0.88.0.tar.gz", hash = "sha256:f4c7f6863d08c869913516f08d658fe53caaf8bcc4fbea3218df343d2a876c58", size = 596654, upload-time = "2026-04-01T19:59:05.287Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/01/8bfa68b886aa436775325a108700f7da39e290870199ce9251934de3e4aa/anthropic-0.104.0.tar.gz", hash = "sha256:1ae8ef4709e90cc2068c8e8a63c1ecb63cc5360d325a4bef8b296aad76148be3", size = 849329, upload-time = "2026-05-21T20:02:05.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/ac/68f646998160c9f2e6f9353a31dd87292ef02b915b455aaf70a52a059a75/anthropic-0.88.0-py3-none-any.whl", hash = "sha256:71898b32332bc75d9739bc10095288d40a29605da6d00da2fe832b1aa036552f", size = 478338, upload-time = "2026-04-01T19:59:03.832Z" }, + { url = "https://files.pythonhosted.org/packages/72/6c/595f1893352dbb4d88f1c2f0983296638783936b2063564aca9abb189990/anthropic-0.104.0-py3-none-any.whl", hash = "sha256:18f73ba3429aab237cdd146a486d20b4da3c008fb4a3ba83f237b27793e884c7", size = 832920, upload-time = "2026-05-21T20:02:07.853Z" }, ] [[package]] @@ -332,14 +332,15 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.9" +version = "1.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, + { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, ] [[package]] @@ -931,12 +932,47 @@ wheels = [ [[package]] name = "fastmcp" -version = "3.2.0" +version = "3.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "fastmcp-slim", extra = ["client", "server"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/a9/5c5a01b6abd5346bf60b97cfd29e4a86661940c27dd562bfcda07fd03519/fastmcp-3.3.1.tar.gz", hash = "sha256:979362ea557de42a5f40342563c7e4b236bcc8e7cd192715f50030695d1a71cd", size = 28681699, upload-time = "2026-05-15T15:50:39.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/11/6b1bdada6ccfe647d615ae63f9106f8136aec17971e9361546af01c7d38e/fastmcp-3.3.1-py3-none-any.whl", hash = "sha256:862440c5c4d281363a5995eee59d77f0f7cac1f18869038729cecf03b02fc522", size = 7903, upload-time = "2026-05-15T15:50:36.424Z" }, +] + +[[package]] +name = "fastmcp-slim" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "pydantic", extra = ["email"] }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/a0/627103e517e1d0d6f1eec633d5662d13e776f01b45ad188e4f5f7478b438/fastmcp_slim-3.3.1.tar.gz", hash = "sha256:0957835fc59452e143ab2f4b7836d2d2df9b2d9958408edc79ba8b56232b2a88", size = 567007, upload-time = "2026-05-15T15:50:10.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/ee/97047f4cc2d7b1d46670d08d8ad01a96e7a748cc01c0b4b351ad8eddbc7a/fastmcp_slim-3.3.1-py3-none-any.whl", hash = "sha256:6cf1c2d77e3adb0d409d6825ed6b0b2a999062973e00b8eea03bd48bf9b4c043", size = 738644, upload-time = "2026-05-15T15:50:08.336Z" }, +] + +[package.optional-dependencies] +client = [ + { name = "authlib" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "opentelemetry-api" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, +] +server = [ { name = "authlib" }, { name = "cyclopts" }, { name = "exceptiongroup" }, + { name = "griffelib" }, { name = "httpx" }, { name = "jsonref" }, { name = "jsonschema-path" }, @@ -944,22 +980,15 @@ dependencies = [ { name = "openapi-pydantic" }, { name = "opentelemetry-api" }, { name = "packaging" }, - { name = "platformdirs" }, { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, - { name = "pydantic", extra = ["email"] }, { name = "pyperclip" }, - { name = "python-dotenv" }, + { name = "python-multipart" }, { name = "pyyaml" }, - { name = "rich" }, { name = "uncalled-for" }, { name = "uvicorn" }, { name = "watchfiles" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/32/4f1b2cfd7b50db89114949f90158b1dcc2c92a1917b9f57c0ff24e47a2f4/fastmcp-3.2.0.tar.gz", hash = "sha256:d4830b8ffc3592d3d9c76dc0f398904cf41f04910e41a0de38cc1004e0903bef", size = 26318581, upload-time = "2026-03-30T20:25:37.692Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/67/684fa2d2de1e7504549d4ca457b4f854ccec3cd3be03bd86b33b599fbf58/fastmcp-3.2.0-py3-none-any.whl", hash = "sha256:e71aba3df16f86f546a4a9e513261d3233bcc92bef0dfa647bac3fa33623f681", size = 705550, upload-time = "2026-03-30T20:25:35.499Z" }, -] [[package]] name = "filelock" @@ -1123,15 +1152,15 @@ wheels = [ [[package]] name = "genai-prices" -version = "0.0.56" +version = "0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/6b/94b3018a672c7775edfb485f0fed8f6068fba75e49b067e8a1ac5eb96764/genai_prices-0.0.56.tar.gz", hash = "sha256:ac24b16a84d0ab97539bfa48dfa4649689de8e3ce71c12ebacef29efb1998045", size = 65872, upload-time = "2026-03-20T20:33:00.732Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/71/0c76010eec75f4b3623d521044785c0977c14adabe1cac72b004349567fb/genai_prices-0.0.61.tar.gz", hash = "sha256:4b3bcfd49f174c05831b09f9ee36557d3648569e2f594af6c24b72031b3f0e52", size = 67806, upload-time = "2026-05-19T17:01:36.902Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/f6/8ef7e4c286deb2709d11ca96a5237caae3ef4876ab3c48095856cfd2df30/genai_prices-0.0.56-py3-none-any.whl", hash = "sha256:dbe86be8f3f556bed1b72209ed36851fec8b01793b3b220f42921a4e7da945f6", size = 68966, upload-time = "2026-03-20T20:33:02.555Z" }, + { url = "https://files.pythonhosted.org/packages/de/ec/b08dc2e834ca00fd8dfedcb17ae2e920667adaad617b45e32b7a3b146f24/genai_prices-0.0.61-py3-none-any.whl", hash = "sha256:d77142f61c13e69909ac19c8e44fd315fd65f3afd714e8d55e914fab0eaf47a2", size = 70853, upload-time = "2026-05-19T17:01:37.858Z" }, ] [[package]] @@ -1551,6 +1580,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] +[[package]] +name = "joserfc" +version = "1.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/dc/5f768c2e391e9afabe5d18e3221346deb5fb6338565f1ccc9e7c6d7befdd/joserfc-1.6.5.tar.gz", hash = "sha256:1482a7db78fb4602e44ed89e51b599d052e091288c7c532c5b694e20149dec48", size = 231881, upload-time = "2026-05-06T04:58:13.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/3b/ad1cb22e75c963b1f07c8a2329bf47227ce7e4361df5eb2fb101b2ce33ef/joserfc-1.6.5-py3-none-any.whl", hash = "sha256:e9878a0f8243fe7b95e11fdda81374ca9f7a689e302751579d3dfdeec559675e", size = 70464, upload-time = "2026-05-06T04:58:11.668Z" }, +] + [[package]] name = "jsonpath-python" version = "1.1.5" @@ -2730,19 +2771,19 @@ email = [ [[package]] name = "pydantic-ai" -version = "1.77.0" +version = "1.100.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic-ai-slim", extra = ["ag-ui", "anthropic", "bedrock", "cli", "cohere", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "spec", "temporal", "ui", "vertexai", "xai"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f0/d3/b6c452f889c055090924aa62bbb791997caa1cfb2eae40a43c4a6fdc112e/pydantic_ai-1.77.0.tar.gz", hash = "sha256:a9c82be7993471c2b9de654eb4973f1d5385215514cb88b201f082d2976e1ae7", size = 12645, upload-time = "2026-04-03T02:16:52.381Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/71/1b82028d95170edf901cede53545c6c14e90fc2982a4cee19f8eda5908a8/pydantic_ai-1.100.0.tar.gz", hash = "sha256:ff0cf885a84b0b9e20d73f75e052de433100186b9f73d92e93a2101f7e03dda9", size = 17435, upload-time = "2026-05-21T04:04:56.524Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/2d/acf07a84ff521607a1e64079cb19a86eafb5bab380aa644644f05fb6493b/pydantic_ai-1.77.0-py3-none-any.whl", hash = "sha256:0a325fc55737dad0beab0ba02a9944fdb290373777de4eabf2763654c7650bba", size = 7551, upload-time = "2026-04-03T02:16:43.839Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3f/50a07a30b95b20df7abe20e46ad8595aad6ea817e8df2d2bc24e9f737b5e/pydantic_ai-1.100.0-py3-none-any.whl", hash = "sha256:4c78ffd0d5fc90d51ac96aaa5d8f2972d02829998e0f53a3476b463fde92173b", size = 7589, upload-time = "2026-05-21T04:04:45.504Z" }, ] [[package]] name = "pydantic-ai-slim" -version = "1.77.0" +version = "1.100.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, @@ -2754,9 +2795,9 @@ dependencies = [ { name = "pydantic-graph" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/a7/ad011e626bed1f275fbaf933181573a50b05c2b9a0be927583d46fb8ff13/pydantic_ai_slim-1.77.0.tar.gz", hash = "sha256:a6e7006a4b048193d45b6ba816d301271e3f5ef1cdc4f9fb340617f382c6ce0d", size = 518781, upload-time = "2026-04-03T02:16:54.524Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/db/3eb296369e3001f82d7552060c3811307809954b7c7ec30499f383d3c87a/pydantic_ai_slim-1.100.0.tar.gz", hash = "sha256:cc755c7970b2e041764b9d2f74d50cbad3cf7989b14cdbf62d356f25eb2a1222", size = 726289, upload-time = "2026-05-21T04:04:59.061Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/c5/5913cc4ae99047901c602f0d8208e3a75a7952b7e57d76169547307d7cea/pydantic_ai_slim-1.77.0-py3-none-any.whl", hash = "sha256:110c516935de384f1beddc36fda04e8df36cdf5bee3a5bfd0da562726182e52b", size = 664494, upload-time = "2026-04-03T02:16:46.668Z" }, + { url = "https://files.pythonhosted.org/packages/49/a6/164b070c59f7c69e81cb5fd38225da24c4587a8fe629af49c72709182ffc/pydantic_ai_slim-1.100.0-py3-none-any.whl", hash = "sha256:422a721b7a7157daff749a6eec9a4ba2e3e7efc28ae5a2bdfe468529eaf81aaf", size = 901788, upload-time = "2026-05-21T04:04:49.147Z" }, ] [package.optional-dependencies] @@ -2799,7 +2840,7 @@ logfire = [ { name = "logfire", extra = ["httpx"] }, ] mcp = [ - { name = "mcp" }, + { name = "fastmcp-slim", extra = ["client"] }, ] mistral = [ { name = "mistralai" }, @@ -2949,7 +2990,7 @@ wheels = [ [[package]] name = "pydantic-evals" -version = "1.77.0" +version = "1.100.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2959,14 +3000,14 @@ dependencies = [ { name = "pyyaml" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/23/8da617d3803362325790e73e6219dec359559c2ef2350b35dbeb9e39c92f/pydantic_evals-1.77.0.tar.gz", hash = "sha256:64a12324c9a3f4fefa34b5a5eb4c2320c0976e425404372fa69f2872f585c3d0", size = 65811, upload-time = "2026-04-03T02:16:55.71Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/c9/6d6299fa7e50dd51d7c03e7e8bcf32810f100f234cc6cf8ca35cedf01f0a/pydantic_evals-1.100.0.tar.gz", hash = "sha256:e536bf3b2d6351be797cdd1aab9797d034fbaf7a056873256cef06e1e80ac696", size = 78523, upload-time = "2026-05-21T04:05:00.621Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/07/b9e6ba4afcce41f70a0f149cd7bc299a0babaf9c62a57d7cb291679a6cc7/pydantic_evals-1.77.0-py3-none-any.whl", hash = "sha256:2b536081e36d70826da216a3a6df8d84e3ac1982fc3b8078c123fd539ce6bfb6", size = 77740, upload-time = "2026-04-03T02:16:48.681Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8e/3b4161b016c676cab1d19f9d1b3672cb61e8f56315fb61bb3367123760d4/pydantic_evals-1.100.0-py3-none-any.whl", hash = "sha256:67f6e2467d5fc300d965118ef46b5cebb07e07227419ee64da9b381c28e34f47", size = 93526, upload-time = "2026-05-21T04:04:51.527Z" }, ] [[package]] name = "pydantic-graph" -version = "1.77.0" +version = "1.100.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -2974,9 +3015,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/40/a8b8e256bb90e4e284b35cc1c5e1a8e2724fa88ad89b7eac958fbf85852b/pydantic_graph-1.77.0.tar.gz", hash = "sha256:ba75dbdf221cd7e366e5c5d250f4d9f3138e05400ea52d3f36330772d989deee", size = 58689, upload-time = "2026-04-03T02:16:56.625Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/8b/eee672fa01eec3ea34e7d8962d54aa16d658ad0723466122a7ba2828caa1/pydantic_graph-1.100.0.tar.gz", hash = "sha256:7651fa6ccce9d88a35b1d2cfe79d5d1dbb2415457503009195014bf31f6075bd", size = 62559, upload-time = "2026-05-21T04:05:01.682Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/09/3c9c3aba8031adbd21d1833e8e4edd749697e50a88fa9bdab641874abe4f/pydantic_graph-1.77.0-py3-none-any.whl", hash = "sha256:063803e87aec901919c2073ccf3fdd6e4fff84e8b05dbfbe8a6c1af63dd12c05", size = 72503, upload-time = "2026-04-03T02:16:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/bb/12/21d6a68743229553c8ba9ca19da73c2d7e18dc6f8fbfd56c8c12c5587cb6/pydantic_graph-1.100.0-py3-none-any.whl", hash = "sha256:ba0b0a70bfd320b58b7012614b857f4b12dbb95b233da7127a6ff973c03d24e7", size = 80101, upload-time = "2026-05-21T04:04:53.635Z" }, ] [[package]] @@ -3042,7 +3083,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -3053,9 +3094,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -3093,11 +3134,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.22" +version = "0.0.29" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" }, ] [[package]] @@ -3504,27 +3545,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, - { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, - { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, - { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, - { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, - { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, - { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, - { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, - { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, - { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, - { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, - { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, - { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, - { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, - { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, +version = "0.15.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, ] [[package]] @@ -3814,26 +3855,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.28" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/c2/a60543fb172ac7adaa3ae43b8db1d0dcd70aa67df254b70bf42f852a24f6/ty-0.0.28.tar.gz", hash = "sha256:1fbde7bc5d154d6f047b570d95665954fa83b75a0dce50d88cf081b40a27ea32", size = 5447781, upload-time = "2026-04-02T21:34:33.556Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/15/c2aa3d4633e6153a2e300d7dd0ebdedf904a60241d1922566f31c5f7f211/ty-0.0.28-py3-none-linux_armv6l.whl", hash = "sha256:6dbfb27524195ab1715163d7be065cc45037509fe529d9763aff6732c919f0d8", size = 10556282, upload-time = "2026-04-02T21:35:04.165Z" }, - { url = "https://files.pythonhosted.org/packages/60/9c/f6183838df89e9692235a71a69a9d4e0f12481bbdf1883f47010075793b0/ty-0.0.28-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8c72a899ba94f7438bd07e897a84b36526b385aaf01d6f3eb6504e869232b3a6", size = 10425770, upload-time = "2026-04-02T21:34:49.144Z" }, - { url = "https://files.pythonhosted.org/packages/68/82/e9208383412f8a320537ef4c44a768d2cb6c1330d9ab33087f0b932ccd1b/ty-0.0.28-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eef67f9cdfd31677bde801b611741dde779271ec6f471f818c7c6eccf515237f", size = 9899999, upload-time = "2026-04-02T21:34:40.297Z" }, - { url = "https://files.pythonhosted.org/packages/4d/26/0442f49589ba393fbd3b50751f8bb82137b036bc509762884f7b21c511d1/ty-0.0.28-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70e7b98a91d8245641be1e4b55af8bc9b1ae82ec189794d35e14e546f1e15e66", size = 10400725, upload-time = "2026-04-02T21:34:42.779Z" }, - { url = "https://files.pythonhosted.org/packages/57/d9/64128f1a7ceba72e49f35dd562533f44d4c56d0cf62efb21692377819dbc/ty-0.0.28-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9bd83d4ad9f99078b830aabb47792fac6dc39368bb0f72f3cc14607173ed6e25", size = 10387410, upload-time = "2026-04-02T21:34:46.889Z" }, - { url = "https://files.pythonhosted.org/packages/cc/52/498b6bdd1d0a985fd14ce83c31186f3b838ad79efdf68ce928f441a6962b/ty-0.0.28-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0172984fc2fcd3e47ccd5da69f36f632cddc410f9a093144a05ad07d67cf06ed", size = 10880982, upload-time = "2026-04-02T21:34:53.687Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c8/fefd616f38a250b28f62ba73728cb6061715f03df0a610dce558a0fdfc0a/ty-0.0.28-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0bbf47d2bea82a09cab2ca4f48922d6c16a36608447acdc64163cd19beb28d3", size = 11459056, upload-time = "2026-04-02T21:34:31.642Z" }, - { url = "https://files.pythonhosted.org/packages/16/15/9e18d763a5ef9c6a69396876586589fd5e0fd0acba35fae8a9a169680f48/ty-0.0.28-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1774c9a0fb071607e3bdfa0ce8365488ac46809fc04ad1706562a8709a023247", size = 11156341, upload-time = "2026-04-02T21:35:01.824Z" }, - { url = "https://files.pythonhosted.org/packages/89/29/8ac0281fc44c3297f0e58699ebf993c13621e32a0fab1025439d3ea8a2f1/ty-0.0.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2849d6d212af78175430e8cc51a962a53851458182eb44a981b0e3981163177", size = 11006089, upload-time = "2026-04-02T21:34:38.111Z" }, - { url = "https://files.pythonhosted.org/packages/dd/de/5b5fdbe3bdb5c6f4918b33f1c55cd975b3d606057089a822439d5151bf93/ty-0.0.28-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3c576c15b867b3913c4a1d9be30ade4682303e24a576d2cc99bfd8f25ae838e9", size = 10367739, upload-time = "2026-04-02T21:34:57.679Z" }, - { url = "https://files.pythonhosted.org/packages/80/82/abdfb27ab988e6bd09502a4573f64a7e72db3e83acd7886af54448703c97/ty-0.0.28-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2e5f13d10b3436bee3ea35851e5af400123f6693bfae48294ddfbbf553fa51ef", size = 10399528, upload-time = "2026-04-02T21:34:51.398Z" }, - { url = "https://files.pythonhosted.org/packages/ba/74/3ccbe468e8480ba53f83a1e52481d3e11756415f0ca1297fb2da65e29612/ty-0.0.28-py3-none-musllinux_1_2_i686.whl", hash = "sha256:759db467e399faedc7d5f1ca4b383dd8ecc71d7d79b2ca6ea6db4ac8e643378a", size = 10586771, upload-time = "2026-04-02T21:34:35.912Z" }, - { url = "https://files.pythonhosted.org/packages/ee/79/545c76dcef0c3f89fb733ec46118aed2a700e79d4e22cb142e3b5a80286c/ty-0.0.28-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0cd44e3c857951cbf3f8647722ca87475614fac8ac0371eb1f200a942315a2c2", size = 11110550, upload-time = "2026-04-02T21:34:55.65Z" }, - { url = "https://files.pythonhosted.org/packages/2c/e4/e3c6f71c95a2cbabd7d88fd698b00b8af48e39aa10e0b10b839410fc3c6d/ty-0.0.28-py3-none-win32.whl", hash = "sha256:88e2c784ec5e0e2fb01b137d92fd595cdc27b98a553f4bb34b8bf138bac1be1e", size = 9985411, upload-time = "2026-04-02T21:34:44.763Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e5/79dbab4856d3d15e5173314ff1846be65d58b31de6efe62ef1c25c663b32/ty-0.0.28-py3-none-win_amd64.whl", hash = "sha256:faaffbef127cb67560ad6dbc6a8f8845a4033b818bcc78ad7af923e02df199db", size = 10986548, upload-time = "2026-04-02T21:34:59.886Z" }, - { url = "https://files.pythonhosted.org/packages/01/b2/cc987aaf5babacc55caf0aeb751c83401e86e05e22ce82dace5a7e7e5354/ty-0.0.28-py3-none-win_arm64.whl", hash = "sha256:34a18ea09ee09612fb6555deccf1eed810e6f770b61a41243b494bcb7f624a1c", size = 10388573, upload-time = "2026-04-02T21:34:29.219Z" }, +version = "0.0.38" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/3b/45be6b37d5060d6917bf7f1f234c00d360fc5f8b7486f8a96af640e25661/ty-0.0.38.tar.gz", hash = "sha256:fbc8d47f7630457669ab41e333dc093897fdb7ead1ffc94dcf8f30b5d39aa56d", size = 5681218, upload-time = "2026-05-20T00:15:32.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/43/ea9b4e57d6a266670dbe34858e92f6093ca054ad1b48f1c82580a72340fb/ty-0.0.38-py3-none-linux_armv6l.whl", hash = "sha256:3501dcf44ca03f813f9cb4fabfdf601adc0ac1337c411405b470530679e37a45", size = 11289326, upload-time = "2026-05-20T00:14:52.371Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ff/24e2f623a1c6b5f5ccf8bf82fccd937033c6a7dba57a4028c7f41270fa4a/ty-0.0.38-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b34b4094b76252c3e8c90762cdd5e8a9f1101534484745ff4b480f71eb38ac2e", size = 11063047, upload-time = "2026-05-20T00:14:42.832Z" }, + { url = "https://files.pythonhosted.org/packages/e9/41/4f0d910f0acbd20b358eda80a5cd6a8361d27ff5b8e87ab559d3f69f125e/ty-0.0.38-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c518ad33a877677365baab2e21d82cf59ffee789203a15a143f5179ee5a1d3f8", size = 10494436, upload-time = "2026-05-20T00:15:24.425Z" }, + { url = "https://files.pythonhosted.org/packages/69/d8/da06833422082aa98b169a391f9197e2d73865e96c90b6979ac886b890a2/ty-0.0.38-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9238494722303eccddc6a27eb647948b694eecd6b974910d13b9e6cd46bbeb6a", size = 11000992, upload-time = "2026-05-20T00:14:58.368Z" }, + { url = "https://files.pythonhosted.org/packages/16/f7/e1172197fb827e6410ca3eb0dc68ef2789f3c70683696f2a0ce5c90764fd/ty-0.0.38-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31d91d7336c5d51bf822ac0df512f300584ca4dcca041fc6a6d7df03a8ddbb31", size = 11058583, upload-time = "2026-05-20T00:15:11.314Z" }, + { url = "https://files.pythonhosted.org/packages/5b/61/7fbaf0c05981e006a8804287819c574dff90a6bf8e96efad7226be0700aa/ty-0.0.38-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65165879814993450710b9349791e4898c65e36b1e14eec554884c06a2f20ff1", size = 11531036, upload-time = "2026-05-20T00:15:14.62Z" }, + { url = "https://files.pythonhosted.org/packages/49/e3/47c0c64e401d50f925df3e52479d4e7626754b2a9e38201d142fdacd6252/ty-0.0.38-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d61868b8d1c4033bf8088191de953fed245c2f9e1bb9d2d53e5699170b0924c", size = 12129991, upload-time = "2026-05-20T00:14:39.475Z" }, + { url = "https://files.pythonhosted.org/packages/90/99/2f452d02901bcd7f1b109cf5b848727ce37f372c3406143aa52d1305d40e/ty-0.0.38-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8f9a9175548c98dbff7707865738c07c2b1f8e07a09b8c68101baebb5dac59a4", size = 11756167, upload-time = "2026-05-20T00:15:27.526Z" }, + { url = "https://files.pythonhosted.org/packages/dd/0c/c7e14d111c813e1a20b82e944f1c997c4631a2bb710eaa64fb6b26835e13/ty-0.0.38-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:375d3a964c6b4aea2e9237fdb5eb9ed03dc43088986a94209a28a4ea3b62001c", size = 11637099, upload-time = "2026-05-20T00:15:21.261Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/ab02659dd1ed62898db7db4d37f9937c80854dd45e95093fa0fe10328d82/ty-0.0.38-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:cdfd547782c45267aa0b52abad31bd406bf4768c264532ef9e2360cd3c6ce048", size = 11813583, upload-time = "2026-05-20T00:14:45.875Z" }, + { url = "https://files.pythonhosted.org/packages/7e/57/bd1b5ebf4e71a4295484afac0202df1740b0807762b86744b1bef4534984/ty-0.0.38-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:858bc675b75626470abe4e6c3b3934b853642b04f2ac4d7139fcefea3b48b213", size = 10975405, upload-time = "2026-05-20T00:15:30.354Z" }, + { url = "https://files.pythonhosted.org/packages/e7/55/0305c78711bbd23922cf291996a08ef9544f4179da98e9a75c14e608f379/ty-0.0.38-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:54be4f00432870da42cd74fe145a3362fd248e22d032c74bd807cb45bf068f94", size = 11097551, upload-time = "2026-05-20T00:14:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/7c/4f/7effe7f9a6ac9719eb7234172c01739c5f888bb47f9acc2ea8da1f4afed3/ty-0.0.38-py3-none-musllinux_1_2_i686.whl", hash = "sha256:494af66a76a86dbf16a3003d3b63b03484aa4c7489dfe11f3ee5413b98b22d60", size = 11214391, upload-time = "2026-05-20T00:15:18.094Z" }, + { url = "https://files.pythonhosted.org/packages/75/cd/d9fdfec3a74a6ad0209fa5e7113ae29d4f457d0651cfbb813b4c6563e0d4/ty-0.0.38-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3d92527c4be78a5ce6d32e8bb0aa2a6988d4076eddf1294e56fdaf06d1a98e7e", size = 11730871, upload-time = "2026-05-20T00:14:49.219Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4a/beefade12d109b4f7793d61b04b4478b1ad4d1465a719e7ff55b2d42461a/ty-0.0.38-py3-none-win32.whl", hash = "sha256:36fc5dd5dc09207ff3004b1560a79a3fb8d12456daeec914a7b802a918da654c", size = 10548583, upload-time = "2026-05-20T00:15:07.892Z" }, + { url = "https://files.pythonhosted.org/packages/15/64/941b205e2e46cc2297c245c64aa7691410b7454fa4d07a6cb3cf59487833/ty-0.0.38-py3-none-win_amd64.whl", hash = "sha256:eef0a8956ba14514076b1a963d13eb32986d9ebad7f0527b3cc01cb68bf35147", size = 11650542, upload-time = "2026-05-20T00:15:01.441Z" }, + { url = "https://files.pythonhosted.org/packages/59/02/c1c4f9ec4b94d95190636fa13f79c32f65165fbe3a0503882d4df164d2ac/ty-0.0.38-py3-none-win_arm64.whl", hash = "sha256:79abfc8658a026c30b1c955613437dab3ef4b12feca56a3e6df50903cc39e07f", size = 11010307, upload-time = "2026-05-20T00:15:04.567Z" }, ] [[package]]