From 0b7137738f4d1b8a7d2945b60b5a291e5192d505 Mon Sep 17 00:00:00 2001 From: Raman Kudaktsin Date: Fri, 30 Jan 2026 18:43:58 +0300 Subject: [PATCH 1/8] feat: check repo and cdn url format --- commit.py | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/commit.py b/commit.py index d61397a..db81177 100644 --- a/commit.py +++ b/commit.py @@ -1,8 +1,10 @@ import asyncio import json +import re import sys from pathlib import Path from typing import Callable +from urllib.parse import urlparse import requests import click @@ -79,6 +81,24 @@ def _fetch_schedule(round_number: int) -> Schedule: raise RuntimeError(f"Failed to parse schedule.json: {str(e)}") +def _validate_repo_format(repo: str) -> bool: + """Validate that repo is in the format 'user/repo'.""" + # Single regex pattern with two groups: user and repo + # Pattern: alphanumeric, hyphens, underscores, dots allowed + pattern = r'([a-zA-Z0-9._-]+)/([a-zA-Z0-9._-]+)' + return bool(re.fullmatch(pattern, repo)) + + +def _validate_cdn_url_format(cdn_url: str) -> bool: + """Validate that cdn_url is a valid URL format with http:// or https:// scheme.""" + try: + result = urlparse(cdn_url) + # Must have http or https scheme and a netloc (domain) + return bool(result.scheme in ('http', 'https') and result.netloc) + except Exception: + return False + + async def _fetch_and_parse_commitments( subtensor_endpoint: str, netuid: int, @@ -167,11 +187,11 @@ def commit_hash_cmd( @cli.command("commit-repo-cdn") -@click.option("--repo", required=True, help="HF repo id (e.g. user/repo)") +@click.option("--repo", required=True, help="Git repository in format 'user/repo' (e.g. mokabetrade/ansible-foundry)") @click.option( "--cdn-url", required=True, - help="URL of the S3 compatible object storage that saves the generated PLY files" + help="URL of the S3 compatible object storage that saves the generated PLY files. Must be a valid URL with scheme (http:// or https://)" ) @click.option("--netuid", default=17, show_default=True) @click.option( @@ -191,8 +211,17 @@ def commit_repo_cdn_cmd( ) -> None: """Commit repo and CDN URL on-chain.""" import bittensor as bt # Bittensor import should be here because bittensor captures command line args for click otherwise - - try: + + if not _validate_repo_format(repo): + click.echo(json.dumps({"success": False, "error": f"Invalid git repository format: {repo}. Expected format: 'user/repo' (e.g. 'mokabetrade/ansible-foundry')"})) + raise SystemExit(1) + + # Validate CDN URL format + if not _validate_cdn_url_format(cdn_url): + click.echo(json.dumps({"success": False, "error": f"Invalid CDN URL format: {cdn_url}. Expected a valid URL with scheme (http:// or https://)"})) + raise SystemExit(1) + + try: state = _fetch_state() except Exception as e: click.echo(json.dumps({"success": False, "error": f"Failed to fetch state: {str(e)}"})) From 282c2624853fff2321abeee7323165beb23fe262 Mon Sep 17 00:00:00 2001 From: Raman Kudaktsin Date: Mon, 2 Feb 2026 13:37:57 +0300 Subject: [PATCH 2/8] feat: exclude partial submissions and table view for list command --- commit.py | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 78 insertions(+), 5 deletions(-) diff --git a/commit.py b/commit.py index db81177..70f9769 100644 --- a/commit.py +++ b/commit.py @@ -323,7 +323,17 @@ async def _commit() -> None: @click.option( "--subtensor.endpoint", "subtensor_endpoint", default="finney", show_default=True ) -def list_all_cmd(netuid: int, subtensor_endpoint: str) -> None: +@click.option( + "--exclude-partial", + is_flag=True, + default=False, + help="Exclude partial commits (missing hash, repo, or cdn_url)", +) +def list_all_cmd( + netuid: int, + subtensor_endpoint: str, + exclude_partial: bool, +) -> None: """List all revealed commitments.""" # Ask user for round number interactively round_number: int = click.prompt("Enter round number", type=int) @@ -351,18 +361,81 @@ def list_all_cmd(netuid: int, subtensor_endpoint: str) -> None: click.echo(json.dumps({"success": False, "error": f"Failed to fetch schedule: {str(e)}"})) raise SystemExit(1) - async def _list(round_number: int, schedule: Schedule, current_round: int) -> list[dict]: + async def _list(round_number: int, schedule: Schedule, current_round: int, exclude_partial: bool) -> list[dict]: import bittensor as bt # Bittensor import should be here because bittensor captures command line args for click otherwise async with bt.async_subtensor(subtensor_endpoint) as subtensor: commitments = await subtensor.get_all_revealed_commitments(netuid=netuid) commitments_dict = _parse_commitments(commitments, round_number, schedule, current_round) results_list = list(commitments_dict.values()) + + # Filter out partial commits if requested + if exclude_partial: + results_list = [ + entry for entry in results_list + if entry.get("commit_hash") and entry.get("repo") and entry.get("cdn_url") + ] + results_list.sort(key=lambda x: x["commit_block"]) return results_list - results = asyncio.run(_list(round_number, schedule, current_round)) - for entry in results: - click.echo(json.dumps(entry)) + results = asyncio.run(_list(round_number, schedule, current_round, exclude_partial)) + + if not results: + click.echo("No commitments found for this round.", err=True) + return + + # Always display as table + _display_commitments_table(results, round_number) + + +def _display_commitments_table(results: list[dict], round_number: int) -> None: + """Display commitments in a formatted table.""" + click.echo(f"\n{'='*140}", err=True) + click.echo(f"Round {round_number} - Commitments ({len(results)} total)", err=True) + click.echo(f"{'='*140}\n", err=True) + + # Compute dynamic widths so that repo and CDN URL are full-text but aligned + repo_header = "Repo" + cdn_header = "CDN URL" + max_repo_len = max(len((entry.get("repo") or "N/A")) for entry in results) if results else len(repo_header) + max_cdn_len = max(len((entry.get("cdn_url") or "N/A")) for entry in results) if results else len(cdn_header) + repo_width = max(max_repo_len, len(repo_header)) + cdn_width = max(max_cdn_len, len(cdn_header)) + + # Table header with dynamic spacing; hotkey shows first 8 chars + header = ( + f"{'#':<4} " + f"{'Hotkey':<10} " + f"{'Block':<10} " + f"{'Commit Hash':<40} " + f"{repo_header:<{repo_width}} " + f"{cdn_header:<{cdn_width}}" + ) + click.echo(header, err=True) + click.echo("-" * 140, err=True) + + # Table rows + for idx, entry in enumerate(results, 1): + full_hotkey = entry.get("hotkey", "N/A") + hotkey = full_hotkey[:8] if full_hotkey != "N/A" else full_hotkey + + commit_hash = entry.get("commit_hash") or "N/A" + commit_block = str(entry.get("commit_block") or "N/A") + + repo = entry.get("repo") or "N/A" + cdn_url = entry.get("cdn_url") or "N/A" + + row = ( + f"{idx:<4} " + f"{hotkey:<10} " + f"{commit_block:<10} " + f"{commit_hash:<40} " + f"{repo:<{repo_width}} " + f"{cdn_url:<{cdn_width}}" + ) + click.echo(row, err=True) + + click.echo(f"\n{'='*140}\n", err=True) def _parse_commitments(commitments: dict, round_number: int, schedule: Schedule, current_round: int) -> dict[str, dict]: From ee907828c587246371c84cb93a4cde6a69e3e181 Mon Sep 17 00:00:00 2001 From: Raman Kudaktsin Date: Mon, 2 Feb 2026 14:21:36 +0300 Subject: [PATCH 3/8] feat: HF_TOKEN for generator --- commit.py | 10 +++++++++- targon_client.py | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/commit.py b/commit.py index 70f9769..73e24a2 100644 --- a/commit.py +++ b/commit.py @@ -489,11 +489,16 @@ def _parse_commitments(commitments: dict, round_number: int, schedule: Schedule, @cli.command("start-generator") @click.option("--image-url", required=True, help="URL of the generator image to start") @click.option("--targon-api-key", required=True, help="Targon API key") -def start_generator_cmd(image_url: str, targon_api_key: str) -> None: +@click.option("--hf-token", "hf_token", default=None, help="HuggingFace token to pass as HF_TOKEN environment variable") +def start_generator_cmd(image_url: str, targon_api_key: str, hf_token: str | None) -> None: """Start the generator container.""" click.echo(f"Starting generator: {image_url}", err=True) try: + env = None + if hf_token: + env = {"HF_TOKEN": hf_token} + container_url = asyncio.run( _create_container( image_url=image_url, @@ -503,6 +508,7 @@ def start_generator_cmd(image_url: str, targon_api_key: str) -> None: port=_GENERATOR_PORT, health_check_path=_GENERATOR_HEALTH_CHECK_PATH, echo=lambda msg: click.echo(msg, err=True), + env=env, ) ) click.echo(json.dumps({"success": True, "container_url": container_url})) @@ -716,6 +722,7 @@ async def _create_container( health_check_path: str, echo: Callable[[str], None], args: list[str] | None = None, + env: dict[str, str] | None = None, ) -> str: """ Create and deploy a container on Targon. @@ -744,6 +751,7 @@ async def _create_container( port=port, container_concurrency=1, args=args, + env=env, ) container = await ensure_running_container( client=targon, diff --git a/targon_client.py b/targon_client.py index c0919dc..4bbae0e 100644 --- a/targon_client.py +++ b/targon_client.py @@ -27,6 +27,7 @@ class ContainerDeployConfig(BaseModel): resource_name: str = "h200-small" port: int = 10006 args: list[str] | None = None + env: dict[str, str] | None = None class TargonClient: @@ -87,6 +88,7 @@ async def deploy_container(self, name: str, config: ContainerDeployConfig) -> No container=TargonContainerConfig( image=config.image, args=config.args, + env=config.env, ), resource_name=config.resource_name, network=NetworkConfig( From b034cede7f9bd41522efc1dc9fceab822efc9456 Mon Sep 17 00:00:00 2001 From: Raman Kudaktsin Date: Mon, 2 Feb 2026 16:24:23 +0300 Subject: [PATCH 4/8] fieat: generator name --- commit.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/commit.py b/commit.py index 73e24a2..b060de5 100644 --- a/commit.py +++ b/commit.py @@ -490,7 +490,8 @@ def _parse_commitments(commitments: dict, round_number: int, schedule: Schedule, @click.option("--image-url", required=True, help="URL of the generator image to start") @click.option("--targon-api-key", required=True, help="Targon API key") @click.option("--hf-token", "hf_token", default=None, help="HuggingFace token to pass as HF_TOKEN environment variable") -def start_generator_cmd(image_url: str, targon_api_key: str, hf_token: str | None) -> None: +@click.option("--name", "container_name", default=None, help="Custom container name (default: generator)") +def start_generator_cmd(image_url: str, targon_api_key: str, hf_token: str | None, container_name: str | None) -> None: """Start the generator container.""" click.echo(f"Starting generator: {image_url}", err=True) @@ -499,10 +500,16 @@ def start_generator_cmd(image_url: str, targon_api_key: str, hf_token: str | Non if hf_token: env = {"HF_TOKEN": hf_token} + # Format container name: "generator_{name}" if name provided, otherwise use default + if container_name: + name = f"generator_{container_name}" + else: + name = _GENERATOR_POD_NAME + container_url = asyncio.run( _create_container( image_url=image_url, - container_name=_GENERATOR_POD_NAME, + container_name=name, targon_api_key=targon_api_key, resource_name="h200-small", port=_GENERATOR_PORT, @@ -648,7 +655,8 @@ async def _stop() -> None: async with TargonClient(api_key=targon_api_key) as targon: containers = await targon.list_containers() for c in containers: - if c.name in [_GENERATOR_POD_NAME, _RENDER_POD_NAME, _JUDGE_POD_NAME]: + # Stop all containers that start with "generator_", plus render and judge pods + if c.name.startswith("generator_") or c.name in [_RENDER_POD_NAME, _JUDGE_POD_NAME]: click.echo(f"Stopping container {c.name} ({c.uid})", err=True) await targon.delete_container(c.uid) try: From 85c167b5603daca53a470d8cb3b13126f8473b1a Mon Sep 17 00:00:00 2001 From: Raman Kudaktsin Date: Mon, 2 Feb 2026 17:58:12 +0300 Subject: [PATCH 5/8] feat: different options for bittensor name and hotkey --- commit.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/commit.py b/commit.py index b060de5..8db9739 100644 --- a/commit.py +++ b/commit.py @@ -119,9 +119,9 @@ async def _fetch_and_parse_commitments( @click.option( "--subtensor.endpoint", "subtensor_endpoint", default="finney", show_default=True ) -@click.option("--wallet.name", "wallet_name", required=True, help="Name of the bittensor wallet to use") -@click.option("--wallet.hotkey", "wallet_hotkey", required=True, help="Hotkey name of the wallet") -@click.option("--wallet.path", "wallet_path", default=None, help="Path to the wallet directory (default: ~/.bittensor)") +@click.option("--wallet-name", "--name", "--wallet_name", "--wallet.name", "wallet_name", required=True, help="Name of the wallet.") +@click.option("--wallet-path", "--wallet_path", "--wallet.path", "-p", "wallet_path", default=None, help="Path where the wallets are located. For example: /Users/btuser/.bittensor/wallets.") +@click.option("--hotkey", "--wallet_hotkey", "--wallet-hotkey", "--wallet.hotkey", "-H", "wallet_hotkey", required=True, help="Hotkey of the wallet") def commit_hash_cmd( commit_hash: str, netuid: int, @@ -197,9 +197,9 @@ def commit_hash_cmd( @click.option( "--subtensor.endpoint", "subtensor_endpoint", default="finney", show_default=True ) -@click.option("--wallet.name", "wallet_name", required=True, help="Name of the bittensor wallet to use") -@click.option("--wallet.hotkey", "wallet_hotkey", required=True, help="Hotkey name of the wallet") -@click.option("--wallet.path", "wallet_path", default=None, help="Path to the wallet directory (default: ~/.bittensor)") +@click.option("--wallet-name", "--name", "--wallet_name", "--wallet.name", "wallet_name", required=True, help="Name of the wallet.") +@click.option("--wallet-path", "--wallet_path", "--wallet.path", "-p", "wallet_path", default=None, help="Path where the wallets are located. For example: /Users/btuser/.bittensor/wallets.") +@click.option("--hotkey", "--wallet_hotkey", "--wallet-hotkey", "--wallet.hotkey", "-H", "wallet_hotkey", required=True, help="Hotkey of the wallet") def commit_repo_cdn_cmd( repo: str, cdn_url: str, From 45f2ea4542523d5a683aad3387d3bc7450c5eaec Mon Sep 17 00:00:00 2001 From: Raman Kudaktsin Date: Tue, 3 Feb 2026 14:31:55 +0300 Subject: [PATCH 6/8] feat: version command --- commit.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/commit.py b/commit.py index 8db9739..6d1a671 100644 --- a/commit.py +++ b/commit.py @@ -38,6 +38,7 @@ ] _JUDGE_MODEL: str = "zai-org/GLM-4.1V-9B-Thinking" _GITHUB_URL: str = "https://raw.githubusercontent.com/404-Repo/404-active-competition/main" +_CLI_VERSION: str = "0.1.0" @click.group() @@ -50,6 +51,12 @@ def cli(verbose: int) -> None: logger.add(sys.stderr, level=levels.get(verbose, "TRACE")) +@cli.command("version") +def version_cmd() -> None: + """Show the CLI version.""" + click.echo(_CLI_VERSION) + + def _fetch_state() -> State: """Download and parse state.json from GitHub.""" state_url = f"{_GITHUB_URL}/state.json" From 973bbb0ebdc8fcef1dc52c3a8ff17fe6ebfdd875 Mon Sep 17 00:00:00 2001 From: Raman Kudaktsin Date: Tue, 3 Feb 2026 18:01:38 +0300 Subject: [PATCH 7/8] fix: linter fixes and version command --- README.md | 38 +++++ commit.py | 373 ++++++++++++++++++++++++++++++++++-------------- generator.py | 12 +- judge.py | 58 ++++---- models.py | 2 +- pyproject.toml | 117 ++++++++++++++- renderer.py | 26 ++-- targon_utils.py | 16 +-- 8 files changed, 469 insertions(+), 173 deletions(-) diff --git a/README.md b/README.md index 1bb7c41..d874590 100644 --- a/README.md +++ b/README.md @@ -452,3 +452,41 @@ On failure, outputs error JSON: - Image files must be PNG format and filenames must match the prompt keys (derived from URL filenames) - Evaluation progress and judge responses are output to stderr, while JSON results go to stdout - The output file is created automatically if it doesn't exist + +## For Development + +To install the package with development dependencies (linting, formatting, type checking tools): + +```bash +pip install -e ".[dev]" +``` + +This will install: +- **ruff** - Fast Python linter +- **black** - Code formatter +- **mypy** - Static type checker +- **bandit** - Security linter +- **pre-commit** - Git hooks framework +- **pytest** - Testing framework (if configured) +- **poethepoet** - Task runner + +After installation, you can run all linting and formatting tools at once: + +```bash +poe lint +``` + +This will run: +- `ruff check .` - Lint code +- `black .` - Format code +- `bandit . -rq -c pyproject.toml` - Security scan +- `mypy --explicit-package-bases .` - Type checking + +Alternatively, you can run individual tools: + +```bash +ruff check . +black . +mypy . +bandit -r . +``` diff --git a/commit.py b/commit.py index 6d1a671..dfbda4b 100644 --- a/commit.py +++ b/commit.py @@ -2,20 +2,20 @@ import json import re import sys +from collections.abc import Callable from pathlib import Path -from typing import Callable from urllib.parse import urlparse -import requests import click -from loguru import logger -from targon_client import TargonClient, ContainerDeployConfig -from targon_utils import ensure_running_container +import requests from generator import Generator +from judge import Judge +from loguru import logger +from models import Schedule, State from renderer import Renderer from targon.client.serverless import ServerlessResourceListItem -from judge import Judge -from models import State, Schedule +from targon_client import ContainerDeployConfig, TargonClient +from targon_utils import ensure_running_container _GENERATOR_POD_NAME: str = "generator" @@ -30,11 +30,16 @@ _JUDGE_HEALTH_CHECK_PATH: str = "/health" _JUDGE_IMAGE_URL: str = "vllm/vllm-openai:latest" _JUDGE_ARGS: list[str] = [ - "--model", "zai-org/GLM-4.1V-9B-Thinking", - "--max-model-len", "8096", - "--tensor-parallel-size", "1", - "--gpu-memory-utilization", "0.95", - "--max-num-seqs", "4", + "--model", + "zai-org/GLM-4.1V-9B-Thinking", + "--max-model-len", + "8096", + "--tensor-parallel-size", + "1", + "--gpu-memory-utilization", + "0.95", + "--max-num-seqs", + "4", ] _JUDGE_MODEL: str = "zai-org/GLM-4.1V-9B-Thinking" _GITHUB_URL: str = "https://raw.githubusercontent.com/404-Repo/404-active-competition/main" @@ -42,9 +47,7 @@ @click.group() -@click.option( - "-v", "--verbose", count=True, help="Verbosity: -v INFO, -vv DEBUG, -vvv TRACE" -) +@click.option("-v", "--verbose", count=True, help="Verbosity: -v INFO, -vv DEBUG, -vvv TRACE") def cli(verbose: int) -> None: levels = {0: "WARNING", 1: "INFO", 2: "DEBUG"} logger.remove() @@ -67,10 +70,10 @@ def _fetch_state() -> State: return State.model_validate(response.json()) except requests.RequestException as e: logger.error(f"Failed to fetch state.json: {e}") - raise RuntimeError(f"Failed to fetch state.json from {state_url}: {str(e)}") + raise RuntimeError(f"Failed to fetch state.json from {state_url}: {str(e)}") from e except json.JSONDecodeError as e: logger.error(f"Failed to parse state.json: {e}") - raise RuntimeError(f"Failed to parse state.json: {str(e)}") + raise RuntimeError(f"Failed to parse state.json: {str(e)}") from e def _fetch_schedule(round_number: int) -> Schedule: @@ -82,17 +85,17 @@ def _fetch_schedule(round_number: int) -> Schedule: return Schedule.model_validate(response.json()) except requests.RequestException as e: logger.error(f"Failed to fetch schedule.json for round {round_number}: {e}") - raise RuntimeError(f"Failed to fetch schedule.json from {schedule_url}: {str(e)}") + raise RuntimeError(f"Failed to fetch schedule.json from {schedule_url}: {str(e)}") from e except json.JSONDecodeError as e: logger.error(f"Failed to parse schedule.json for round {round_number}: {e}") - raise RuntimeError(f"Failed to parse schedule.json: {str(e)}") + raise RuntimeError(f"Failed to parse schedule.json: {str(e)}") from e def _validate_repo_format(repo: str) -> bool: """Validate that repo is in the format 'user/repo'.""" # Single regex pattern with two groups: user and repo # Pattern: alphanumeric, hyphens, underscores, dots allowed - pattern = r'([a-zA-Z0-9._-]+)/([a-zA-Z0-9._-]+)' + pattern = r"([a-zA-Z0-9._-]+)/([a-zA-Z0-9._-]+)" return bool(re.fullmatch(pattern, repo)) @@ -101,7 +104,7 @@ def _validate_cdn_url_format(cdn_url: str) -> bool: try: result = urlparse(cdn_url) # Must have http or https scheme and a netloc (domain) - return bool(result.scheme in ('http', 'https') and result.netloc) + return bool(result.scheme in ("http", "https") and result.netloc) except Exception: return False @@ -114,7 +117,9 @@ async def _fetch_and_parse_commitments( current_round: int, ) -> dict[str, dict]: """Fetch commitments from subtensor and parse them for a specific round.""" - import bittensor as bt # Bittensor import should be here because bittensor captures command line args for click otherwise + # Bittensor import should be here because bittensor captures command line args for click otherwise + import bittensor as bt + async with bt.async_subtensor(subtensor_endpoint) as subtensor: raw_commitments = await subtensor.get_all_revealed_commitments(netuid=netuid) return _parse_commitments(raw_commitments, round_number, schedule, current_round) @@ -123,12 +128,35 @@ async def _fetch_and_parse_commitments( @cli.command("commit-hash") @click.option("--hash", "commit_hash", required=True, help="HF commit SHA") @click.option("--netuid", default=17, show_default=True) +@click.option("--subtensor.endpoint", "subtensor_endpoint", default="finney", show_default=True) +@click.option( + "--wallet-name", + "--name", + "--wallet_name", + "--wallet.name", + "wallet_name", + required=True, + help="Name of the wallet.", +) +@click.option( + "--wallet-path", + "--wallet_path", + "--wallet.path", + "-p", + "wallet_path", + default=None, + help=("Path where the wallets are located. " "For example: /Users/btuser/.bittensor/wallets."), +) @click.option( - "--subtensor.endpoint", "subtensor_endpoint", default="finney", show_default=True + "--hotkey", + "--wallet_hotkey", + "--wallet-hotkey", + "--wallet.hotkey", + "-H", + "wallet_hotkey", + required=True, + help="Hotkey of the wallet", ) -@click.option("--wallet-name", "--name", "--wallet_name", "--wallet.name", "wallet_name", required=True, help="Name of the wallet.") -@click.option("--wallet-path", "--wallet_path", "--wallet.path", "-p", "wallet_path", default=None, help="Path where the wallets are located. For example: /Users/btuser/.bittensor/wallets.") -@click.option("--hotkey", "--wallet_hotkey", "--wallet-hotkey", "--wallet.hotkey", "-H", "wallet_hotkey", required=True, help="Hotkey of the wallet") def commit_hash_cmd( commit_hash: str, netuid: int, @@ -138,28 +166,39 @@ def commit_hash_cmd( wallet_path: str | None, ) -> None: """Commit revision hash on-chain.""" - import bittensor as bt # Bittensor import should be here because bittensor captures command line args for click otherwise - - try: + # Bittensor import should be here because bittensor captures command line args for click otherwise + import bittensor as bt + + try: state = _fetch_state() except Exception as e: click.echo(json.dumps({"success": False, "error": f"Failed to fetch state: {str(e)}"})) - raise SystemExit(1) + raise SystemExit(1) from None try: schedule = _fetch_schedule(state.current_round) except Exception as e: click.echo(json.dumps({"success": False, "error": f"Failed to fetch schedule: {str(e)}"})) - raise SystemExit(1) + raise SystemExit(1) from None try: current_block = asyncio.run(bt.async_subtensor(subtensor_endpoint).get_current_block()) if current_block < schedule.earliest_reveal_block: - click.echo(json.dumps({"success": False, "error": f"Current block {current_block} is before the earliest reveal block {schedule.earliest_reveal_block}"})) - raise SystemExit(1) + click.echo( + json.dumps( + { + "success": False, + "error": ( + f"Current block {current_block} is before the earliest reveal block " + f"{schedule.earliest_reveal_block}" + ), + } + ) + ) + raise SystemExit(1) from None except Exception as e: click.echo(json.dumps({"success": False, "error": f"Failed to fetch current block: {str(e)}"})) - raise SystemExit(1) + raise SystemExit(1) from None round_to_commit = state.current_round if current_block <= schedule.latest_reveal_block else state.current_round + 1 try: @@ -179,8 +218,11 @@ def commit_hash_cmd( elif not commitments[hotkey]["repo"] or not commitments[hotkey]["cdn_url"]: click.echo(f"WARNING: You have not commited repo and cdn_url for round {round_to_commit}.", err=True) except Exception as e: - click.echo(f"WARNING: Failed to fetch information about your commitments in round {round_to_commit}: {str(e)}", err=True) - + click.echo( + f"WARNING: Failed to fetch information about your commitments in round {round_to_commit}: {str(e)}", + err=True, + ) + _run_commit( data={"commit": commit_hash}, netuid=netuid, @@ -196,17 +238,43 @@ def commit_hash_cmd( @cli.command("commit-repo-cdn") @click.option("--repo", required=True, help="Git repository in format 'user/repo' (e.g. mokabetrade/ansible-foundry)") @click.option( - "--cdn-url", - required=True, - help="URL of the S3 compatible object storage that saves the generated PLY files. Must be a valid URL with scheme (http:// or https://)" + "--cdn-url", + required=True, + help=( + "URL of the S3 compatible object storage that saves the generated PLY files. " + "Must be a valid URL with scheme (http:// or https://)" + ), ) @click.option("--netuid", default=17, show_default=True) +@click.option("--subtensor.endpoint", "subtensor_endpoint", default="finney", show_default=True) +@click.option( + "--wallet-name", + "--name", + "--wallet_name", + "--wallet.name", + "wallet_name", + required=True, + help="Name of the wallet.", +) +@click.option( + "--wallet-path", + "--wallet_path", + "--wallet.path", + "-p", + "wallet_path", + default=None, + help=("Path where the wallets are located. " "For example: /Users/btuser/.bittensor/wallets."), +) @click.option( - "--subtensor.endpoint", "subtensor_endpoint", default="finney", show_default=True + "--hotkey", + "--wallet_hotkey", + "--wallet-hotkey", + "--wallet.hotkey", + "-H", + "wallet_hotkey", + required=True, + help="Hotkey of the wallet", ) -@click.option("--wallet-name", "--name", "--wallet_name", "--wallet.name", "wallet_name", required=True, help="Name of the wallet.") -@click.option("--wallet-path", "--wallet_path", "--wallet.path", "-p", "wallet_path", default=None, help="Path where the wallets are located. For example: /Users/btuser/.bittensor/wallets.") -@click.option("--hotkey", "--wallet_hotkey", "--wallet-hotkey", "--wallet.hotkey", "-H", "wallet_hotkey", required=True, help="Hotkey of the wallet") def commit_repo_cdn_cmd( repo: str, cdn_url: str, @@ -217,37 +285,67 @@ def commit_repo_cdn_cmd( wallet_path: str | None, ) -> None: """Commit repo and CDN URL on-chain.""" - import bittensor as bt # Bittensor import should be here because bittensor captures command line args for click otherwise - + # Bittensor import should be here because bittensor captures command line args for click otherwise + import bittensor as bt + if not _validate_repo_format(repo): - click.echo(json.dumps({"success": False, "error": f"Invalid git repository format: {repo}. Expected format: 'user/repo' (e.g. 'mokabetrade/ansible-foundry')"})) - raise SystemExit(1) - + click.echo( + json.dumps( + { + "success": False, + "error": ( + f"Invalid git repository format: {repo}. " + "Expected format: 'user/repo' (e.g. 'mokabetrade/ansible-foundry')" + ), + } + ) + ) + raise SystemExit(1) from None + # Validate CDN URL format if not _validate_cdn_url_format(cdn_url): - click.echo(json.dumps({"success": False, "error": f"Invalid CDN URL format: {cdn_url}. Expected a valid URL with scheme (http:// or https://)"})) - raise SystemExit(1) - + click.echo( + json.dumps( + { + "success": False, + "error": ( + f"Invalid CDN URL format: {cdn_url}. " "Expected a valid URL with scheme (http:// or https://)" + ), + } + ) + ) + raise SystemExit(1) from None + try: state = _fetch_state() except Exception as e: click.echo(json.dumps({"success": False, "error": f"Failed to fetch state: {str(e)}"})) - raise SystemExit(1) + raise SystemExit(1) from None try: schedule = _fetch_schedule(state.current_round) except Exception as e: click.echo(json.dumps({"success": False, "error": f"Failed to fetch schedule: {str(e)}"})) - raise SystemExit(1) + raise SystemExit(1) from None try: current_block = asyncio.run(bt.async_subtensor(subtensor_endpoint).get_current_block()) if current_block < schedule.earliest_reveal_block: - click.echo(json.dumps({"success": False, "error": f"Current block {current_block} is before the earliest reveal block {schedule.earliest_reveal_block}"})) - raise SystemExit(1) + click.echo( + json.dumps( + { + "success": False, + "error": ( + f"Current block {current_block} is before the earliest reveal block " + f"{schedule.earliest_reveal_block}" + ), + } + ) + ) + raise SystemExit(1) from None except Exception as e: click.echo(json.dumps({"success": False, "error": f"Failed to fetch current block: {str(e)}"})) - raise SystemExit(1) + raise SystemExit(1) from None round_to_commit = state.current_round if current_block <= schedule.latest_reveal_block else state.current_round + 1 try: @@ -263,16 +361,41 @@ def commit_repo_cdn_cmd( wallet = bt.wallet(name=wallet_name, hotkey=wallet_hotkey, path=wallet_path) hotkey = wallet.hotkey.ss58_address if hotkey not in commitments: - click.echo(json.dumps({"success": False, "error": f"You have not committed hash for round {round_to_commit}. Please commit hash first."})) - raise SystemExit(1) + click.echo( + json.dumps( + { + "success": False, + "error": ( + f"You have not committed hash for round {round_to_commit}. " "Please commit hash first." + ), + } + ) + ) + raise SystemExit(1) from None elif not commitments[hotkey]["commit_hash"]: - click.echo(json.dumps({"success": False, "error": f"You have not committed hash for round {round_to_commit}. Please commit hash first."})) - raise SystemExit(1) + click.echo( + json.dumps( + { + "success": False, + "error": ( + f"You have not committed hash for round {round_to_commit}. " "Please commit hash first." + ), + } + ) + ) + raise SystemExit(1) from None except SystemExit: raise except Exception as e: - click.echo(json.dumps({"success": False, "error": f"Failed to fetch information about your commitments in round {round_to_commit}: {str(e)}"})) - raise SystemExit(1) + click.echo( + json.dumps( + { + "success": False, + "error": f"Failed to fetch information about your commitments in round {round_to_commit}: {str(e)}", + } + ) + ) + raise SystemExit(1) from None _run_commit( data={"repo": repo, "cdn_url": cdn_url}, @@ -297,7 +420,9 @@ def _run_commit( state: State, current_round: int, ) -> None: - import bittensor as bt # Bittensor import should be here because bittensor captures --help command otherwise + # Bittensor import should be here because bittensor captures --help command otherwise + import bittensor as bt + wallet = bt.wallet(name=wallet_name, hotkey=wallet_hotkey, path=wallet_path) logger.info(f"Committing {data} with wallet {wallet_name}@{wallet_hotkey}") @@ -322,14 +447,12 @@ async def _commit() -> None: except Exception as e: logger.error(f"Commit failed: {e}") click.echo(json.dumps({"success": False, "error": str(e)})) - raise SystemExit(1) + raise SystemExit(1) from None @cli.command("list-all") @click.option("--netuid", default=17, show_default=True) -@click.option( - "--subtensor.endpoint", "subtensor_endpoint", default="finney", show_default=True -) +@click.option("--subtensor.endpoint", "subtensor_endpoint", default="finney", show_default=True) @click.option( "--exclude-partial", is_flag=True, @@ -351,13 +474,20 @@ def list_all_cmd( state = _fetch_state() current_round = state.current_round if round_number > current_round + 1: - click.echo(json.dumps({"success": False, "error": f"Round {round_number} is not yet revealed. Next round is {current_round + 1}."})) - raise SystemExit(1) + click.echo( + json.dumps( + { + "success": False, + "error": (f"Round {round_number} is not yet revealed. " f"Next round is {current_round + 1}."), + } + ) + ) + raise SystemExit(1) from None except Exception as e: logger.error(f"Failed to fetch state: {e}") click.echo(json.dumps({"success": False, "error": f"Failed to fetch state: {str(e)}"})) - raise SystemExit(1) - + raise SystemExit(1) from None + # Fetch schedule for the round. # If the round is the next round while current round is in progress, fetch the schedule for the current round. try: @@ -366,22 +496,25 @@ def list_all_cmd( except Exception as e: logger.error(f"Failed to fetch schedule: {e}") click.echo(json.dumps({"success": False, "error": f"Failed to fetch schedule: {str(e)}"})) - raise SystemExit(1) + raise SystemExit(1) from None async def _list(round_number: int, schedule: Schedule, current_round: int, exclude_partial: bool) -> list[dict]: - import bittensor as bt # Bittensor import should be here because bittensor captures command line args for click otherwise + # Bittensor import should be here because bittensor captures command line args for click otherwise + import bittensor as bt + async with bt.async_subtensor(subtensor_endpoint) as subtensor: commitments = await subtensor.get_all_revealed_commitments(netuid=netuid) commitments_dict = _parse_commitments(commitments, round_number, schedule, current_round) results_list = list(commitments_dict.values()) - + # Filter out partial commits if requested if exclude_partial: results_list = [ - entry for entry in results_list + entry + for entry in results_list if entry.get("commit_hash") and entry.get("repo") and entry.get("cdn_url") ] - + results_list.sort(key=lambda x: x["commit_block"]) return results_list @@ -404,8 +537,8 @@ def _display_commitments_table(results: list[dict], round_number: int) -> None: # Compute dynamic widths so that repo and CDN URL are full-text but aligned repo_header = "Repo" cdn_header = "CDN URL" - max_repo_len = max(len((entry.get("repo") or "N/A")) for entry in results) if results else len(repo_header) - max_cdn_len = max(len((entry.get("cdn_url") or "N/A")) for entry in results) if results else len(cdn_header) + max_repo_len = max(len(entry.get("repo") or "N/A") for entry in results) if results else len(repo_header) + max_cdn_len = max(len(entry.get("cdn_url") or "N/A") for entry in results) if results else len(cdn_header) repo_width = max(max_repo_len, len(repo_header)) cdn_width = max(max_cdn_len, len(cdn_header)) @@ -420,7 +553,7 @@ def _display_commitments_table(results: list[dict], round_number: int) -> None: ) click.echo(header, err=True) click.echo("-" * 140, err=True) - + # Table rows for idx, entry in enumerate(results, 1): full_hotkey = entry.get("hotkey", "N/A") @@ -441,7 +574,7 @@ def _display_commitments_table(results: list[dict], round_number: int) -> None: f"{cdn_url:<{cdn_width}}" ) click.echo(row, err=True) - + click.echo(f"\n{'='*140}\n", err=True) @@ -457,9 +590,11 @@ def _parse_commitments(commitments: dict, round_number: int, schedule: Schedule, for block, data in entries: if round_number == current_round + 1 and block <= schedule.latest_reveal_block: continue - if round_number <= current_round and (block < schedule.earliest_reveal_block or block > schedule.latest_reveal_block): + if round_number <= current_round and ( + block < schedule.earliest_reveal_block or block > schedule.latest_reveal_block + ): continue - + try: parsed = json.loads(data) except json.JSONDecodeError: @@ -496,23 +631,28 @@ def _parse_commitments(commitments: dict, round_number: int, schedule: Schedule, @cli.command("start-generator") @click.option("--image-url", required=True, help="URL of the generator image to start") @click.option("--targon-api-key", required=True, help="Targon API key") -@click.option("--hf-token", "hf_token", default=None, help="HuggingFace token to pass as HF_TOKEN environment variable") +@click.option( + "--hf-token", + "hf_token", + default=None, + help="HuggingFace token to pass as HF_TOKEN environment variable", +) @click.option("--name", "container_name", default=None, help="Custom container name (default: generator)") def start_generator_cmd(image_url: str, targon_api_key: str, hf_token: str | None, container_name: str | None) -> None: """Start the generator container.""" click.echo(f"Starting generator: {image_url}", err=True) - + try: env = None if hf_token: env = {"HF_TOKEN": hf_token} - + # Format container name: "generator_{name}" if name provided, otherwise use default if container_name: name = f"generator_{container_name}" else: name = _GENERATOR_POD_NAME - + container_url = asyncio.run( _create_container( image_url=image_url, @@ -529,11 +669,11 @@ def start_generator_cmd(image_url: str, targon_api_key: str, hf_token: str | Non except KeyboardInterrupt: logger.warning("Generator start interrupted by user") click.echo(json.dumps({"success": False, "error": "Interrupted by user"})) - raise SystemExit(130) # Standard exit code for SIGINT + raise SystemExit(130) from None # Standard exit code for SIGINT except Exception as e: logger.error(f"Generator start failed: {e}") click.echo(json.dumps({"success": False, "error": str(e)})) - raise SystemExit(1) + raise SystemExit(1) from None @cli.command("start-renderer") @@ -541,7 +681,7 @@ def start_generator_cmd(image_url: str, targon_api_key: str, hf_token: str | Non def start_renderer_cmd(targon_api_key: str) -> None: """Start the renderer container.""" click.echo(f"Starting renderer: {_RENDER_IMAGE_URL}", err=True) - + try: container_url = asyncio.run( _create_container( @@ -558,11 +698,11 @@ def start_renderer_cmd(targon_api_key: str) -> None: except KeyboardInterrupt: logger.warning("Renderer start interrupted by user") click.echo(json.dumps({"success": False, "error": "Interrupted by user"})) - raise SystemExit(130) # Standard exit code for SIGINT + raise SystemExit(130) from None # Standard exit code for SIGINT except Exception as e: logger.error(f"Renderer start failed: {e}") click.echo(json.dumps({"success": False, "error": str(e)})) - raise SystemExit(1) + raise SystemExit(1) from None @cli.command("render") @@ -607,20 +747,24 @@ def start_judge_cmd(targon_api_key: str) -> None: except KeyboardInterrupt: logger.warning("Judge start interrupted by user") click.echo(json.dumps({"success": False, "error": "Interrupted by user"})) - raise SystemExit(130) # Standard exit code for SIGINT + raise SystemExit(130) from None # Standard exit code for SIGINT except Exception as e: logger.error(f"Judge start failed: {e}") click.echo(json.dumps({"success": False, "error": str(e)})) - raise SystemExit(1) + raise SystemExit(1) from None + - @cli.command("judge") @click.option("--prompt-file", required=True, help="Path to the file with prompts that are valid URLs.") @click.option("--image-dir-1", required=True, help="Path to the directory containing the first set of images.") @click.option("--image-dir-2", required=True, help="Path to the directory containing the second set of images.") @click.option("--endpoint", required=True, help="Judge endpoint URL.") @click.option("--seed", required=True, help="Seed for generation.") -@click.option("--output-file", default="duels.json", help="Path to the JSON file where duel results will be saved (default: duels.json).") +@click.option( + "--output-file", + default="duels.json", + help="Path to the JSON file where duel results will be saved (default: duels.json).", +) def judge_cmd( prompt_file: str, image_dir_1: str, @@ -645,37 +789,43 @@ def judge_cmd( except KeyboardInterrupt: logger.warning("Judge interrupted by user") click.echo(json.dumps({"success": False, "error": "Interrupted by user"})) - raise SystemExit(130) # Standard exit code for SIGINT + raise SystemExit(130) from None # Standard exit code for SIGINT except Exception as e: logger.error(f"Judge failed: {e}") click.echo(json.dumps({"success": False, "error": str(e)})) - raise SystemExit(1) + raise SystemExit(1) from None finally: click.echo(json.dumps({"success": True})) + @cli.command("stop-pods") @click.option("--targon-api-key", required=True, help="Targon API key.") def stop_pods_cmd(targon_api_key: str) -> None: """Stop the generator, render and judge pods.""" click.echo("Stopping pods...", err=True) + async def _stop() -> None: async with TargonClient(api_key=targon_api_key) as targon: containers = await targon.list_containers() for c in containers: # Stop all containers that start with "generator_", plus render and judge pods - if c.name.startswith("generator_") or c.name in [_RENDER_POD_NAME, _JUDGE_POD_NAME]: + if c.name.startswith("generator_") or c.name in [ + _RENDER_POD_NAME, + _JUDGE_POD_NAME, + ]: click.echo(f"Stopping container {c.name} ({c.uid})", err=True) await targon.delete_container(c.uid) + try: asyncio.run(_stop()) except KeyboardInterrupt: logger.warning("Pods stop interrupted by user") click.echo(json.dumps({"success": False, "error": "Interrupted by user"})) - raise SystemExit(130) # Standard exit code for SIGINT + raise SystemExit(130) from None # Standard exit code for SIGINT except Exception as e: logger.error(f"Pods stop failed: {e}") click.echo(json.dumps({"success": False, "error": str(e)})) - raise SystemExit(1) + raise SystemExit(1) from None @cli.command("generate") @@ -688,24 +838,24 @@ def generate_cmd( endpoint: str, seed: str, output_folder: str, -) -> None: +) -> None: """Generate models using the generator endpoint.""" # Read prompts from prompt file click.echo("Reading prompts from file...", err=True) try: - with open(prompts_file, "r") as f: + with Path(prompts_file).open() as f: prompts = [line.strip() for line in f.readlines() if line.strip()] except FileNotFoundError: click.echo(f"Prompts file {prompts_file} not found", err=True) - raise SystemExit(1) + raise SystemExit(1) from None except Exception as e: click.echo(f"Error reading prompts file: {e}", err=True) - raise SystemExit(1) - + raise SystemExit(1) from None + if not prompts: click.echo("No prompts found in file", err=True) - raise SystemExit(1) - + raise SystemExit(1) from None + click.echo(f"Found {len(prompts)} prompts to process", err=True) # Create Generator instance @@ -715,17 +865,17 @@ def generate_cmd( output_folder=Path(output_folder), echo=lambda msg: click.echo(msg, err=True), ) - + try: asyncio.run(generator.generate_all(prompts)) click.echo(json.dumps({"success": True})) except KeyboardInterrupt: logger.warning("Generation interrupted by user") click.echo(json.dumps({"success": False, "error": "Interrupted by user"})) - raise SystemExit(130) # Standard exit code for SIGINT + raise SystemExit(130) from None # Standard exit code for SIGINT except Exception as e: click.echo(f"Generation failed: {e}", err=True) - raise SystemExit(1) + raise SystemExit(1) from None async def _create_container( @@ -778,7 +928,8 @@ async def _create_container( if container: echo(f"Container deployed successfully. UID: {container.uid}") echo(f"Container URL: {container.url}") - return container.url + url: str = str(container.url) + return url else: raise RuntimeError("Failed to deploy and start container") except (KeyboardInterrupt, asyncio.CancelledError): diff --git a/generator.py b/generator.py index 151c2d9..bf0b7cf 100644 --- a/generator.py +++ b/generator.py @@ -1,6 +1,6 @@ import asyncio +from collections.abc import Callable from pathlib import Path -from typing import Callable import httpx @@ -28,7 +28,7 @@ def __init__( self.seed = seed self.output_folder = Path(output_folder) self.echo = echo or (lambda msg: None) - + # Create output folder if it doesn't exist self.output_folder.mkdir(parents=True, exist_ok=True) @@ -61,7 +61,7 @@ async def generate_all(self, prompts: list[str]) -> None: ] self.echo(f"Generated {len(tasks)} tasks") results = await asyncio.gather(*tasks, return_exceptions=True) - for prompt, result in zip(prompts, results): + for prompt, result in zip(prompts, results, strict=False): if isinstance(result, Exception): self.echo(f"Prompt {prompt} generation failed: {result}") else: @@ -161,9 +161,7 @@ async def _generate_with_retries( generation_http_backoff_base * (2 ** (attempt - 1)), generation_http_backoff_max, ) - self.echo( - f"Prompt {prompt_key} generation attempt {attempt + 1}/{max_attempts} after {backoff:.1f}s" - ) + self.echo(f"Prompt {prompt_key} generation attempt {attempt + 1}/{max_attempts} after {backoff:.1f}s") await asyncio.sleep(backoff) result = await self._generate_attempt( @@ -223,7 +221,7 @@ async def _generate_attempt( try: content = await response.aread() - except Exception as e: + except Exception: return None download_time = asyncio.get_running_loop().time() - start_time - elapsed diff --git a/judge.py b/judge.py index 3222e23..4250f0f 100644 --- a/judge.py +++ b/judge.py @@ -1,12 +1,13 @@ -from pathlib import Path -from pybase64 import b64encode -import httpx -import click import asyncio import json -from pydantic import BaseModel, Field +from pathlib import Path from typing import Literal + +import click +import httpx from openai import AsyncOpenAI +from pybase64 import b64encode +from pydantic import BaseModel, Field SYSTEM_PROMPT = """ @@ -67,22 +68,22 @@ async def judge(self, prompt_file: Path, image_dir_1: Path, image_dir_2: Path, o raise FileNotFoundError(f"Prompt file {prompt_file} does not exist") if not prompt_file.is_file(): raise ValueError(f"Prompt file {prompt_file} is not a file") - with open(prompt_file, "r") as f: + with prompt_file.open() as f: prompts = [line.strip() for line in f.readlines() if line.strip()] prompt_to_url = {prompt.split("/")[-1].split(".")[0]: prompt for prompt in prompts} - + if not image_dir_1.exists(): raise FileNotFoundError(f"Image directory {image_dir_1} does not exist") if not image_dir_1.is_dir(): raise ValueError(f"Image directory {image_dir_1} is not a directory") prompt_to_file_1 = {image_file.name.split(".")[0]: image_file for image_file in image_dir_1.glob("*.png")} - + if not image_dir_2.exists(): raise FileNotFoundError(f"Image directory {image_dir_2} does not exist") if not image_dir_2.is_dir(): raise ValueError(f"Image directory {image_dir_2} is not a directory") prompt_to_file_2 = {image_file.name.split(".")[0]: image_file for image_file in image_dir_2.glob("*.png")} - + tasks = [] prompt_keys = [] # Track which prompt key corresponds to each task try: @@ -106,10 +107,10 @@ async def judge(self, prompt_file: Path, image_dir_1: Path, image_dir_2: Path, o prompt_keys.append(prompt) click.echo(f"Generated {len(tasks)} tasks", err=True) results = await asyncio.gather(*tasks, return_exceptions=True) - + # Collect successful duel results duel_results = {} - for prompt_key, result in zip(prompt_keys, results): + for prompt_key, result in zip(prompt_keys, results, strict=False): if isinstance(result, Exception): click.echo(f"Prompt {prompt_key} failed: {result}", err=True) duel_results[prompt_key] = { @@ -126,13 +127,13 @@ async def judge(self, prompt_file: Path, image_dir_1: Path, image_dir_2: Path, o "issues": f"Unexpected result type: {type(result)}", "error": True, } - + # Save results to JSON file output_file.parent.mkdir(parents=True, exist_ok=True) - with open(output_file, "w") as f: + with output_file.open("w") as f: json.dump(duel_results, f, indent=2) click.echo(f"Saved {len(duel_results)} duel results to {output_file}", err=True) - + except (KeyboardInterrupt, asyncio.CancelledError): click.echo("\nInterrupted by user. Cancelling tasks and cleaning up...", err=True) # Cancel all running tasks @@ -168,21 +169,21 @@ async def _process_prompt( prompt_image = response.content # Get file 1 image - with open(file_1, "rb") as f: + with file_1.open("rb") as f: file_1_image = f.read() # Get file 2 image - with open(file_2, "rb") as f: + with file_2.open("rb") as f: file_2_image = f.read() - + client = self._create_openai_client() click.echo(f"Processing prompt {prompt_name}...", err=True) - + # Run position-balanced duel (two calls with swapped order) result_1, result_2 = await asyncio.gather( self.ask_judge(process_sem, client, prompt_name, prompt_image, file_1_image, file_2_image, self.seed), self.ask_judge(process_sem, client, prompt_name, prompt_image, file_2_image, file_1_image, self.seed), ) - + # Calculate average penalties (position-balanced) left_penalty = (result_1.penalty_1 + result_2.penalty_2) / 2 right_penalty = (result_1.penalty_2 + result_2.penalty_1) / 2 @@ -217,7 +218,7 @@ async def ask_judge( seed: int, ) -> JudgeResponse: """Ask the judge to evaluate two models against a prompt image. - + Args: process_sem: Semaphore to control concurrent processing client: OpenAI client instance @@ -226,7 +227,7 @@ async def ask_judge( left_image: Bytes of the first model image (4 views) right_image: Bytes of the second model image (4 views) seed: Random seed for reproducibility - + Returns: JudgeResponse with penalties and issues """ @@ -235,7 +236,7 @@ async def ask_judge( prompt_img_b64 = b64encode(prompt_image).decode("utf-8") left_img_b64 = b64encode(left_image).decode("utf-8") right_img_b64 = b64encode(right_image).decode("utf-8") - + messages = [ {"role": "system", "content": SYSTEM_PROMPT}, { @@ -247,10 +248,7 @@ async def ask_judge( "image_url": {"url": f"data:image/png;base64,{prompt_img_b64}"}, }, {"type": "text", "text": "First 3D model (4 different views):"}, - { - "type": "image_url", - "image_url": {"url": f"data:image/png;base64,{left_img_b64}"} - }, + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{left_img_b64}"}}, {"type": "text", "text": "Second 3D model (4 different views):"}, { "type": "image_url", @@ -268,7 +266,7 @@ async def ask_judge( }, } - completion = await client.chat.completions.create( + completion = await client.chat.completions.create( # type: ignore[call-overload] model=self.model, messages=messages, temperature=self.temperature, @@ -277,7 +275,10 @@ async def ask_judge( seed=seed, ) - result = JudgeResponse.model_validate_json(completion.choices[0].message.content) + content = completion.choices[0].message.content + if content is None: + raise ValueError("Received None content from judge API") + result = JudgeResponse.model_validate_json(content) click.echo(f"Judge response for prompt {prompt_name}: {result}", err=True) return result @@ -289,4 +290,3 @@ def _create_openai_client(self) -> AsyncOpenAI: timeout=self.timeout, http_client=httpx.AsyncClient(limits=httpx.Limits(max_keepalive_connections=10, max_connections=20)), ) - diff --git a/models.py b/models.py index f605655..bc42e90 100644 --- a/models.py +++ b/models.py @@ -8,4 +8,4 @@ class State(BaseModel): class Schedule(BaseModel): earliest_reveal_block: int - latest_reveal_block: int \ No newline at end of file + latest_reveal_block: int diff --git a/pyproject.toml b/pyproject.toml index bfa49d8..d3cc064 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "404-gen-commit" +name = "404-cli" version = "0.1.0" description = "Command line tool for the 404 subnet to submit miner solutions" requires-python = ">=3.8" @@ -20,9 +20,124 @@ dependencies = [ [project.scripts] 404-cli = "commit:cli" +[project.optional-dependencies] +dev = [ + "poetry==2.1.1", + "poethepoet[poetry_plugin]==0.36.0", + "bandit==1.8.6", + "black==25.1.0", + "mypy==1.16.1", + "ruff==0.12.2", + "pre-commit==4.2.0", + "types-aiofiles==24.1.0.20250708", + "types-requests==2.32.4.20260107", +] + [tool.setuptools] py-modules = ["commit", "generator", "renderer", "judge", "models", "targon_client", "targon_utils"] [build-system] requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" + +[tool.black] +target-version = ['py312'] +line-length = 120 +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.pytest_cache + | \.idea + | \.venv + | \.vscode +)/ +''' + +[tool.ruff] +target-version = "py312" +cache-dir = ".reports/ruff_cache" +line-length = 120 +src = ["sources"] +namespace-packages = ["sources"] +fix = true +output-format = "full" +include = ["*.py"] +exclude = [ + ".*", + "tests/" +] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle + "F", # pyflakes + "UP", # pyupgrade + "I", # isort + "S", # flake8-bandit + "B", # flake8-bugbear + "Q", # flake8-quotes + "ASYNC", # flake8-async + "PTH", # flake8-use-pathlib +] +fixable = ["ALL"] +unfixable = [] +per-file-ignores = { "tests/**/*.py" = ["S101"] } + +[tool.ruff.lint.flake8-bugbear] +extend-immutable-calls = [] + +[tool.ruff.lint.flake8-quotes] +docstring-quotes = "double" + +[tool.ruff.lint.isort] +combine-as-imports = true +lines-after-imports = 2 + +[tool.bandit] +skips = ["B104"] +exclude_dirs = [ + "./.*/**", + "./tests/**", +] + +[tool.mypy] +cache_dir = ".reports/mypy_cache/" +ignore_missing_imports = true +disallow_incomplete_defs = true +disallow_untyped_defs = true +warn_unused_ignores = false +warn_return_any = true +warn_no_return = false +warn_unreachable = true +strict_equality = true +warn_redundant_casts = true +exclude = [ + '.venv/.*' +] + +[[tool.mypy.overrides]] +module = [ + # place modules to skip here +] +ignore_errors = true + +[tool.pytest.ini_options] +cache_dir = ".reports/pytest_cache" +minversion = "6.0" +addopts = "-ra -v --asyncio-mode=auto" +testpaths = ["tests", ] +pythonpath = ["."] +asyncio_default_fixture_loop_scope = "function" + +[tool.poe] +poetry_command = "" + +[tool.poe.tasks.lint] +ignore_fail = "return_non_zero" +sequence = [ + { cmd = "ruff check ." }, + { cmd = "black ." }, + { cmd = "bandit . -rq -c pyproject.toml" }, + { cmd = "mypy --explicit-package-bases ." }, +] diff --git a/renderer.py b/renderer.py index f30b01d..8efc0e3 100644 --- a/renderer.py +++ b/renderer.py @@ -1,10 +1,10 @@ import asyncio import json -from loguru import logger -import click -import httpx from pathlib import Path +import click +import httpx +from loguru import logger class Renderer: @@ -25,13 +25,7 @@ async def render(self) -> None: glb_files = list(self._data_dir.glob("*.glb")) all_files = ply_files + glb_files tasks = [ - asyncio.create_task( - self._process_prompt( - process_sem=process_sem, - file=file - ) - ) - for file in all_files + asyncio.create_task(self._process_prompt(process_sem=process_sem, file=file)) for file in all_files ] await asyncio.gather(*tasks, return_exceptions=True) except KeyboardInterrupt: @@ -39,11 +33,11 @@ async def render(self) -> None: if not task.done(): task.cancel() await asyncio.gather(*tasks, return_exceptions=True) - raise SystemExit(130) + raise SystemExit(130) from None except Exception as e: logger.error(f"Renderer failed: {e}") click.echo(json.dumps({"success": False, "error": str(e)})) - raise SystemExit(1) + raise SystemExit(1) from None async def _process_prompt(self, *, process_sem: asyncio.Semaphore, file: Path) -> None: """Render the .ply or .glb files using the renderer endpoint.""" @@ -53,10 +47,10 @@ async def _process_prompt(self, *, process_sem: asyncio.Semaphore, file: Path) - timeout = httpx.Timeout(connect=300.0, read=300.0, write=300.0, pool=300.0) async with httpx.AsyncClient(timeout=timeout) as client: try: - with open(file, "rb") as f: + with file.open("rb") as f: file_contents = f.read() if file.name.endswith(".ply"): - endpoint = f"{self._endpoint}/render_ply" + endpoint = f"{self._endpoint}/render_ply" elif file.name.endswith(".glb"): endpoint = f"{self._endpoint}/render_glb" else: @@ -68,7 +62,7 @@ async def _process_prompt(self, *, process_sem: asyncio.Semaphore, file: Path) - response.raise_for_status() content = response.content output_file = self._output_dir / f"{file.name.split('.')[0]}.png" - with open(output_file, "wb") as f: + with output_file.open("wb") as f: f.write(content) click.echo(f"Rendered {file.name} to {output_file}", err=True) except Exception as e: @@ -77,4 +71,4 @@ async def _process_prompt(self, *, process_sem: asyncio.Semaphore, file: Path) - except Exception as e: logger.error(f"Renderer failed: {e}") click.echo(json.dumps({"success": False, "error": str(e)}), err=True) - raise SystemExit(1) + raise SystemExit(1) from None diff --git a/targon_utils.py b/targon_utils.py index 0c70051..859fccf 100644 --- a/targon_utils.py +++ b/targon_utils.py @@ -1,10 +1,9 @@ import asyncio -from typing import Callable +from collections.abc import Callable import httpx from loguru import logger from targon.client.serverless import ServerlessResourceListItem - from targon_client import ContainerDeployConfig, TargonClient, TargonClientError @@ -71,15 +70,16 @@ async def wait_for_healthy( response = await http.get(health_url) response.raise_for_status() if response.status_code == 200: - _log(f"Container at {url} healthy", echo, "info") + _log(f"Container at {url} healthy", echo, "info") return True - except Exception as e: + except Exception: _log(f"Container not ready yet: {time_elapsed:.1f}/{timeout:.1f}s", echo, "info") await asyncio.sleep(check_interval) time_elapsed = asyncio.get_running_loop().time() - start _log(f"Container at {url} not healthy within {timeout}s. Timeout reached.", echo, "error") return False + async def ensure_running_container( client: TargonClient, name: str, @@ -103,12 +103,12 @@ async def ensure_running_container( # Deploy deploy_start = asyncio.get_running_loop().time() try: - _log(f"Deploying container with config.", echo) + _log("Deploying container with config.", echo) await client.deploy_container(name, config) except TargonClientError: return None - _log(f"Waiting for container to become visible.", echo) + _log("Waiting for container to become visible.", echo) container = await wait_for_visible( client, name, @@ -117,7 +117,7 @@ async def ensure_running_container( echo=echo, ) if not container: - _log(f"Container failed to become visible", echo, "error") + _log("Container failed to become visible", echo, "error") return None deploy_time = asyncio.get_running_loop().time() - deploy_start @@ -134,7 +134,7 @@ async def ensure_running_container( health_check_path=health_check_path, echo=echo, ): - _log(f"Container failed health check, deleting", echo, "error") + _log("Container failed health check, deleting", echo, "error") await client.delete_container(container.uid) return None From 1a2ae77ae42985b1a01ac5801110df5a5623fa45 Mon Sep 17 00:00:00 2001 From: Raman Kudaktsin Date: Thu, 5 Feb 2026 18:19:30 +0300 Subject: [PATCH 8/8] feat: multiple replicas for renderer and generator --- commit.py | 97 ++++++++++++++++++++++++++++++++++++++++++++++-- generator.py | 7 +++- renderer.py | 12 +++++- targon_client.py | 6 ++- 4 files changed, 112 insertions(+), 10 deletions(-) diff --git a/commit.py b/commit.py index dfbda4b..9c59a29 100644 --- a/commit.py +++ b/commit.py @@ -638,7 +638,39 @@ def _parse_commitments(commitments: dict, round_number: int, schedule: Schedule, help="HuggingFace token to pass as HF_TOKEN environment variable", ) @click.option("--name", "container_name", default=None, help="Custom container name (default: generator)") -def start_generator_cmd(image_url: str, targon_api_key: str, hf_token: str | None, container_name: str | None) -> None: +@click.option( + "--container-concurrency", + "container_concurrency", + type=int, + default=1, + show_default=True, + help="Maximum concurrent requests per generator replica.", +) +@click.option( + "--min-replicas", + "min_replicas", + type=int, + default=1, + show_default=True, + help="Minimum number of generator replicas.", +) +@click.option( + "--max-replicas", + "max_replicas", + type=int, + default=2, + show_default=True, + help="Maximum number of generator replicas.", +) +def start_generator_cmd( + image_url: str, + targon_api_key: str, + hf_token: str | None, + container_name: str | None, + container_concurrency: int, + min_replicas: int, + max_replicas: int, +) -> None: """Start the generator container.""" click.echo(f"Starting generator: {image_url}", err=True) @@ -663,6 +695,9 @@ def start_generator_cmd(image_url: str, targon_api_key: str, hf_token: str | Non health_check_path=_GENERATOR_HEALTH_CHECK_PATH, echo=lambda msg: click.echo(msg, err=True), env=env, + container_concurrency=container_concurrency, + min_replicas=min_replicas, + max_replicas=max_replicas, ) ) click.echo(json.dumps({"success": True, "container_url": container_url})) @@ -678,7 +713,36 @@ def start_generator_cmd(image_url: str, targon_api_key: str, hf_token: str | Non @cli.command("start-renderer") @click.option("--targon-api-key", required=True, help="Targon API key") -def start_renderer_cmd(targon_api_key: str) -> None: +@click.option( + "--container-concurrency", + "container_concurrency", + type=int, + default=1, + show_default=True, + help="Maximum concurrent requests per renderer replica.", +) +@click.option( + "--min-replicas", + "min_replicas", + type=int, + default=1, + show_default=True, + help="Minimum number of renderer replicas.", +) +@click.option( + "--max-replicas", + "max_replicas", + type=int, + default=2, + show_default=True, + help="Maximum number of renderer replicas.", +) +def start_renderer_cmd( + targon_api_key: str, + container_concurrency: int, + min_replicas: int, + max_replicas: int, +) -> None: """Start the renderer container.""" click.echo(f"Starting renderer: {_RENDER_IMAGE_URL}", err=True) @@ -692,6 +756,9 @@ def start_renderer_cmd(targon_api_key: str) -> None: port=_RENDER_PORT, health_check_path=_RENDER_HEALTH_CHECK_PATH, echo=lambda msg: click.echo(msg, err=True), + container_concurrency=container_concurrency, + min_replicas=min_replicas, + max_replicas=max_replicas, ) ) click.echo(json.dumps({"success": True, "container_url": container_url})) @@ -709,7 +776,14 @@ def start_renderer_cmd(targon_api_key: str) -> None: @click.option("--data-dir", required=True, help="Path to the directory containing the .ply files to render") @click.option("--endpoint", required=True, help="Renderer endpoint URL.") @click.option("--output-dir", default="results", help="Path to the directory where the rendered images will be saved.") -def render_cmd(data_dir: str, endpoint: str, output_dir: str) -> None: +@click.option( + "--concurrency", + type=int, + default=1, + show_default=True, + help="Maximum number of files rendered concurrently.", +) +def render_cmd(data_dir: str, endpoint: str, output_dir: str, concurrency: int) -> None: """Render the .ply files using the renderer endpoint.""" click.echo(f"Rendering {data_dir} with endpoint {endpoint}", err=True) try: @@ -717,6 +791,7 @@ def render_cmd(data_dir: str, endpoint: str, output_dir: str) -> None: data_dir=data_dir, endpoint=endpoint, output_dir=output_dir, + concurrency=concurrency, ) asyncio.run(renderer.render()) click.echo(json.dumps({"success": True, "output_dir": output_dir})) @@ -833,11 +908,19 @@ async def _stop() -> None: @click.option("--endpoint", required=True, help="Generator endpoint URL.") @click.option("--seed", required=True, help="Seed for generation.") @click.option("--output-folder", default="results", help="Folder path where generated .ply files will be saved.") +@click.option( + "--concurrency", + type=int, + default=8, + show_default=True, + help="Maximum number of prompts / HTTP requests processed concurrently.", +) def generate_cmd( prompts_file: str, endpoint: str, seed: str, output_folder: str, + concurrency: int, ) -> None: """Generate models using the generator endpoint.""" # Read prompts from prompt file @@ -864,6 +947,7 @@ def generate_cmd( seed=int(seed), output_folder=Path(output_folder), echo=lambda msg: click.echo(msg, err=True), + concurrency=concurrency, ) try: @@ -888,6 +972,9 @@ async def _create_container( echo: Callable[[str], None], args: list[str] | None = None, env: dict[str, str] | None = None, + container_concurrency: int = 1, + min_replicas: int = 1, + max_replicas: int = 2, ) -> str: """ Create and deploy a container on Targon. @@ -914,7 +1001,9 @@ async def _create_container( image=image_url, resource_name=resource_name, port=port, - container_concurrency=1, + container_concurrency=container_concurrency, + min_replicas=min_replicas, + max_replicas=max_replicas, args=args, env=env, ) diff --git a/generator.py b/generator.py index bf0b7cf..1028156 100644 --- a/generator.py +++ b/generator.py @@ -14,6 +14,7 @@ def __init__( seed: int, output_folder: Path, echo: Callable[[str], None] | None = None, + concurrency: int = 8, ) -> None: """ Initialize the Generator. @@ -23,11 +24,13 @@ def __init__( seed: Seed value for generation (ensures reproducibility) output_folder: Path to folder where .ply files will be saved echo: Optional callback function for logging messages + concurrency: Max concurrent prompts / HTTP requests """ self.endpoint = endpoint self.seed = seed self.output_folder = Path(output_folder) self.echo = echo or (lambda msg: None) + self.concurrency = concurrency # Create output folder if it doesn't exist self.output_folder.mkdir(parents=True, exist_ok=True) @@ -46,8 +49,8 @@ async def generate_all(self, prompts: list[str]) -> None: tasks = [] try: self.echo(f"Processing {len(prompts)} prompts...") - request_sem = asyncio.Semaphore(1) # Using semaphores to limit request to one at a time. - process_sem = asyncio.Semaphore(8) # Limiting request to control traffic + request_sem = asyncio.Semaphore(self.concurrency) + process_sem = asyncio.Semaphore(self.concurrency) tasks = [ asyncio.create_task( self._process_prompt( diff --git a/renderer.py b/renderer.py index 8efc0e3..cf86029 100644 --- a/renderer.py +++ b/renderer.py @@ -8,18 +8,26 @@ class Renderer: - def __init__(self, *, endpoint: str, data_dir: str, output_dir: str) -> None: + def __init__( + self, + *, + endpoint: str, + data_dir: str, + output_dir: str, + concurrency: int = 1, + ) -> None: self._endpoint = endpoint self._data_dir = Path(data_dir) self._output_dir = Path(output_dir) self._output_dir.mkdir(parents=True, exist_ok=True) + self._concurrency = concurrency async def render(self) -> None: """Render the .ply and .glb files using the renderer endpoint.""" click.echo(f"Rendering {self._data_dir} with endpoint {self._endpoint}", err=True) tasks: list[asyncio.Task] = [] try: - process_sem = asyncio.Semaphore(1) + process_sem = asyncio.Semaphore(self._concurrency) # Collect both .ply and .glb files ply_files = list(self._data_dir.glob("*.ply")) glb_files = list(self._data_dir.glob("*.glb")) diff --git a/targon_client.py b/targon_client.py index 4bbae0e..0341eb8 100644 --- a/targon_client.py +++ b/targon_client.py @@ -24,6 +24,8 @@ class ContainerDeployConfig(BaseModel): image: str container_concurrency: int + min_replicas: int = 1 + max_replicas: int = 2 resource_name: str = "h200-small" port: int = 10006 args: list[str] | None = None @@ -96,8 +98,8 @@ async def deploy_container(self, name: str, config: ContainerDeployConfig) -> No visibility="external", ), scaling=AutoScalingConfig( - min_replicas=1, - max_replicas=1, + min_replicas=config.min_replicas, + max_replicas=config.max_replicas, container_concurrency=config.container_concurrency, target_concurrency=config.container_concurrency, ),