Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 55 additions & 5 deletions gittensor/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,60 @@ def __call__(self, *_a, **_kw):
from gittensor import __version__
from gittensor.cli.issue_commands import register_commands
from gittensor.cli.issue_commands.help import StyledAliasGroup, StyledGroup
from gittensor.cli.issue_commands.helpers import CONFIG_FILE, GITTENSOR_DIR, console, err_console


@click.group(cls=StyledAliasGroup)
from gittensor.cli.issue_commands.helpers import CONFIG_FILE, GITTENSOR_DIR, console, emit_error_json, err_console


def _argv_requests_machine_json(argv: list[str]) -> bool:
"""True if the user asked for JSON on stdout (matches issue/miner flags)."""
return '--json' in argv or '--json-output' in argv


def _click_exception_error_type(exc: click.ClickException) -> str:
"""Map Click exceptions to the same ``error.type`` strings issue commands use."""
if isinstance(exc, click.BadParameter):
return 'bad_parameter'
if isinstance(exc, click.UsageError):
return 'usage_error'
return 'click_exception'


class GittensorRootCli(StyledAliasGroup):
"""Root group: in machine-json mode, Click parse/usage errors emit the same JSON envelope as commands."""

def main(
self,
args=None,
prog_name=None,
complete_var=None,
standalone_mode=True,
windows_expand_args=True,
**extra,
):
json_argv = list(sys.argv[1:] if args is None else args)
if standalone_mode and _argv_requests_machine_json(json_argv):
try:
return super().main(
args=args,
prog_name=prog_name,
complete_var=complete_var,
standalone_mode=False,
windows_expand_args=windows_expand_args,
**extra,
)
except click.ClickException as e:
emit_error_json(str(e), error_type=_click_exception_error_type(e))
sys.exit(e.exit_code)
return super().main(
args=args,
prog_name=prog_name,
complete_var=complete_var,
standalone_mode=standalone_mode,
windows_expand_args=windows_expand_args,
**extra,
)


@click.group(cls=GittensorRootCli)
@click.version_option(version=__version__, prog_name='gittensor')
def cli():
"""Gittensor CLI - Manage issue bounties and validator operations"""
Expand Down Expand Up @@ -189,7 +239,7 @@ def completion(shell):


def main():
"""Main entry point for the CLI"""
"""Main entry point for the CLI."""
cli()


