cartha-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cartha_cli/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """Cartha CLI package."""
2
+
3
+
4
+ def main() -> None: # pragma: no cover
5
+ import sys
6
+
7
+ argv = sys.argv[1:]
8
+
9
+ if not argv:
10
+ sys.argv = [sys.argv[0]]
11
+ from .commands.help import print_root_help
12
+
13
+ print_root_help()
14
+ raise SystemExit(0)
15
+
16
+ if argv[0] in {"-h", "--help"}:
17
+ original = sys.argv[:]
18
+ sys.argv = [sys.argv[0]]
19
+ from .commands.help import print_root_help
20
+
21
+ print_root_help()
22
+ sys.argv = original
23
+ raise SystemExit(0)
24
+
25
+ original = sys.argv[:]
26
+ sanitized = [original[0]] + [arg for arg in argv if arg not in {"-h", "--help"}]
27
+ sys.argv = sanitized
28
+ from .main import app
29
+
30
+ sys.argv = original
31
+ app()
32
+
33
+
34
+ __all__ = ["main"]
cartha_cli/bt.py ADDED
@@ -0,0 +1,206 @@
1
+ """Bittensor convenience wrappers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass
7
+
8
+ try:
9
+ import bittensor as bt
10
+ except ImportError: # pragma: no cover - surfaced at call time
11
+ bt = None
12
+
13
+
14
+ def get_subtensor(network: str) -> bt.Subtensor:
15
+ if bt is None: # pragma: no cover - safeguarded for tests
16
+ raise RuntimeError("bittensor is not installed")
17
+ return bt.subtensor(network=network)
18
+
19
+
20
+ def get_wallet(name: str, hotkey: str) -> bt.wallet:
21
+ if bt is None: # pragma: no cover
22
+ raise RuntimeError("bittensor is not installed")
23
+ return bt.wallet(name=name, hotkey=hotkey)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class RegistrationResult:
28
+ status: str
29
+ success: bool
30
+ uid: int | None
31
+ hotkey: str
32
+ extrinsic: str | None = None # Extrinsic hash (e.g., "5759123-5")
33
+ balance_before: float | None = None # Balance before registration
34
+ balance_after: float | None = None # Balance after registration
35
+
36
+
37
+ def register_hotkey(
38
+ *,
39
+ network: str,
40
+ wallet_name: str,
41
+ hotkey_name: str,
42
+ netuid: int,
43
+ burned: bool = True,
44
+ cuda: bool = False,
45
+ wait_for_finalization: bool = True,
46
+ wait_for_inclusion: bool = False,
47
+ dev_id: int | list[int] | None = 0,
48
+ tpb: int = 256,
49
+ num_processes: int | None = None,
50
+ ) -> RegistrationResult:
51
+ """Register a hotkey on the target subnet and return the resulting UID."""
52
+
53
+ subtensor = get_subtensor(network)
54
+ wallet = get_wallet(wallet_name, hotkey_name)
55
+ hotkey_ss58 = wallet.hotkey.ss58_address
56
+
57
+ if subtensor.is_hotkey_registered(hotkey_ss58, netuid=netuid):
58
+ neuron = subtensor.get_neuron_for_pubkey_and_subnet(hotkey_ss58, netuid)
59
+ uid = None if getattr(neuron, "is_null", False) else getattr(neuron, "uid", None)
60
+ return RegistrationResult(status="already", success=True, uid=uid, hotkey=hotkey_ss58)
61
+
62
+ # Get balance before registration
63
+ balance_before = None
64
+ balance_after = None
65
+ extrinsic = None
66
+
67
+ try:
68
+ balance_obj = subtensor.get_balance(wallet.coldkeypub.ss58_address)
69
+ # Convert Balance object to float using .tao property
70
+ balance_before = balance_obj.tao if hasattr(balance_obj, "tao") else float(balance_obj)
71
+ except Exception:
72
+ pass # Balance may not be available, continue anyway
73
+
74
+ if burned:
75
+ # burned_register returns (success, block_info) or just success
76
+ registration_result = subtensor.burned_register(
77
+ wallet=wallet,
78
+ netuid=netuid,
79
+ wait_for_finalization=wait_for_finalization,
80
+ )
81
+
82
+ # Handle both return types: bool or (bool, message)
83
+ if isinstance(registration_result, tuple):
84
+ ok, message = registration_result
85
+ if isinstance(message, str) and message:
86
+ extrinsic = message
87
+ else:
88
+ ok = registration_result
89
+
90
+ status = "burned"
91
+ else:
92
+ ok = subtensor.register(
93
+ wallet=wallet,
94
+ netuid=netuid,
95
+ wait_for_finalization=wait_for_finalization,
96
+ wait_for_inclusion=wait_for_inclusion,
97
+ cuda=cuda,
98
+ dev_id=dev_id,
99
+ tpb=tpb,
100
+ num_processes=num_processes,
101
+ log_verbose=False,
102
+ )
103
+ status = "pow"
104
+ if isinstance(ok, tuple) and len(ok) == 2:
105
+ ok, message = ok
106
+ if isinstance(message, str):
107
+ extrinsic = message
108
+
109
+ if not ok:
110
+ return RegistrationResult(
111
+ status=status,
112
+ success=False,
113
+ uid=None,
114
+ hotkey=hotkey_ss58,
115
+ balance_before=balance_before,
116
+ balance_after=balance_after,
117
+ extrinsic=extrinsic,
118
+ )
119
+
120
+ # Get balance after registration
121
+ try:
122
+ balance_obj = subtensor.get_balance(wallet.coldkeypub.ss58_address)
123
+ # Convert Balance object to float using .tao property
124
+ balance_after = balance_obj.tao if hasattr(balance_obj, "tao") else float(balance_obj)
125
+ except Exception:
126
+ pass
127
+
128
+ neuron = subtensor.get_neuron_for_pubkey_and_subnet(hotkey_ss58, netuid)
129
+ uid = None if getattr(neuron, "is_null", False) else getattr(neuron, "uid", None)
130
+
131
+ return RegistrationResult(
132
+ status=status,
133
+ success=True,
134
+ uid=uid,
135
+ hotkey=hotkey_ss58,
136
+ balance_before=balance_before,
137
+ balance_after=balance_after,
138
+ extrinsic=extrinsic,
139
+ )
140
+
141
+
142
+ def get_burn_cost(network: str, netuid: int) -> float | None:
143
+ """Get the burn cost (registration cost) for a subnet.
144
+
145
+ Args:
146
+ network: Bittensor network name (e.g., "test", "finney")
147
+ netuid: Subnet netuid
148
+
149
+ Returns:
150
+ Burn cost in TAO as a float, or None if unavailable
151
+ """
152
+ # Try async SubtensorInterface method (most reliable)
153
+ try:
154
+ from bittensor_cli.src.bittensor.balances import Balance # type: ignore[import-not-found]
155
+ from bittensor_cli.src.bittensor.subtensor_interface import ( # type: ignore[import-not-found]
156
+ SubtensorInterface,
157
+ )
158
+
159
+ async def _fetch_burn_cost() -> float:
160
+ async with SubtensorInterface(network=network) as subtensor_async:
161
+ block_hash = await subtensor_async.substrate.get_chain_head()
162
+ burn_raw = await subtensor_async.get_hyperparameter(
163
+ param_name="Burn",
164
+ netuid=netuid,
165
+ block_hash=block_hash,
166
+ )
167
+ register_cost = (
168
+ Balance.from_rao(int(burn_raw)) if burn_raw is not None else Balance(0)
169
+ )
170
+ tao_value = (
171
+ register_cost.tao if hasattr(register_cost, "tao") else float(register_cost)
172
+ )
173
+ return float(tao_value)
174
+
175
+ return asyncio.run(_fetch_burn_cost())
176
+ except ImportError:
177
+ # bittensor-cli not available, try fallback synchronous method
178
+ try:
179
+ subtensor = get_subtensor(network)
180
+ # Use get_hyperparameter("Burn", netuid) - this works synchronously
181
+ if hasattr(subtensor, "get_hyperparameter"):
182
+ burn_raw = subtensor.get_hyperparameter("Burn", netuid=netuid)
183
+ if burn_raw is not None:
184
+ # Convert from rao to TAO
185
+ from bittensor import Balance
186
+
187
+ balance_obj = Balance.from_rao(int(burn_raw))
188
+ return balance_obj.tao if hasattr(balance_obj, "tao") else float(balance_obj)
189
+ except Exception:
190
+ pass
191
+ except Exception as exc:
192
+ # Log other exceptions but don't show warnings in normal operation
193
+ import warnings
194
+
195
+ warnings.warn(f"Failed to fetch burn cost: {exc}", UserWarning, stacklevel=2)
196
+
197
+ return None
198
+
199
+
200
+ __all__ = [
201
+ "get_subtensor",
202
+ "get_wallet",
203
+ "register_hotkey",
204
+ "get_burn_cost",
205
+ "RegistrationResult",
206
+ ]
@@ -0,0 +1,25 @@
1
+ """CLI command modules."""
2
+
3
+ from . import (
4
+ config,
5
+ health,
6
+ miner_password,
7
+ miner_status,
8
+ pair_status,
9
+ pools,
10
+ prove_lock,
11
+ register,
12
+ version,
13
+ )
14
+
15
+ __all__ = [
16
+ "config",
17
+ "health",
18
+ "miner_password",
19
+ "miner_status",
20
+ "pair_status",
21
+ "pools",
22
+ "prove_lock",
23
+ "register",
24
+ "version",
25
+ ]
@@ -0,0 +1,76 @@
1
+ """Common utilities and helpers for CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import NoReturn
6
+
7
+ import bittensor as bt
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from ..config import settings
12
+
13
+ console = Console()
14
+
15
+ _TRACE_ENABLED = False
16
+
17
+
18
+ def set_trace_enabled(enabled: bool) -> None:
19
+ """Set whether trace mode is enabled."""
20
+ global _TRACE_ENABLED
21
+ _TRACE_ENABLED = enabled
22
+
23
+
24
+ def trace_enabled() -> bool:
25
+ """Check if trace mode is enabled."""
26
+ return _TRACE_ENABLED
27
+
28
+
29
+ def exit_with_error(message: str, code: int = 1) -> NoReturn:
30
+ """Exit with an error message."""
31
+ console.print(f"[bold red]{message}[/]")
32
+ raise typer.Exit(code=code)
33
+
34
+
35
+ def handle_wallet_exception(
36
+ *,
37
+ wallet_name: str | None,
38
+ wallet_hotkey: str | None,
39
+ exc: Exception,
40
+ ) -> None:
41
+ """Handle wallet-related exceptions."""
42
+ detail = str(exc).strip()
43
+ name = wallet_name or "<unknown>"
44
+ hotkey = wallet_hotkey or "<unknown>"
45
+ message = (
46
+ f"Unable to open coldkey '{name}' hotkey '{hotkey}'. "
47
+ "Ensure the wallet exists, hotkey files are present, and the key is unlocked."
48
+ )
49
+ if detail:
50
+ message += f" ({detail})"
51
+ exit_with_error(message)
52
+
53
+
54
+ def handle_unexpected_exception(context: str, exc: Exception) -> None:
55
+ """Handle unexpected exceptions."""
56
+ if trace_enabled():
57
+ raise
58
+ detail = str(exc).strip()
59
+ message = context
60
+ if detail:
61
+ message += f" ({detail})"
62
+ exit_with_error(message)
63
+
64
+
65
+ def log_endpoint_banner() -> None:
66
+ """Log the endpoint banner based on verifier URL."""
67
+ verifier_url = settings.verifier_url.lower()
68
+ if verifier_url.startswith("http://127.0.0.1"):
69
+ console.print("[bold cyan]Using local verifier endpoint[/]")
70
+ elif "pr-" in verifier_url:
71
+ console.print("[bold cyan]Using Cartha DEV network verifier[/]")
72
+ elif "cartha-verifier-826542474079.us-central1.run.app" in verifier_url:
73
+ console.print("[bold cyan]Using Cartha Testnet Verifier[/]")
74
+ else:
75
+ console.print("[bold cyan]Using Cartha network verifier[/]")
76
+
@@ -0,0 +1,294 @@
1
+ """Configuration command - view and set environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import typer
10
+ from rich.table import Table
11
+
12
+ from ..config import Settings
13
+ from .common import console
14
+
15
+
16
+ # Environment variable documentation
17
+ ENV_VAR_DOCS: dict[str, dict[str, Any]] = {
18
+ "CARTHA_VERIFIER_URL": {
19
+ "description": "URL of the Cartha verifier service",
20
+ "default": "https://cartha-verifier-826542474079.us-central1.run.app",
21
+ "required": False,
22
+ },
23
+ "CARTHA_NETWORK": {
24
+ "description": "Bittensor network name (e.g., 'finney', 'test')",
25
+ "default": "finney",
26
+ "required": False,
27
+ },
28
+ "CARTHA_NETUID": {
29
+ "description": "Subnet UID (netuid) for the Cartha subnet",
30
+ "default": "35",
31
+ "required": False,
32
+ },
33
+ "CARTHA_EVM_PK": {
34
+ "description": "EVM private key for signing transactions (sensitive)",
35
+ "default": None,
36
+ "required": False,
37
+ "sensitive": True,
38
+ },
39
+ "CARTHA_RETRY_MAX_ATTEMPTS": {
40
+ "description": "Maximum number of retry attempts for failed requests",
41
+ "default": "3",
42
+ "required": False,
43
+ },
44
+ "CARTHA_RETRY_BACKOFF_FACTOR": {
45
+ "description": "Backoff factor for exponential retry delays",
46
+ "default": "1.5",
47
+ "required": False,
48
+ },
49
+ "CARTHA_LOCK_UI_URL": {
50
+ "description": "URL of the Cartha Lock UI frontend",
51
+ "default": "https://cartha.finance",
52
+ "required": False,
53
+ },
54
+ }
55
+
56
+
57
+ def _get_env_file_path() -> Path:
58
+ """Get the path to the .env file in the current directory."""
59
+ return Path.cwd() / ".env"
60
+
61
+
62
+ def _read_env_file() -> dict[str, str]:
63
+ """Read existing .env file and return as dict."""
64
+ env_file = _get_env_file_path()
65
+ env_vars: dict[str, str] = {}
66
+
67
+ if env_file.exists():
68
+ try:
69
+ with open(env_file, "r", encoding="utf-8") as f:
70
+ for line in f:
71
+ line = line.strip()
72
+ # Skip empty lines and comments
73
+ if not line or line.startswith("#"):
74
+ continue
75
+ # Parse KEY=VALUE format
76
+ if "=" in line:
77
+ key, value = line.split("=", 1)
78
+ env_vars[key.strip()] = value.strip().strip('"').strip("'")
79
+ except Exception:
80
+ pass
81
+
82
+ return env_vars
83
+
84
+
85
+ def _write_env_file(env_vars: dict[str, str], remove_vars: set[str] | None = None) -> None:
86
+ """Write environment variables to .env file.
87
+
88
+ Args:
89
+ env_vars: Dictionary of variables to set/update
90
+ remove_vars: Optional set of variable names to remove from file
91
+ """
92
+ env_file = _get_env_file_path()
93
+ remove_vars = remove_vars or set()
94
+
95
+ # Read existing file to preserve comments and other vars
96
+ existing_lines: list[str] = []
97
+ existing_vars: set[str] = set()
98
+
99
+ if env_file.exists():
100
+ with open(env_file, "r", encoding="utf-8") as f:
101
+ for line in f:
102
+ stripped = line.strip()
103
+ if stripped and not stripped.startswith("#") and "=" in stripped:
104
+ key = stripped.split("=", 1)[0].strip()
105
+ existing_vars.add(key)
106
+ # Skip lines for variables we want to remove
107
+ if key in remove_vars:
108
+ continue
109
+ existing_lines.append(line.rstrip())
110
+
111
+ # Update or add new vars
112
+ for key, value in env_vars.items():
113
+ if key in existing_vars and key not in remove_vars:
114
+ # Update existing line
115
+ for i, line in enumerate(existing_lines):
116
+ if line.strip() and not line.strip().startswith("#") and line.startswith(f"{key}="):
117
+ existing_lines[i] = f"{key}={value}"
118
+ break
119
+ elif key not in remove_vars:
120
+ # Add new line
121
+ existing_lines.append(f"{key}={value}")
122
+
123
+ # Write back to file
124
+ with open(env_file, "w", encoding="utf-8") as f:
125
+ for line in existing_lines:
126
+ f.write(line + "\n")
127
+
128
+
129
+ def config_list() -> None:
130
+ """List all available environment variables and their current values."""
131
+ console.print("\n[bold cyan]━━━ Configuration ━━━[/]")
132
+ console.print()
133
+
134
+ # Get default values
135
+ default_settings = Settings()
136
+
137
+ # Get current values from environment
138
+ current_values: dict[str, str | None] = {}
139
+ for env_var in ENV_VAR_DOCS.keys():
140
+ current_values[env_var] = os.getenv(env_var)
141
+
142
+ # Create table
143
+ table = Table(show_header=True, header_style="bold cyan")
144
+ table.add_column("Variable", style="cyan", no_wrap=True)
145
+ table.add_column("Description", style="dim")
146
+ table.add_column("Current Value", style="green")
147
+ table.add_column("Default", style="dim")
148
+ table.add_column("Source", justify="center")
149
+
150
+ for env_var, doc in ENV_VAR_DOCS.items():
151
+ current_val = current_values.get(env_var)
152
+ default_val = doc.get("default")
153
+ is_sensitive = doc.get("sensitive", False)
154
+
155
+ # Format current value
156
+ if current_val is not None:
157
+ if is_sensitive:
158
+ display_current = "[REDACTED]"
159
+ else:
160
+ display_current = current_val
161
+ source = "[green]●[/] env"
162
+ else:
163
+ display_current = default_val or "-"
164
+ source = "[dim]○[/] default"
165
+
166
+ # Format default value
167
+ display_default = default_val or "-"
168
+
169
+ table.add_row(
170
+ env_var,
171
+ doc["description"],
172
+ display_current,
173
+ display_default,
174
+ source,
175
+ )
176
+
177
+ console.print(table)
178
+ console.print()
179
+ console.print("[dim]● = Set via environment variable[/]")
180
+ console.print("[dim]○ = Using default value[/]")
181
+ console.print()
182
+ console.print(f"[dim]Environment file: {_get_env_file_path()}[/]")
183
+ console.print("[dim]Use 'cartha config set <VAR> <VALUE>' to set a value[/]")
184
+
185
+
186
+ def config_set(
187
+ variable: str = typer.Argument(..., help="Environment variable name (e.g., CARTHA_VERIFIER_URL)"),
188
+ value: str = typer.Argument(..., help="Value to set"),
189
+ env_file: bool = typer.Option(
190
+ True,
191
+ "--env-file/--no-env-file",
192
+ help="Write to .env file (default: True). If False, only sets in current session.",
193
+ ),
194
+ ) -> None:
195
+ """Set an environment variable value.
196
+
197
+ By default, writes to .env file in the current directory. Use --no-env-file
198
+ to only set for the current session (doesn't persist).
199
+ """
200
+ # Validate variable name
201
+ if variable not in ENV_VAR_DOCS:
202
+ console.print(f"[bold red]✗ Unknown variable: {variable}[/]")
203
+ console.print(f"\nAvailable variables:")
204
+ for var_name in ENV_VAR_DOCS.keys():
205
+ console.print(f" • {var_name}")
206
+ raise typer.Exit(code=1)
207
+
208
+ doc = ENV_VAR_DOCS[variable]
209
+ is_sensitive = doc.get("sensitive", False)
210
+
211
+ if env_file:
212
+ # Read existing .env file
213
+ env_vars = _read_env_file()
214
+
215
+ # Update the variable
216
+ env_vars[variable] = value
217
+
218
+ # Write back
219
+ try:
220
+ _write_env_file(env_vars)
221
+ display_value = "[REDACTED]" if is_sensitive else value
222
+ console.print(f"[bold green]✓ Set {variable}={display_value}[/]")
223
+ console.print(f"[dim]Written to: {_get_env_file_path()}[/]")
224
+ console.print("[yellow]Note: Restart your terminal or run 'source .env' to apply changes[/]")
225
+ except Exception as exc:
226
+ console.print(f"[bold red]✗ Failed to write to .env file: {exc}[/]")
227
+ raise typer.Exit(code=1)
228
+ else:
229
+ # Only set for current session
230
+ os.environ[variable] = value
231
+ display_value = "[REDACTED]" if is_sensitive else value
232
+ console.print(f"[bold green]✓ Set {variable}={display_value}[/] (current session only)")
233
+ console.print("[yellow]Note: This will not persist after the terminal session ends[/]")
234
+
235
+
236
+ def config_get(
237
+ variable: str = typer.Argument(..., help="Environment variable name (e.g., CARTHA_VERIFIER_URL)"),
238
+ ) -> None:
239
+ """Get the current value of an environment variable."""
240
+ if variable not in ENV_VAR_DOCS:
241
+ console.print(f"[bold red]✗ Unknown variable: {variable}[/]")
242
+ console.print(f"\nAvailable variables:")
243
+ for var_name in ENV_VAR_DOCS.keys():
244
+ console.print(f" • {var_name}")
245
+ raise typer.Exit(code=1)
246
+
247
+ doc = ENV_VAR_DOCS[variable]
248
+ is_sensitive = doc.get("sensitive", False)
249
+
250
+ # Get current value
251
+ current_val = os.getenv(variable)
252
+ default_val = doc.get("default")
253
+
254
+ console.print(f"\n[bold cyan]{variable}[/]")
255
+ console.print(f"Description: {doc['description']}")
256
+ console.print(f"Default: {default_val or 'None'}")
257
+
258
+ if current_val is not None:
259
+ display_value = "[REDACTED]" if is_sensitive else current_val
260
+ console.print(f"Current Value: [green]{display_value}[/] (from environment)")
261
+ else:
262
+ console.print(f"Current Value: [dim]{default_val or 'None'}[/] (using default)")
263
+
264
+
265
+ def config_unset(
266
+ variable: str = typer.Argument(..., help="Environment variable name to unset"),
267
+ env_file: bool = typer.Option(
268
+ True,
269
+ "--env-file/--no-env-file",
270
+ help="Remove from .env file (default: True). If False, only unsets in current session.",
271
+ ),
272
+ ) -> None:
273
+ """Unset an environment variable (remove from .env file or current session)."""
274
+ if variable not in ENV_VAR_DOCS:
275
+ console.print(f"[bold red]✗ Unknown variable: {variable}[/]")
276
+ raise typer.Exit(code=1)
277
+
278
+ if env_file:
279
+ # Read existing .env file to check if it exists
280
+ env_vars = _read_env_file()
281
+
282
+ if variable in env_vars:
283
+ # Remove from file
284
+ _write_env_file({}, remove_vars={variable})
285
+ console.print(f"[bold green]✓ Removed {variable} from .env file[/]")
286
+ else:
287
+ console.print(f"[yellow]⚠ {variable} not found in .env file[/]")
288
+ else:
289
+ # Remove from current session
290
+ if variable in os.environ:
291
+ del os.environ[variable]
292
+ console.print(f"[bold green]✓ Removed {variable} from current session[/]")
293
+ else:
294
+ console.print(f"[yellow]⚠ {variable} not set in current session[/]")