diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 3af83c735..973ce74bc 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -43,6 +43,8 @@ Constants, COLORS, HYPERPARAMS, + HYPERPARAMS_METADATA, + RootSudoOnly, WalletOptions, ) from bittensor_cli.src.bittensor import utils @@ -6368,9 +6370,15 @@ def sudo_set( This command allows subnet owners to modify hyperparameters such as its tempo, emission rates, and other hyperparameters. + When listing hyperparameters, descriptions, ownership information, and side-effects are displayed to help you make informed decisions. + + You can also set custom hyperparameters not in the standard list by using the exact parameter name from the chain metadata. + EXAMPLE [green]$[/green] btcli sudo set --netuid 1 --param tempo --value 400 + + [green]$[/green] btcli sudo set --netuid 1 --param custom_param_name --value 123 """ self.verbosity_handler(quiet, verbose, json_output, prompt) proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) @@ -6394,9 +6402,42 @@ def sudo_set( [field.name for field in fields(SubnetHyperparameters)] ) console.print("Available hyperparameters:\n") + + # Create a table to show hyperparameters with descriptions + + param_table = Table( + Column("[white]#", style="dim", width=4), + Column("[white]HYPERPARAMETER", style=COLORS.SU.HYPERPARAMETER), + Column("[white]OWNER SETTABLE", style="bright_cyan", width=18), + Column("[white]DESCRIPTION", style="dim", overflow="fold"), + box=box.SIMPLE, + show_edge=False, + pad_edge=False, + ) + for idx, param in enumerate(hyperparam_list, start=1): - console.print(f" {idx}. {param}") + metadata = HYPERPARAMS_METADATA.get(param, {}) + description = metadata.get("description", "No description available.") + + # Check ownership from HYPERPARAMS + _, root_sudo = HYPERPARAMS.get(param, ("", RootSudoOnly.FALSE)) + if root_sudo == RootSudoOnly.TRUE: + owner_settable_str = "[red]No (Root Only)[/red]" + elif root_sudo == RootSudoOnly.COMPLICATED: + owner_settable_str = "[yellow]COMPLICATED[/yellow]" + else: + owner_settable_str = "[green]Yes[/green]" + + param_table.add_row( + str(idx), + f"[bold]{param}[/bold]", + owner_settable_str, + description, + ) + + console.print(param_table) console.print() + choice = IntPrompt.ask( "Enter the [bold]number[/bold] of the hyperparameter", choices=[str(i) for i in range(1, len(hyperparam_list) + 1)], @@ -6404,13 +6445,50 @@ def sudo_set( ) param_name = hyperparam_list[choice - 1] + # Show additional info for selected parameter + metadata = HYPERPARAMS_METADATA.get(param_name, {}) + if metadata: + console.print(f"\n[bold cyan]Selected:[/bold cyan] {param_name}") + description = metadata.get("description", "No description available.") + docs_link = metadata.get("docs_link", "") + if docs_link: + # Show description text followed by clickable blue [link] at the end + console.print( + f"{description} [bright_blue underline link=https://{docs_link}]link[/]" + ) + else: + console.print(f"{description}") + side_effects = metadata.get("side_effects", "") + if side_effects: + console.print(f"[dim]Side Effects:[/dim] {side_effects}") + if docs_link: + console.print( + f"[dim]📚 Docs:[/dim] [link]https://{docs_link}[/link]\n" + ) + if param_name in ["alpha_high", "alpha_low"]: if not prompt: - err_console.print( - f"[{COLORS.SU.HYPERPARAM}]alpha_high[/{COLORS.SU.HYPERPARAM}] and " - f"[{COLORS.SU.HYPERPARAM}]alpha_low[/{COLORS.SU.HYPERPARAM}] " - f"values cannot be set with `--no-prompt`" + err_msg = ( + f"alpha_high and alpha_low values cannot be set with `--no-prompt`. " + f"They must be set together via the alpha_values parameter." ) + if json_output: + json_str = json.dumps( + { + "success": False, + "err_msg": err_msg, + "extrinsic_identifier": None, + }, + ensure_ascii=True, + ) + sys.stdout.write(json_str + "\n") + sys.stdout.flush() + else: + err_console.print( + f"[{COLORS.SU.HYPERPARAM}]alpha_high[/{COLORS.SU.HYPERPARAM}] and " + f"[{COLORS.SU.HYPERPARAM}]alpha_low[/{COLORS.SU.HYPERPARAM}] " + f"values cannot be set with `--no-prompt`" + ) return False param_name = "alpha_values" low_val = FloatPrompt.ask(f"Enter the new value for {arg__('alpha_low')}") @@ -6418,10 +6496,26 @@ def sudo_set( param_value = f"{low_val},{high_val}" if param_name == "yuma_version": if not prompt: - err_console.print( - f"[{COLORS.SU.HYPERPARAM}]yuma_version[/{COLORS.SU.HYPERPARAM}]" - f" is set using a different hyperparameter, and thus cannot be set with `--no-prompt`" + err_msg = ( + "yuma_version is set using a different hyperparameter (yuma3_enabled), " + "and thus cannot be set with `--no-prompt`" ) + if json_output: + json_str = json.dumps( + { + "success": False, + "err_msg": err_msg, + "extrinsic_identifier": None, + }, + ensure_ascii=True, + ) + sys.stdout.write(json_str + "\n") + sys.stdout.flush() + else: + err_console.print( + f"[{COLORS.SU.HYPERPARAM}]yuma_version[/{COLORS.SU.HYPERPARAM}]" + f" is set using a different hyperparameter, and thus cannot be set with `--no-prompt`" + ) return False if Confirm.ask( f"[{COLORS.SU.HYPERPARAM}]yuma_version[/{COLORS.SU.HYPERPARAM}] can only be used to toggle Yuma 3. " @@ -6436,10 +6530,26 @@ def sudo_set( else: return False if param_name == "subnet_is_active": - err_console.print( - f"[{COLORS.SU.HYPERPARAM}]subnet_is_active[/{COLORS.SU.HYPERPARAM}] " - f"is set by using {arg__('btcli subnets start')} command." + err_msg = ( + "subnet_is_active is set by using the 'btcli subnets start' command, " + "not via sudo set" ) + if json_output: + json_str = json.dumps( + { + "success": False, + "err_msg": err_msg, + "extrinsic_identifier": None, + }, + ensure_ascii=True, + ) + sys.stdout.write(json_str + "\n") + sys.stdout.flush() + else: + err_console.print( + f"[{COLORS.SU.HYPERPARAM}]subnet_is_active[/{COLORS.SU.HYPERPARAM}] " + f"is set by using {arg__('btcli subnets start')} command." + ) return False if not param_value: @@ -6467,29 +6577,58 @@ def sudo_set( f"param_name: {param_name}\n" f"param_value: {param_value}" ) - result, err_msg, ext_id = self._run_command( - sudo.sudo_set_hyperparameter( - wallet=wallet, - subtensor=self.initialize_chain(network), - netuid=netuid, - proxy=proxy, - param_name=param_name, - param_value=param_value, - prompt=prompt, - json_output=json_output, - ) - ) if json_output: - json_console.print( - json.dumps( + try: + result, err_msg, ext_id = self._run_command( + sudo.sudo_set_hyperparameter( + wallet=wallet, + subtensor=self.initialize_chain(network), + netuid=netuid, + proxy=proxy, + param_name=param_name, + param_value=param_value, + prompt=prompt, + json_output=json_output, + ) + ) + json_str = json.dumps( { "success": result, "err_msg": err_msg, "extrinsic_identifier": ext_id, - } + }, + ensure_ascii=True, + ) + sys.stdout.write(json_str + "\n") + sys.stdout.flush() + return result + except Exception as e: + # Ensure JSON output even on exceptions + json_str = json.dumps( + { + "success": False, + "err_msg": str(e), + "extrinsic_identifier": None, + }, + ensure_ascii=True, + ) + sys.stdout.write(json_str + "\n") + sys.stdout.flush() + raise + else: + result, err_msg, ext_id = self._run_command( + sudo.sudo_set_hyperparameter( + wallet=wallet, + subtensor=self.initialize_chain(network), + netuid=netuid, + proxy=proxy, + param_name=param_name, + param_value=param_value, + prompt=prompt, + json_output=json_output, ) ) - return result + return result def sudo_get( self, @@ -6502,6 +6641,8 @@ def sudo_get( """ Shows a list of the hyperparameters for the specified subnet. + Displays hyperparameter values along with descriptions, ownership information (which parameters can be set by subnet owners vs root sudo), and side-effects. + EXAMPLE [green]$[/green] btcli sudo get --netuid 1 diff --git a/bittensor_cli/src/__init__.py b/bittensor_cli/src/__init__.py index 9b4e1c3c3..b66f2a8eb 100644 --- a/bittensor_cli/src/__init__.py +++ b/bittensor_cli/src/__init__.py @@ -683,12 +683,255 @@ class RootSudoOnly(Enum): "bonds_reset_enabled": ("sudo_set_bonds_reset_enabled", RootSudoOnly.FALSE), "transfers_enabled": ("sudo_set_toggle_transfer", RootSudoOnly.FALSE), "min_allowed_uids": ("sudo_set_min_allowed_uids", RootSudoOnly.TRUE), + # Note: These are displayed but not directly settable via HYPERPARAMS + # They are derived or set via other mechanisms + "alpha_high": ("", RootSudoOnly.FALSE), # Derived from alpha_values + "alpha_low": ("", RootSudoOnly.FALSE), # Derived from alpha_values + "max_weights_limit": ( + "sudo_set_max_weight_limit", + RootSudoOnly.FALSE, + ), # Alias for max_weights_limit + "subnet_is_active": ("", RootSudoOnly.FALSE), # Set via btcli subnets start + "yuma_version": ("", RootSudoOnly.FALSE), # Related to yuma3_enabled } HYPERPARAMS_MODULE = { "user_liquidity_enabled": "Swap", } +# Hyperparameter metadata: descriptions, side-effects, ownership, and documentation links +HYPERPARAMS_METADATA = { + "rho": { + "description": "Rho controls the rate at which weights decay over time.", + "side_effects": "Changing rho affects how quickly neurons' influence diminishes, impacting consensus dynamics.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#rho", + }, + "kappa": { + "description": "Kappa determines the scaling factor for consensus calculations.", + "side_effects": "Modifying kappa changes how validator votes are weighted in consensus mechanisms.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#kappa", + }, + "immunity_period": { + "description": "Duration (in blocks) during which newly registered neurons are protected from certain penalties.", + "side_effects": "Increasing immunity period gives new neurons more time to establish themselves before facing penalties.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#immunityperiod", + }, + "min_allowed_weights": { + "description": "Minimum number of weight connections a neuron must maintain to stay active.", + "side_effects": "Lower values allow neurons with fewer connections to remain active; higher values enforce stricter connectivity requirements.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#minallowedweights", + }, + "max_weights_limit": { + "description": "Maximum number of weight connections a neuron can have with other neurons.", + "side_effects": "Limits the maximum out-degree of the network graph, affecting network topology and consensus.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#maxweightslimit", + }, + "tempo": { + "description": "Number of blocks between epoch transitions", + "side_effects": "Lower tempo means more frequent updates but higher chain load. Higher tempo reduces frequency but may slow responsiveness.", + "owner_settable": False, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#tempo", + }, + "min_difficulty": { + "description": "Minimum proof-of-work difficulty required for registration", + "side_effects": "Increasing min_difficulty raises the computational barrier for new neuron registrations.", + "owner_settable": False, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#mindifficulty", + }, + "max_difficulty": { + "description": "Maximum proof-of-work difficulty cap.", + "side_effects": "Caps the maximum computational requirement, ensuring registration remains feasible.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#maxdifficulty", + }, + "weights_version": { + "description": "Version key for weight sets.", + "side_effects": "Changing this invalidates all existing weights, forcing neurons to resubmit weights.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#weightsversion", + }, + "weights_rate_limit": { + "description": "Maximum number of weight updates allowed per epoch.", + "side_effects": "Lower values reduce chain load but may limit legitimate weight updates. Higher values allow more flexibility.", + "owner_settable": False, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#weightsratelimit--commitmentratelimit", + }, + "adjustment_interval": { + "description": "Number of blocks between automatic difficulty adjustments.", + "side_effects": "Shorter intervals make difficulty more responsive but may cause volatility. Longer intervals provide stability.", + "owner_settable": False, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#adjustmentinterval", + }, + "activity_cutoff": { + "description": "Minimum activity level required for neurons to remain active.", + "side_effects": "Lower values keep more neurons active; higher values prune inactive neurons more aggressively.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#activitycutoff", + }, + "target_regs_per_interval": { + "description": "Target number of new registrations per adjustment interval.", + "side_effects": "Affects how the difficulty adjustment algorithm targets registration rates.", + "owner_settable": False, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#targetregistrationsperinterval", + }, + "min_burn": { + "description": "Minimum TAO burn amount required for subnet registration.", + "side_effects": "Increasing min_burn raises the barrier to entry, potentially reducing spam but also limiting participation.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#minburn", + }, + "max_burn": { + "description": "Maximum TAO burn amount cap for subnet registration.", + "side_effects": "Caps registration costs, ensuring registration remains accessible even as difficulty increases.", + "owner_settable": False, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#maxburn", + }, + "bonds_moving_avg": { + "description": "Moving average window size for bond calculations.", + "side_effects": "Larger windows provide smoother bond values but slower response to changes. Smaller windows react faster but may be more volatile.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#bondsmovingaverage", + }, + "max_regs_per_block": { + "description": "Maximum number of registrations allowed per block.", + "side_effects": "Lower values reduce chain load but may create registration bottlenecks. Higher values allow more throughput.", + "owner_settable": False, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#maxregistrationsperblock", + }, + "serving_rate_limit": { + "description": "Rate limit for serving requests.", + "side_effects": "Affects network throughput and prevents individual neurons from monopolizing serving capacity.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#servingratelimit", + }, + "max_validators": { + "description": "Maximum number of validators allowed in the subnet.", + "side_effects": "Lower values reduce consensus overhead but limit decentralization. Higher values increase decentralization but may slow consensus.", + "owner_settable": False, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#maxallowedvalidators", + }, + "adjustment_alpha": { + "description": "Alpha parameter for difficulty adjustment algorithm.", + "side_effects": "Higher values make difficulty adjustments more aggressive; lower values provide smoother transitions.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#adjustmentalpha", + }, + "difficulty": { + "description": "Current proof-of-work difficulty for registration.", + "side_effects": "Directly affects registration cost and time. Higher difficulty makes registration harder and more expensive.", + "owner_settable": False, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#difficulty", + }, + "commit_reveal_period": { + "description": "Duration (in blocks) for commit-reveal weight submission scheme.", + "side_effects": "Longer periods provide more time for commits but delay weight revelation. Shorter periods increase frequency.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#commitrevealperiod", + }, + "commit_reveal_weights_enabled": { + "description": "Enable or disable commit-reveal scheme for weight submissions.", + "side_effects": "Enabling prevents front-running of weight submissions. Disabling allows immediate weight visibility.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#commitrevealweightsenabled", + }, + "alpha_values": { + "description": "Alpha range [low, high] for stake calculations.", + "side_effects": "Affects how stake is converted and calculated. Changing these values impacts staking economics.", + "owner_settable": True, + "docs_link": "", + }, + "liquid_alpha_enabled": { + "description": "Enable or disable liquid alpha staking mechanism.", + "side_effects": "Enabling provides more staking flexibility. Disabling uses traditional staking mechanisms.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#liquidalphaenabled", + }, + "registration_allowed": { + "description": "Enable or disable new registrations to the subnet.", + "side_effects": "Disabling registration closes the subnet to new participants. Enabling allows open registration.", + "owner_settable": False, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#networkregistrationallowed", + }, + "network_pow_registration_allowed": { + "description": "Enable or disable proof-of-work based registration.", + "side_effects": "Disabling removes PoW requirement, potentially allowing easier registration. Enabling enforces computational proof.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#networkpowregistrationallowed", + }, + "yuma3_enabled": { + "description": "Enable or disable Yuma3 consensus mechanism.", + "side_effects": "Enabling Yuma3 activates advanced consensus features. Disabling uses standard consensus mechanisms.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#yumaversion", + }, + "alpha_sigmoid_steepness": { + "description": "Steepness parameter for alpha sigmoid function.", + "side_effects": "Affects how alpha values are transformed in staking calculations. Higher values create steeper curves.", + "owner_settable": False, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#alphasigmoidsteepness", + }, + "user_liquidity_enabled": { + "description": "Enable or disable user liquidity features.", + "side_effects": "Enabling allows liquidity provision and swaps. Disabling restricts liquidity operations.", + "owner_settable": True, # COMPLICATED - can be set by owner or sudo + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#userliquidityenabled", + }, + "bonds_reset_enabled": { + "description": "Enable or disable periodic bond resets.", + "side_effects": "Enabling provides periodic bond resets, preventing bond accumulation. Disabling allows bonds to accumulate.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#bondsresetenabled", + }, + "transfers_enabled": { + "description": "Enable or disable TAO transfers within the subnet.", + "side_effects": "Enabling allows TAO transfers between neurons. Disabling prevents all transfer operations.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#toggletransfer", + }, + "min_allowed_uids": { + "description": "Minimum number of UIDs (neurons) required for the subnet to remain active.", + "side_effects": "If subnet falls below this threshold, it may be deactivated. Higher values enforce stricter minimums.", + "owner_settable": False, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#minalloweduids", + }, + # Additional hyperparameters that appear in chain data but aren't directly settable via HYPERPARAMS + "alpha_high": { + "description": "High bound of the alpha range for stake calculations.", + "side_effects": "Affects the upper bound of alpha conversion in staking mechanisms. Set via alpha_values parameter.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#alphasigmoidsteepness", + }, + "alpha_low": { + "description": "Low bound of the alpha range for stake calculations.", + "side_effects": "Affects the lower bound of alpha conversion in staking mechanisms. Set via alpha_values parameter.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#alphasigmoidsteepness", + }, + "max_weights_limit": { + "description": "Maximum number of weight connections a neuron can have with other neurons.", + "side_effects": "Limits the maximum out-degree of the network graph, affecting network topology and consensus.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#maxweightlimit", + }, + "subnet_is_active": { + "description": "Whether the subnet is currently active and operational.", + "side_effects": "When inactive, the subnet cannot process requests or participate in network operations. Set via 'btcli subnets start' command.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#subnetisactive", + }, + "yuma_version": { + "description": "Version of the Yuma consensus mechanism.", + "side_effects": "Changing the version affects which Yuma consensus features are active. Use yuma3_enabled to toggle Yuma3.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#yuma3", + }, +} + # Help Panels for cli help HELP_PANELS = { "WALLET": { diff --git a/bittensor_cli/src/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py index ca4b56099..bffb4f822 100644 --- a/bittensor_cli/src/bittensor/utils.py +++ b/bittensor_cli/src/bittensor/utils.py @@ -4,6 +4,7 @@ import math import os import sqlite3 +import sys import webbrowser from contextlib import contextmanager from pathlib import Path @@ -42,10 +43,21 @@ GLOBAL_MAX_SUBNET_COUNT = 4096 MEV_SHIELD_PUBLIC_KEY_SIZE = 1184 -console = Console() -json_console = Console() -err_console = Console(stderr=True) -verbose_console = Console(quiet=True) +# Detect if we're in a test environment (pytest captures stdout, making it non-TTY) +# or if NO_COLOR is set, disable colors +# Also check for pytest environment variables +_is_pytest = "pytest" in sys.modules or os.getenv("PYTEST_CURRENT_TEST") is not None +_no_color = os.getenv("NO_COLOR", "") != "" or not sys.stdout.isatty() or _is_pytest +# Force no terminal detection when in pytest or when stdout is not a TTY +_force_terminal = False if (_is_pytest or not sys.stdout.isatty()) else None +console = Console(no_color=_no_color, force_terminal=_force_terminal) +json_console = Console( + markup=False, highlight=False, force_terminal=False, no_color=True +) +err_console = Console(stderr=True, no_color=_no_color, force_terminal=_force_terminal) +verbose_console = Console( + quiet=True, no_color=_no_color, force_terminal=_force_terminal +) jinja_env = Environment( loader=PackageLoader("bittensor_cli", "src/bittensor/templates"), diff --git a/bittensor_cli/src/commands/sudo.py b/bittensor_cli/src/commands/sudo.py index 9992fe77e..86f034e67 100644 --- a/bittensor_cli/src/commands/sudo.py +++ b/bittensor_cli/src/commands/sudo.py @@ -1,5 +1,7 @@ import asyncio import json +import re +import sys from typing import TYPE_CHECKING, Union, Optional, Type from async_substrate_interface import AsyncExtrinsicReceipt @@ -12,6 +14,7 @@ from bittensor_cli.src import ( HYPERPARAMS, HYPERPARAMS_MODULE, + HYPERPARAMS_METADATA, RootSudoOnly, DelegatesDetails, COLOR_PALETTE, @@ -366,7 +369,7 @@ async def set_hyperparameter_extrinsic( to_sudo_or_not_to_sudo = True # default to sudo true when no-prompt is set else: to_sudo_or_not_to_sudo = Confirm.ask( - f"This hyperparam can be executed as sudo or not. Do you want to execute as sudo [y] or not [n]?" + "This hyperparam can be executed as sudo or not. Do you want to execute as sudo [y] or not [n]?" ) if to_sudo_or_not_to_sudo: call = await substrate.compose_call( @@ -751,7 +754,15 @@ async def sudo_set_hyperparameter( f"Hyperparameter [dark_orange]{param_name}[/dark_orange] value is not within bounds. " f"Value is {param_value} but must be {value}" ) - err_console.print(err_msg) + if json_output: + json_str = json.dumps( + {"success": False, "err_msg": err_msg, "extrinsic_identifier": None}, + ensure_ascii=True, + ) + sys.stdout.write(json_str + "\n") + sys.stdout.flush() + else: + err_console.print(err_msg) return False, err_msg, None if json_output: prompt = False @@ -767,55 +778,185 @@ async def sudo_set_hyperparameter( return success, err_msg, ext_id +def _sanitize_json_string( + value: Union[str, int, float, bool, None], +) -> Union[str, int, float, bool, None]: + """Sanitize string values for JSON output by removing control characters. + + Non-string values are returned as-is. + """ + if isinstance(value, str): + # Remove all control characters (0x00-0x1F and 0x7F-0x9F) and replace with space + sanitized = re.sub(r"[\x00-\x1f\x7f-\x9f]", " ", value) + # Collapse multiple spaces into single space + sanitized = " ".join(sanitized.split()) + return sanitized + return value + + async def get_hyperparameters( - subtensor: "SubtensorInterface", netuid: int, json_output: bool = False + subtensor: "SubtensorInterface", + netuid: int, + json_output: bool = False, + show_descriptions: bool = True, ) -> bool: """View hyperparameters of a subnetwork.""" print_verbose("Fetching hyperparameters") - if not await subtensor.subnet_exists(netuid): - print_error(f"Subnet with netuid {netuid} does not exist.") - return False - subnet, subnet_info = await asyncio.gather( - subtensor.get_subnet_hyperparameters(netuid), subtensor.subnet(netuid) - ) - if subnet_info is None: - print_error(f"Subnet with netuid {netuid} does not exist.") + try: + if not await subtensor.subnet_exists(netuid): + error_msg = f"Subnet with netuid {netuid} does not exist." + if json_output: + json_str = json.dumps({"error": error_msg}, ensure_ascii=True) + sys.stdout.write(json_str + "\n") + sys.stdout.flush() + else: + print_error(error_msg) + return False + subnet, subnet_info = await asyncio.gather( + subtensor.get_subnet_hyperparameters(netuid), subtensor.subnet(netuid) + ) + if subnet_info is None: + error_msg = f"Subnet with netuid {netuid} does not exist." + if json_output: + json_str = json.dumps({"error": error_msg}, ensure_ascii=True) + sys.stdout.write(json_str + "\n") + sys.stdout.flush() + else: + print_error(error_msg) + return False + except Exception as e: + if json_output: + json_str = json.dumps({"error": str(e)}, ensure_ascii=True) + sys.stdout.write(json_str + "\n") + sys.stdout.flush() + else: + raise return False - table = Table( - Column("[white]HYPERPARAMETER", style=COLOR_PALETTE.SU.HYPERPARAMETER), - Column("[white]VALUE", style=COLOR_PALETTE.SU.VALUE), - Column("[white]NORMALIZED", style=COLOR_PALETTE.SU.NORMAL), - title=f"[{COLOR_PALETTE.G.HEADER}]\nSubnet Hyperparameters\n NETUID: " - f"[{COLOR_PALETTE.G.SUBHEAD}]{netuid}" - f"{f' ({subnet_info.subnet_name})' if subnet_info.subnet_name is not None else ''}" - f"[/{COLOR_PALETTE.G.SUBHEAD}]" - f" - Network: [{COLOR_PALETTE.G.SUBHEAD}]{subtensor.network}[/{COLOR_PALETTE.G.SUBHEAD}]\n", - show_footer=True, - width=None, - pad_edge=False, - box=box.SIMPLE, - show_edge=True, - ) + # Determine if we should show extended info (descriptions, ownership) + show_extended = show_descriptions and not json_output + + if show_extended: + table = Table( + Column("[white]HYPERPARAMETER", style=COLOR_PALETTE.SU.HYPERPARAMETER), + Column("[white]VALUE", style=COLOR_PALETTE.SU.VALUE), + Column("[white]NORMALIZED", style=COLOR_PALETTE.SU.NORMAL), + Column("[white]OWNER SETTABLE", style="bright_cyan"), + Column("[white]DESCRIPTION", style="dim", overflow="fold"), + title=f"[{COLOR_PALETTE.G.HEADER}]\nSubnet Hyperparameters\n NETUID: " + f"[{COLOR_PALETTE.G.SUBHEAD}]{netuid}" + f"{f' ({subnet_info.subnet_name})' if subnet_info.subnet_name is not None else ''}" + f"[/{COLOR_PALETTE.G.SUBHEAD}]" + f" - Network: [{COLOR_PALETTE.G.SUBHEAD}]{subtensor.network}[/{COLOR_PALETTE.G.SUBHEAD}]\n", + show_footer=True, + width=None, + pad_edge=False, + box=box.SIMPLE, + show_edge=True, + ) + else: + table = Table( + Column("[white]HYPERPARAMETER", style=COLOR_PALETTE.SU.HYPERPARAMETER), + Column("[white]VALUE", style=COLOR_PALETTE.SU.VALUE), + Column("[white]NORMALIZED", style=COLOR_PALETTE.SU.NORMAL), + title=f"[{COLOR_PALETTE.G.HEADER}]\nSubnet Hyperparameters\n NETUID: " + f"[{COLOR_PALETTE.G.SUBHEAD}]{netuid}" + f"{f' ({subnet_info.subnet_name})' if subnet_info.subnet_name is not None else ''}" + f"[/{COLOR_PALETTE.G.SUBHEAD}]" + f" - Network: [{COLOR_PALETTE.G.SUBHEAD}]{subtensor.network}[/{COLOR_PALETTE.G.SUBHEAD}]\n", + show_footer=True, + width=None, + pad_edge=False, + box=box.SIMPLE, + show_edge=True, + ) dict_out = [] normalized_values = normalize_hyperparameters(subnet, json_output=json_output) sorted_values = sorted(normalized_values, key=lambda x: x[0]) for param, value, norm_value in sorted_values: if not json_output: - table.add_row(" " + param, value, norm_value) + if show_extended: + # Get metadata for this hyperparameter + metadata = HYPERPARAMS_METADATA.get(param, {}) + description = metadata.get("description", "No description available.") + + # Check actual ownership from HYPERPARAMS + _, root_sudo = HYPERPARAMS.get(param, ("", RootSudoOnly.FALSE)) + if root_sudo == RootSudoOnly.TRUE: + owner_settable_str = "[red]No (Root Only)[/red]" + elif root_sudo == RootSudoOnly.COMPLICATED: + owner_settable_str = "[yellow]COMPLICATED (Owner/Sudo)[/yellow]" + else: + owner_settable_str = "[green]Yes[/green]" + + # Format description with docs link if available + docs_link = metadata.get("docs_link", "") + if docs_link: + # Use Rich markup to create description with clickable bright blue [link] at the end + description_with_link = f"{description} [bright_blue underline link=https://{docs_link}]link[/]" + else: + description_with_link = description + + table.add_row( + " " + param, + value, + norm_value, + owner_settable_str, + description_with_link, + ) + else: + table.add_row(" " + param, value, norm_value) else: + metadata = HYPERPARAMS_METADATA.get(param, {}) + # Sanitize all string fields for JSON output - remove control characters + description = metadata.get("description", "No description available.") + side_effects = metadata.get("side_effects", "No side effects documented.") + docs_link = metadata.get("docs_link", "") + + # Sanitize all string values to ensure valid JSON output dict_out.append( { - "hyperparameter": param, - "value": value, - "normalized_value": norm_value, + "hyperparameter": _sanitize_json_string(str(param)), + "value": _sanitize_json_string(value), + "normalized_value": _sanitize_json_string(norm_value), + "owner_settable": bool(metadata.get("owner_settable", False)), + "description": _sanitize_json_string(description), + "side_effects": _sanitize_json_string(side_effects), + "docs_link": _sanitize_json_string(docs_link), } ) if json_output: - json_console.print(json.dumps(dict_out)) + # Use ensure_ascii=True to properly escape all non-ASCII and control characters + # Write directly to stdout to avoid any Rich Console formatting + import sys + + json_str = json.dumps(dict_out, ensure_ascii=True) + sys.stdout.write(json_str + "\n") + sys.stdout.flush() + return True else: console.print(table) + if show_extended: + console.print( + "\n[dim]💡 Tip: Use [bold]btcli sudo set --param --value [/bold] to modify hyperparameters." + ) + console.print( + "[dim]💡 Tip: Subnet owners can set parameters marked '[green]Yes[/green]'. " + "Parameters marked '[red]No (Root Only)[/red]' require root sudo access." + ) + console.print( + "[dim]💡 Tip: To set custom hyperparameters not in this list, use the exact parameter name from the chain metadata." + ) + console.print( + f"[dim] Example: [bold]btcli sudo set --netuid {netuid} --param custom_param_name --value 123[/bold]" + ) + console.print( + "[dim] The parameter name must match exactly as defined in the chain's AdminUtils pallet metadata." + ) + console.print( + "[dim]📚 For detailed documentation, visit: [link]https://docs.bittensor.com[/link]" + ) return True @@ -867,7 +1008,7 @@ async def get_senate( ) dict_output.append({"name": member_name, "ss58_address": ss58_address}) if json_output: - json_console.print(json.dumps(dict_output)) + json_console.print(json.dumps(dict_output, ensure_ascii=True)) return console.print(table) @@ -960,7 +1101,7 @@ async def proposals( } ) if json_output: - json_console.print(json.dumps(dict_output)) + json_console.print(json.dumps(dict_output, ensure_ascii=True)) console.print(table) console.print( "\n[dim]* Both Ayes and Nays percentages are calculated relative to the proposal's threshold.[/dim]" diff --git a/tests/e2e_tests/test_hyperparams_setting.py b/tests/e2e_tests/test_hyperparams_setting.py index c21008bb5..b17dfee86 100644 --- a/tests/e2e_tests/test_hyperparams_setting.py +++ b/tests/e2e_tests/test_hyperparams_setting.py @@ -83,8 +83,34 @@ def test_hyperparams_setting(local_chain, wallet_setup): hp = {} for hyperparam in all_hyperparams: hp[hyperparam["hyperparameter"]] = hyperparam["value"] + # Verify new metadata fields are present in JSON output + assert "description" in hyperparam, ( + f"Missing description for {hyperparam['hyperparameter']}" + ) + assert "side_effects" in hyperparam, ( + f"Missing side_effects for {hyperparam['hyperparameter']}" + ) + assert "owner_settable" in hyperparam, ( + f"Missing owner_settable for {hyperparam['hyperparameter']}" + ) + assert "docs_link" in hyperparam, ( + f"Missing docs_link for {hyperparam['hyperparameter']}" + ) + # Verify description is not empty (unless it's a parameter without metadata) + assert isinstance(hyperparam["description"], str), ( + f"Description should be string for {hyperparam['hyperparameter']}" + ) + + # Skip parameters that cannot be set with --no-prompt + SKIP_PARAMS = {"alpha_high", "alpha_low", "subnet_is_active", "yuma_version"} + for key, (_, sudo_only) in HYPERPARAMS.items(): - if key in hp.keys() and sudo_only == RootSudoOnly.FALSE: + print(f"key: {key}, sudo_only: {sudo_only}") + if ( + key in hp.keys() + and sudo_only == RootSudoOnly.FALSE + and key not in SKIP_PARAMS + ): if isinstance(hp[key], bool): new_val = not hp[key] elif isinstance(hp[key], int): diff --git a/tests/e2e_tests/test_staking_sudo.py b/tests/e2e_tests/test_staking_sudo.py index 4f5346207..e76ff1627 100644 --- a/tests/e2e_tests/test_staking_sudo.py +++ b/tests/e2e_tests/test_staking_sudo.py @@ -499,6 +499,23 @@ def line(key: str) -> Union[str, bool]: # Parse all hyperparameters and single out max_burn in TAO all_hyperparams = json.loads(hyperparams.stdout) + # Verify new metadata fields are present in JSON output + for hyperparam in all_hyperparams: + assert "description" in hyperparam, ( + f"Missing description for {hyperparam['hyperparameter']}" + ) + assert "side_effects" in hyperparam, ( + f"Missing side_effects for {hyperparam['hyperparameter']}" + ) + assert "owner_settable" in hyperparam, ( + f"Missing owner_settable for {hyperparam['hyperparameter']}" + ) + assert "docs_link" in hyperparam, ( + f"Missing docs_link for {hyperparam['hyperparameter']}" + ) + assert isinstance(hyperparam["description"], str), ( + f"Description should be string for {hyperparam['hyperparameter']}" + ) max_burn_tao = next( filter(lambda x: x["hyperparameter"] == "max_burn", all_hyperparams) )["value"] @@ -518,9 +535,15 @@ def line(key: str) -> Union[str, bool]: ], ) hyperparams_json_output = json.loads(hyperparams_json.stdout) - max_burn_tao_from_json = next( + # Verify metadata fields are present in this JSON output too + max_burn_param = next( filter(lambda x: x["hyperparameter"] == "max_burn", hyperparams_json_output) - )["value"] + ) + assert "description" in max_burn_param, "Missing description for max_burn" + assert "side_effects" in max_burn_param, "Missing side_effects for max_burn" + assert "owner_settable" in max_burn_param, "Missing owner_settable for max_burn" + assert "docs_link" in max_burn_param, "Missing docs_link for max_burn" + max_burn_tao_from_json = max_burn_param["value"] assert Balance.from_rao(max_burn_tao_from_json) == Balance.from_tao(100.0) # Change max_burn hyperparameter to 10 TAO @@ -587,11 +610,25 @@ def line(key: str) -> Union[str, bool]: ], ) updated_hyperparams_json_output = json.loads(updated_hyperparams_json.stdout) - max_burn_tao_from_json = next( + # Verify metadata fields are still present after update + max_burn_updated = next( filter( lambda x: x["hyperparameter"] == "max_burn", updated_hyperparams_json_output ) - )["value"] + ) + assert "description" in max_burn_updated, ( + "Missing description for max_burn after update" + ) + assert "side_effects" in max_burn_updated, ( + "Missing side_effects for max_burn after update" + ) + assert "owner_settable" in max_burn_updated, ( + "Missing owner_settable for max_burn after update" + ) + assert "docs_link" in max_burn_updated, ( + "Missing docs_link for max_burn after update" + ) + max_burn_tao_from_json = max_burn_updated["value"] assert Balance.from_rao(max_burn_tao_from_json) == Balance.from_tao(10.0) change_yuma3_hyperparam = exec_command_alice(