Expand Down
21 changes: 14 additions & 7 deletions gittensor/cli/miner_commands/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,15 @@
show_default=True,
help='Minimum validator stake (α) to probe.',
)
@click.option('--json-output', 'json_mode', is_flag=True, default=False, help='Output results as JSON.')
def miner_check(wallet_name, wallet_hotkey, netuid, network, rpc_url, min_vtrust, min_stake, json_mode):
@click.option(
'--json',
'--json-output',
'as_json',
is_flag=True,
default=False,
help='Output results as JSON (machine-readable). --json-output is a deprecated alias.',
)
def miner_check(wallet_name, wallet_hotkey, netuid, network, rpc_url, min_vtrust, min_stake, as_json):
"""Check how many validators have your PAT stored.

Sends a lightweight probe to each validator — no PAT is transmitted.
Expand All @@ -80,15 +87,15 @@ def miner_check(wallet_name, wallet_hotkey, netuid, network, rpc_url, min_vtrust
try:
wallet, subtensor, metagraph, dendrite = _connect_bittensor(wallet_name, wallet_hotkey, ws_endpoint, netuid)
except Exception as e:
_error(f'Failed to initialize bittensor: {e}', json_mode)
_error(f'Failed to initialize bittensor: {e}', as_json)
sys.exit(1)

# Verify miner is registered
_require_registered(wallet, metagraph, netuid, json_mode)
_require_registered(wallet, metagraph, netuid, as_json)

# 3. Find active validator axons (vtrust + serving + stake threshold)
validator_axons, validator_uids, excluded = _require_validator_axons(
metagraph, json_mode, min_vtrust=min_vtrust, min_stake=min_stake
metagraph, as_json, min_vtrust=min_vtrust, min_stake=min_stake
)

# 4. Send check probes
Expand Down Expand Up @@ -125,7 +132,7 @@ async def _check():
valid_count = counts['valid']

# 6. Display results
if json_mode:
if as_json:
click.echo(
json.dumps(
{
Expand All @@ -152,4 +159,4 @@ async def _check():

console.print(table)
console.print(f'\n[bold]{valid_count}/{len(results)} validators have a valid PAT stored.[/bold]')
_render_skipped_validators(excluded, json_mode)
_render_skipped_validators(excluded, as_json)
47 changes: 33 additions & 14 deletions gittensor/cli/miner_commands/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,28 +104,39 @@ def _status(message: str):


def _print(message: str) -> None:
"""Print a status/info message to stderr (safe under --json-output)."""
"""Print a status/info message to stderr (safe under --json)."""
err_console.print(message)


def _error(msg: str, json_mode: bool) -> None:
"""Print an error message in the appropriate format."""
if json_mode:
click.echo(json.dumps({'success': False, 'error': msg}))
def _error(msg: str, as_json: bool, *, error_type: str = 'cli_error') -> None:
"""Print an error message in the appropriate format (JSON matches issue/admin commands)."""
if as_json:
click.echo(
json.dumps(
{
'success': False,
'error': {'type': error_type, 'message': msg},
}
)
)
else:
err_console.print(f'[red]Error: {msg}[/red]')


def _require_registered(wallet, metagraph, netuid: int, json_mode: bool) -> None:
def _require_registered(wallet, metagraph, netuid: int, as_json: bool) -> None:
"""Exit with error if wallet hotkey is not registered on the subnet."""
if wallet.hotkey.ss58_address not in metagraph.hotkeys:
_error(f'Hotkey {wallet.hotkey.ss58_address[:16]}... is not registered on subnet {netuid}.', json_mode)
_error(
f'Hotkey {wallet.hotkey.ss58_address[:16]}... is not registered on subnet {netuid}.',
as_json,
error_type='not_registered',
)
sys.exit(1)


def _require_validator_axons(
metagraph,
json_mode: bool,
as_json: bool,
*,
min_vtrust: float = DEFAULT_MIN_VALIDATOR_VTRUST,
min_stake: float = DEFAULT_MIN_VALIDATOR_STAKE,
Expand All @@ -140,20 +151,28 @@ def _require_validator_axons(
f'No validators passed --min-vtrust={min_vtrust:g} / '
f'--min-stake={min_stake:,.0f} α; all {len(excluded)} candidate(s) excluded.'
)
if json_mode:
click.echo(json.dumps({'success': False, 'error': msg, 'skipped': excluded}))
if as_json:
click.echo(
json.dumps(
{
'success': False,
'error': {'type': 'no_validators', 'message': msg},
'skipped': excluded,
}
)
)
else:
_render_skipped_validators(excluded, json_mode)
_render_skipped_validators(excluded, as_json)
console.print(f'[red]Error: {msg}[/red]')
else:
_error('No reachable validator axons found on the network.', json_mode)
_error('No reachable validator axons found on the network.', as_json, error_type='no_validators')
sys.exit(1)
return validator_axons, validator_uids, excluded


def _render_skipped_validators(excluded: list[dict], json_mode: bool) -> None:
def _render_skipped_validators(excluded: list[dict], as_json: bool) -> None:
"""Print a 'Skipped Validators' table when any high-vtrust UIDs were filtered."""
if json_mode or not excluded:
if as_json or not excluded:
return
table = Table(title='Skipped Validators')
table.add_column('UID', style='cyan', justify='right')
Expand Down
31 changes: 21 additions & 10 deletions gittensor/cli/miner_commands/post.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,15 @@
show_default=True,
help='Minimum validator stake (α) to broadcast to.',
)
@click.option('--json-output', 'json_mode', is_flag=True, default=False, help='Output results as JSON.')
def miner_post(wallet_name, wallet_hotkey, netuid, network, rpc_url, pat, min_vtrust, min_stake, json_mode):
@click.option(
'--json',
'--json-output',
'as_json',
is_flag=True,
default=False,
help='Output results as JSON (machine-readable). --json-output is a deprecated alias.',
)
def miner_post(wallet_name, wallet_hotkey, netuid, network, rpc_url, pat, min_vtrust, min_stake, as_json):
"""Broadcast your GitHub PAT to all validators on the network.

