diff --git a/README.md b/README.md index e3d8d38ad..53f33fa75 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ - [Install from source](#install-from-source) - [Install on Windows](#install-on-windows) - [Verify the installation](#verify-the-installation) +- [Shell completion](#shell-completion) - [Configuration](#configuration) - [Example config file](#example-config-file) - [License](#license) @@ -126,6 +127,31 @@ The above command will show you the version of the `btcli` you just installed. --- +## Shell completion + +Generate completion scripts: + +```bash +btcli completion bash +btcli completion zsh +btcli completion fish +btcli completion powershell +``` + +Install completion automatically: + +```bash +btcli completion bash --install +btcli completion zsh --install +btcli completion fish --install +``` + +If you need to override the profile file used for installation (bash/zsh/PowerShell), pass `--rc-path`. + +--- + +--- + ## Configuration You can set the commonly used values, such as your hotkey and coldkey names, the default chain URL or the network name you use, and more, in `config.yml`. You can override these values by explicitly passing them in the command line for any `btcli` command. @@ -170,15 +196,27 @@ btcli config --help ### ENV VARS BTCLI accepts a few environment variables that can alter how it works: + - USE_TORCH (default 0): If set to 1, will use torch instead of numpy - DISK_CACHE (default 0, also settable in config): If set to 1 (or set in config), will use disk caching for various safe-cachable substrate calls (such as block number to block hash mapping), which can speed up subsequent calls. - BTCLI_CONFIG_PATH (default `~/.bittensor/config.yml`): This will set the config file location, creating if it does not exist. - BTCLI_DEBUG_FILE (default `~/.bittensor/debug.txt`): The file stores the most recent's command's debug log. +- BTCLI_PAGER (default unset): If set (e.g. `1`), table output may be shown in a pager for some commands. + +### Output tips + +Some list commands support multiple output formats and column selection. For example: + +```bash +btcli subnets list --output json +btcli subnets list --columns netuid,name,market_cap --wide +``` --- ## Debugging + BTCLI will store a debug log for every command run. This file is overwritten for each new command run. The default location of this file is `~/.bittensor/debug.txt` and can be set with the `BTCLI_DEBUG_FILE` env var (see above section). diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 16e68c254..da96a2a6b 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -132,6 +132,54 @@ def arg__(arg_name: str) -> str: return f"[{COLORS.G.ARG}]{arg_name}[/{COLORS.G.ARG}]" +def _btcli_completion_var(prog_name: str) -> str: + return f"_{prog_name.upper().replace('-', '_')}_COMPLETE" + + +def _append_block_if_missing(file_path: Path, marker: str, block: str) -> bool: + file_path.parent.mkdir(parents=True, exist_ok=True) + existing = "" + if file_path.exists(): + existing = file_path.read_text(encoding="utf-8", errors="ignore") + if marker in existing: + return False + content = existing + if content and not content.endswith("\n"): + content += "\n" + content += block + if not content.endswith("\n"): + content += "\n" + file_path.write_text(content, encoding="utf-8") + return True + + +def _detect_shell_rc(shell: str) -> Path: + home = Path(os.path.expanduser("~")) + if shell == "bash": + for candidate in (home / ".bashrc", home / ".bash_profile", home / ".profile"): + if candidate.exists(): + return candidate + return home / ".bashrc" + if shell == "zsh": + zshrc = home / ".zshrc" + return zshrc + raise ValueError("unsupported shell") + + +def _default_powershell_profile_path() -> Optional[Path]: + profile_env = os.getenv("PROFILE") + if profile_env: + return Path(profile_env) + + # Best-effort defaults + home = Path(os.path.expanduser("~")) + if os.name == "nt": + return home / "Documents" / "PowerShell" / "Microsoft.PowerShell_profile.ps1" + + # If running on non-Windows, we can't reliably know the user's PowerShell profile. + return None + + def is_valid_ss58_address_param(address: Optional[str]) -> Optional[str]: """ Evaluates whether a non-None address is a valid SS58 address. Used as a callback for @@ -1374,6 +1422,100 @@ def __init__(self): self.utils_app.command("convert")(self.convert) self.utils_app.command("latency")(self.best_connection) + # top-level misc + self.app.command("completion")(self.completion) + + def completion( + self, + shell: str = typer.Argument( + ..., help="Shell to generate completions for: bash, zsh, fish, powershell." + ), + install: bool = typer.Option( + False, + "--install", + help="Install completion for the chosen shell by updating profile files.", + ), + rc_path: Optional[Path] = typer.Option( + None, + "--rc-path", + help="Override the profile file to update (bash/zsh) or PowerShell profile path.", + ), + ): + """Print or install shell completion for `btcli`.""" + + shell_norm = shell.strip().lower() + allowed = {"bash", "zsh", "fish", "powershell"} + if shell_norm not in allowed: + raise typer.BadParameter( + "shell must be one of: bash, zsh, fish, powershell" + ) + + prog_name = "btcli" + complete_var = _btcli_completion_var(prog_name) + + try: + from bittensor_cli.src.bittensor.utils import get_completion_script + except Exception as e: + raise typer.BadParameter(f"Unable to generate completion script: {e}") + + if not install: + print(prog_name, complete_var, shell_norm) + script = get_completion_script(prog_name, complete_var, shell_norm) + typer.echo(script, nl=False) + return + + # Install paths and blocks + if shell_norm in {"bash", "zsh"}: + target_rc = rc_path or _detect_shell_rc(shell_norm) + marker = f"# >>> btcli completion ({shell_norm}) >>>" + end_marker = f"# <<< btcli completion ({shell_norm}) <<<" + block = ( + f"{marker}\n" + f"# Auto-generated by `btcli completion {shell_norm} --install`\n" + f'eval "$({complete_var}={shell_norm}_source {prog_name})"\n' + f"{end_marker}\n" + ) + changed = _append_block_if_missing(target_rc, marker, block) + if changed: + typer.echo(f"Installed completion into: {target_rc}") + typer.echo("Restart your shell or source the file to activate.") + else: + typer.echo(f"Completion already installed in: {target_rc}") + return + + if shell_norm == "fish": + completions_dir = ( + Path(os.path.expanduser("~")) / ".config" / "fish" / "completions" + ) + completions_dir.mkdir(parents=True, exist_ok=True) + target_file = completions_dir / f"{prog_name}.fish" + script = get_completion_script(prog_name, complete_var, "fish") + target_file.write_text(script, encoding="utf-8") + typer.echo(f"Installed completion into: {target_file}") + typer.echo("Restart fish to activate (or run `exec fish`).") + return + + # powershell + target_profile = rc_path or _default_powershell_profile_path() + if target_profile is None: + raise typer.BadParameter( + "Unable to detect PowerShell profile path. Provide --rc-path to a .ps1 profile file." + ) + marker = "# >>> btcli completion (powershell) >>>" + end_marker = "# <<< btcli completion (powershell) <<<" + block = ( + f"{marker}\n" + f"# Auto-generated by `btcli completion powershell --install`\n" + f"Invoke-Expression (({prog_name} completion powershell) | Out-String)\n" + f"{end_marker}\n" + ) + changed = _append_block_if_missing(target_profile, marker, block) + if changed: + typer.echo(f"Installed completion into: {target_profile}") + typer.echo("Restart PowerShell to activate.") + else: + typer.echo(f"Completion already installed in: {target_profile}") + def generate_command_tree(self) -> Tree: """ Generates a rich.Tree of the commands, subcommands, and groups of this app @@ -7275,6 +7417,61 @@ def sudo_trim( def subnets_list( self, network: Optional[list[str]] = Options.network, + output: Optional[str] = typer.Option( + None, + "--output", + "-o", + help="Output format: table, json, yaml. Overrides --json-output when set.", + ), + columns: Optional[str] = typer.Option( + None, + "--columns", + help=( + "Comma-separated column ids to display. " + "Available: netuid,name,price,market_cap,emission,tao_flow_ema,liquidity,stake,supply,tempo,mechanisms" + ), + ), + no_header: bool = typer.Option( + False, + "--no-header", + help="Hide table header/title/footers (table output only).", + ), + wide: bool = typer.Option( + False, + "--wide", + "-w", + help="Wider table output (less truncation).", + ), + netuids: str = typer.Option( + "", + "--netuids", + "-n", + help="Filter to specific netuid(s). Comma-separated, e.g. `--netuids 0,1,2`.", + ), + name_contains: Optional[str] = typer.Option( + None, + "--name-contains", + "--name", + help="Filter subnets whose name contains this string (case-insensitive).", + ), + sort_by: Optional[str] = typer.Option( + None, + "--sort-by", + "--sort_by", + help="Sort by one of: market_cap, netuid, name, price, emission, tao_flow_ema, stake, supply, tempo, mechanisms.", + ), + sort_order: Optional[str] = typer.Option( + None, + "--sort-order", + "--sort_order", + help="Sort order: asc/ascending or desc/descending (default depends on column).", + ), + limit: Optional[int] = typer.Option( + None, + "--limit", + "--top", + help="Limit rows displayed (applies after filtering + sorting).", + ), quiet: bool = Options.quiet, verbose: bool = Options.verbose, live_mode: bool = Options.live, @@ -7306,9 +7503,94 @@ def subnets_list( [green]$[/green] btcli subnets list """ + output_format = None + if output is not None: + output_norm = output.strip().lower() + if output_norm not in {"table", "json", "yaml"}: + raise typer.BadParameter("--output must be one of: table, json, yaml") + output_format = output_norm + json_output = output_norm == "json" + if json_output and live_mode: print_error("Cannot use `--json-output` and `--live` at the same time.") return + + if output_format in {"json", "yaml"} and live_mode: + print_error( + "Cannot use `--output json/yaml` and `--live` at the same time." + ) + return + + selected_columns = None + if columns is not None: + cols = [c.strip().lower() for c in columns.split(",") if c.strip()] + if not cols: + raise typer.BadParameter("--columns cannot be empty") + allowed_cols = { + "netuid", + "name", + "price", + "market_cap", + "emission", + "tao_flow_ema", + "liquidity", + "stake", + "supply", + "tempo", + "mechanisms", + } + unknown = [c for c in cols if c not in allowed_cols] + if unknown: + raise typer.BadParameter( + "Unknown column(s): " + + ", ".join(unknown) + + ". Allowed: " + + ", ".join(sorted(allowed_cols)) + ) + selected_columns = cols + + if netuids: + netuids = parse_to_list( + netuids, + int, + "Netuids must be a comma-separated list of ints, e.g., `--netuids 0,1,2`.", + ) + else: + netuids = None + + if limit is not None and limit <= 0: + raise typer.BadParameter("--limit must be a positive integer") + + if sort_order is not None: + sort_order_norm = sort_order.strip().lower() + if sort_order_norm in {"asc", "ascending"}: + sort_order = "asc" + elif sort_order_norm in {"desc", "descending", "reverse"}: + sort_order = "desc" + else: + raise typer.BadParameter( + "--sort-order must be one of: asc/ascending or desc/descending/reverse" + ) + + if sort_by is not None: + sort_by = sort_by.strip().lower() + allowed = { + "market_cap", + "netuid", + "name", + "price", + "emission", + "tao_flow_ema", + "stake", + "supply", + "tempo", + "mechanisms", + } + if sort_by not in allowed: + raise typer.BadParameter( + "--sort-by must be one of: " + ", ".join(sorted(allowed)) + ) + self.verbosity_handler(quiet, verbose, json_output, prompt=False) subtensor = self.initialize_chain(network) return self._run_command( @@ -7320,6 +7602,15 @@ def subnets_list( verbose, live_mode, json_output, + netuids=netuids, + name_contains=name_contains, + sort_by=sort_by, + sort_order=sort_order, + limit=limit, + output=output_format or ("json" if json_output else "table"), + columns=selected_columns, + no_header=no_header, + wide=wide, ) ) @@ -7969,6 +8260,64 @@ def subnets_metagraph( "is ignored when used with `--reuse-last`.", ), network: Optional[list[str]] = Options.network, + output: Optional[str] = typer.Option( + None, + "--output", + "-o", + help="Output format: table, json, yaml.", + ), + columns: Optional[str] = typer.Option( + None, + "--columns", + help=( + "Comma-separated column ids to display. " + "Available: uid,global_stake,local_stake,stake_weight,rank,trust,consensus,incentive,dividends,emission,vtrust,val,updated,active,axon,hotkey,coldkey" + ), + ), + no_header: bool = typer.Option( + False, + "--no-header", + help="Hide table header/title/footers (table output only).", + ), + wide: bool = typer.Option( + False, + "--wide", + "-w", + help="Wider table output (less truncation).", + ), + uids: str = typer.Option( + "", + "--uids", + "-u", + help="Filter to specific neuron uid(s). Comma-separated, e.g. `--uids 0,1,2`.", + ), + hotkey_contains: Optional[str] = typer.Option( + None, + "--hotkey-contains", + "--hotkey", + help="Filter rows whose hotkey contains this string (case-insensitive).", + ), + sort_by: Optional[str] = typer.Option( + None, + "--sort-by", + "--sort_by", + help=( + "Sort by one of: uid, global_stake, local_stake, stake_weight, rank, trust, consensus, " + "incentive, dividends, emission, vtrust, val, updated, active, axon, hotkey, coldkey." + ), + ), + sort_order: Optional[str] = typer.Option( + None, + "--sort-order", + "--sort_order", + help="Sort order: asc/ascending or desc/descending (default depends on column).", + ), + limit: Optional[int] = typer.Option( + None, + "--limit", + "--top", + help="Limit rows displayed (applies after filtering + sorting).", + ), reuse_last: bool = Options.reuse_last, html_output: bool = Options.html_output, quiet: bool = Options.quiet, @@ -8028,6 +8377,103 @@ def subnets_metagraph( [blue bold]Note[/blue bold]: This command is not intended to be used as a standalone function within user code. """ self.verbosity_handler(quiet, verbose, json_output=False, prompt=False) + + output_format = None + if output is not None: + output_norm = output.strip().lower() + if output_norm not in {"table", "json", "yaml"}: + raise typer.BadParameter("--output must be one of: table, json, yaml") + output_format = output_norm + + if output_format in {"json", "yaml"} and html_output: + print_error( + "Cannot use `--output json/yaml` and `--html` at the same time." + ) + raise typer.Exit() + + selected_columns = None + if columns is not None: + cols = [c.strip().lower() for c in columns.split(",") if c.strip()] + if not cols: + raise typer.BadParameter("--columns cannot be empty") + allowed_cols = { + "uid", + "global_stake", + "local_stake", + "stake_weight", + "rank", + "trust", + "consensus", + "incentive", + "dividends", + "emission", + "vtrust", + "val", + "updated", + "active", + "axon", + "hotkey", + "coldkey", + } + unknown = [c for c in cols if c not in allowed_cols] + if unknown: + raise typer.BadParameter( + "Unknown column(s): " + + ", ".join(unknown) + + ". Allowed: " + + ", ".join(sorted(allowed_cols)) + ) + selected_columns = cols + + if uids: + uids = parse_to_list( + uids, + int, + "UIDs must be a comma-separated list of ints, e.g., `--uids 0,1,2`.", + ) + else: + uids = None + + if limit is not None and limit <= 0: + raise typer.BadParameter("--limit must be a positive integer") + + if sort_order is not None: + sort_order_norm = sort_order.strip().lower() + if sort_order_norm in {"asc", "ascending"}: + sort_order = "asc" + elif sort_order_norm in {"desc", "descending", "reverse"}: + sort_order = "desc" + else: + raise typer.BadParameter( + "--sort-order must be one of: asc/ascending or desc/descending/reverse" + ) + + if sort_by is not None: + sort_by = sort_by.strip().lower() + allowed = { + "uid", + "global_stake", + "local_stake", + "stake_weight", + "rank", + "trust", + "consensus", + "incentive", + "dividends", + "emission", + "vtrust", + "val", + "updated", + "active", + "axon", + "hotkey", + "coldkey", + } + if sort_by not in allowed: + raise typer.BadParameter( + "--sort-by must be one of: " + ", ".join(sorted(allowed)) + ) + if (reuse_last or html_output) and self.config.get("use_cache") is False: print_error( "Unable to use `--reuse-last` or `--html` when config `no-cache` is set to `True`. " @@ -8059,6 +8505,15 @@ def subnets_metagraph( html_output, not self.config.get("use_cache", True), self.config.get("metagraph_cols", {}), + output=output_format or "table", + columns=selected_columns, + no_header=no_header, + wide=wide, + uids=uids, + hotkey_contains=hotkey_contains, + sort_by=sort_by, + sort_order=sort_order, + limit=limit, ) ) diff --git a/bittensor_cli/src/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py index a5c1896d3..c9f06094f 100644 --- a/bittensor_cli/src/bittensor/utils.py +++ b/bittensor_cli/src/bittensor/utils.py @@ -1958,3 +1958,67 @@ async def check_img_mimetype(img_url: str) -> tuple[bool, str, str]: return True, response.content_type, "" except aiohttp.ClientError: return False, "", "Could not fetch image" + + +# Note, only BASH version 4.4 and later have the nosort option. +COMPLETION_SCRIPT_BASH = """ +%(complete_func)s() { + local IFS=$'\n' + COMPREPLY=( $( env COMP_WORDS="${COMP_WORDS[*]}" \\ + COMP_CWORD=$COMP_CWORD \\ + %(autocomplete_var)s=complete $1 ) ) + return 0 +} +%(complete_func)setup() { + local COMPLETION_OPTIONS="" + local BASH_VERSION_ARR=(${BASH_VERSION//./ }) + # Only BASH version 4.4 and later have the nosort option. + if [ ${BASH_VERSION_ARR[0]} -gt 4 ] || ([ ${BASH_VERSION_ARR[0]} -eq 4 ] && [ ${BASH_VERSION_ARR[1]} -ge 4 ]); then + COMPLETION_OPTIONS="-o nosort" + fi + complete $COMPLETION_OPTIONS -F %(complete_func)s %(script_names)s +} +%(complete_func)setup +""" + +COMPLETION_SCRIPT_ZSH = """ +%(complete_func)s() { + local -a completions + local -a completions_with_descriptions + local -a response + response=("${(@f)$( env COMP_WORDS=\"${words[*]}\" \\ + COMP_CWORD=$((CURRENT-1)) \\ + %(autocomplete_var)s=\"complete_zsh\" \\ + %(script_names)s )}") + for key descr in ${(kv)response}; do + if [[ "$descr" == "_" ]]; then + completions+=("$key") + else + completions_with_descriptions+=("$key":"$descr") + fi + done + if [ -n "$completions_with_descriptions" ]; then + _describe -V unsorted completions_with_descriptions -U -Q + fi + if [ -n "$completions" ]; then + compadd -U -V unsorted -Q -a completions + fi + compstate[insert]="automenu" +} +compdef %(complete_func)s %(script_names)s +""" + +_invalid_ident_char_re = re.compile(r"[^a-zA-Z0-9_]") + + +def get_completion_script(prog_name, complete_var, shell): + cf_name = _invalid_ident_char_re.sub("", prog_name.replace("-", "_")) + script = COMPLETION_SCRIPT_ZSH if shell == "zsh" else COMPLETION_SCRIPT_BASH + return ( + script + % { + "complete_func": "_%s_completion" % cf_name, + "script_names": prog_name, + "autocomplete_var": complete_var, + } + ).strip() + ";" diff --git a/bittensor_cli/src/commands/subnets/subnets.py b/bittensor_cli/src/commands/subnets/subnets.py index ad0404b4f..0f9c14e61 100644 --- a/bittensor_cli/src/commands/subnets/subnets.py +++ b/bittensor_cli/src/commands/subnets/subnets.py @@ -1,5 +1,6 @@ import asyncio import json +import os import sqlite3 from typing import TYPE_CHECKING, Optional, cast @@ -11,6 +12,7 @@ from rich.progress import Progress, BarColumn, TextColumn from rich.table import Column, Table from rich import box +from yaml import safe_dump from bittensor_cli.src import COLOR_PALETTE from bittensor_cli.src.bittensor.balances import Balance @@ -51,6 +53,85 @@ TAO_WEIGHT = 0.18 + +def filter_sort_limit_subnets( + *, + subnets: list, + mechanisms: dict[int, int], + ema_tao_inflow: dict, + netuids: Optional[list[int]] = None, + name_contains: Optional[str] = None, + sort_by: Optional[str] = None, + sort_order: Optional[str] = None, + limit: Optional[int] = None, +) -> list: + """Pure helper used by `subnets_list` for filtering/sorting. + + Intentionally keeps netuid 0 (root) as the first row when present. + """ + + filtered = subnets + if netuids is not None: + netuid_set = set(netuids) + filtered = [s for s in filtered if s.netuid in netuid_set] + + if name_contains: + needle = name_contains.strip().lower() + if needle: + filtered = [ + s for s in filtered if needle in (get_subnet_name(s) or "").lower() + ] + + if sort_by is None: + root = [s for s in filtered if s.netuid == 0] + rest = [s for s in filtered if s.netuid != 0] + rest = sorted( + rest, + key=lambda x: (x.alpha_in.tao + x.alpha_out.tao) * x.price.tao, + reverse=True, + ) + ordered = (root[:1] + rest) if root else rest + else: + reverse = True + if sort_order is not None: + reverse = sort_order == "desc" + else: + reverse = sort_by not in {"netuid", "name", "tempo"} + + def key_fn(s): + if sort_by == "netuid": + return s.netuid + if sort_by == "name": + return (get_subnet_name(s) or "").lower() + if sort_by == "price": + return float(s.price.tao) + if sort_by == "market_cap": + return float((s.alpha_in.tao + s.alpha_out.tao) * s.price.tao) + if sort_by == "emission": + return 0.0 if s.netuid == 0 else float(s.tao_in_emission.tao) + if sort_by == "tao_flow_ema": + inflow = ema_tao_inflow.get(s.netuid) + return 0.0 if inflow is None else float(inflow.tao) + if sort_by == "stake": + return float(s.alpha_out.tao) + if sort_by == "supply": + return float(s.alpha_in.tao + s.alpha_out.tao) + if sort_by == "tempo": + return -1 if s.netuid == 0 else int(s.tempo) + if sort_by == "mechanisms": + return int(mechanisms.get(s.netuid, 1)) + return s.netuid + + root = [s for s in filtered if s.netuid == 0] + rest = [s for s in filtered if s.netuid != 0] + rest = sorted(rest, key=key_fn, reverse=reverse) + ordered = (root[:1] + rest) if root else rest + + if limit is not None: + return ordered[:limit] + return ordered + + # helpers and extrinsics @@ -314,9 +395,48 @@ async def subnets_list( verbose: bool, live: bool, json_output: bool, + netuids: Optional[list[int]] = None, + name_contains: Optional[str] = None, + sort_by: Optional[str] = None, + sort_order: Optional[str] = None, + limit: Optional[int] = None, + output: str = "table", + columns: Optional[list[str]] = None, + no_header: bool = False, + wide: bool = False, ): """List all subnet netuids in the network.""" + output_norm = (output or "table").strip().lower() + if output_norm not in {"table", "json", "yaml"}: + raise ValueError("output must be one of: table, json, yaml") + if live and output_norm in {"json", "yaml"}: + raise ValueError("Cannot use output=json/yaml with live=True") + + default_columns = [ + "netuid", + "name", + "price", + "market_cap", + "emission", + "tao_flow_ema", + "liquidity", + "stake", + "supply", + "tempo", + "mechanisms", + ] + allowed_columns = set(default_columns) + selected_columns = columns or default_columns + unknown = [c for c in selected_columns if c not in allowed_columns] + if unknown: + raise ValueError( + "Unknown column(s): " + + ", ".join(unknown) + + ". Allowed: " + + ", ".join(default_columns) + ) + async def fetch_subnet_data(): block_hash = await subtensor.substrate.get_chain_head() subnets_, mechanisms, block_number_, ema_tao_inflow = await asyncio.gather( @@ -326,15 +446,17 @@ async def fetch_subnet_data(): subtensor.get_all_subnet_ema_tao_inflow(block_hash=block_hash), ) - # Sort subnets by market cap, keeping the root subnet in the first position - root_subnet = next(s for s in subnets_ if s.netuid == 0) - other_subnets = sorted( - [s for s in subnets_ if s.netuid != 0], - key=lambda x: (x.alpha_in.tao + x.alpha_out.tao) * x.price.tao, - reverse=True, + ordered_subnets = filter_sort_limit_subnets( + subnets=subnets_, + mechanisms=mechanisms, + ema_tao_inflow=ema_tao_inflow, + netuids=netuids, + name_contains=name_contains, + sort_by=sort_by, + sort_order=sort_order, + limit=limit, ) - sorted_subnets = [root_subnet] + other_subnets - return sorted_subnets, block_number_, mechanisms, ema_tao_inflow + return ordered_subnets, block_number_, mechanisms, ema_tao_inflow def calculate_emission_stats( subnets_: list, block_number_: int @@ -362,11 +484,16 @@ def define_table( total_netuids: int, tao_emission_percentage: str, total_tao_flow_ema: float, + selected_columns: list[str], + hide_header: bool, ): defined_table = Table( - title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Subnets" + title=None + if hide_header + else f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Subnets" f"\nNetwork: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{subtensor.network}\n\n", - show_footer=True, + show_header=not hide_header, + show_footer=False if hide_header else True, show_edge=False, header_style="bold white", border_style="bright_black", @@ -376,71 +503,87 @@ def define_table( pad_edge=True, ) - defined_table.add_column( - "[bold white]Netuid", - style="grey89", - justify="center", - footer=str(total_netuids), - ) - defined_table.add_column("[bold white]Name", style="cyan", justify="left") - defined_table.add_column( - f"[bold white]Price \n({Balance.get_unit(0)}_in/{Balance.get_unit(1)}_in)", - style="dark_sea_green2", - justify="left", - footer=f"τ {total_rate}", - ) - defined_table.add_column( - f"[bold white]Market Cap \n({Balance.get_unit(1)} * Price)", - style="steel_blue3", - justify="left", - ) - defined_table.add_column( - f"[bold white]Emission ({Balance.get_unit(0)})", - style=COLOR_PALETTE["POOLS"]["EMISSION"], - justify="left", - footer=f"τ {total_emissions}", - ) - defined_table.add_column( - f"[bold white]Net Inflow EMA ({Balance.get_unit(0)})", - style=COLOR_PALETTE["POOLS"]["ALPHA_OUT"], - justify="left", - footer=f"τ {total_tao_flow_ema}", - ) - defined_table.add_column( - f"[bold white]P ({Balance.get_unit(0)}_in, {Balance.get_unit(1)}_in)", - style=COLOR_PALETTE["STAKE"]["TAO"], - justify="left", - footer=f"{tao_emission_percentage}", - ) - defined_table.add_column( - f"[bold white]Stake ({Balance.get_unit(1)}_out)", - style=COLOR_PALETTE["STAKE"]["STAKE_ALPHA"], - justify="left", - ) - defined_table.add_column( - f"[bold white]Supply ({Balance.get_unit(1)})", - style=COLOR_PALETTE["POOLS"]["ALPHA_IN"], - justify="left", - ) + column_defs: dict[str, dict] = { + "netuid": { + "header": "[bold white]Netuid", + "style": "grey89", + "justify": "center", + "footer": str(total_netuids), + }, + "name": {"header": "[bold white]Name", "style": "cyan", "justify": "left"}, + "price": { + "header": f"[bold white]Price \n({Balance.get_unit(0)}_in/{Balance.get_unit(1)}_in)", + "style": "dark_sea_green2", + "justify": "left", + "footer": f"τ {total_rate}", + }, + "market_cap": { + "header": f"[bold white]Market Cap \n({Balance.get_unit(1)} * Price)", + "style": "steel_blue3", + "justify": "left", + }, + "emission": { + "header": f"[bold white]Emission ({Balance.get_unit(0)})", + "style": COLOR_PALETTE["POOLS"]["EMISSION"], + "justify": "left", + "footer": f"τ {total_emissions}", + }, + "tao_flow_ema": { + "header": f"[bold white]Net Inflow EMA ({Balance.get_unit(0)})", + "style": COLOR_PALETTE["POOLS"]["ALPHA_OUT"], + "justify": "left", + "footer": f"τ {total_tao_flow_ema}", + }, + "liquidity": { + "header": f"[bold white]P ({Balance.get_unit(0)}_in, {Balance.get_unit(1)}_in)", + "style": COLOR_PALETTE["STAKE"]["TAO"], + "justify": "left", + "footer": f"{tao_emission_percentage}", + }, + "stake": { + "header": f"[bold white]Stake ({Balance.get_unit(1)}_out)", + "style": COLOR_PALETTE["STAKE"]["STAKE_ALPHA"], + "justify": "left", + }, + "supply": { + "header": f"[bold white]Supply ({Balance.get_unit(1)})", + "style": COLOR_PALETTE["POOLS"]["ALPHA_IN"], + "justify": "left", + }, + "tempo": { + "header": "[bold white]Tempo (k/n)", + "style": COLOR_PALETTE["GENERAL"]["TEMPO"], + "justify": "left", + "overflow": "fold", + }, + "mechanisms": { + "header": "[bold white]Mechanisms", + "style": COLOR_PALETTE["GENERAL"]["SUBHEADING_EXTRA_1"], + "justify": "center", + }, + } - defined_table.add_column( - "[bold white]Tempo (k/n)", - style=COLOR_PALETTE["GENERAL"]["TEMPO"], - justify="left", - overflow="fold", - ) - defined_table.add_column( - "[bold white]Mechanisms", - style=COLOR_PALETTE["GENERAL"]["SUBHEADING_EXTRA_1"], - justify="center", - ) + for col_id in selected_columns: + kwargs = column_defs[col_id].copy() + header = kwargs.pop("header") + defined_table.add_column(header, **kwargs) return defined_table # Non-live mode - def _create_table(subnets_, block_number_, mechanisms, ema_tao_inflow): + def _create_table( + subnets_, + block_number_, + mechanisms, + ema_tao_inflow, + selected_columns, + hide_header, + is_wide, + ): rows = [] _, percentage_string = calculate_emission_stats(subnets_, block_number_) + name_max_len = 40 if is_wide else 20 + for subnet in subnets_: netuid = subnet.netuid # The default symbols for 123 and 124 are visually identical: @@ -500,7 +643,7 @@ def _create_table(subnets_, block_number_, mechanisms, ema_tao_inflow): netuid_cell = str(netuid) subnet_name_cell = ( f"[{COLOR_PALETTE.G.SYM}]{subnet.symbol if netuid != 0 else 'τ'}[/{COLOR_PALETTE.G.SYM}]" - f" {get_subnet_name(subnet)}" + f" {get_subnet_name(subnet, max_length=name_max_len)}" ) emission_cell = f"τ {emission_tao:,.4f}" @@ -527,19 +670,19 @@ def _create_table(subnets_, block_number_, mechanisms, ema_tao_inflow): mechanisms_cell = str(mechanisms.get(netuid, 1)) rows.append( - ( - netuid_cell, # Netuid - subnet_name_cell, # Name - price_cell, # Rate τ_in/α_in - market_cap_cell, # Market Cap - emission_cell, # Emission (τ) - ema_flow_cell, # EMA TAO Inflow (τ) - liquidity_cell, # Liquidity (t_in, a_in) - alpha_out_cell, # Stake α_out - supply_cell, # Supply - tempo_cell, # Tempo k/n - mechanisms_cell, # Mechanism count - ) + { + "netuid": netuid_cell, + "name": subnet_name_cell, + "price": price_cell, + "market_cap": market_cap_cell, + "emission": emission_cell, + "tao_flow_ema": ema_flow_cell, + "liquidity": liquidity_cell, + "stake": alpha_out_cell, + "supply": supply_cell, + "tempo": tempo_cell, + "mechanisms": mechanisms_cell, + } ) total_emissions = round( @@ -561,10 +704,12 @@ def _create_table(subnets_, block_number_, mechanisms, ema_tao_inflow): total_netuids, percentage_string, total_tao_flow_ema, + selected_columns, + hide_header, ) for row in rows: - defined_table.add_row(*row) + defined_table.add_row(*[row[c] for c in selected_columns]) return defined_table def dict_table(subnets_, block_number_, mechanisms, ema_tao_inflow) -> dict: @@ -628,7 +773,14 @@ def dict_table(subnets_, block_number_, mechanisms, ema_tao_inflow) -> dict: # Live mode def create_table_live( - subnets_, previous_data_, block_number_, mechanisms, ema_tao_inflow + subnets_, + previous_data_, + block_number_, + mechanisms, + ema_tao_inflow, + selected_columns: list[str], + hide_header: bool, + is_wide: bool, ): def format_cell( value, previous_value, unit="", unit_first=False, precision=4, millify=False @@ -732,6 +884,7 @@ def format_liquidity_cell( return f"{tao_str}, {alpha_str}" rows = [] + name_max_len = 40 if is_wide else 20 current_data = {} # To store current values for comparison in the next update _, percentage_string = calculate_emission_stats(subnets_, block_number_) @@ -774,7 +927,7 @@ def format_liquidity_cell( netuid_cell = str(netuid) subnet_name_cell = ( f"[{COLOR_PALETTE['GENERAL']['SYMBOL']}]{subnet.symbol if netuid != 0 else 'τ'}[/{COLOR_PALETTE['GENERAL']['SYMBOL']}]" - f" {get_subnet_name(subnet)}" + f" {get_subnet_name(subnet, max_length=name_max_len)}" ) emission_cell = format_cell( emission_tao, @@ -869,19 +1022,19 @@ def format_liquidity_cell( ) rows.append( - ( - netuid_cell, # Netuid - subnet_name_cell, # Name - price_cell, # Rate τ_in/α_in - market_cap_cell, # Market Cap - emission_cell, # Emission (τ) - tao_flow_ema_cell, # EMA TAO Inflow (τ) - liquidity_cell, # Liquidity (t_in, a_in) - alpha_out_cell, # Stake α_out - supply_cell, # Supply - tempo_cell, # Tempo k/n - str(mechanisms.get(netuid, 1)), # Mechanisms - ) + { + "netuid": netuid_cell, + "name": subnet_name_cell, + "price": price_cell, + "market_cap": market_cap_cell, + "emission": emission_cell, + "tao_flow_ema": tao_flow_ema_cell, + "liquidity": liquidity_cell, + "stake": alpha_out_cell, + "supply": supply_cell, + "tempo": tempo_cell, + "mechanisms": str(mechanisms.get(netuid, 1)), + } ) # Calculate totals @@ -910,10 +1063,12 @@ def format_liquidity_cell( total_netuids, percentage_string, total_tao_flow_ema, + selected_columns, + hide_header, ) for row in rows: - table.add_row(*row) + table.add_row(*[row[c] for c in selected_columns]) return table, current_data # Live mode @@ -953,7 +1108,14 @@ def format_liquidity_cell( ) table, current_data = create_table_live( - subnets, previous_data, block_number, mechanisms, ema_tao_inflow + subnets, + previous_data, + block_number, + mechanisms, + ema_tao_inflow, + selected_columns, + no_header, + wide, ) previous_data = current_data progress.reset(progress_task) @@ -980,15 +1142,27 @@ def format_liquidity_cell( else: # Non-live mode subnets, block_number, mechanisms, ema_tao_inflow = await fetch_subnet_data() - if json_output: - json_console.print( - json.dumps( - dict_table(subnets, block_number, mechanisms, ema_tao_inflow) - ) - ) + if output_norm in {"json", "yaml"} or json_output: + payload = dict_table(subnets, block_number, mechanisms, ema_tao_inflow) + if output_norm == "yaml": + console.print(safe_dump(payload, sort_keys=False)) + else: + json_console.print(json.dumps(payload)) else: - table = _create_table(subnets, block_number, mechanisms, ema_tao_inflow) - console.print(table) + table = _create_table( + subnets, + block_number, + mechanisms, + ema_tao_inflow, + selected_columns, + no_header, + wide, + ) + if os.getenv("BTCLI_PAGER"): + with console.pager(styles=True): + console.print(table) + else: + console.print(table) return # TODO: Temporarily returning till we update docs @@ -2028,6 +2202,97 @@ async def _storage_key(storage_fn: str) -> StorageKey: # TODO: Confirm emissions, incentive, Dividends are to be fetched from subnet_state or keep NeuronInfo + + +def filter_sort_limit_metagraph_rows( + *, + rows: list[list], + uids: Optional[list[int]] = None, + hotkey_contains: Optional[str] = None, + sort_by: Optional[str] = None, + sort_order: Optional[str] = None, + limit: Optional[int] = None, +) -> list[list]: + """Pure helper used by `metagraph_cmd` for filtering/sorting/limiting. + + Row schema matches `metagraph_cmd` table_data creation order. + """ + + filtered = rows + if uids is not None: + uid_set = set(uids) + filtered = [r for r in filtered if int(r[0]) in uid_set] + + if hotkey_contains: + needle = hotkey_contains.strip().lower() + if needle: + filtered = [r for r in filtered if needle in str(r[15]).lower()] + + if sort_by is not None: + idx_map = { + "uid": 0, + "global_stake": 1, + "local_stake": 2, + "stake_weight": 3, + "rank": 4, + "trust": 5, + "consensus": 6, + "incentive": 7, + "dividends": 8, + "emission": 9, + "vtrust": 10, + "val": 11, + "updated": 12, + "active": 13, + "axon": 14, + "hotkey": 15, + "coldkey": 16, + } + col_idx = idx_map[sort_by] + + def as_num(val): + try: + return float(val) + except Exception: + try: + return float(str(val).replace("τ", "").strip()) + except Exception: + return 0.0 + + def as_int(val): + try: + return int(val) + except Exception: + return 0 + + def key_fn(r): + if sort_by in {"uid", "updated", "active"}: + return as_int(r[col_idx]) + if sort_by in {"axon", "hotkey", "coldkey"}: + return str(r[col_idx]).lower() + if sort_by == "val": + return 1 if str(r[col_idx]).strip() else 0 + return as_num(r[col_idx]) + + if sort_order is not None: + reverse = sort_order == "desc" + else: + reverse = sort_by not in { + "uid", + "axon", + "hotkey", + "coldkey", + "updated", + "active", + } + + filtered = sorted(filtered, key=key_fn, reverse=reverse) + + if limit is not None: + return filtered[:limit] + return filtered + + async def metagraph_cmd( subtensor: Optional["SubtensorInterface"], netuid: Optional[int], @@ -2035,8 +2300,168 @@ async def metagraph_cmd( html_output: bool, no_cache: bool, display_cols: dict, + output: str = "table", + columns: Optional[list[str]] = None, + no_header: bool = False, + wide: bool = False, + uids: Optional[list[int]] = None, + hotkey_contains: Optional[str] = None, + sort_by: Optional[str] = None, + sort_order: Optional[str] = None, + limit: Optional[int] = None, ): """Prints an entire metagraph.""" + output_norm = (output or "table").strip().lower() + if output_norm not in {"table", "json", "yaml"}: + raise ValueError("output must be one of: table, json, yaml") + + # `--html` remains mutually exclusive with structured output. + if html_output and output_norm in {"json", "yaml"}: + raise ValueError("Cannot use output=json/yaml with html_output=True") + + allowed_column_ids = [ + "uid", + "global_stake", + "local_stake", + "stake_weight", + "rank", + "trust", + "consensus", + "incentive", + "dividends", + "emission", + "vtrust", + "val", + "updated", + "active", + "axon", + "hotkey", + "coldkey", + ] + allowed_columns = set(allowed_column_ids) + selected_columns = columns or allowed_column_ids + unknown = [c for c in selected_columns if c not in allowed_columns] + if unknown: + raise ValueError( + "Unknown column(s): " + + ", ".join(unknown) + + ". Allowed: " + + ", ".join(allowed_column_ids) + ) + + def _id_to_key(col_id: str) -> str: + return { + "uid": "UID", + "global_stake": "GLOBAL_STAKE", + "local_stake": "LOCAL_STAKE", + "stake_weight": "STAKE_WEIGHT", + "rank": "RANK", + "trust": "TRUST", + "consensus": "CONSENSUS", + "incentive": "INCENTIVE", + "dividends": "DIVIDENDS", + "emission": "EMISSION", + "vtrust": "VTRUST", + "val": "VAL", + "updated": "UPDATED", + "active": "ACTIVE", + "axon": "AXON", + "hotkey": "HOTKEY", + "coldkey": "COLDKEY", + }[col_id] + + def _recompute_totals(meta: dict, rows: list[list]) -> dict: + # Recomputes totals shown in footers for the displayed subset. + def _parse_netuid_from_meta(meta: dict) -> Optional[int]: + # meta["net"] is like "finney:1" + try: + net_str = meta.get("net", "") + if ":" in net_str: + return int(net_str.split(":", 1)[1]) + except Exception: + return None + return None + + if not rows: + meta = dict(meta) + meta["total_neurons"] = "0" + meta["total_global_stake"] = "τ 0.00000" + meta["total_local_stake"] = ( + f"{Balance.get_unit(_parse_netuid_from_meta(meta) or 0)} 0.00000" + ) + meta["rank"] = "0.00000" + meta["validator_trust"] = "0.00000" + meta["trust"] = "0.00000" + meta["consensus"] = "0.00000" + meta["incentive"] = "0.00000" + meta["dividends"] = "0.00000" + meta["emission"] = "ρ0" + return meta + + netuid_for_units = _parse_netuid_from_meta(meta) or 0 + total_global = 0.0 + total_local = 0.0 + total_rank = 0.0 + total_vtrust = 0.0 + total_trust = 0.0 + total_consensus = 0.0 + total_incentive = 0.0 + total_dividends = 0.0 + total_emission = 0 + + for idx_row in range(len(rows)): + try: + total_global += float(rows[idx_row][1]) + except Exception: + pass + try: + total_local += float(rows[idx_row][2]) + except Exception: + pass + try: + total_rank += float(rows[idx_row][4]) + except Exception: + pass + try: + total_trust += float(rows[idx_row][5]) + except Exception: + pass + try: + total_consensus += float(rows[idx_row][6]) + except Exception: + pass + try: + total_incentive += float(rows[idx_row][7]) + except Exception: + pass + try: + total_dividends += float(rows[idx_row][8]) + except Exception: + pass + try: + total_emission += int(rows[idx_row][9]) + except Exception: + pass + try: + total_vtrust += float(rows[idx_row][10]) + except Exception: + pass + + meta = dict(meta) + meta["total_neurons"] = str(len(rows)) + meta["total_global_stake"] = "τ {:.5f}".format(total_global) + meta["total_local_stake"] = ( + f"{Balance.get_unit(netuid_for_units)} " + "{:.5f}".format(total_local) + ) + meta["rank"] = "{:.5f}".format(total_rank) + meta["validator_trust"] = "{:.5f}".format(total_vtrust) + meta["trust"] = "{:.5f}".format(total_trust) + meta["consensus"] = "{:.5f}".format(total_consensus) + meta["incentive"] = "{:.5f}".format(total_incentive) + meta["dividends"] = "{:.5f}".format(total_dividends) + meta["emission"] = "ρ{}".format(int(total_emission)) + return meta + # TODO allow config to set certain columns if not reuse_last: cast("SubtensorInterface", subtensor) @@ -2115,8 +2540,8 @@ async def metagraph_cmd( if ep.is_serving else "[light_goldenrod2]none[/light_goldenrod2]" ), - ep.hotkey[:10], - ep.coldkey[:10], + ep.hotkey, + ep.coldkey, ] db_row = [ neuron.uid, @@ -2134,8 +2559,8 @@ async def metagraph_cmd( metagraph.block.item() - metagraph.last_update[uid].item(), metagraph.active[uid].item(), (ep.ip + ":" + str(ep.port) if ep.is_serving else "ERROR"), - ep.hotkey[:10], - ep.coldkey[:10], + ep.hotkey, + ep.coldkey, ] db_table.append(db_row) total_global_stake += metagraph.global_stake[uid] @@ -2207,6 +2632,98 @@ async def metagraph_cmd( ) return + # Apply filter/sort/limit after loading (works with --reuse-last too) + table_data = filter_sort_limit_metagraph_rows( + rows=table_data, + uids=uids, + hotkey_contains=hotkey_contains, + sort_by=sort_by, + sort_order=sort_order, + limit=limit, + ) + metadata_info = _recompute_totals(metadata_info, table_data) + + # Structured output (json/yaml) + if output_norm in {"json", "yaml"}: + idx_map = { + "uid": 0, + "global_stake": 1, + "local_stake": 2, + "stake_weight": 3, + "rank": 4, + "trust": 5, + "consensus": 6, + "incentive": 7, + "dividends": 8, + "emission": 9, + "vtrust": 10, + "val": 11, + "updated": 12, + "active": 13, + "axon": 14, + "hotkey": 15, + "coldkey": 16, + } + + def cast_row(r: list) -> dict: + out = {} + for col_id in selected_columns: + val = r[idx_map[col_id]] + if col_id in {"uid", "updated", "active", "emission"}: + try: + out[col_id] = int(val) + except Exception: + out[col_id] = val + elif col_id in { + "global_stake", + "local_stake", + "stake_weight", + "rank", + "trust", + "consensus", + "incentive", + "dividends", + "vtrust", + }: + try: + out[col_id] = float(val) + except Exception: + out[col_id] = val + elif col_id == "val": + out[col_id] = bool(str(val).strip()) + else: + out[col_id] = val + return out + + payload = { + "net": metadata_info.get("net"), + "block": metadata_info.get("block"), + "N": metadata_info.get("N"), + "N0": metadata_info.get("N0"), + "N1": metadata_info.get("N1"), + "issuance": metadata_info.get("issuance"), + "difficulty": metadata_info.get("difficulty"), + "total_neurons": int(metadata_info.get("total_neurons", "0") or 0), + "totals": { + "total_global_stake": metadata_info.get("total_global_stake"), + "total_local_stake": metadata_info.get("total_local_stake"), + "rank": metadata_info.get("rank"), + "validator_trust": metadata_info.get("validator_trust"), + "trust": metadata_info.get("trust"), + "consensus": metadata_info.get("consensus"), + "incentive": metadata_info.get("incentive"), + "dividends": metadata_info.get("dividends"), + "emission": metadata_info.get("emission"), + }, + "rows": [cast_row(r) for r in table_data], + } + + if output_norm == "yaml": + console.print(safe_dump(payload, sort_keys=False)) + else: + json_console.print(json.dumps(payload)) + return + if html_output: try: render_table( @@ -2468,16 +2985,33 @@ async def metagraph_cmd( ), ), } + # Ensure display_cols has all keys. + display_cols_effective = { + k: bool(display_cols.get(k, True)) for k in cols.keys() + } + if columns is not None: + # Override config for this run. + display_cols_effective = {k: False for k in cols.keys()} + for col_id in selected_columns: + display_cols_effective[_id_to_key(col_id)] = True + + # Build table columns in requested order. table_cols: list[Column] = [] table_cols_indices: list[int] = [] - for k, (idx, v) in cols.items(): - if display_cols[k] is True: - table_cols_indices.append(idx) - table_cols.append(v) + ordered_keys = ( + [_id_to_key(c) for c in selected_columns] + if columns is not None + else [k for k in cols.keys() if display_cols_effective.get(k) is True] + ) + for k in ordered_keys: + idx, col = cols[k] + table_cols_indices.append(idx) + table_cols.append(col) table = Table( *table_cols, - show_footer=True, + show_header=not no_header, + show_footer=False if no_header else True, show_edge=False, header_style="bold white", border_style="bright_black", @@ -2486,7 +3020,9 @@ async def metagraph_cmd( title_justify="center", show_lines=False, expand=True, - title=( + title=None + if no_header + else ( f"[underline dark_orange]Metagraph[/underline dark_orange]\n\n" f"Net: [bright_cyan]{metadata_info['net']}[/bright_cyan], " f"Block: [bright_cyan]{metadata_info['block']}[/bright_cyan], " @@ -2498,22 +3034,28 @@ async def metagraph_cmd( pad_edge=True, ) - if all(x is False for x in display_cols.values()): + if all(x is False for x in display_cols_effective.values()): console.print("You have selected no columns to display in your config.") table.add_row(" " * 256) # allows title to be printed - elif any(x is False for x in display_cols.values()): - console.print( - "Limiting column display output based on your config settings. Hiding columns " - f"{', '.join([k for (k, v) in display_cols.items() if v is False])}" - ) + else: + hotkey_len = 20 if wide else 10 for row in table_data: + # Apply column selection and truncation behavior. new_row = [row[idx] for idx in table_cols_indices] + # Truncate hotkey/coldkey for table output unless --wide. + # (Only affects display; cached/raw data remains full.) + for i, global_idx in enumerate(table_cols_indices): + if global_idx == 15: # HOTKEY + new_row[i] = str(new_row[i])[:hotkey_len] + elif global_idx == 16: # COLDKEY + new_row[i] = str(new_row[i])[:hotkey_len] table.add_row(*new_row) - else: - for row in table_data: - table.add_row(*row) - console.print(table) + if os.getenv("BTCLI_PAGER"): + with console.pager(styles=True): + console.print(table) + else: + console.print(table) def create_identity_table(title: str = None): diff --git a/tests/unit_tests/test_metagraph_list_sort_filter.py b/tests/unit_tests/test_metagraph_list_sort_filter.py new file mode 100644 index 000000000..4eb9c0894 --- /dev/null +++ b/tests/unit_tests/test_metagraph_list_sort_filter.py @@ -0,0 +1,98 @@ +import pytest + +from bittensor_cli.src.commands.subnets.subnets import filter_sort_limit_metagraph_rows + + +def _row( + *, + uid: int, + global_stake: float = 0.0, + local_stake: float = 0.0, + stake_weight: float = 0.0, + rank: float = 0.0, + trust: float = 0.0, + consensus: float = 0.0, + incentive: float = 0.0, + dividends: float = 0.0, + emission: int = 0, + vtrust: float = 0.0, + val: str = "", + updated: int = 0, + active: int = 1, + axon: str = "none", + hotkey: str = "hk", + coldkey: str = "ck", +): + # Row schema matches `metagraph_cmd` table_data creation order. + return [ + str(uid), + f"{global_stake:.4f}", + f"{local_stake:.4f}", + f"{stake_weight:.4f}", + f"{rank:.5f}", + f"{trust:.5f}", + f"{consensus:.5f}", + f"{incentive:.5f}", + f"{dividends:.5f}", + str(int(emission)), + f"{vtrust:.5f}", + val, + str(int(updated)), + str(int(active)), + axon, + hotkey, + coldkey, + ] + + +def test_filter_by_uids_keeps_only_selected(): + rows = [ + _row(uid=0, global_stake=1.0, hotkey="a"), + _row(uid=1, global_stake=2.0, hotkey="b"), + _row(uid=2, global_stake=3.0, hotkey="c"), + ] + + out = filter_sort_limit_metagraph_rows(rows=rows, uids=[2]) + + assert [int(r[0]) for r in out] == [2] + + +def test_filter_by_hotkey_contains_case_insensitive(): + rows = [ + _row(uid=1, hotkey="MyHotKey"), + _row(uid=2, hotkey="Other"), + ] + + out = filter_sort_limit_metagraph_rows(rows=rows, hotkey_contains="hotkey") + + assert [int(r[0]) for r in out] == [1] + + +def test_sort_by_global_stake_desc_default(): + rows = [ + _row(uid=1, global_stake=10.0), + _row(uid=2, global_stake=50.0), + _row(uid=3, global_stake=20.0), + ] + + out = filter_sort_limit_metagraph_rows(rows=rows, sort_by="global_stake") + + assert [int(r[0]) for r in out] == [2, 3, 1] + + +def test_sort_by_uid_asc_and_limit(): + rows = [ + _row(uid=5), + _row(uid=2), + _row(uid=4), + _row(uid=1), + ] + + out = filter_sort_limit_metagraph_rows( + rows=rows, + sort_by="uid", + sort_order="asc", + limit=3, + ) + + assert [int(r[0]) for r in out] == [1, 2, 4] diff --git a/tests/unit_tests/test_subnets_list_sort_filter.py b/tests/unit_tests/test_subnets_list_sort_filter.py new file mode 100644 index 000000000..a21db762e --- /dev/null +++ b/tests/unit_tests/test_subnets_list_sort_filter.py @@ -0,0 +1,100 @@ +from types import SimpleNamespace + +import pytest + +from bittensor_cli.src.commands.subnets.subnets import filter_sort_limit_subnets + + +def _subnet( + *, + netuid: int, + name: str, + alpha_in: float, + alpha_out: float, + price: float, + emission: float = 0.0, + tempo: int = 0, +): + # Minimal shape required by `filter_sort_limit_subnets`. + return SimpleNamespace( + netuid=netuid, + subnet_name=name, + subnet_identity=None, + alpha_in=SimpleNamespace(tao=alpha_in), + alpha_out=SimpleNamespace(tao=alpha_out), + price=SimpleNamespace(tao=price), + tao_in_emission=SimpleNamespace(tao=emission), + tempo=tempo, + ) + + +def test_filter_by_netuids_keeps_only_selected(): + subnets = [ + _subnet(netuid=0, name="root", alpha_in=0, alpha_out=0, price=1), + _subnet(netuid=1, name="alpha", alpha_in=10, alpha_out=5, price=2), + _subnet(netuid=2, name="beta", alpha_in=8, alpha_out=4, price=3), + ] + + out = filter_sort_limit_subnets( + subnets=subnets, + mechanisms={0: 1, 1: 1, 2: 2}, + ema_tao_inflow={}, + netuids=[2], + ) + + assert [s.netuid for s in out] == [2] + + +def test_filter_by_name_contains_case_insensitive(): + subnets = [ + _subnet(netuid=0, name="root", alpha_in=0, alpha_out=0, price=1), + _subnet(netuid=10, name="MySubnet", alpha_in=10, alpha_out=0, price=1), + _subnet(netuid=11, name="Other", alpha_in=10, alpha_out=0, price=1), + ] + + out = filter_sort_limit_subnets( + subnets=subnets, + mechanisms={}, + ema_tao_inflow={}, + name_contains="subnet", + ) + + assert [s.netuid for s in out] == [10] + + +def test_sort_by_price_desc_default_for_numeric(): + subnets = [ + _subnet(netuid=0, name="root", alpha_in=0, alpha_out=0, price=1), + _subnet(netuid=1, name="a", alpha_in=1, alpha_out=1, price=2), + _subnet(netuid=2, name="b", alpha_in=1, alpha_out=1, price=5), + ] + + out = filter_sort_limit_subnets( + subnets=subnets, + mechanisms={}, + ema_tao_inflow={}, + sort_by="price", + ) + + # root always first; rest sorted desc + assert [s.netuid for s in out] == [0, 2, 1] + + +def test_sort_by_netuid_asc_and_limit(): + subnets = [ + _subnet(netuid=0, name="root", alpha_in=0, alpha_out=0, price=1), + _subnet(netuid=5, name="e", alpha_in=1, alpha_out=1, price=1), + _subnet(netuid=3, name="c", alpha_in=1, alpha_out=1, price=1), + _subnet(netuid=4, name="d", alpha_in=1, alpha_out=1, price=1), + ] + + out = filter_sort_limit_subnets( + subnets=subnets, + mechanisms={}, + ema_tao_inflow={}, + sort_by="netuid", + sort_order="asc", + limit=3, + ) + + assert [s.netuid for s in out] == [0, 3, 4]