meshtensor-cli 9.18.1__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.
- meshtensor_cli/__init__.py +22 -0
- meshtensor_cli/cli.py +10742 -0
- meshtensor_cli/doc_generation_helper.py +4 -0
- meshtensor_cli/src/__init__.py +1085 -0
- meshtensor_cli/src/commands/__init__.py +0 -0
- meshtensor_cli/src/commands/axon/__init__.py +0 -0
- meshtensor_cli/src/commands/axon/axon.py +132 -0
- meshtensor_cli/src/commands/crowd/__init__.py +0 -0
- meshtensor_cli/src/commands/crowd/contribute.py +621 -0
- meshtensor_cli/src/commands/crowd/contributors.py +200 -0
- meshtensor_cli/src/commands/crowd/create.py +783 -0
- meshtensor_cli/src/commands/crowd/dissolve.py +219 -0
- meshtensor_cli/src/commands/crowd/refund.py +233 -0
- meshtensor_cli/src/commands/crowd/update.py +418 -0
- meshtensor_cli/src/commands/crowd/utils.py +124 -0
- meshtensor_cli/src/commands/crowd/view.py +991 -0
- meshtensor_cli/src/commands/governance/__init__.py +0 -0
- meshtensor_cli/src/commands/governance/governance.py +794 -0
- meshtensor_cli/src/commands/liquidity/__init__.py +0 -0
- meshtensor_cli/src/commands/liquidity/liquidity.py +699 -0
- meshtensor_cli/src/commands/liquidity/utils.py +202 -0
- meshtensor_cli/src/commands/proxy.py +700 -0
- meshtensor_cli/src/commands/stake/__init__.py +0 -0
- meshtensor_cli/src/commands/stake/add.py +799 -0
- meshtensor_cli/src/commands/stake/auto_staking.py +306 -0
- meshtensor_cli/src/commands/stake/children_hotkeys.py +865 -0
- meshtensor_cli/src/commands/stake/claim.py +770 -0
- meshtensor_cli/src/commands/stake/list.py +738 -0
- meshtensor_cli/src/commands/stake/move.py +1211 -0
- meshtensor_cli/src/commands/stake/remove.py +1466 -0
- meshtensor_cli/src/commands/stake/wizard.py +323 -0
- meshtensor_cli/src/commands/subnets/__init__.py +0 -0
- meshtensor_cli/src/commands/subnets/mechanisms.py +515 -0
- meshtensor_cli/src/commands/subnets/price.py +733 -0
- meshtensor_cli/src/commands/subnets/subnets.py +2908 -0
- meshtensor_cli/src/commands/sudo.py +1294 -0
- meshtensor_cli/src/commands/tc/__init__.py +0 -0
- meshtensor_cli/src/commands/tc/tc.py +190 -0
- meshtensor_cli/src/commands/treasury/__init__.py +0 -0
- meshtensor_cli/src/commands/treasury/treasury.py +194 -0
- meshtensor_cli/src/commands/view.py +354 -0
- meshtensor_cli/src/commands/wallets.py +2311 -0
- meshtensor_cli/src/commands/weights.py +467 -0
- meshtensor_cli/src/meshtensor/__init__.py +0 -0
- meshtensor_cli/src/meshtensor/balances.py +313 -0
- meshtensor_cli/src/meshtensor/chain_data.py +1263 -0
- meshtensor_cli/src/meshtensor/extrinsics/__init__.py +0 -0
- meshtensor_cli/src/meshtensor/extrinsics/mev_shield.py +174 -0
- meshtensor_cli/src/meshtensor/extrinsics/registration.py +1861 -0
- meshtensor_cli/src/meshtensor/extrinsics/root.py +550 -0
- meshtensor_cli/src/meshtensor/extrinsics/serving.py +255 -0
- meshtensor_cli/src/meshtensor/extrinsics/transfer.py +239 -0
- meshtensor_cli/src/meshtensor/meshtensor_interface.py +2598 -0
- meshtensor_cli/src/meshtensor/minigraph.py +254 -0
- meshtensor_cli/src/meshtensor/networking.py +12 -0
- meshtensor_cli/src/meshtensor/templates/main-filters.j2 +24 -0
- meshtensor_cli/src/meshtensor/templates/main-header.j2 +36 -0
- meshtensor_cli/src/meshtensor/templates/neuron-details.j2 +111 -0
- meshtensor_cli/src/meshtensor/templates/price-multi.j2 +113 -0
- meshtensor_cli/src/meshtensor/templates/price-single.j2 +99 -0
- meshtensor_cli/src/meshtensor/templates/subnet-details-header.j2 +49 -0
- meshtensor_cli/src/meshtensor/templates/subnet-details.j2 +32 -0
- meshtensor_cli/src/meshtensor/templates/subnet-metrics.j2 +57 -0
- meshtensor_cli/src/meshtensor/templates/subnets-table.j2 +28 -0
- meshtensor_cli/src/meshtensor/templates/table.j2 +267 -0
- meshtensor_cli/src/meshtensor/templates/view.css +1058 -0
- meshtensor_cli/src/meshtensor/templates/view.j2 +43 -0
- meshtensor_cli/src/meshtensor/templates/view.js +1053 -0
- meshtensor_cli/src/meshtensor/utils.py +2007 -0
- meshtensor_cli/version.py +23 -0
- meshtensor_cli-9.18.1.dist-info/METADATA +261 -0
- meshtensor_cli-9.18.1.dist-info/RECORD +74 -0
- meshtensor_cli-9.18.1.dist-info/WHEEL +4 -0
- meshtensor_cli-9.18.1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,2007 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import json
|
|
3
|
+
from collections import namedtuple
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
import sys
|
|
8
|
+
import webbrowser
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Collection, Optional, Union, Callable, Generator
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
from functools import partial
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
import aiohttp
|
|
17
|
+
from async_substrate_interface import AsyncExtrinsicReceipt
|
|
18
|
+
from meshtensor_wallet import Wallet, Keypair
|
|
19
|
+
from meshtensor_wallet.utils import SS58_FORMAT
|
|
20
|
+
from meshtensor_wallet.errors import KeyFileError, PasswordError
|
|
21
|
+
from meshtensor_wallet import utils
|
|
22
|
+
from jinja2 import Template, Environment, PackageLoader, select_autoescape
|
|
23
|
+
from markupsafe import Markup
|
|
24
|
+
import numpy as np
|
|
25
|
+
from numpy.typing import NDArray
|
|
26
|
+
from rich.console import Console
|
|
27
|
+
from rich.prompt import Confirm, Prompt
|
|
28
|
+
from scalecodec import GenericCall
|
|
29
|
+
from scalecodec.utils.ss58 import ss58_encode, ss58_decode
|
|
30
|
+
import typer
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
from meshtensor_cli.src.meshtensor.balances import Balance
|
|
34
|
+
from meshtensor_cli.src import defaults, Constants
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from meshtensor_cli.src.meshtensor.chain_data import SubnetHyperparameters
|
|
39
|
+
from rich.prompt import PromptBase
|
|
40
|
+
|
|
41
|
+
BT_DOCS_LINK = "https://docs.learnmeshtensor.org"
|
|
42
|
+
|
|
43
|
+
GLOBAL_MAX_SUBNET_COUNT = 4096
|
|
44
|
+
MEV_SHIELD_PUBLIC_KEY_SIZE = 1184
|
|
45
|
+
|
|
46
|
+
# Detect if we're in a test environment (pytest captures stdout, making it non-TTY)
|
|
47
|
+
# or if NO_COLOR is set, disable colors
|
|
48
|
+
# Also check for pytest environment variables
|
|
49
|
+
_is_pytest = "pytest" in sys.modules or os.getenv("PYTEST_CURRENT_TEST") is not None
|
|
50
|
+
_no_color = os.getenv("NO_COLOR", "") != "" or not sys.stdout.isatty() or _is_pytest
|
|
51
|
+
# Force no terminal detection when in pytest or when stdout is not a TTY
|
|
52
|
+
_force_terminal = False if (_is_pytest or not sys.stdout.isatty()) else None
|
|
53
|
+
console = Console(no_color=_no_color, force_terminal=_force_terminal)
|
|
54
|
+
json_console = Console(
|
|
55
|
+
markup=False, highlight=False, force_terminal=False, no_color=True
|
|
56
|
+
)
|
|
57
|
+
err_console = Console(stderr=True, no_color=_no_color, force_terminal=_force_terminal)
|
|
58
|
+
verbose_console = Console(
|
|
59
|
+
quiet=True, no_color=_no_color, force_terminal=_force_terminal
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def confirm_action(
|
|
64
|
+
message: str,
|
|
65
|
+
default: bool = False,
|
|
66
|
+
decline: bool = False,
|
|
67
|
+
quiet: bool = False,
|
|
68
|
+
) -> bool:
|
|
69
|
+
"""
|
|
70
|
+
Ask for user confirmation with support for auto-decline via --no flag.
|
|
71
|
+
|
|
72
|
+
When decline=True (--no flag is set):
|
|
73
|
+
- Prints the prompt message (unless quiet=True / --quiet is specified)
|
|
74
|
+
- Automatically returns False (declines)
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
message: The confirmation message to display.
|
|
78
|
+
default: Default value if user just presses Enter (only used in interactive mode).
|
|
79
|
+
decline: If True, automatically decline without prompting.
|
|
80
|
+
quiet: If True, suppresses the prompt message when auto-declining.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
True if confirmed, False if declined.
|
|
84
|
+
"""
|
|
85
|
+
if decline:
|
|
86
|
+
if not quiet:
|
|
87
|
+
console.print(f"{message} [Auto-declined via --no flag]")
|
|
88
|
+
return False
|
|
89
|
+
return Confirm.ask(message, default=default)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
jinja_env = Environment(
|
|
93
|
+
loader=PackageLoader("meshtensor_cli", "src/meshtensor/templates"),
|
|
94
|
+
autoescape=select_autoescape(),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
UnlockStatus = namedtuple("UnlockStatus", ["success", "message"])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _Hotkey:
|
|
101
|
+
def __init__(self, hotkey_ss58=None):
|
|
102
|
+
self.ss58_address = hotkey_ss58
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class _Coldkeypub:
|
|
106
|
+
def __init__(self, coldkey_ss58=None):
|
|
107
|
+
self.ss58_address = coldkey_ss58
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class WalletLike:
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
name=None,
|
|
114
|
+
hotkey_ss58=None,
|
|
115
|
+
hotkey_str=None,
|
|
116
|
+
coldkeypub_ss58=None,
|
|
117
|
+
):
|
|
118
|
+
self.name = name
|
|
119
|
+
self.hotkey_ss58 = hotkey_ss58
|
|
120
|
+
self.hotkey_str = hotkey_str
|
|
121
|
+
self._hotkey = _Hotkey(hotkey_ss58)
|
|
122
|
+
self._coldkeypub = _Coldkeypub(coldkeypub_ss58)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def hotkey(self):
|
|
126
|
+
return self._hotkey
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def coldkeypub(self):
|
|
130
|
+
return self._coldkeypub
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def print_console(message: str, colour: str, console_: Console, title: str = ""):
|
|
134
|
+
title_part = f"[bold {colour}][{title}]:[/bold {colour}] " if title else ""
|
|
135
|
+
console_.print(f"{title_part}[{colour}]{message}[/{colour}]\n")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def print_verbose(message: str, status=None):
|
|
139
|
+
"""Print verbose messages while temporarily pausing the status spinner."""
|
|
140
|
+
if status:
|
|
141
|
+
status.stop()
|
|
142
|
+
print_console(message, "green", verbose_console, "Verbose")
|
|
143
|
+
status.start()
|
|
144
|
+
else:
|
|
145
|
+
print_console(message, "green", verbose_console, "Verbose")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def print_error(message: str, status=None):
|
|
149
|
+
"""Print error messages while temporarily pausing the status spinner."""
|
|
150
|
+
error_message = f":cross_mark: {message}"
|
|
151
|
+
if status:
|
|
152
|
+
status.stop()
|
|
153
|
+
print_console(error_message, "red", err_console)
|
|
154
|
+
status.start()
|
|
155
|
+
else:
|
|
156
|
+
print_console(error_message, "red", err_console)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def print_success(message: str, status=None):
|
|
160
|
+
"""Print success messages while temporarily pausing the status spinner."""
|
|
161
|
+
success_message = f":white_heavy_check_mark: {message}"
|
|
162
|
+
if status:
|
|
163
|
+
status.stop()
|
|
164
|
+
print_console(success_message, "green", console)
|
|
165
|
+
status.start()
|
|
166
|
+
else:
|
|
167
|
+
print_console(success_message, "green", console)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def print_protection_warnings(
|
|
171
|
+
mev_protection: bool,
|
|
172
|
+
safe_staking: Optional[bool] = None,
|
|
173
|
+
command_name: str = "",
|
|
174
|
+
) -> None:
|
|
175
|
+
"""
|
|
176
|
+
Print warnings about missing MEV protection and/or limit price protection.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
mev_protection: Whether MEV protection is enabled.
|
|
180
|
+
safe_staking: Whether safe staking (limit price protection) is enabled.
|
|
181
|
+
None if limit price protection is not available for this command.
|
|
182
|
+
command_name: Name of the command (e.g., "stake add") for context.
|
|
183
|
+
"""
|
|
184
|
+
warnings = []
|
|
185
|
+
|
|
186
|
+
if not mev_protection:
|
|
187
|
+
warnings.append(
|
|
188
|
+
"⚠️ [dim][yellow]Warning:[/yellow] MEV protection is disabled. "
|
|
189
|
+
"This transaction may be exposed to MEV attacks.[/dim]"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if safe_staking is not None and not safe_staking:
|
|
193
|
+
warnings.append(
|
|
194
|
+
"⚠️ [dim][yellow]Warning:[/yellow] Limit price protection (safe staking) is disabled. "
|
|
195
|
+
"This transaction may be subject to slippage.[/dim]"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if warnings:
|
|
199
|
+
if command_name:
|
|
200
|
+
console.print(f"\n[dim]Protection status for '{command_name}':[/dim]")
|
|
201
|
+
for warning in warnings:
|
|
202
|
+
console.print(warning)
|
|
203
|
+
console.print()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
MESHLET_PER_MESH = 1e9
|
|
207
|
+
U16_MAX = 65535
|
|
208
|
+
U64_MAX = 18446744073709551615
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def u16_normalized_float(x: int) -> float:
|
|
212
|
+
"""Converts a u16 int to a float"""
|
|
213
|
+
return float(x) / float(U16_MAX)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def u64_normalized_float(x: int) -> float:
|
|
217
|
+
"""Converts a u64 int to a float"""
|
|
218
|
+
return float(x) / float(U64_MAX)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def string_to_u64(value: str) -> int:
|
|
222
|
+
"""Converts a string to u64"""
|
|
223
|
+
return float_to_u64(float(value))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def float_to_u64(value: float) -> int:
|
|
227
|
+
"""Converts a float to a u64 int"""
|
|
228
|
+
# Ensure the input is within the expected range
|
|
229
|
+
if not (0 <= value <= 1):
|
|
230
|
+
raise ValueError("Input value must be between 0 and 1")
|
|
231
|
+
|
|
232
|
+
# Convert the float to a u64 value
|
|
233
|
+
return int(value * (2**64 - 1))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def u64_to_float(value: int) -> float:
|
|
237
|
+
u64_max = 2**64 - 1
|
|
238
|
+
# Allow for a small margin of error (e.g., 1) to account for potential rounding issues
|
|
239
|
+
if not (0 <= value <= u64_max + 1):
|
|
240
|
+
raise ValueError(
|
|
241
|
+
f"Input value ({value}) must be between 0 and {u64_max} (2^64 - 1)"
|
|
242
|
+
)
|
|
243
|
+
return min(value / u64_max, 1.0) # Ensure the result is never greater than 1.0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def string_to_u16(value: str) -> int:
|
|
247
|
+
"""Converts a string to a u16 int"""
|
|
248
|
+
return float_to_u16(float(value))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def float_to_u16(value: float) -> int:
|
|
252
|
+
# Ensure the input is within the expected range
|
|
253
|
+
if not (0 <= value <= 1):
|
|
254
|
+
raise ValueError("Input value must be between 0 and 1")
|
|
255
|
+
|
|
256
|
+
# Calculate the u16 representation
|
|
257
|
+
u16_max = 65535
|
|
258
|
+
return int(value * u16_max)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def u16_to_float(value: int) -> float:
|
|
262
|
+
# Ensure the input is within the expected range
|
|
263
|
+
if not (0 <= value <= 65535):
|
|
264
|
+
raise ValueError("Input value must be between 0 and 65535")
|
|
265
|
+
|
|
266
|
+
# Calculate the float representation
|
|
267
|
+
u16_max = 65535
|
|
268
|
+
return value / u16_max
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def convert_weight_uids_and_vals_to_tensor(
|
|
272
|
+
n: int, uids: Collection[int], weights: Collection[int]
|
|
273
|
+
) -> NDArray[np.float32]:
|
|
274
|
+
"""
|
|
275
|
+
Converts weights and uids from chain representation into a `np.array` (inverse operation from
|
|
276
|
+
convert_weights_and_uids_for_emit)
|
|
277
|
+
|
|
278
|
+
:param n: number of neurons on network.
|
|
279
|
+
:param uids: Tensor of uids as destinations for passed weights.
|
|
280
|
+
:param weights: Tensor of weights.
|
|
281
|
+
|
|
282
|
+
:return: row_weights: Converted row weights.
|
|
283
|
+
"""
|
|
284
|
+
row_weights = np.zeros([n], dtype=np.float32)
|
|
285
|
+
for uid_j, wij in list(zip(uids, weights)):
|
|
286
|
+
row_weights[uid_j] = float(
|
|
287
|
+
wij
|
|
288
|
+
) # assumes max-upscaled values (w_max = U16_MAX).
|
|
289
|
+
row_sum = row_weights.sum()
|
|
290
|
+
if row_sum > 0:
|
|
291
|
+
row_weights /= row_sum # normalize
|
|
292
|
+
return row_weights
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def convert_bond_uids_and_vals_to_tensor(
|
|
296
|
+
n: int, uids: list[int], bonds: list[int]
|
|
297
|
+
) -> NDArray[np.int64]:
|
|
298
|
+
"""Converts bond and uids from chain representation into a np.array.
|
|
299
|
+
|
|
300
|
+
:param n: number of neurons on network.
|
|
301
|
+
:param uids: Tensor of uids as destinations for passed bonds.
|
|
302
|
+
:param bonds: Tensor of bonds.
|
|
303
|
+
|
|
304
|
+
:return: Converted row bonds.
|
|
305
|
+
"""
|
|
306
|
+
row_bonds = np.zeros([n], dtype=np.int64)
|
|
307
|
+
|
|
308
|
+
for uid_j, bij in list(zip(uids, bonds)):
|
|
309
|
+
row_bonds[uid_j] = int(bij)
|
|
310
|
+
return row_bonds
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def convert_root_weight_uids_and_vals_to_tensor(
|
|
314
|
+
n: int, uids: list[int], weights: list[int], subnets: list[int]
|
|
315
|
+
) -> NDArray:
|
|
316
|
+
"""
|
|
317
|
+
Converts root weights and uids from chain representation into a `np.array` or `torch.FloatTensor` (inverse operation
|
|
318
|
+
from `convert_weights_and_uids_for_emit`)
|
|
319
|
+
|
|
320
|
+
:param n: number of neurons on network.
|
|
321
|
+
:param uids: Tensor of uids as destinations for passed weights.
|
|
322
|
+
:param weights: Tensor of weights.
|
|
323
|
+
:param subnets: list of subnets on the network
|
|
324
|
+
|
|
325
|
+
:return: row_weights: Converted row weights.
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
row_weights = np.zeros([n], dtype=np.float32)
|
|
329
|
+
for uid_j, wij in list(zip(uids, weights)):
|
|
330
|
+
if uid_j in subnets:
|
|
331
|
+
index_s = subnets.index(uid_j)
|
|
332
|
+
row_weights[index_s] = float(
|
|
333
|
+
wij
|
|
334
|
+
) # assumes max-upscaled values (w_max = U16_MAX).
|
|
335
|
+
else:
|
|
336
|
+
# TODO standardise logging
|
|
337
|
+
# logging.warning(
|
|
338
|
+
# f"Incorrect Subnet uid {uid_j} in Subnets {subnets}. The subnet is unavailable at the moment."
|
|
339
|
+
# )
|
|
340
|
+
continue
|
|
341
|
+
row_sum = row_weights.sum()
|
|
342
|
+
if row_sum > 0:
|
|
343
|
+
row_weights /= row_sum # normalize
|
|
344
|
+
return row_weights
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def get_hotkey_wallets_for_wallet(
|
|
348
|
+
wallet: Wallet, show_nulls: bool = False, show_encrypted: bool = False
|
|
349
|
+
) -> list[Optional[Wallet]]:
|
|
350
|
+
"""
|
|
351
|
+
Returns wallet objects with hotkeys for a single given wallet
|
|
352
|
+
|
|
353
|
+
:param wallet: Wallet object to use for the path
|
|
354
|
+
:param show_nulls: will add `None` into the output if a hotkey is encrypted or not on the device
|
|
355
|
+
:param show_encrypted: will add some basic info about the encrypted hotkey
|
|
356
|
+
|
|
357
|
+
:return: a list of wallets (with Nones included for cases of a hotkey being encrypted or not on the device, if
|
|
358
|
+
`show_nulls` is set to `True`)
|
|
359
|
+
"""
|
|
360
|
+
hotkey_wallets = []
|
|
361
|
+
wallet_path = Path(wallet.path).expanduser()
|
|
362
|
+
hotkeys_path = wallet_path / wallet.name / "hotkeys"
|
|
363
|
+
try:
|
|
364
|
+
hotkeys = [entry.name for entry in hotkeys_path.iterdir()]
|
|
365
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
366
|
+
hotkeys = []
|
|
367
|
+
for h_name in hotkeys:
|
|
368
|
+
if h_name.endswith("pub.txt"):
|
|
369
|
+
if h_name.split("pub.txt")[0] in hotkeys:
|
|
370
|
+
continue
|
|
371
|
+
else:
|
|
372
|
+
hotkey_for_name = Wallet(
|
|
373
|
+
path=str(wallet_path),
|
|
374
|
+
name=wallet.name,
|
|
375
|
+
hotkey=h_name.split("pub.txt")[0],
|
|
376
|
+
)
|
|
377
|
+
else:
|
|
378
|
+
hotkey_for_name = Wallet(
|
|
379
|
+
path=str(wallet_path), name=wallet.name, hotkey=h_name
|
|
380
|
+
)
|
|
381
|
+
try:
|
|
382
|
+
exists = (
|
|
383
|
+
hotkey_for_name.hotkey_file.exists_on_device()
|
|
384
|
+
or hotkey_for_name.hotkeypub_file.exists_on_device()
|
|
385
|
+
)
|
|
386
|
+
if (
|
|
387
|
+
exists
|
|
388
|
+
and not hotkey_for_name.hotkey_file.is_encrypted()
|
|
389
|
+
# and hotkey_for_name.coldkeypub.ss58_address
|
|
390
|
+
and get_hotkey_pub_ss58(hotkey_for_name)
|
|
391
|
+
):
|
|
392
|
+
hotkey_wallets.append(hotkey_for_name)
|
|
393
|
+
elif (
|
|
394
|
+
show_encrypted and exists and hotkey_for_name.hotkey_file.is_encrypted()
|
|
395
|
+
):
|
|
396
|
+
hotkey_wallets.append(
|
|
397
|
+
WalletLike(str(wallet_path), "<ENCRYPTED>", h_name)
|
|
398
|
+
)
|
|
399
|
+
elif show_nulls:
|
|
400
|
+
hotkey_wallets.append(None)
|
|
401
|
+
except (
|
|
402
|
+
UnicodeDecodeError,
|
|
403
|
+
AttributeError,
|
|
404
|
+
TypeError,
|
|
405
|
+
KeyFileError,
|
|
406
|
+
ValueError,
|
|
407
|
+
): # usually an unrelated file like .DS_Store
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
return hotkey_wallets
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def get_coldkey_wallets_for_path(path: str) -> list[Wallet]:
|
|
414
|
+
"""Gets all wallets with coldkeys from a given path"""
|
|
415
|
+
wallet_path = Path(path).expanduser()
|
|
416
|
+
try:
|
|
417
|
+
wallets = [
|
|
418
|
+
Wallet(name=directory.name, path=path)
|
|
419
|
+
for directory in wallet_path.iterdir()
|
|
420
|
+
if directory.is_dir()
|
|
421
|
+
]
|
|
422
|
+
except FileNotFoundError:
|
|
423
|
+
wallets = []
|
|
424
|
+
return wallets
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def get_all_wallets_for_path(path: str) -> list[Wallet]:
|
|
428
|
+
"""Gets all wallets from a given path."""
|
|
429
|
+
all_wallets = []
|
|
430
|
+
cold_wallets = get_coldkey_wallets_for_path(path)
|
|
431
|
+
for cold_wallet in cold_wallets:
|
|
432
|
+
try:
|
|
433
|
+
if (
|
|
434
|
+
cold_wallet.coldkeypub_file.exists_on_device()
|
|
435
|
+
and not cold_wallet.coldkeypub_file.is_encrypted()
|
|
436
|
+
):
|
|
437
|
+
all_wallets.extend(get_hotkey_wallets_for_wallet(cold_wallet))
|
|
438
|
+
except UnicodeDecodeError: # usually an incorrect file like .DS_Store
|
|
439
|
+
continue
|
|
440
|
+
return all_wallets
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def validate_coldkey_presence(
|
|
444
|
+
wallets: list[Wallet],
|
|
445
|
+
) -> tuple[list[Wallet], list[Wallet]]:
|
|
446
|
+
"""
|
|
447
|
+
Validates the presence of coldkeypub.txt for each wallet.
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
tuple[list[Wallet], list[Wallet]]: A tuple containing two lists:
|
|
451
|
+
- The first list contains wallets with the required coldkey.
|
|
452
|
+
- The second list contains wallets without the required coldkey.
|
|
453
|
+
"""
|
|
454
|
+
valid_wallets = []
|
|
455
|
+
invalid_wallets = []
|
|
456
|
+
|
|
457
|
+
for wallet in wallets:
|
|
458
|
+
if not os.path.exists(wallet.coldkeypub_file.path):
|
|
459
|
+
invalid_wallets.append(wallet)
|
|
460
|
+
else:
|
|
461
|
+
valid_wallets.append(wallet)
|
|
462
|
+
return valid_wallets, invalid_wallets
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def is_valid_wallet(wallet: Wallet) -> tuple[bool, bool]:
|
|
466
|
+
"""
|
|
467
|
+
Verifies that the wallet with specified parameters.
|
|
468
|
+
|
|
469
|
+
:param wallet: a Wallet instance
|
|
470
|
+
|
|
471
|
+
:return: tuple[bool], whether wallet appears valid, whether valid hotkey in wallet
|
|
472
|
+
"""
|
|
473
|
+
return (
|
|
474
|
+
all(
|
|
475
|
+
[
|
|
476
|
+
os.path.exists(wp := os.path.expanduser(wallet.path)),
|
|
477
|
+
os.path.exists(os.path.join(wp, wallet.name)),
|
|
478
|
+
]
|
|
479
|
+
),
|
|
480
|
+
os.path.isfile(os.path.join(wp, wallet.name, "hotkeys", wallet.hotkey_str)),
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def is_valid_ss58_address(address: str) -> bool:
|
|
485
|
+
"""
|
|
486
|
+
Checks if the given address is a valid ss58 address.
|
|
487
|
+
|
|
488
|
+
:param address: The address to check.
|
|
489
|
+
|
|
490
|
+
:return: `True` if the address is a valid ss58 address for Meshtensor, `False` otherwise.
|
|
491
|
+
"""
|
|
492
|
+
try:
|
|
493
|
+
return utils.is_valid_ss58_address(
|
|
494
|
+
address
|
|
495
|
+
) # Default substrate ss58 format (legacy)
|
|
496
|
+
except IndexError:
|
|
497
|
+
return False
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def is_valid_ss58_address_prompt(text: str) -> str:
|
|
501
|
+
valid = False
|
|
502
|
+
address = ""
|
|
503
|
+
while not valid:
|
|
504
|
+
address = Prompt.ask(text).strip()
|
|
505
|
+
valid = is_valid_ss58_address(address)
|
|
506
|
+
return address
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def is_valid_ed25519_pubkey(public_key: Union[str, bytes]) -> bool:
|
|
510
|
+
"""
|
|
511
|
+
Checks if the given public_key is a valid ed25519 key.
|
|
512
|
+
|
|
513
|
+
:param public_key: The public_key to check.
|
|
514
|
+
|
|
515
|
+
:return: True if the public_key is a valid ed25519 key, False otherwise.
|
|
516
|
+
|
|
517
|
+
"""
|
|
518
|
+
try:
|
|
519
|
+
if isinstance(public_key, str):
|
|
520
|
+
if len(public_key) != 64 and len(public_key) != 66:
|
|
521
|
+
raise ValueError("a public_key should be 64 or 66 characters")
|
|
522
|
+
elif isinstance(public_key, bytes):
|
|
523
|
+
if len(public_key) != 32:
|
|
524
|
+
raise ValueError("a public_key should be 32 bytes")
|
|
525
|
+
else:
|
|
526
|
+
raise ValueError("public_key must be a string or bytes")
|
|
527
|
+
|
|
528
|
+
keypair = Keypair(public_key=public_key, ss58_format=SS58_FORMAT)
|
|
529
|
+
|
|
530
|
+
ss58_addr = keypair.ss58_address
|
|
531
|
+
return ss58_addr is not None
|
|
532
|
+
|
|
533
|
+
except (ValueError, IndexError):
|
|
534
|
+
return False
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def is_valid_meshtensor_address_or_public_key(address: Union[str, bytes]) -> bool:
|
|
538
|
+
"""
|
|
539
|
+
Checks if the given address is a valid destination address.
|
|
540
|
+
|
|
541
|
+
:param address: The address to check.
|
|
542
|
+
|
|
543
|
+
:return: True if the address is a valid destination address, False otherwise.
|
|
544
|
+
"""
|
|
545
|
+
if isinstance(address, str):
|
|
546
|
+
# Check if ed25519
|
|
547
|
+
if address.startswith("0x"):
|
|
548
|
+
return is_valid_ed25519_pubkey(address)
|
|
549
|
+
else:
|
|
550
|
+
# Assume ss58 address
|
|
551
|
+
return is_valid_ss58_address(address)
|
|
552
|
+
elif isinstance(address, bytes):
|
|
553
|
+
# Check if ed25519
|
|
554
|
+
return is_valid_ed25519_pubkey(address)
|
|
555
|
+
else:
|
|
556
|
+
# Invalid address type
|
|
557
|
+
return False
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def decode_account_id(account_id_bytes: Union[tuple[int], tuple[tuple[int]]]):
|
|
561
|
+
if isinstance(account_id_bytes, tuple) and isinstance(account_id_bytes[0], tuple):
|
|
562
|
+
account_id_bytes = account_id_bytes[0]
|
|
563
|
+
# Convert the AccountId bytes to a Base64 string
|
|
564
|
+
return ss58_encode(bytes(account_id_bytes).hex(), SS58_FORMAT)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def encode_account_id(ss58_address: str) -> bytes:
|
|
568
|
+
return bytes.fromhex(ss58_decode(ss58_address, SS58_FORMAT))
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def ss58_to_vec_u8(ss58_address: str) -> list[int]:
|
|
572
|
+
"""
|
|
573
|
+
Converts an SS58 address to a list of integers (vector of u8).
|
|
574
|
+
|
|
575
|
+
:param ss58_address: The SS58 address to be converted.
|
|
576
|
+
|
|
577
|
+
:return: A list of integers representing the byte values of the SS58 address.
|
|
578
|
+
"""
|
|
579
|
+
ss58_bytes: bytes = encode_account_id(ss58_address)
|
|
580
|
+
encoded_address: list[int] = [int(byte) for byte in ss58_bytes]
|
|
581
|
+
return encoded_address
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def get_explorer_root_url_by_network_from_map(
|
|
585
|
+
network: str, network_map: dict[str, dict[str, str]]
|
|
586
|
+
) -> dict[str, str]:
|
|
587
|
+
"""
|
|
588
|
+
Returns the explorer root url for the given network name from the given network map.
|
|
589
|
+
|
|
590
|
+
:param network: The network to get the explorer url for.
|
|
591
|
+
:param network_map: The network map to get the explorer url from.
|
|
592
|
+
|
|
593
|
+
:return: The explorer url for the given network.
|
|
594
|
+
"""
|
|
595
|
+
explorer_urls: dict[str, str] = {}
|
|
596
|
+
for entity_nm, entity_network_map in network_map.items():
|
|
597
|
+
if network in entity_network_map:
|
|
598
|
+
explorer_urls[entity_nm] = entity_network_map[network]
|
|
599
|
+
|
|
600
|
+
return explorer_urls
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def get_explorer_url_for_network(
|
|
604
|
+
network: str, block_hash: str, network_map: dict[str, dict[str, str]]
|
|
605
|
+
) -> dict[str, str]:
|
|
606
|
+
"""
|
|
607
|
+
Returns the explorer url for the given block hash and network.
|
|
608
|
+
|
|
609
|
+
:param network: The network to get the explorer url for.
|
|
610
|
+
:param block_hash: The block hash to get the explorer url for.
|
|
611
|
+
:param network_map: The network maps to get the explorer urls from.
|
|
612
|
+
|
|
613
|
+
:return: The explorer url for the given block hash and network
|
|
614
|
+
"""
|
|
615
|
+
# TODO remove
|
|
616
|
+
|
|
617
|
+
explorer_urls: dict[str, str] = {}
|
|
618
|
+
# Will be None if the network is not known. i.e. not in network_map
|
|
619
|
+
explorer_root_urls: dict[str, str] = get_explorer_root_url_by_network_from_map(
|
|
620
|
+
network, network_map
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
if explorer_root_urls != {}:
|
|
624
|
+
# We are on a known network.
|
|
625
|
+
explorer_opentensor_url = "{root_url}/query/{block_hash}".format(
|
|
626
|
+
root_url=explorer_root_urls.get("opentensor"), block_hash=block_hash
|
|
627
|
+
)
|
|
628
|
+
explorer_taostats_url = "{root_url}/hash/{block_hash}".format(
|
|
629
|
+
root_url=explorer_root_urls.get("taostats"), block_hash=block_hash
|
|
630
|
+
)
|
|
631
|
+
explorer_urls["opentensor"] = explorer_opentensor_url
|
|
632
|
+
explorer_urls["taostats"] = explorer_taostats_url
|
|
633
|
+
|
|
634
|
+
return explorer_urls
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def format_error_message(error_message: Union[dict, Exception]) -> str:
|
|
638
|
+
"""
|
|
639
|
+
Formats an error message from the Meshtensor error information for use in extrinsics.
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
error_message: A dictionary containing the error information from Meshtensor, or a SubstrateRequestException
|
|
643
|
+
containing dictionary literal args.
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
str: A formatted error message string.
|
|
647
|
+
"""
|
|
648
|
+
err_name = "UnknownError"
|
|
649
|
+
err_type = "UnknownType"
|
|
650
|
+
err_description = "Unknown Description"
|
|
651
|
+
|
|
652
|
+
if isinstance(error_message, Exception):
|
|
653
|
+
# generally gotten through SubstrateRequestException args
|
|
654
|
+
new_error_message = None
|
|
655
|
+
for arg in error_message.args:
|
|
656
|
+
try:
|
|
657
|
+
d = ast.literal_eval(arg)
|
|
658
|
+
if isinstance(d, dict):
|
|
659
|
+
if "error" in d:
|
|
660
|
+
new_error_message = d["error"]
|
|
661
|
+
break
|
|
662
|
+
elif all(x in d for x in ["code", "message", "data"]):
|
|
663
|
+
new_error_message = d
|
|
664
|
+
break
|
|
665
|
+
except (ValueError, TypeError, SyntaxError, MemoryError, RecursionError):
|
|
666
|
+
pass
|
|
667
|
+
if new_error_message is None:
|
|
668
|
+
return_val = " ".join(error_message.args)
|
|
669
|
+
|
|
670
|
+
return f"Meshtensor returned: {return_val}"
|
|
671
|
+
else:
|
|
672
|
+
error_message = new_error_message
|
|
673
|
+
|
|
674
|
+
if isinstance(error_message, dict):
|
|
675
|
+
# meshtensor error structure
|
|
676
|
+
if (
|
|
677
|
+
error_message.get("code")
|
|
678
|
+
and error_message.get("message")
|
|
679
|
+
and error_message.get("data")
|
|
680
|
+
):
|
|
681
|
+
err_name = "SubstrateRequestException"
|
|
682
|
+
err_type = error_message.get("message", "")
|
|
683
|
+
err_data = error_message.get("data", "")
|
|
684
|
+
|
|
685
|
+
# meshtensor custom error marker
|
|
686
|
+
if err_data.startswith("Custom error:"):
|
|
687
|
+
err_description = (
|
|
688
|
+
f"{err_data} | Please consult {BT_DOCS_LINK}/errors/custom"
|
|
689
|
+
)
|
|
690
|
+
else:
|
|
691
|
+
err_description = err_data
|
|
692
|
+
|
|
693
|
+
elif (
|
|
694
|
+
error_message.get("type")
|
|
695
|
+
and error_message.get("name")
|
|
696
|
+
and error_message.get("docs")
|
|
697
|
+
):
|
|
698
|
+
err_type = error_message.get("type", err_type)
|
|
699
|
+
err_name = error_message.get("name", err_name)
|
|
700
|
+
err_docs = error_message.get("docs", [err_description])
|
|
701
|
+
err_description = (
|
|
702
|
+
" ".join(err_docs) if not isinstance(err_docs, str) else err_docs
|
|
703
|
+
)
|
|
704
|
+
err_description += (
|
|
705
|
+
f" | Please consult {BT_DOCS_LINK}/errors/meshtensor#{err_name.lower()}"
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
elif error_message.get("code") and error_message.get("message"):
|
|
709
|
+
err_type = error_message.get("code", err_name)
|
|
710
|
+
err_name = "Custom type"
|
|
711
|
+
err_description = error_message.get("message", err_description)
|
|
712
|
+
|
|
713
|
+
else:
|
|
714
|
+
print_error(
|
|
715
|
+
f"String representation of real error_message: {str(error_message)}"
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
return f"Meshtensor returned `{err_name}({err_type})` error. This means: `{err_description}`."
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def convert_blocks_to_time(blocks: int, block_time: int = 12) -> tuple[int, int, int]:
|
|
722
|
+
"""
|
|
723
|
+
Converts number of blocks into number of hours, minutes, seconds.
|
|
724
|
+
:param blocks: number of blocks
|
|
725
|
+
:param block_time: time per block, by default this is 12
|
|
726
|
+
:return: tuple containing number of hours, number of minutes, number of seconds
|
|
727
|
+
"""
|
|
728
|
+
seconds = blocks * block_time
|
|
729
|
+
hours = seconds // 3600
|
|
730
|
+
minutes = (seconds % 3600) // 60
|
|
731
|
+
remaining_seconds = seconds % 60
|
|
732
|
+
return hours, minutes, remaining_seconds
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def decode_hex_identity_dict(info_dictionary) -> dict[str, Any]:
|
|
736
|
+
"""
|
|
737
|
+
Decodes hex-encoded strings in a dictionary.
|
|
738
|
+
|
|
739
|
+
This function traverses the given dictionary, identifies hex-encoded strings, and decodes them into readable
|
|
740
|
+
strings. It handles nested dictionaries and lists within the dictionary.
|
|
741
|
+
|
|
742
|
+
Args:
|
|
743
|
+
info_dictionary (dict): The dictionary containing hex-encoded strings to decode.
|
|
744
|
+
|
|
745
|
+
Returns:
|
|
746
|
+
dict: The dictionary with decoded strings.
|
|
747
|
+
|
|
748
|
+
Examples:
|
|
749
|
+
input_dict = {
|
|
750
|
+
"name": {"value": "0x6a6f686e"},
|
|
751
|
+
"additional": [
|
|
752
|
+
{"data1": "0x64617461"},
|
|
753
|
+
("data2", "0x64617461")
|
|
754
|
+
]
|
|
755
|
+
}
|
|
756
|
+
decode_hex_identity_dict(input_dict)
|
|
757
|
+
{'name': 'john', 'additional': [('data1', 'data'), ('data2', 'data')]}
|
|
758
|
+
"""
|
|
759
|
+
|
|
760
|
+
def get_decoded(data: Optional[str]) -> str:
|
|
761
|
+
"""Decodes a hex-encoded string."""
|
|
762
|
+
if data is None:
|
|
763
|
+
return ""
|
|
764
|
+
try:
|
|
765
|
+
return hex_to_bytes(data).decode()
|
|
766
|
+
except (UnicodeDecodeError, ValueError):
|
|
767
|
+
print(f"Could not decode: {key}: {item}")
|
|
768
|
+
|
|
769
|
+
for key, value in info_dictionary.items():
|
|
770
|
+
if isinstance(value, dict):
|
|
771
|
+
item = list(value.values())[0]
|
|
772
|
+
if isinstance(item, str) and item.startswith("0x"):
|
|
773
|
+
try:
|
|
774
|
+
info_dictionary[key] = get_decoded(item)
|
|
775
|
+
except UnicodeDecodeError:
|
|
776
|
+
print(f"Could not decode: {key}: {item}")
|
|
777
|
+
else:
|
|
778
|
+
info_dictionary[key] = item
|
|
779
|
+
if key == "additional":
|
|
780
|
+
additional = []
|
|
781
|
+
for item in value:
|
|
782
|
+
if isinstance(item, dict):
|
|
783
|
+
for k, v in item.items():
|
|
784
|
+
additional.append((k, get_decoded(v)))
|
|
785
|
+
else:
|
|
786
|
+
if isinstance(item, (tuple, list)) and len(item) == 2:
|
|
787
|
+
k_, v = item
|
|
788
|
+
k = k_ if k_ is not None else ""
|
|
789
|
+
additional.append((k, get_decoded(v)))
|
|
790
|
+
info_dictionary[key] = additional
|
|
791
|
+
|
|
792
|
+
return info_dictionary
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def get_human_readable(num: float, suffix="H"):
|
|
796
|
+
"""
|
|
797
|
+
Converts a number to a human-readable string.
|
|
798
|
+
|
|
799
|
+
:return: human-readable string representation of a number.
|
|
800
|
+
"""
|
|
801
|
+
for unit in ["", "K", "M", "G", "T", "P", "E", "Z"]:
|
|
802
|
+
if abs(num) < 1000.0:
|
|
803
|
+
return f"{num:3.1f}{unit}{suffix}"
|
|
804
|
+
num /= 1000.0
|
|
805
|
+
return f"{num:.1f}Y{suffix}"
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def millify(n: int):
|
|
809
|
+
"""
|
|
810
|
+
Convert a large number into a more readable format with appropriate suffixes.
|
|
811
|
+
|
|
812
|
+
This function transforms a large integer into a shorter, human-readable string with
|
|
813
|
+
suffixes such as K, M, B, and T for thousands, millions, billions, and trillions,
|
|
814
|
+
respectively. The number is formatted to two decimal places.
|
|
815
|
+
|
|
816
|
+
:param n: The number to be converted.
|
|
817
|
+
|
|
818
|
+
:return: The formatted string representing the number with a suffix.
|
|
819
|
+
"""
|
|
820
|
+
mill_names = ["", " K", " M", " B", " T"]
|
|
821
|
+
n_ = float(n)
|
|
822
|
+
mill_idx = max(
|
|
823
|
+
0,
|
|
824
|
+
min(
|
|
825
|
+
len(mill_names) - 1,
|
|
826
|
+
int(math.floor(0 if n_ == 0 else math.log10(abs(n_)) / 3)),
|
|
827
|
+
),
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
return "{:.2f}{}".format(n_ / 10 ** (3 * mill_idx), mill_names[mill_idx])
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def millify_tao(n: float, start_at: str = "K") -> str:
|
|
834
|
+
"""
|
|
835
|
+
Dupe of millify, but for ease in converting mesh values.
|
|
836
|
+
Allows thresholds to be specified for different suffixes.
|
|
837
|
+
"""
|
|
838
|
+
mill_names = ["", "k", "m", "b", "t"]
|
|
839
|
+
thresholds = {"K": 1, "M": 2, "B": 3, "T": 4}
|
|
840
|
+
|
|
841
|
+
if start_at not in thresholds:
|
|
842
|
+
raise ValueError(f"start_at must be one of {list(thresholds.keys())}")
|
|
843
|
+
|
|
844
|
+
n_ = float(n)
|
|
845
|
+
if n_ == 0:
|
|
846
|
+
return "0.00"
|
|
847
|
+
|
|
848
|
+
mill_idx = int(math.floor(math.log10(abs(n_)) / 3))
|
|
849
|
+
|
|
850
|
+
# Number's index is below our threshold, return with commas
|
|
851
|
+
if mill_idx < thresholds[start_at]:
|
|
852
|
+
return f"{n_:,.2f}"
|
|
853
|
+
|
|
854
|
+
mill_idx = max(thresholds[start_at], min(len(mill_names) - 1, mill_idx))
|
|
855
|
+
|
|
856
|
+
return "{:.2f}{}".format(n_ / 10 ** (3 * mill_idx), mill_names[mill_idx])
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def normalize_hyperparameters(
|
|
860
|
+
subnet: "SubnetHyperparameters",
|
|
861
|
+
json_output: bool = False,
|
|
862
|
+
) -> list[tuple[str, str, str]]:
|
|
863
|
+
"""
|
|
864
|
+
Normalizes the hyperparameters of a subnet.
|
|
865
|
+
|
|
866
|
+
:param subnet: The subnet hyperparameters object.
|
|
867
|
+
:param json_output: Whether this normalisation will be for a JSON output or console string (determines whether
|
|
868
|
+
items get stringified or safe for JSON encoding)
|
|
869
|
+
|
|
870
|
+
:return: A list of tuples containing the parameter name, value, and normalized value.
|
|
871
|
+
"""
|
|
872
|
+
param_mappings = {
|
|
873
|
+
"adjustment_alpha": u64_normalized_float,
|
|
874
|
+
"min_difficulty": u64_normalized_float,
|
|
875
|
+
"max_difficulty": u64_normalized_float,
|
|
876
|
+
"difficulty": u64_normalized_float,
|
|
877
|
+
"bonds_moving_avg": u64_normalized_float,
|
|
878
|
+
"max_weight_limit": u16_normalized_float,
|
|
879
|
+
"kappa": u16_normalized_float,
|
|
880
|
+
"alpha_high": u16_normalized_float,
|
|
881
|
+
"alpha_low": u16_normalized_float,
|
|
882
|
+
"alpha_sigmoid_steepness": u16_normalized_float,
|
|
883
|
+
"min_burn": Balance.from_meshlet,
|
|
884
|
+
"max_burn": Balance.from_meshlet,
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
normalized_values: list[tuple[str, str, str]] = []
|
|
888
|
+
subnet_dict = subnet.__dict__
|
|
889
|
+
|
|
890
|
+
for param, value in subnet_dict.items():
|
|
891
|
+
try:
|
|
892
|
+
if param in param_mappings:
|
|
893
|
+
norm_value = param_mappings[param](value)
|
|
894
|
+
if isinstance(norm_value, float):
|
|
895
|
+
norm_value = f"{norm_value:.{10}g}"
|
|
896
|
+
if isinstance(norm_value, Balance) and json_output:
|
|
897
|
+
norm_value = norm_value.to_dict()
|
|
898
|
+
else:
|
|
899
|
+
norm_value = value
|
|
900
|
+
except Exception:
|
|
901
|
+
# meshtensor.logging.warning(f"Error normalizing parameter '{param}': {e}")
|
|
902
|
+
norm_value = "-"
|
|
903
|
+
if not json_output:
|
|
904
|
+
normalized_values.append((param, str(value), str(norm_value)))
|
|
905
|
+
else:
|
|
906
|
+
normalized_values.append((param, value, norm_value))
|
|
907
|
+
|
|
908
|
+
return normalized_values
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
class TableDefinition:
|
|
912
|
+
"""
|
|
913
|
+
Base class for address book table definitions/functions
|
|
914
|
+
"""
|
|
915
|
+
|
|
916
|
+
name: str
|
|
917
|
+
cols: tuple[tuple[str, str], ...]
|
|
918
|
+
|
|
919
|
+
@staticmethod
|
|
920
|
+
@contextmanager
|
|
921
|
+
def get_db() -> Generator[tuple[sqlite3.Connection, sqlite3.Cursor], None, None]:
|
|
922
|
+
"""
|
|
923
|
+
Helper function to get a DB connection
|
|
924
|
+
"""
|
|
925
|
+
with DB() as (conn, cursor):
|
|
926
|
+
yield conn, cursor
|
|
927
|
+
|
|
928
|
+
@classmethod
|
|
929
|
+
def create_if_not_exists(cls, conn: sqlite3.Connection, _: sqlite3.Cursor) -> None:
|
|
930
|
+
"""
|
|
931
|
+
Creates the table if it doesn't exist.
|
|
932
|
+
Args:
|
|
933
|
+
conn: sqlite3 connection
|
|
934
|
+
_: sqlite3 cursor
|
|
935
|
+
"""
|
|
936
|
+
columns_ = ", ".join([" ".join(x) for x in cls.cols])
|
|
937
|
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {cls.name} ({columns_})")
|
|
938
|
+
conn.commit()
|
|
939
|
+
|
|
940
|
+
@classmethod
|
|
941
|
+
def read_rows(
|
|
942
|
+
cls,
|
|
943
|
+
_: sqlite3.Connection,
|
|
944
|
+
cursor: sqlite3.Cursor,
|
|
945
|
+
include_header: bool = True,
|
|
946
|
+
) -> list[tuple[Union[str, int], ...]]:
|
|
947
|
+
"""
|
|
948
|
+
Reads rows from a table.
|
|
949
|
+
|
|
950
|
+
Args:
|
|
951
|
+
_: sqlite3 connection
|
|
952
|
+
cursor: sqlite3 cursor
|
|
953
|
+
include_header: Whether to include the header row
|
|
954
|
+
|
|
955
|
+
Returns:
|
|
956
|
+
rows of the table, with column names as the header row if `include_header` is set
|
|
957
|
+
|
|
958
|
+
"""
|
|
959
|
+
header = tuple(x[0] for x in cls.cols)
|
|
960
|
+
cols = ", ".join(header)
|
|
961
|
+
cursor.execute(f"SELECT {cols} FROM {cls.name}")
|
|
962
|
+
rows = cursor.fetchall()
|
|
963
|
+
if not include_header:
|
|
964
|
+
return rows
|
|
965
|
+
else:
|
|
966
|
+
return [header] + rows
|
|
967
|
+
|
|
968
|
+
@classmethod
|
|
969
|
+
def clear_table(
|
|
970
|
+
cls,
|
|
971
|
+
conn: sqlite3.Connection,
|
|
972
|
+
cursor: sqlite3.Cursor,
|
|
973
|
+
):
|
|
974
|
+
"""Truncates the table. Use with caution."""
|
|
975
|
+
cursor.execute(f"DELETE FROM {cls.name}")
|
|
976
|
+
conn.commit()
|
|
977
|
+
|
|
978
|
+
@classmethod
|
|
979
|
+
def update_entry(cls, *args, **kwargs):
|
|
980
|
+
"""
|
|
981
|
+
Updates an existing entry in the table.
|
|
982
|
+
"""
|
|
983
|
+
raise NotImplementedError()
|
|
984
|
+
|
|
985
|
+
@classmethod
|
|
986
|
+
def add_entry(cls, *args, **kwargs):
|
|
987
|
+
"""
|
|
988
|
+
Adds an entry to the table.
|
|
989
|
+
"""
|
|
990
|
+
raise NotImplementedError()
|
|
991
|
+
|
|
992
|
+
@classmethod
|
|
993
|
+
def delete_entry(cls, *args, **kwargs):
|
|
994
|
+
"""
|
|
995
|
+
Deletes an entry from the table.
|
|
996
|
+
"""
|
|
997
|
+
raise NotImplementedError()
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
class AddressBook(TableDefinition):
|
|
1001
|
+
name = "address_book"
|
|
1002
|
+
cols = (("name", "TEXT"), ("ss58_address", "TEXT"), ("note", "TEXT"))
|
|
1003
|
+
|
|
1004
|
+
@classmethod
|
|
1005
|
+
def add_entry(
|
|
1006
|
+
cls,
|
|
1007
|
+
conn: sqlite3.Connection,
|
|
1008
|
+
_: sqlite3.Cursor,
|
|
1009
|
+
*,
|
|
1010
|
+
name: str,
|
|
1011
|
+
ss58_address: str,
|
|
1012
|
+
note: str,
|
|
1013
|
+
) -> None:
|
|
1014
|
+
conn.execute(
|
|
1015
|
+
f"INSERT INTO {cls.name} (name, ss58_address, note) VALUES (?, ?, ?)",
|
|
1016
|
+
(name, ss58_address, note),
|
|
1017
|
+
)
|
|
1018
|
+
conn.commit()
|
|
1019
|
+
|
|
1020
|
+
@classmethod
|
|
1021
|
+
def update_entry(
|
|
1022
|
+
cls,
|
|
1023
|
+
conn: sqlite3.Connection,
|
|
1024
|
+
cursor: sqlite3.Cursor,
|
|
1025
|
+
*,
|
|
1026
|
+
name: str,
|
|
1027
|
+
ss58_address: Optional[str] = None,
|
|
1028
|
+
note: Optional[str] = None,
|
|
1029
|
+
):
|
|
1030
|
+
cursor.execute(
|
|
1031
|
+
f"SELECT ss58_address, note FROM {cls.name} WHERE name = ?",
|
|
1032
|
+
(name,),
|
|
1033
|
+
)
|
|
1034
|
+
row = cursor.fetchone()
|
|
1035
|
+
ss58_address_ = ss58_address or row[0]
|
|
1036
|
+
note_ = note or row[1]
|
|
1037
|
+
conn.execute(
|
|
1038
|
+
f"UPDATE {cls.name} SET ss58_address = ?, note = ? WHERE name = ?",
|
|
1039
|
+
(ss58_address_, note_, name),
|
|
1040
|
+
)
|
|
1041
|
+
conn.commit()
|
|
1042
|
+
|
|
1043
|
+
@classmethod
|
|
1044
|
+
def delete_entry(
|
|
1045
|
+
cls, conn: sqlite3.Connection, cursor: sqlite3.Cursor, *, name: str
|
|
1046
|
+
):
|
|
1047
|
+
conn.execute(
|
|
1048
|
+
f"DELETE FROM {cls.name} WHERE name = ?",
|
|
1049
|
+
(name,),
|
|
1050
|
+
)
|
|
1051
|
+
conn.commit()
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
class ProxyAddressBook(TableDefinition):
|
|
1055
|
+
name = "proxy_address_book"
|
|
1056
|
+
cols = (
|
|
1057
|
+
("name", "TEXT"),
|
|
1058
|
+
("ss58_address", "TEXT"),
|
|
1059
|
+
("delay", "INTEGER"),
|
|
1060
|
+
("spawner", "TEXT"),
|
|
1061
|
+
("proxy_type", "TEXT"),
|
|
1062
|
+
("note", "TEXT"),
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
@classmethod
|
|
1066
|
+
def update_entry(
|
|
1067
|
+
cls,
|
|
1068
|
+
conn: sqlite3.Connection,
|
|
1069
|
+
cursor: sqlite3.Cursor,
|
|
1070
|
+
*,
|
|
1071
|
+
name: str,
|
|
1072
|
+
ss58_address: Optional[str] = None,
|
|
1073
|
+
delay: Optional[int] = None,
|
|
1074
|
+
spawner: Optional[str] = None,
|
|
1075
|
+
proxy_type: Optional[str] = None,
|
|
1076
|
+
note: Optional[str] = None,
|
|
1077
|
+
) -> None:
|
|
1078
|
+
cursor.execute(
|
|
1079
|
+
f"SELECT ss58_address, spawner, proxy_type, delay, note FROM {cls.name} WHERE name = ?",
|
|
1080
|
+
(name,),
|
|
1081
|
+
)
|
|
1082
|
+
row = cursor.fetchone()
|
|
1083
|
+
ss58_address_ = ss58_address or row[0]
|
|
1084
|
+
spawner_ = spawner or row[1]
|
|
1085
|
+
proxy_type_ = proxy_type or row[2]
|
|
1086
|
+
delay = delay if delay is not None else row[3]
|
|
1087
|
+
note_ = note or row[4]
|
|
1088
|
+
conn.execute(
|
|
1089
|
+
f"UPDATE {cls.name} SET ss58_address = ?, spawner = ?, proxy_type = ?, delay = ?, note = ? WHERE name = ?",
|
|
1090
|
+
(ss58_address_, spawner_, proxy_type_, delay, note_, name),
|
|
1091
|
+
)
|
|
1092
|
+
conn.commit()
|
|
1093
|
+
|
|
1094
|
+
@classmethod
|
|
1095
|
+
def add_entry(
|
|
1096
|
+
cls,
|
|
1097
|
+
conn: sqlite3.Connection,
|
|
1098
|
+
_: sqlite3.Cursor,
|
|
1099
|
+
*,
|
|
1100
|
+
name: str,
|
|
1101
|
+
ss58_address: str,
|
|
1102
|
+
delay: int,
|
|
1103
|
+
spawner: str,
|
|
1104
|
+
proxy_type: str,
|
|
1105
|
+
note: str,
|
|
1106
|
+
) -> None:
|
|
1107
|
+
conn.execute(
|
|
1108
|
+
f"INSERT INTO {cls.name} (name, ss58_address, delay, spawner, proxy_type, note) VALUES (?, ?, ?, ?, ?, ?)",
|
|
1109
|
+
(name, ss58_address, delay, spawner, proxy_type, note),
|
|
1110
|
+
)
|
|
1111
|
+
conn.commit()
|
|
1112
|
+
|
|
1113
|
+
@classmethod
|
|
1114
|
+
def delete_entry(
|
|
1115
|
+
cls,
|
|
1116
|
+
conn: sqlite3.Connection,
|
|
1117
|
+
_: sqlite3.Cursor,
|
|
1118
|
+
*,
|
|
1119
|
+
name: str,
|
|
1120
|
+
):
|
|
1121
|
+
conn.execute(
|
|
1122
|
+
f"DELETE FROM {cls.name} WHERE name = ?",
|
|
1123
|
+
(name,),
|
|
1124
|
+
)
|
|
1125
|
+
conn.commit()
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
class ProxyAnnouncements(TableDefinition):
|
|
1129
|
+
name = "proxy_announcements"
|
|
1130
|
+
cols = (
|
|
1131
|
+
("id", "INTEGER PRIMARY KEY"),
|
|
1132
|
+
("address", "TEXT"),
|
|
1133
|
+
("epoch_time", "INTEGER"),
|
|
1134
|
+
("block", "INTEGER"),
|
|
1135
|
+
("call_hash", "TEXT"),
|
|
1136
|
+
("call", "TEXT"),
|
|
1137
|
+
("call_serialized", "TEXT"),
|
|
1138
|
+
("executed", "INTEGER"),
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
@classmethod
|
|
1142
|
+
def add_entry(
|
|
1143
|
+
cls,
|
|
1144
|
+
conn: sqlite3.Connection,
|
|
1145
|
+
_: sqlite3.Cursor,
|
|
1146
|
+
*,
|
|
1147
|
+
address: str,
|
|
1148
|
+
epoch_time: int,
|
|
1149
|
+
block: int,
|
|
1150
|
+
call_hash: str,
|
|
1151
|
+
call: GenericCall,
|
|
1152
|
+
executed: bool = False,
|
|
1153
|
+
) -> None:
|
|
1154
|
+
call_hex = call.data.to_hex()
|
|
1155
|
+
call_serialized = json.dumps(call.serialize())
|
|
1156
|
+
executed_int = int(executed)
|
|
1157
|
+
conn.execute(
|
|
1158
|
+
f"INSERT INTO {cls.name} (address, epoch_time, block, call_hash, call, call_serialized, executed)"
|
|
1159
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
1160
|
+
(
|
|
1161
|
+
address,
|
|
1162
|
+
epoch_time,
|
|
1163
|
+
block,
|
|
1164
|
+
call_hash,
|
|
1165
|
+
call_hex,
|
|
1166
|
+
call_serialized,
|
|
1167
|
+
executed_int,
|
|
1168
|
+
),
|
|
1169
|
+
)
|
|
1170
|
+
conn.commit()
|
|
1171
|
+
|
|
1172
|
+
@classmethod
|
|
1173
|
+
def delete_entry(
|
|
1174
|
+
cls,
|
|
1175
|
+
conn: sqlite3.Connection,
|
|
1176
|
+
_: sqlite3.Cursor,
|
|
1177
|
+
*,
|
|
1178
|
+
address: str,
|
|
1179
|
+
epoch_time: int,
|
|
1180
|
+
block: int,
|
|
1181
|
+
call_hash: str,
|
|
1182
|
+
):
|
|
1183
|
+
conn.execute(
|
|
1184
|
+
f"DELETE FROM {cls.name} WHERE call_hash = ? AND address = ? AND epoch_time = ? AND block = ?",
|
|
1185
|
+
(call_hash, address, epoch_time, block),
|
|
1186
|
+
)
|
|
1187
|
+
conn.commit()
|
|
1188
|
+
|
|
1189
|
+
@classmethod
|
|
1190
|
+
def mark_as_executed(cls, conn: sqlite3.Connection, _: sqlite3.Cursor, idx: int):
|
|
1191
|
+
conn.execute(
|
|
1192
|
+
f"UPDATE {cls.name} SET executed = ? WHERE id = ?",
|
|
1193
|
+
(1, idx),
|
|
1194
|
+
)
|
|
1195
|
+
conn.commit()
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
class DB:
|
|
1199
|
+
"""
|
|
1200
|
+
For ease of interaction with the SQLite database used for --reuse-last and --html outputs of tables
|
|
1201
|
+
|
|
1202
|
+
Also for address book
|
|
1203
|
+
"""
|
|
1204
|
+
|
|
1205
|
+
def __init__(
|
|
1206
|
+
self,
|
|
1207
|
+
db_path: Optional[str] = None,
|
|
1208
|
+
row_factory=None,
|
|
1209
|
+
):
|
|
1210
|
+
if db_path is None:
|
|
1211
|
+
if path_from_env := os.getenv("BTCLI_PROXIES_PATH"):
|
|
1212
|
+
db_path = path_from_env
|
|
1213
|
+
else:
|
|
1214
|
+
db_path = os.path.join(
|
|
1215
|
+
os.path.expanduser(defaults.config.base_path), "meshtensor.db"
|
|
1216
|
+
)
|
|
1217
|
+
self.db_path = db_path
|
|
1218
|
+
self.conn: Optional[sqlite3.Connection] = None
|
|
1219
|
+
self.row_factory = row_factory
|
|
1220
|
+
|
|
1221
|
+
def __enter__(self):
|
|
1222
|
+
self.conn = sqlite3.connect(self.db_path)
|
|
1223
|
+
self.conn.row_factory = self.row_factory
|
|
1224
|
+
return self.conn, self.conn.cursor()
|
|
1225
|
+
|
|
1226
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
1227
|
+
if self.conn:
|
|
1228
|
+
self.conn.close()
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def create_and_populate_table(
|
|
1232
|
+
title: str, columns: list[tuple[str, str]], rows: list[list]
|
|
1233
|
+
) -> None:
|
|
1234
|
+
"""
|
|
1235
|
+
Creates and populates the rows of a table in the SQLite database.
|
|
1236
|
+
|
|
1237
|
+
Warning:
|
|
1238
|
+
Will overwrite the existing table.
|
|
1239
|
+
|
|
1240
|
+
:param title: title of the table
|
|
1241
|
+
:param columns: [(column name, column type), ...]
|
|
1242
|
+
:param rows: [[element, element, ...], ...]
|
|
1243
|
+
:return: None
|
|
1244
|
+
"""
|
|
1245
|
+
blob_cols = []
|
|
1246
|
+
for idx, (_, col_type) in enumerate(columns):
|
|
1247
|
+
if col_type == "BLOB":
|
|
1248
|
+
blob_cols.append(idx)
|
|
1249
|
+
if blob_cols:
|
|
1250
|
+
for row in rows:
|
|
1251
|
+
for idx in blob_cols:
|
|
1252
|
+
row[idx] = row[idx].to_bytes(row[idx].bit_length() + 7, byteorder="big")
|
|
1253
|
+
with DB() as (conn, cursor):
|
|
1254
|
+
drop_query = f"DROP TABLE IF EXISTS {title}"
|
|
1255
|
+
cursor.execute(drop_query)
|
|
1256
|
+
conn.commit()
|
|
1257
|
+
columns_ = ", ".join([" ".join(x) for x in columns])
|
|
1258
|
+
creation_query = f"CREATE TABLE IF NOT EXISTS {title} ({columns_})"
|
|
1259
|
+
conn.execute(creation_query)
|
|
1260
|
+
conn.commit()
|
|
1261
|
+
query = f"INSERT INTO {title} ({', '.join([x[0] for x in columns])}) VALUES ({', '.join(['?'] * len(columns))})"
|
|
1262
|
+
cursor.executemany(query, rows)
|
|
1263
|
+
conn.commit()
|
|
1264
|
+
return
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def read_table(table_name: str, order_by: str = "") -> tuple[list, list]:
|
|
1268
|
+
"""
|
|
1269
|
+
Reads a table from a SQLite database, returning back a column names and rows as a tuple
|
|
1270
|
+
:param table_name: the table name in the database
|
|
1271
|
+
:param order_by: the order of the columns in the table, optional
|
|
1272
|
+
:return: ([column names], [rows])
|
|
1273
|
+
"""
|
|
1274
|
+
with DB() as (conn, cursor):
|
|
1275
|
+
cursor.execute(f"PRAGMA table_info({table_name})")
|
|
1276
|
+
columns_info = cursor.fetchall()
|
|
1277
|
+
column_names = [info[1] for info in columns_info]
|
|
1278
|
+
column_types = [info[2] for info in columns_info]
|
|
1279
|
+
cursor.execute(f"SELECT * FROM {table_name} {order_by}")
|
|
1280
|
+
rows = cursor.fetchall()
|
|
1281
|
+
blob_cols = []
|
|
1282
|
+
for idx, col_type in enumerate(column_types):
|
|
1283
|
+
if col_type == "BLOB":
|
|
1284
|
+
blob_cols.append(idx)
|
|
1285
|
+
if blob_cols:
|
|
1286
|
+
rows = [list(row) for row in rows]
|
|
1287
|
+
for row in rows:
|
|
1288
|
+
for idx in blob_cols:
|
|
1289
|
+
row[idx] = int.from_bytes(row[idx], byteorder="big")
|
|
1290
|
+
return column_names, rows
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def update_metadata_table(table_name: str, values: dict[str, str]) -> None:
|
|
1294
|
+
"""
|
|
1295
|
+
Used for updating the metadata for storing a table. This includes items like total_neurons, etc.
|
|
1296
|
+
:param table_name: the name of the table you're referencing inside of the metadata table (this is generally
|
|
1297
|
+
going to be the same as the table for which you have rows.)
|
|
1298
|
+
:param values: {key: value} dict for items you wish to insert
|
|
1299
|
+
:return: None
|
|
1300
|
+
"""
|
|
1301
|
+
with DB() as (conn, cursor):
|
|
1302
|
+
cursor.execute(
|
|
1303
|
+
"CREATE TABLE IF NOT EXISTS metadata (TableName TEXT, Key TEXT, Value TEXT)"
|
|
1304
|
+
)
|
|
1305
|
+
conn.commit()
|
|
1306
|
+
for key, value in values.items():
|
|
1307
|
+
cursor.execute(
|
|
1308
|
+
"UPDATE metadata SET Value = ? WHERE Key = ? AND TableName = ?",
|
|
1309
|
+
(value, key, table_name),
|
|
1310
|
+
)
|
|
1311
|
+
conn.commit()
|
|
1312
|
+
if cursor.rowcount == 0:
|
|
1313
|
+
cursor.execute(
|
|
1314
|
+
"INSERT INTO metadata (TableName, Key, Value) VALUES (?, ?, ?)",
|
|
1315
|
+
(table_name, key, value),
|
|
1316
|
+
)
|
|
1317
|
+
conn.commit()
|
|
1318
|
+
return
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def get_metadata_table(table_name: str) -> dict[str, str]:
|
|
1322
|
+
"""
|
|
1323
|
+
Retrieves the metadata dict for the specified table.
|
|
1324
|
+
:param table_name: Table name within the metadata table.
|
|
1325
|
+
:return: {key: value} dict for metadata items.
|
|
1326
|
+
"""
|
|
1327
|
+
with DB() as (conn, cursor):
|
|
1328
|
+
cursor.execute(
|
|
1329
|
+
"SELECT Key, Value FROM metadata WHERE TableName = ?", (table_name,)
|
|
1330
|
+
)
|
|
1331
|
+
data = cursor.fetchall()
|
|
1332
|
+
return dict(data)
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
def render_table(table_name: str, table_info: str, columns: list[dict], show=True):
|
|
1336
|
+
"""
|
|
1337
|
+
Renders the table to HTML, and displays it in the browser
|
|
1338
|
+
:param table_name: The table name in the database
|
|
1339
|
+
:param table_info: Think of this like a subtitle
|
|
1340
|
+
:param columns: list of dicts that conform to Tabulator's expected columns format
|
|
1341
|
+
:param show: whether to open a browser window with the rendered table HTML
|
|
1342
|
+
:return: None
|
|
1343
|
+
"""
|
|
1344
|
+
db_cols, rows = read_table(table_name)
|
|
1345
|
+
template_dir = os.path.join(os.path.dirname(__file__), "templates")
|
|
1346
|
+
with open(os.path.join(template_dir, "table.j2"), "r") as f:
|
|
1347
|
+
template = Template(f.read())
|
|
1348
|
+
rendered = template.render(
|
|
1349
|
+
title=table_name,
|
|
1350
|
+
columns=Markup(columns),
|
|
1351
|
+
rows=Markup([{c: v for (c, v) in zip(db_cols, r)} for r in rows]),
|
|
1352
|
+
column_names=db_cols,
|
|
1353
|
+
table_info=table_info,
|
|
1354
|
+
tree=False,
|
|
1355
|
+
)
|
|
1356
|
+
output_file = "/tmp/meshtensor_table.html"
|
|
1357
|
+
with open(output_file, "w+") as f:
|
|
1358
|
+
f.write(rendered)
|
|
1359
|
+
if show:
|
|
1360
|
+
webbrowser.open(f"file://{output_file}")
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
def render_tree(
|
|
1364
|
+
table_name: str,
|
|
1365
|
+
table_info: str,
|
|
1366
|
+
columns: list[dict],
|
|
1367
|
+
parent_column: int = 0,
|
|
1368
|
+
show=True,
|
|
1369
|
+
):
|
|
1370
|
+
"""
|
|
1371
|
+
Largely the same as render_table, but this renders the table with nested data.
|
|
1372
|
+
This is done by a table looking like: (FOO ANY, BAR ANY, BAZ ANY, CHILD INTEGER)
|
|
1373
|
+
where CHILD is 0 or 1, determining if the row should be treated as a child of another row.
|
|
1374
|
+
The parent and child rows should contain same value for the given parent_column
|
|
1375
|
+
|
|
1376
|
+
E.g. Let's say you have rows as such:
|
|
1377
|
+
(COLDKEY TEXT, BALANCE REAL, STAKE REAL, CHILD INTEGER)
|
|
1378
|
+
("5GTjidas", 1.0, 0.0, 0)
|
|
1379
|
+
("5GTjidas", 0.0, 1.0, 1)
|
|
1380
|
+
("DJIDSkod", 1.0, 0.0, 0)
|
|
1381
|
+
|
|
1382
|
+
This will be rendered as:
|
|
1383
|
+
Coldkey | Balance | Stake
|
|
1384
|
+
5GTjidas | 1.0 | 0.0
|
|
1385
|
+
└ | 0.0 | 1.0
|
|
1386
|
+
DJIDSkod | 1.0 | 0.0
|
|
1387
|
+
|
|
1388
|
+
:param table_name: The table name in the database
|
|
1389
|
+
:param table_info: Think of this like a subtitle
|
|
1390
|
+
:param columns: list of dicts that conform to Tabulator's expected columns format
|
|
1391
|
+
:param parent_column: the index of the column to use as for parent reference
|
|
1392
|
+
:param show: whether to open a browser window with the rendered table HTML
|
|
1393
|
+
:return: None
|
|
1394
|
+
"""
|
|
1395
|
+
db_cols, rows = read_table(table_name, "ORDER BY CHILD ASC")
|
|
1396
|
+
template_dir = os.path.join(os.path.dirname(__file__), "templates")
|
|
1397
|
+
result = []
|
|
1398
|
+
parent_dicts = {}
|
|
1399
|
+
for row in rows:
|
|
1400
|
+
row_dict = {c: v for (c, v) in zip(db_cols, row)}
|
|
1401
|
+
child = row_dict["CHILD"]
|
|
1402
|
+
del row_dict["CHILD"]
|
|
1403
|
+
if child == 0:
|
|
1404
|
+
row_dict["_children"] = []
|
|
1405
|
+
result.append(row_dict)
|
|
1406
|
+
parent_dicts[row_dict[db_cols[parent_column]]] = (
|
|
1407
|
+
row_dict # Reference to row obj
|
|
1408
|
+
)
|
|
1409
|
+
elif child == 1:
|
|
1410
|
+
parent_key = row[parent_column]
|
|
1411
|
+
row_dict[db_cols[parent_column]] = None
|
|
1412
|
+
if parent_key in parent_dicts:
|
|
1413
|
+
parent_dicts[parent_key]["_children"].append(row_dict)
|
|
1414
|
+
with open(os.path.join(template_dir, "table.j2"), "r") as f:
|
|
1415
|
+
template = Template(f.read())
|
|
1416
|
+
rendered = template.render(
|
|
1417
|
+
title=table_name,
|
|
1418
|
+
columns=Markup(columns),
|
|
1419
|
+
rows=Markup(result),
|
|
1420
|
+
column_names=db_cols,
|
|
1421
|
+
table_info=table_info,
|
|
1422
|
+
tree=True,
|
|
1423
|
+
)
|
|
1424
|
+
output_file = "/tmp/meshtensor_table.html"
|
|
1425
|
+
with open(output_file, "w+") as f:
|
|
1426
|
+
f.write(rendered)
|
|
1427
|
+
if show:
|
|
1428
|
+
webbrowser.open(f"file://{output_file}")
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def ensure_address_book_tables_exist():
|
|
1432
|
+
"""
|
|
1433
|
+
Creates address book tables if they don't exist.
|
|
1434
|
+
|
|
1435
|
+
Should be run at startup to ensure that the address book tables exist.
|
|
1436
|
+
"""
|
|
1437
|
+
with DB() as (conn, cursor):
|
|
1438
|
+
for table in (AddressBook, ProxyAddressBook, ProxyAnnouncements):
|
|
1439
|
+
table.create_if_not_exists(conn, cursor)
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
def group_subnets(registrations):
|
|
1443
|
+
if not registrations:
|
|
1444
|
+
return ""
|
|
1445
|
+
|
|
1446
|
+
ranges = []
|
|
1447
|
+
start = registrations[0]
|
|
1448
|
+
|
|
1449
|
+
for i in range(1, len(registrations)):
|
|
1450
|
+
if registrations[i] != registrations[i - 1] + 1:
|
|
1451
|
+
# Append the current range or single number
|
|
1452
|
+
if start == registrations[i - 1]:
|
|
1453
|
+
ranges.append(str(start))
|
|
1454
|
+
else:
|
|
1455
|
+
ranges.append(f"{start}-{registrations[i - 1]}")
|
|
1456
|
+
start = registrations[i]
|
|
1457
|
+
|
|
1458
|
+
# Append the final range or single number
|
|
1459
|
+
if start == registrations[-1]:
|
|
1460
|
+
ranges.append(str(start))
|
|
1461
|
+
else:
|
|
1462
|
+
ranges.append(f"{start}-{registrations[-1]}")
|
|
1463
|
+
|
|
1464
|
+
return ", ".join(ranges)
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
def parse_subnet_range(input_str: str, total_subnets: int) -> list[int]:
|
|
1468
|
+
"""
|
|
1469
|
+
Parse subnet range input like "1-24, 30-40, 5".
|
|
1470
|
+
|
|
1471
|
+
Args:
|
|
1472
|
+
input_str: Comma-separated list of subnets and ranges
|
|
1473
|
+
Examples: "1-5", "1,2,3", "1-5, 10, 20-25"
|
|
1474
|
+
total_subnets: Total number of subnets available
|
|
1475
|
+
|
|
1476
|
+
Returns:
|
|
1477
|
+
Sorted list of unique subnet IDs
|
|
1478
|
+
|
|
1479
|
+
Raises:
|
|
1480
|
+
ValueError: If input format is invalid
|
|
1481
|
+
|
|
1482
|
+
Examples:
|
|
1483
|
+
>>> parse_subnet_range("1-5, 10")
|
|
1484
|
+
[1, 2, 3, 4, 5, 10]
|
|
1485
|
+
>>> parse_subnet_range("5, 3, 1")
|
|
1486
|
+
[1, 3, 5]
|
|
1487
|
+
"""
|
|
1488
|
+
subnets = set()
|
|
1489
|
+
parts = [p.strip() for p in input_str.split(",") if p.strip()]
|
|
1490
|
+
for part in parts:
|
|
1491
|
+
if "-" in part:
|
|
1492
|
+
try:
|
|
1493
|
+
start, end = part.split("-", 1)
|
|
1494
|
+
start_num = int(start.strip())
|
|
1495
|
+
end_num = int(end.strip())
|
|
1496
|
+
|
|
1497
|
+
if start_num > end_num:
|
|
1498
|
+
raise ValueError(f"Invalid range '{part}': start must be ≤ end")
|
|
1499
|
+
|
|
1500
|
+
if end_num - start_num > total_subnets:
|
|
1501
|
+
raise ValueError(
|
|
1502
|
+
f"Range '{part}' is not valid (total of {total_subnets} subnets)"
|
|
1503
|
+
)
|
|
1504
|
+
|
|
1505
|
+
subnets.update(range(start_num, end_num + 1))
|
|
1506
|
+
except ValueError as e:
|
|
1507
|
+
if "invalid literal" in str(e):
|
|
1508
|
+
raise ValueError(f"Invalid range '{part}': must be 'start-end'")
|
|
1509
|
+
raise
|
|
1510
|
+
else:
|
|
1511
|
+
try:
|
|
1512
|
+
subnets.add(int(part))
|
|
1513
|
+
except ValueError:
|
|
1514
|
+
raise ValueError(f"Invalid subnet ID '{part}': must be a number")
|
|
1515
|
+
|
|
1516
|
+
return sorted(subnets)
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def validate_chain_endpoint(endpoint_url) -> tuple[bool, str]:
|
|
1520
|
+
parsed = urlparse(endpoint_url)
|
|
1521
|
+
if parsed.scheme not in ("ws", "wss"):
|
|
1522
|
+
return False, (
|
|
1523
|
+
f"Invalid URL or network name provided: [bright_cyan]({endpoint_url})[/bright_cyan].\n"
|
|
1524
|
+
"Allowed network names are [bright_cyan]finney, test, local[/bright_cyan]. "
|
|
1525
|
+
"Valid chain endpoints should use the scheme [bright_cyan]`ws` or `wss`[/bright_cyan].\n"
|
|
1526
|
+
)
|
|
1527
|
+
if not parsed.netloc:
|
|
1528
|
+
return False, "Invalid URL passed as the endpoint"
|
|
1529
|
+
return True, ""
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
def retry_prompt(
|
|
1533
|
+
helper_text: str,
|
|
1534
|
+
rejection: Callable,
|
|
1535
|
+
rejection_text: str,
|
|
1536
|
+
default="",
|
|
1537
|
+
show_default=False,
|
|
1538
|
+
prompt_type: "PromptBase.ask" = Prompt.ask,
|
|
1539
|
+
):
|
|
1540
|
+
"""
|
|
1541
|
+
Allows for asking prompts again if they do not meet a certain criteria (as defined in `rejection`)
|
|
1542
|
+
Args:
|
|
1543
|
+
helper_text: The helper text to display for the prompt
|
|
1544
|
+
rejection: A function that returns True if the input should be rejected, and False if it should be accepted
|
|
1545
|
+
rejection_text: The text to display to the user if their input hits the rejection
|
|
1546
|
+
default: the default value to use for the prompt, default ""
|
|
1547
|
+
show_default: whether to show the default, default False
|
|
1548
|
+
prompt_type: the type of prompt, default `typer.prompt`
|
|
1549
|
+
|
|
1550
|
+
Returns: the input value (or default)
|
|
1551
|
+
|
|
1552
|
+
"""
|
|
1553
|
+
while True:
|
|
1554
|
+
var = prompt_type(helper_text, default=default, show_default=show_default)
|
|
1555
|
+
if not rejection(var):
|
|
1556
|
+
return var
|
|
1557
|
+
else:
|
|
1558
|
+
print_error(rejection_text)
|
|
1559
|
+
|
|
1560
|
+
|
|
1561
|
+
def validate_netuid(value: int) -> int:
|
|
1562
|
+
if value is not None and value < 0:
|
|
1563
|
+
raise typer.BadParameter("Negative netuid passed. Please use correct netuid.")
|
|
1564
|
+
return value
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
def validate_uri(uri: str) -> str:
|
|
1568
|
+
if not uri:
|
|
1569
|
+
return None
|
|
1570
|
+
clean_uri = uri.lstrip("/").lower()
|
|
1571
|
+
if not clean_uri.isalnum():
|
|
1572
|
+
raise typer.BadParameter(
|
|
1573
|
+
f"Invalid URI format: {uri}. URI must contain only alphanumeric characters (e.g. 'alice', 'bob')"
|
|
1574
|
+
)
|
|
1575
|
+
return f"//{clean_uri.capitalize()}"
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
def get_effective_network(config, network: Optional[list[str]]) -> str:
|
|
1579
|
+
"""
|
|
1580
|
+
Determines the effective network to be used, considering the network parameter,
|
|
1581
|
+
the configuration, and the default.
|
|
1582
|
+
"""
|
|
1583
|
+
if network:
|
|
1584
|
+
network_ = ""
|
|
1585
|
+
for item in network:
|
|
1586
|
+
if item.startswith("ws"):
|
|
1587
|
+
network_ = item
|
|
1588
|
+
break
|
|
1589
|
+
else:
|
|
1590
|
+
network_ = item
|
|
1591
|
+
return network_
|
|
1592
|
+
elif config.get("network"):
|
|
1593
|
+
return config["network"]
|
|
1594
|
+
else:
|
|
1595
|
+
return defaults.meshtensor.network
|
|
1596
|
+
|
|
1597
|
+
|
|
1598
|
+
def is_meshlet_network(network: str) -> bool:
|
|
1599
|
+
"""Check if the given network is 'meshlet'."""
|
|
1600
|
+
network = network.lower()
|
|
1601
|
+
meshlet_identifiers = [
|
|
1602
|
+
"meshlet",
|
|
1603
|
+
Constants.meshlet_entrypoint,
|
|
1604
|
+
]
|
|
1605
|
+
return (
|
|
1606
|
+
network == "meshlet"
|
|
1607
|
+
or network in meshlet_identifiers
|
|
1608
|
+
or "meshlet.chain.opentensor.ai" in network
|
|
1609
|
+
)
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
def prompt_for_identity(
|
|
1613
|
+
current_identity: dict,
|
|
1614
|
+
name: Optional[str],
|
|
1615
|
+
web_url: Optional[str],
|
|
1616
|
+
image_url: Optional[str],
|
|
1617
|
+
discord: Optional[str],
|
|
1618
|
+
description: Optional[str],
|
|
1619
|
+
additional: Optional[str],
|
|
1620
|
+
github_repo: Optional[str],
|
|
1621
|
+
):
|
|
1622
|
+
"""
|
|
1623
|
+
Prompts the user for identity fields with validation.
|
|
1624
|
+
Returns a dictionary with the updated fields.
|
|
1625
|
+
"""
|
|
1626
|
+
identity_fields = {}
|
|
1627
|
+
|
|
1628
|
+
fields = [
|
|
1629
|
+
("name", "[blue]Display name[/blue]", name, 256),
|
|
1630
|
+
("url", "[blue]Web URL[/blue]", web_url, 256),
|
|
1631
|
+
("image", "[blue]Image URL[/blue]", image_url, 1024),
|
|
1632
|
+
("discord", "[blue]Discord handle[/blue]", discord, 256),
|
|
1633
|
+
("description", "[blue]Description[/blue]", description, 1024),
|
|
1634
|
+
("additional", "[blue]Additional information[/blue]", additional, 1024),
|
|
1635
|
+
("github_repo", "[blue]GitHub repository URL[/blue]", github_repo, 256),
|
|
1636
|
+
]
|
|
1637
|
+
|
|
1638
|
+
if not any(
|
|
1639
|
+
[name, web_url, image_url, discord, description, additional, github_repo]
|
|
1640
|
+
):
|
|
1641
|
+
console.print(
|
|
1642
|
+
"\n[yellow]All fields are optional. Press Enter to skip and keep the default/existing value.[/yellow]\n"
|
|
1643
|
+
"[dark_sea_green3]Tip: Entering a space and pressing Enter will clear existing default value.\n"
|
|
1644
|
+
)
|
|
1645
|
+
|
|
1646
|
+
for key, prompt, value, byte_limit in fields:
|
|
1647
|
+
text_rejection = partial(
|
|
1648
|
+
retry_prompt,
|
|
1649
|
+
rejection=lambda x: len(x.encode("utf-8")) > byte_limit,
|
|
1650
|
+
rejection_text=f"[red]Error:[/red] {key} field must be <= {byte_limit} bytes.",
|
|
1651
|
+
)
|
|
1652
|
+
|
|
1653
|
+
if value:
|
|
1654
|
+
identity_fields[key] = value
|
|
1655
|
+
else:
|
|
1656
|
+
identity_fields[key] = text_rejection(
|
|
1657
|
+
prompt,
|
|
1658
|
+
default=current_identity.get(key, ""),
|
|
1659
|
+
show_default=True,
|
|
1660
|
+
)
|
|
1661
|
+
|
|
1662
|
+
return identity_fields
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
def prompt_for_subnet_identity(
|
|
1666
|
+
current_identity: dict,
|
|
1667
|
+
subnet_name: Optional[str],
|
|
1668
|
+
github_repo: Optional[str],
|
|
1669
|
+
subnet_contact: Optional[str],
|
|
1670
|
+
subnet_url: Optional[str],
|
|
1671
|
+
discord: Optional[str],
|
|
1672
|
+
description: Optional[str],
|
|
1673
|
+
logo_url: Optional[str],
|
|
1674
|
+
additional: Optional[str],
|
|
1675
|
+
):
|
|
1676
|
+
"""
|
|
1677
|
+
Prompts the user for required subnet identity fields with validation.
|
|
1678
|
+
Returns a dictionary with the updated fields.
|
|
1679
|
+
|
|
1680
|
+
Args:
|
|
1681
|
+
subnet_name (Optional[str]): Name of the subnet
|
|
1682
|
+
github_repo (Optional[str]): GitHub repository URL
|
|
1683
|
+
subnet_contact (Optional[str]): Contact information for subnet (email)
|
|
1684
|
+
|
|
1685
|
+
Returns:
|
|
1686
|
+
dict: Dictionary containing the subnet identity fields
|
|
1687
|
+
"""
|
|
1688
|
+
identity_fields = {}
|
|
1689
|
+
|
|
1690
|
+
fields = [
|
|
1691
|
+
(
|
|
1692
|
+
"subnet_name",
|
|
1693
|
+
"[blue]Subnet name [dim](optional)[/blue]",
|
|
1694
|
+
subnet_name,
|
|
1695
|
+
lambda x: x and len(x.encode("utf-8")) > 256,
|
|
1696
|
+
"[red]Error:[/red] Subnet name must be <= 256 bytes.",
|
|
1697
|
+
),
|
|
1698
|
+
(
|
|
1699
|
+
"github_repo",
|
|
1700
|
+
"[blue]GitHub repository URL [dim](optional)[/blue]",
|
|
1701
|
+
github_repo,
|
|
1702
|
+
lambda x: x
|
|
1703
|
+
and (not is_valid_github_url(x) or len(x.encode("utf-8")) > 1024),
|
|
1704
|
+
"[red]Error:[/red] Please enter a valid GitHub repository URL (e.g., https://github.com/username/repo).",
|
|
1705
|
+
),
|
|
1706
|
+
(
|
|
1707
|
+
"subnet_contact",
|
|
1708
|
+
"[blue]Contact email [dim](optional)[/blue]",
|
|
1709
|
+
subnet_contact,
|
|
1710
|
+
lambda x: x and (not is_valid_contact(x) or len(x.encode("utf-8")) > 1024),
|
|
1711
|
+
"[red]Error:[/red] Please enter a valid email address.",
|
|
1712
|
+
),
|
|
1713
|
+
(
|
|
1714
|
+
"subnet_url",
|
|
1715
|
+
"[blue]Subnet URL [dim](optional)[/blue]",
|
|
1716
|
+
subnet_url,
|
|
1717
|
+
lambda x: x and len(x.encode("utf-8")) > 1024,
|
|
1718
|
+
"[red]Error:[/red] Please enter a valid URL <= 1024 bytes.",
|
|
1719
|
+
),
|
|
1720
|
+
(
|
|
1721
|
+
"discord",
|
|
1722
|
+
"[blue]Discord handle [dim](optional)[/blue]",
|
|
1723
|
+
discord,
|
|
1724
|
+
lambda x: x and len(x.encode("utf-8")) > 256,
|
|
1725
|
+
"[red]Error:[/red] Please enter a valid Discord handle <= 256 bytes.",
|
|
1726
|
+
),
|
|
1727
|
+
(
|
|
1728
|
+
"description",
|
|
1729
|
+
"[blue]Description [dim](optional)[/blue]",
|
|
1730
|
+
description,
|
|
1731
|
+
lambda x: x and len(x.encode("utf-8")) > 1024,
|
|
1732
|
+
"[red]Error:[/red] Description must be <= 1024 bytes.",
|
|
1733
|
+
),
|
|
1734
|
+
(
|
|
1735
|
+
"logo_url",
|
|
1736
|
+
"[blue]Logo URL [dim](optional)[/blue]",
|
|
1737
|
+
logo_url,
|
|
1738
|
+
lambda x: x and len(x.encode("utf-8")) > 1024,
|
|
1739
|
+
"[red]Error:[/red] Logo URL must be <= 1024 bytes.",
|
|
1740
|
+
),
|
|
1741
|
+
(
|
|
1742
|
+
"additional",
|
|
1743
|
+
"[blue]Additional information [dim](optional)[/blue]",
|
|
1744
|
+
additional,
|
|
1745
|
+
lambda x: x and len(x.encode("utf-8")) > 1024,
|
|
1746
|
+
"[red]Error:[/red] Additional information must be <= 1024 bytes.",
|
|
1747
|
+
),
|
|
1748
|
+
]
|
|
1749
|
+
|
|
1750
|
+
for key, prompt, value, rejection_func, rejection_msg in fields:
|
|
1751
|
+
if value:
|
|
1752
|
+
if rejection_func(value):
|
|
1753
|
+
raise ValueError(rejection_msg)
|
|
1754
|
+
identity_fields[key] = value
|
|
1755
|
+
else:
|
|
1756
|
+
identity_fields[key] = retry_prompt(
|
|
1757
|
+
prompt,
|
|
1758
|
+
rejection=rejection_func,
|
|
1759
|
+
rejection_text=rejection_msg,
|
|
1760
|
+
default=current_identity.get(key, ""),
|
|
1761
|
+
show_default=True,
|
|
1762
|
+
)
|
|
1763
|
+
|
|
1764
|
+
return identity_fields
|
|
1765
|
+
|
|
1766
|
+
|
|
1767
|
+
def is_valid_github_url(url: str) -> bool:
|
|
1768
|
+
"""
|
|
1769
|
+
Validates if the provided URL is a valid GitHub repository URL.
|
|
1770
|
+
|
|
1771
|
+
Args:
|
|
1772
|
+
url (str): URL to validate
|
|
1773
|
+
|
|
1774
|
+
Returns:
|
|
1775
|
+
bool: True if valid GitHub repo URL, False otherwise
|
|
1776
|
+
"""
|
|
1777
|
+
try:
|
|
1778
|
+
parsed = urlparse(url)
|
|
1779
|
+
if parsed.netloc != "github.com":
|
|
1780
|
+
return False
|
|
1781
|
+
|
|
1782
|
+
# Check path follows github.com/user/repo format
|
|
1783
|
+
path_parts = [p for p in parsed.path.split("/") if p]
|
|
1784
|
+
if len(path_parts) < 2: # Need at least username/repo
|
|
1785
|
+
return False
|
|
1786
|
+
|
|
1787
|
+
return True
|
|
1788
|
+
except Exception: # TODO figure out the exceptions that can be raised in here
|
|
1789
|
+
return False
|
|
1790
|
+
|
|
1791
|
+
|
|
1792
|
+
def is_valid_contact(contact: str) -> bool:
|
|
1793
|
+
"""
|
|
1794
|
+
Validates if the provided contact is a valid email address.
|
|
1795
|
+
|
|
1796
|
+
Args:
|
|
1797
|
+
contact (str): Contact information to validate
|
|
1798
|
+
|
|
1799
|
+
Returns:
|
|
1800
|
+
bool: True if valid email, False otherwise
|
|
1801
|
+
"""
|
|
1802
|
+
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
|
1803
|
+
return bool(re.match(email_pattern, contact))
|
|
1804
|
+
|
|
1805
|
+
|
|
1806
|
+
def get_subnet_name(subnet_info, max_length: int = 20) -> str:
|
|
1807
|
+
"""Get the subnet name, prioritizing subnet_identity.subnet_name over subnet.subnet_name.
|
|
1808
|
+
Truncates the name if it exceeds max_length.
|
|
1809
|
+
|
|
1810
|
+
Args:
|
|
1811
|
+
subnet_info: The subnet dynamic info
|
|
1812
|
+
max_length: Maximum length of the returned name. Names longer than this will be truncated with '...'
|
|
1813
|
+
|
|
1814
|
+
Returns:
|
|
1815
|
+
str: The subnet name (truncated if necessary) or empty string if no name is found
|
|
1816
|
+
"""
|
|
1817
|
+
name = (
|
|
1818
|
+
subnet_info.subnet_identity.subnet_name
|
|
1819
|
+
if hasattr(subnet_info, "subnet_identity")
|
|
1820
|
+
and subnet_info.subnet_identity is not None
|
|
1821
|
+
and subnet_info.subnet_identity.subnet_name is not None
|
|
1822
|
+
else (subnet_info.subnet_name if subnet_info.subnet_name is not None else "")
|
|
1823
|
+
)
|
|
1824
|
+
|
|
1825
|
+
if len(name) > max_length:
|
|
1826
|
+
return name[: max_length - 3] + "..."
|
|
1827
|
+
return name
|
|
1828
|
+
|
|
1829
|
+
|
|
1830
|
+
def validate_rate_tolerance(value: Optional[float]) -> Optional[float]:
|
|
1831
|
+
"""Validates rate tolerance input"""
|
|
1832
|
+
if value is not None:
|
|
1833
|
+
if value < 0:
|
|
1834
|
+
raise typer.BadParameter(
|
|
1835
|
+
"Rate tolerance cannot be negative (less than 0%)."
|
|
1836
|
+
)
|
|
1837
|
+
if value > 1:
|
|
1838
|
+
raise typer.BadParameter("Rate tolerance cannot be greater than 1 (100%).")
|
|
1839
|
+
if value > 0.5:
|
|
1840
|
+
console.print(
|
|
1841
|
+
f"[yellow]Warning: High rate tolerance of {value * 100}% specified. "
|
|
1842
|
+
"This may result in unfavorable transaction execution.[/yellow]"
|
|
1843
|
+
)
|
|
1844
|
+
return value
|
|
1845
|
+
|
|
1846
|
+
|
|
1847
|
+
def unlock_key(
|
|
1848
|
+
wallet: Wallet, unlock_type="cold", print_out: bool = True
|
|
1849
|
+
) -> "UnlockStatus":
|
|
1850
|
+
"""
|
|
1851
|
+
Attempts to decrypt a wallet's coldkey or hotkey
|
|
1852
|
+
Args:
|
|
1853
|
+
wallet: a Wallet object
|
|
1854
|
+
unlock_type: the key type, 'cold' or 'hot'
|
|
1855
|
+
print_out: whether to print out the error message to the err_console
|
|
1856
|
+
|
|
1857
|
+
Returns: UnlockStatus for success status of unlock, with error message if unsuccessful
|
|
1858
|
+
|
|
1859
|
+
"""
|
|
1860
|
+
if unlock_type == "cold":
|
|
1861
|
+
unlocker = "unlock_coldkey"
|
|
1862
|
+
elif unlock_type == "hot":
|
|
1863
|
+
unlocker = "unlock_hotkey"
|
|
1864
|
+
else:
|
|
1865
|
+
raise ValueError(
|
|
1866
|
+
f"Invalid unlock type provided: {unlock_type}. Must be 'cold' or 'hot'."
|
|
1867
|
+
)
|
|
1868
|
+
try:
|
|
1869
|
+
getattr(wallet, unlocker)()
|
|
1870
|
+
return UnlockStatus(True, "")
|
|
1871
|
+
except PasswordError:
|
|
1872
|
+
err_msg = f"The password used to decrypt your {unlock_type.capitalize()}key Keyfile is invalid."
|
|
1873
|
+
if print_out:
|
|
1874
|
+
print_error(f"Failed: {err_msg}")
|
|
1875
|
+
return unlock_key(wallet, unlock_type, print_out)
|
|
1876
|
+
return UnlockStatus(False, err_msg)
|
|
1877
|
+
except KeyFileError:
|
|
1878
|
+
err_msg = f"{unlock_type.capitalize()}key Keyfile is corrupt, non-writable, or non-readable, or non-existent."
|
|
1879
|
+
if print_out:
|
|
1880
|
+
print_error(f"Failed: {err_msg}")
|
|
1881
|
+
return UnlockStatus(False, err_msg)
|
|
1882
|
+
|
|
1883
|
+
|
|
1884
|
+
def hex_to_bytes(hex_str: str) -> bytes:
|
|
1885
|
+
"""
|
|
1886
|
+
Converts a hex-encoded string into bytes. Handles 0x-prefixed and non-prefixed hex-encoded strings.
|
|
1887
|
+
"""
|
|
1888
|
+
if hex_str.startswith("0x"):
|
|
1889
|
+
bytes_result = bytes.fromhex(hex_str[2:])
|
|
1890
|
+
else:
|
|
1891
|
+
bytes_result = bytes.fromhex(hex_str)
|
|
1892
|
+
return bytes_result
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
def blocks_to_duration(blocks: int) -> str:
|
|
1896
|
+
"""Convert blocks to human readable duration string using two largest units.
|
|
1897
|
+
|
|
1898
|
+
Args:
|
|
1899
|
+
blocks (int): Number of blocks (12s per block)
|
|
1900
|
+
|
|
1901
|
+
Returns:
|
|
1902
|
+
str: Duration string like '2d 5h', '3h 45m', '2m 10s', or '0s'
|
|
1903
|
+
"""
|
|
1904
|
+
if blocks <= 0:
|
|
1905
|
+
return "0s"
|
|
1906
|
+
|
|
1907
|
+
seconds = blocks * 12
|
|
1908
|
+
intervals = [
|
|
1909
|
+
("d", 86400), # 60 * 60 * 24
|
|
1910
|
+
("h", 3600), # 60 * 60
|
|
1911
|
+
("m", 60),
|
|
1912
|
+
("s", 1),
|
|
1913
|
+
]
|
|
1914
|
+
results = []
|
|
1915
|
+
for unit, seconds_per_unit in intervals:
|
|
1916
|
+
unit_count = seconds // seconds_per_unit
|
|
1917
|
+
seconds %= seconds_per_unit
|
|
1918
|
+
if unit_count > 0:
|
|
1919
|
+
results.append(f"{unit_count}{unit}")
|
|
1920
|
+
# Return only the first two non-zero units
|
|
1921
|
+
return " ".join(results[:2]) or "0s"
|
|
1922
|
+
|
|
1923
|
+
|
|
1924
|
+
def get_hotkey_pub_ss58(wallet: Wallet) -> str:
|
|
1925
|
+
"""
|
|
1926
|
+
Helper fn to retrieve the hotkeypub ss58 of a wallet that may have been created before
|
|
1927
|
+
bt-wallet 3.1.1 and thus not have a wallet hotkeypub. In this case, it will return the hotkey
|
|
1928
|
+
SS58.
|
|
1929
|
+
"""
|
|
1930
|
+
try:
|
|
1931
|
+
return wallet.hotkeypub.ss58_address
|
|
1932
|
+
except (KeyFileError, AttributeError):
|
|
1933
|
+
return wallet.hotkey.ss58_address
|
|
1934
|
+
|
|
1935
|
+
|
|
1936
|
+
def get_netuid_and_subuid_by_storage_index(storage_index: int) -> tuple[int, int]:
|
|
1937
|
+
"""Returns the netuid and subuid from the storage index.
|
|
1938
|
+
|
|
1939
|
+
Chain APIs (e.g., SubMetagraph response) returns netuid which is storage index that encodes both the netuid and
|
|
1940
|
+
subuid. This function reverses the encoding to extract these components.
|
|
1941
|
+
|
|
1942
|
+
Parameters:
|
|
1943
|
+
storage_index: The storage index of the subnet.
|
|
1944
|
+
|
|
1945
|
+
Returns:
|
|
1946
|
+
tuple[int, int]:
|
|
1947
|
+
- netuid subnet identifier.
|
|
1948
|
+
- subuid identifier.
|
|
1949
|
+
"""
|
|
1950
|
+
return (
|
|
1951
|
+
storage_index % GLOBAL_MAX_SUBNET_COUNT,
|
|
1952
|
+
storage_index // GLOBAL_MAX_SUBNET_COUNT,
|
|
1953
|
+
)
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
async def print_extrinsic_id(
|
|
1957
|
+
extrinsic_receipt: Optional[AsyncExtrinsicReceipt],
|
|
1958
|
+
) -> None:
|
|
1959
|
+
"""
|
|
1960
|
+
Prints the extrinsic identifier to the console. If the substrate attached to the extrinsic receipt is on a finney
|
|
1961
|
+
node, it will also include a link to browse the extrinsic in mesh dot app.
|
|
1962
|
+
Args:
|
|
1963
|
+
extrinsic_receipt: AsyncExtrinsicReceipt object from a successful extrinsic submission.
|
|
1964
|
+
"""
|
|
1965
|
+
if extrinsic_receipt is None or not (await extrinsic_receipt.is_success):
|
|
1966
|
+
return
|
|
1967
|
+
substrate = extrinsic_receipt.substrate
|
|
1968
|
+
ext_id = await extrinsic_receipt.get_extrinsic_identifier()
|
|
1969
|
+
if substrate:
|
|
1970
|
+
query = await substrate.rpc_request("system_chainType", [])
|
|
1971
|
+
if query.get("result") == "Live":
|
|
1972
|
+
console.print(
|
|
1973
|
+
f":white_heavy_check_mark: Your extrinsic has been included as {ext_id}: "
|
|
1974
|
+
f"[blue]https://tao.app/extrinsic/{ext_id}[/blue]"
|
|
1975
|
+
)
|
|
1976
|
+
return
|
|
1977
|
+
console.print(
|
|
1978
|
+
f":white_heavy_check_mark: Your extrinsic has been included as {ext_id}"
|
|
1979
|
+
)
|
|
1980
|
+
return
|
|
1981
|
+
|
|
1982
|
+
|
|
1983
|
+
async def check_img_mimetype(img_url: str) -> tuple[bool, str, str]:
|
|
1984
|
+
"""
|
|
1985
|
+
Checks to see if the given URL is an image, as defined by its mimetype.
|
|
1986
|
+
|
|
1987
|
+
Args:
|
|
1988
|
+
img_url: the URL to check
|
|
1989
|
+
|
|
1990
|
+
Returns:
|
|
1991
|
+
tuple:
|
|
1992
|
+
bool: True if the URL has a MIME type indicating image (e.g. 'image/...'), False otherwise.
|
|
1993
|
+
str: MIME type of the URL.
|
|
1994
|
+
str: error message if the URL could not be retrieved
|
|
1995
|
+
|
|
1996
|
+
"""
|
|
1997
|
+
async with aiohttp.ClientSession() as session:
|
|
1998
|
+
try:
|
|
1999
|
+
async with session.get(img_url) as response:
|
|
2000
|
+
if response.status != 200:
|
|
2001
|
+
return False, "", "Could not fetch image"
|
|
2002
|
+
elif "image/" not in response.content_type:
|
|
2003
|
+
return False, response.content_type, ""
|
|
2004
|
+
else:
|
|
2005
|
+
return True, response.content_type, ""
|
|
2006
|
+
except aiohttp.ClientError:
|
|
2007
|
+
return False, "", "Could not fetch image"
|