Validators will validate your PAT (test GitHub API access),
Expand All @@ -90,8 +97,12 @@ def miner_post(wallet_name, wallet_hotkey, netuid, network, rpc_url, pat, min_vt
# 1. Load and validate PAT locally (flag > env var > interactive prompt)
pat = pat or os.environ.get('GITTENSOR_MINER_PAT')
if not pat:
if json_mode:
_error('--pat flag or GITTENSOR_MINER_PAT environment variable is required for JSON mode.', json_mode)
if as_json:
_error(
'--pat flag or GITTENSOR_MINER_PAT environment variable is required when using --json.',
as_json,
error_type='missing_pat',
)
sys.exit(1)
pat = click.prompt('Enter your GitHub Personal Access Token', hide_input=True)

Expand All @@ -100,7 +111,7 @@ def miner_post(wallet_name, wallet_hotkey, netuid, network, rpc_url, pat, min_vt
github_login = _validate_pat_locally(pat)

if github_login is None:
_error('GitHub PAT is invalid or expired. Check your GITTENSOR_MINER_PAT.', json_mode)
_error('GitHub PAT is invalid or expired. Check your GITTENSOR_MINER_PAT.', as_json)
sys.exit(1)

_print(f'[green]PAT is valid.[/green] GitHub account: [bold]@{github_login}[/bold]')
Expand All @@ -117,15 +128,15 @@ def miner_post(wallet_name, wallet_hotkey, netuid, network, rpc_url, pat, min_vt
try:
wallet, subtensor, metagraph, dendrite = _connect_bittensor(wallet_name, wallet_hotkey, ws_endpoint, netuid)
except Exception as e:
_error(f'Failed to initialize bittensor: {e}', json_mode)
_error(f'Failed to initialize bittensor: {e}', as_json)
sys.exit(1)

# Verify miner is registered
_require_registered(wallet, metagraph, netuid, json_mode)
_require_registered(wallet, metagraph, netuid, as_json)

# 4. Find active validator axons (vtrust + serving + stake threshold)
validator_axons, validator_uids, excluded = _require_validator_axons(
metagraph, json_mode, min_vtrust=min_vtrust, min_stake=min_stake
metagraph, as_json, min_vtrust=min_vtrust, min_stake=min_stake
)

# 5. Broadcast
Expand Down Expand Up @@ -162,7 +173,7 @@ async def _broadcast():
accepted_count = counts['accepted']

# 7. Display results
if json_mode:
if as_json:
click.echo(
json.dumps(
{
Expand Down Expand Up @@ -190,7 +201,7 @@ async def _broadcast():

console.print(table)
console.print(f'\n[bold]{accepted_count}/{len(results)} validators accepted your PAT.[/bold]')
_render_skipped_validators(excluded, json_mode)
_render_skipped_validators(excluded, as_json)


def _validate_pat_locally(pat: str) -> str | None:
Expand Down
27 changes: 17 additions & 10 deletions gittensor/cli/miner_commands/score.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@
_DEV_HOTKEY = 'dev'


def _die(msg: str, json_mode: bool) -> NoReturn:
_error(msg, json_mode)
def _die(msg: str, as_json: bool) -> NoReturn:
_error(msg, as_json)
sys.exit(1)


def _resolve_pat(cli_pat: Optional[str], json_mode: bool) -> str:
def _resolve_pat(cli_pat: Optional[str], as_json: bool) -> str:
pat = cli_pat or os.environ.get('GITTENSOR_MINER_PAT')
if not pat:
_die('--pat flag or GITTENSOR_MINER_PAT environment variable is required.', json_mode)
_die('--pat flag or GITTENSOR_MINER_PAT environment variable is required.', as_json)
return pat


Expand Down Expand Up @@ -231,8 +231,15 @@ def _drain_logs() -> None:
show_default=True,
help="Bittensor log verbosity. 'info' surfaces the validator pipeline's per-step progress on stderr.",
)
@click.option('--json-output', 'json_mode', is_flag=True, default=False, help='Emit result as JSON on stdout.')
def score_command(pat: Optional[str], log_level: str, json_mode: bool) -> None:
@click.option(
'--json',
'--json-output',
'as_json',
is_flag=True,
default=False,
help='Emit result as JSON on stdout. --json-output is a deprecated alias.',
)
def score_command(pat: Optional[str], log_level: str, as_json: bool) -> None:
"""Locally run the validator scoring pipeline end-to-end for the miner identified by --pat.

No subtensor, wallet, DB, axon, or wandb is touched.
Expand All @@ -243,7 +250,7 @@ def score_command(pat: Optional[str], log_level: str, json_mode: bool) -> None:
"""
import asyncio

resolved_pat = _resolve_pat(pat, json_mode)
resolved_pat = _resolve_pat(pat, as_json)

# Deferred imports: keeps --help fast (these pull bittensor + the validator graph).
from gittensor.validator.forward import (
Expand All @@ -264,7 +271,7 @@ def score_command(pat: Optional[str], log_level: str, json_mode: bool) -> None:
stub = cast('Validator', _StubValidator(_DEV_UID, _DEV_HOTKEY))
miner_uids = {_DEV_UID}

if json_mode:
if as_json:
master_repositories = load_master_repo_weights()
programming_languages = load_programming_language_weights()
token_config = load_token_config()
Expand Down Expand Up @@ -299,13 +306,13 @@ async def _run() -> Dict[str, Any]:
},
}

if not json_mode:
if not as_json:
console.print('[bold cyan]Running validator pipeline...[/bold cyan]')
payload = asyncio.run(_run())

_drain_logs()

if json_mode:
if as_json:
emit_json(payload)
else:
_render_table(payload)
28 changes: 28 additions & 0 deletions tests/cli/test_cli_json_error_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,31 @@ def test_admin_info_human_mode_exits_non_zero_on_soft_read_failure(cli_root, run

assert result.exit_code == 1
assert 'Could not read contract configuration' in result.output


def test_issues_list_json_mode_bad_id_type_emits_bad_parameter(cli_root, runner):
"""Invalid --id value must yield JSON on stdout when --json is set (Click parse path)."""
result = runner.invoke(cli_root, ['issues', 'list', '--json', '--id', 'not-an-int'], catch_exceptions=False)
assert result.exit_code != 0
payload = json.loads(result.stdout)
assert payload['success'] is False
assert payload['error']['type'] == 'bad_parameter'
msg = payload['error']['message'].lower()
assert 'not-an-int' in msg or 'integer' in msg or 'valid' in msg


def test_issues_list_json_mode_unknown_option_emits_usage_error(cli_root, runner):
result = runner.invoke(cli_root, ['issues', 'list', '--json', '--not-a-real-option'], catch_exceptions=False)
assert result.exit_code != 0
payload = json.loads(result.stdout)
assert payload['success'] is False
assert payload['error']['type'] == 'usage_error'
assert 'not-a-real-option' in payload['error']['message'] or 'no such option' in payload['error']['message'].lower()


def test_miner_check_json_mode_unknown_option_emits_usage_error(cli_root, runner):
result = runner.invoke(cli_root, ['miner', 'check', '--json', '--not-a-real-option'], catch_exceptions=False)
assert result.exit_code != 0
payload = json.loads(result.stdout)
assert payload['success'] is False
assert payload['error']['type'] == 'usage_error'
Loading