chutes-miner-cli 0.2.0__tar.gz

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.
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.3
2
+ Name: chutes-miner-cli
3
+ Version: 0.2.0
4
+ Summary: Chutes miner CLI
5
+ License: MIT
6
+ Author: Jon Durbin
7
+ Author-email: jon@jondurbin.com
8
+ Requires-Python: >=3.12,<4.0
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Requires-Dist: aiohttp[speedups] (>3.10,<4)
18
+ Requires-Dist: click (>8.1.0,<8.2.0)
19
+ Requires-Dist: loguru (>=0.7.3,<0.8.0)
20
+ Requires-Dist: py-bip39-bindings (>=0.1.11,<0.2.0)
21
+ Requires-Dist: pydantic (>2.9,<3)
22
+ Requires-Dist: rich (>13.0.0)
23
+ Requires-Dist: setuptools (>80.0)
24
+ Requires-Dist: substrate-interface (>=1.7.11,<2.0.0)
25
+ Requires-Dist: typer (>=0.14.0,<0.15.0)
26
+ Project-URL: Homepage, https://github.com/rayonlabs/chutes-miner/tree/main/cli
27
+ Description-Content-Type: text/markdown
28
+
29
+
File without changes
@@ -0,0 +1,487 @@
1
+ import json
2
+ import asyncio
3
+ from typing import Optional
4
+ import aiohttp
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+ from rich import box
9
+ import datetime
10
+ from chutes_miner_cli.util import sign_request
11
+ from loguru import logger
12
+
13
+ app = typer.Typer(no_args_is_help=True)
14
+
15
+
16
+ def format_memory(memory_bytes):
17
+ """
18
+ Convert memory from bytes to GB and format nicely.
19
+ """
20
+ return f"{memory_bytes / (1024**3):.1f}GB"
21
+
22
+
23
+ def format_date(date_str):
24
+ """
25
+ Format datetime string to a more readable format.
26
+ """
27
+ dt = datetime.datetime.fromisoformat(date_str)
28
+ return dt.strftime("%Y-%m-%d %H:%M")
29
+
30
+
31
+ def format_verification(error, verified_at):
32
+ """
33
+ Helper to format table cell for GPU verification.
34
+ """
35
+ if verified_at:
36
+ return f"[green]Verified: {format_date(verified_at)}[/green]"
37
+ elif error:
38
+ return f"[red]Error: {error}[/red]"
39
+ return "[yellow]Pending[/yellow]"
40
+
41
+
42
+ async def delete_preflight(server, hotkey, miner_api):
43
+ """
44
+ Check if a node has any jobs running and confirm the miner wants
45
+ to crush their incentives if they terminate regardless.
46
+ """
47
+
48
+ async with aiohttp.ClientSession(raise_for_status=True) as session:
49
+ headers, payload_string = sign_request(hotkey, purpose="management")
50
+ async with session.get(
51
+ f"{miner_api.rstrip('/')}/servers/{server}/delete_preflight",
52
+ headers=headers,
53
+ ) as resp:
54
+ data = await resp.json()
55
+ if data.get("jobs"):
56
+ logger.warning(
57
+ f"There are {len(data['jobs'])} jobs running on server {server}, "
58
+ "deleting this node will be highly detrimental to your score."
59
+ )
60
+ user_input = input("Continue (y/n)? ").strip().lower()
61
+ if user_input != "y":
62
+ return False
63
+ return True
64
+
65
+
66
+ def display_local_inventory(inventory):
67
+ """
68
+ Render inventory in fancy tables.
69
+ """
70
+ console = Console()
71
+ for server in inventory:
72
+ server_table = Table(title=f"Server: {server['name']}", box=box.ROUNDED)
73
+ server_table.add_column("Property", style="cyan")
74
+ server_table.add_column("Value")
75
+ server_table.add_row("Status", server["status"])
76
+ server_table.add_row("GPUs", str(server["gpu_count"]))
77
+ server_table.add_row("Memory/GPU", f"{server['memory_per_gpu']}GB")
78
+ server_table.add_row("CPU/GPU", str(server["cpu_per_gpu"]))
79
+ server_table.add_row("Hourly Cost", f"${server['hourly_cost']:.2f}")
80
+ server_table.add_row("IP Address", server["ip_address"])
81
+ server_table.add_row("Created", format_date(server["created_at"]))
82
+ console.print(server_table)
83
+ console.print()
84
+
85
+ # Deployments.
86
+ if server["deployments"]:
87
+ deploy_table = Table(title="Active Deployments", box=box.ROUNDED)
88
+ deploy_table.add_column("Model Name")
89
+ deploy_table.add_column("GPUs")
90
+ deploy_table.add_column("Port")
91
+ deploy_table.add_column("Created")
92
+ deploy_table.add_column("Status")
93
+ for deploy in server["deployments"]:
94
+ status_text = (
95
+ "[green]Active[/green]"
96
+ if deploy["active"] and not deploy["stub"]
97
+ else "[red]Inactive[/red]"
98
+ )
99
+ deploy_table.add_row(
100
+ deploy["chute"]["name"],
101
+ str(len(deploy["gpus"])),
102
+ str(deploy["port"]),
103
+ format_date(deploy["created_at"]),
104
+ status_text,
105
+ )
106
+ console.print(deploy_table)
107
+ console.print()
108
+
109
+ # GPU details.
110
+ gpu_table = Table(title="GPU Details", box=box.ROUNDED)
111
+ gpu_table.add_column("Name")
112
+ gpu_table.add_column("Memory")
113
+ gpu_table.add_column("Clock (MHz)")
114
+ gpu_table.add_column("Processors")
115
+ gpu_table.add_column("Status")
116
+ for gpu in server["gpus"]:
117
+ status_text = "[green]Verified[/green]" if gpu["verified"] else "[red]Unverified[/red]"
118
+ gpu_table.add_row(
119
+ gpu["device_info"]["name"],
120
+ format_memory(gpu["device_info"]["memory"]),
121
+ str(int(gpu["device_info"]["clock_rate"] / 1000)),
122
+ str(gpu["device_info"]["processors"]),
123
+ status_text,
124
+ )
125
+
126
+ console.print(gpu_table)
127
+ console.print("\n" + "=" * 80 + "\n")
128
+
129
+
130
+ def display_remote_inventory(inventory):
131
+ """
132
+ Render remote/validator inventory.
133
+ """
134
+ console = Console()
135
+ table = Table(title="GPU Information")
136
+ table.add_column("Name", style="cyan")
137
+ table.add_column("Chute", style="cyan")
138
+ table.add_column("Memory (GB)", justify="right", style="green")
139
+ table.add_column("Clock (MHz)", justify="right", style="red")
140
+ table.add_column("Created At", style="blue")
141
+ table.add_column("GPU Verification", style="white")
142
+ table.add_column("Instance verification", style="white")
143
+ for gpu in inventory:
144
+ table.add_row(
145
+ gpu["name"],
146
+ f"{gpu['chute_id']} {gpu['chute']}",
147
+ format_memory(gpu["memory"]),
148
+ f"{gpu['clock_rate'] / 1000:.0f}",
149
+ format_date(gpu["created_at"]),
150
+ format_verification(gpu["verification_error"], gpu["verified_at"]),
151
+ format_verification(gpu["inst_verification_error"], gpu["inst_verified_at"]),
152
+ )
153
+ console.print(table)
154
+ console.print("\n" + "=" * 80 + "\n")
155
+
156
+
157
+ def local_inventory(
158
+ raw_json: bool = typer.Option(False, help="Display raw JSON output"),
159
+ hotkey: str = typer.Option(..., help="Path to the hotkey file for your miner"),
160
+ miner_api: str = typer.Option("http://127.0.0.1:32000", help="Miner API base URL"),
161
+ ):
162
+ """
163
+ Show local inventory.
164
+ """
165
+
166
+ async def _local_inventory():
167
+ nonlocal hotkey, miner_api, raw_json
168
+ async with aiohttp.ClientSession(raise_for_status=True) as session:
169
+ headers, _ = sign_request(hotkey, purpose="management")
170
+ async with session.get(
171
+ f"{miner_api.rstrip('/')}/servers/",
172
+ headers=headers,
173
+ timeout=30,
174
+ ) as resp:
175
+ inventory = await resp.json()
176
+ if raw_json:
177
+ print(json.dumps(inventory, indent=2))
178
+ else:
179
+ display_local_inventory(inventory)
180
+
181
+ asyncio.run(_local_inventory())
182
+
183
+
184
+ def remote_inventory(
185
+ raw_json: bool = typer.Option(False, help="Display raw JSON output"),
186
+ hotkey: str = typer.Option(..., help="Path to the hotkey file for your miner"),
187
+ validator_api: str = typer.Option("https://api.chutes.ai", help="Validator API base URL"),
188
+ ):
189
+ """
190
+ Show remote (i.e., what the validator has tracked) inventory.
191
+ """
192
+
193
+ async def _remote_inventory():
194
+ nonlocal hotkey, validator_api, raw_json
195
+ async with aiohttp.ClientSession(raise_for_status=True) as session:
196
+ headers, _ = sign_request(hotkey, purpose="miner", remote=True)
197
+ inventory = []
198
+ gpu_map = {}
199
+ async with session.get(f"{validator_api}/miner/nodes/", headers=headers) as resp:
200
+ async for content_enc in resp.content:
201
+ content = content_enc.decode()
202
+ if content.startswith("data: "):
203
+ inventory.append(json.loads(content[6:]))
204
+ gpu_map[inventory[-1]["uuid"]] = inventory[-1]
205
+ gpu_map[inventory[-1]["uuid"]].update(
206
+ {
207
+ "chute": None,
208
+ "chute_id": None,
209
+ "inst_verification_error": None,
210
+ "inst_verified_at": None,
211
+ }
212
+ )
213
+ async with session.get(f"{validator_api}/miner/inventory", headers=headers) as resp:
214
+ for item in await resp.json():
215
+ if item["gpu_id"] in gpu_map:
216
+ gpu_map[item["gpu_id"]].update(
217
+ {
218
+ "chute": item["chute_name"],
219
+ "chute_id": item["chute_id"],
220
+ "inst_verification_error": item["verification_error"],
221
+ "inst_verified_at": item["last_verified_at"],
222
+ }
223
+ )
224
+ inventory = sorted(inventory, key=lambda o: o["created_at"])
225
+ if raw_json:
226
+ print(json.dumps(inventory, indent=2))
227
+ else:
228
+ display_remote_inventory(inventory)
229
+
230
+ asyncio.run(_remote_inventory())
231
+
232
+
233
+ def add_node(
234
+ name: str = typer.Option(..., help="Name of the server/node"),
235
+ validator: str = typer.Option(..., help="Validator ss58 this node is allocated to"),
236
+ hourly_cost: float = typer.Option(..., help="Hourly cost, used in optimizing autoscaling"),
237
+ gpu_short_ref: str = typer.Option(..., help="GPU short reference"),
238
+ hotkey: str = typer.Option(..., help="Path to the hotkey file for your miner"),
239
+ agent_api: Optional[str] = typer.Option(None, help="Agent API base URL (K3s Only)"),
240
+ miner_api: str = typer.Option("http://127.0.0.1:32000", help="Miner API base URL"),
241
+ ):
242
+ """
243
+ Entrypoint for adding a new kubernetes node.
244
+ """
245
+
246
+ async def _add_node():
247
+ nonlocal name, validator, hourly_cost, gpu_short_ref, hotkey, miner_api
248
+ async with aiohttp.ClientSession(raise_for_status=False) as session:
249
+ payload = {
250
+ "name": name,
251
+ "validator": validator,
252
+ "hourly_cost": hourly_cost,
253
+ "gpu_short_ref": gpu_short_ref,
254
+ "agent_api": agent_api,
255
+ }
256
+ headers, payload_string = sign_request(hotkey, payload=payload)
257
+ async with session.post(
258
+ f"{miner_api.rstrip('/')}/servers/",
259
+ headers=headers,
260
+ data=payload_string,
261
+ timeout=900,
262
+ ) as resp:
263
+ if resp.status != 200:
264
+ print(f"\033[31mError adding node:\n{await resp.text()}\033[0m")
265
+ resp.raise_for_status()
266
+ async for content in resp.content:
267
+ if content.strip():
268
+ payload = json.loads(content.decode()[6:])
269
+ print(f"\033[34m{payload['timestamp']}\033[0m {payload['message']}")
270
+
271
+ asyncio.run(_add_node())
272
+
273
+
274
+ def delete_node(
275
+ name: str = typer.Option(..., help="Name of the server/node"),
276
+ hotkey: str = typer.Option(..., help="Path to the hotkey file for your miner"),
277
+ miner_api: str = typer.Option("http://127.0.0.1:32000", help="Miner API base URL"),
278
+ ):
279
+ """
280
+ Entrypoint for deleting a kubernetes node.
281
+ """
282
+
283
+ async def _delete_node():
284
+ nonlocal name, hotkey, miner_api
285
+
286
+ # Before we do anything, check if the server has any jobs running. Deleting
287
+ # a job is very, very bad.
288
+ if not await delete_preflight(name, hotkey, miner_api):
289
+ return
290
+
291
+ # Proceed with deletion.
292
+ async with aiohttp.ClientSession(raise_for_status=False) as session:
293
+ headers, payload_string = sign_request(hotkey, purpose="management")
294
+ async with session.delete(
295
+ f"{miner_api.rstrip('/')}/servers/{name}",
296
+ headers=headers,
297
+ ) as resp:
298
+ if resp.status == 404:
299
+ logger.warning(f"Node not found: {name}")
300
+ return
301
+ resp.raise_for_status()
302
+ print(json.dumps(await resp.json(), indent=2))
303
+
304
+ asyncio.run(_delete_node())
305
+
306
+
307
+ def purge_deployments(
308
+ hotkey: str = typer.Option(..., help="Path to the hotkey file for your miner"),
309
+ miner_api: str = typer.Option("http://127.0.0.1:32000", help="Miner API base URL"),
310
+ ):
311
+ """
312
+ Rebalance all chutes - this just deletes all current instances and let's gepetto re-scale for max $$$
313
+ This does not purge jobs!
314
+ """
315
+
316
+ async def _purge_deployments():
317
+ nonlocal hotkey, miner_api
318
+ async with aiohttp.ClientSession(raise_for_status=True) as session:
319
+ headers, payload_string = sign_request(hotkey, purpose="management")
320
+ async with session.delete(
321
+ f"{miner_api.rstrip('/')}/deployments/purge",
322
+ headers=headers,
323
+ ) as resp:
324
+ print(json.dumps(await resp.json(), indent=2))
325
+
326
+ asyncio.run(_purge_deployments())
327
+
328
+
329
+ def purge_deployment(
330
+ deployment_id: str = typer.Option(
331
+ None, "--deployment-id", "-d", help="The ID of the deployment to purge."
332
+ ),
333
+ node_id: str = typer.Option(
334
+ None, "--node-id", "-n", help="The ID of the node to purge the deployment from."
335
+ ),
336
+ hotkey: str = typer.Option(..., help="Path to the hotkey file for your miner"),
337
+ miner_api: str = typer.Option("http://127.0.0.1:32000", help="Miner API base URL"),
338
+ ):
339
+ """
340
+ Purge the target deployment
341
+ """
342
+
343
+ if (deployment_id is None and node_id is None) or (
344
+ deployment_id is not None and node_id is not None
345
+ ):
346
+ typer.echo("Error: Either deployment_id or node_id must be provided, but not both.")
347
+ raise typer.Exit(1)
348
+
349
+ target_id = deployment_id or node_id
350
+ endpoint = f"deployments/{target_id}" if deployment_id else f"servers/{target_id}/deployments"
351
+
352
+ async def _purge_deployment():
353
+ nonlocal target_id, hotkey, miner_api, endpoint
354
+
355
+ # Make sure there aren't jobs and or miner wants to delete anyways.
356
+ if node_id and not await delete_preflight(node_id, hotkey, miner_api):
357
+ return
358
+
359
+ async with aiohttp.ClientSession(raise_for_status=True) as session:
360
+ headers, payload_string = sign_request(hotkey, purpose="management")
361
+ async with session.delete(
362
+ f"{miner_api.rstrip('/')}/{endpoint}",
363
+ headers=headers,
364
+ ) as resp:
365
+ print(json.dumps(await resp.json(), indent=2))
366
+
367
+ asyncio.run(_purge_deployment())
368
+
369
+
370
+ def scorch_remote(
371
+ hotkey: str = typer.Option(..., help="Path to the hotkey file for your miner"),
372
+ validator_api: str = typer.Option("https://api.chutes.ai", help="Validator API base URL"),
373
+ ):
374
+ """
375
+ Purge all inventory from the validator API.
376
+ """
377
+
378
+ async def _scorch_remote():
379
+ nonlocal hotkey, validator_api
380
+ async with aiohttp.ClientSession(raise_for_status=True) as session:
381
+ headers, _ = sign_request(hotkey, purpose="miner", remote=True)
382
+ async with session.get(
383
+ f"{validator_api.rstrip('/')}/miner/nodes/",
384
+ headers=headers,
385
+ ) as resp:
386
+ async for line in resp.content:
387
+ if not line or not line.startswith(b"data:"):
388
+ continue
389
+ gpu = json.loads(line.decode()[6:])
390
+ print(f"Deleting {gpu['name']} with uuid {gpu['uuid']}")
391
+ headers, _ = sign_request(hotkey, purpose="nodes", remote=True)
392
+ async with session.delete(
393
+ f"{validator_api.rstrip('/')}/nodes/{gpu['uuid']}",
394
+ headers=headers,
395
+ ) as resp:
396
+ print(f" successfully deleted {gpu['name']} with uuid {gpu['uuid']}")
397
+
398
+ asyncio.run(_scorch_remote())
399
+
400
+
401
+ def delete_remote(
402
+ gpu_id: str = typer.Option(help="GPU UUID to delete, aka node_node on validator side"),
403
+ hotkey: str = typer.Option(..., help="Path to the hotkey file for your miner"),
404
+ validator_api: str = typer.Option("https://api.chutes.ai", help="Validator API base URL"),
405
+ ):
406
+ """
407
+ Delete a single GPU from validator.
408
+ """
409
+
410
+ async def _delete_remote():
411
+ nonlocal hotkey, validator_api, gpu_id
412
+ async with aiohttp.ClientSession(raise_for_status=True) as session:
413
+ headers, _ = sign_request(hotkey, purpose="nodes", remote=True)
414
+ async with session.delete(
415
+ f"{validator_api.rstrip('/')}/nodes/{gpu_id}",
416
+ headers=headers,
417
+ ) as _:
418
+ print(f" successfully deleted {gpu_id}")
419
+
420
+ asyncio.run(_delete_remote())
421
+
422
+
423
+ async def _lock_or_unlock_server(lock: bool, name: str, hotkey: str, miner_api: str):
424
+ async with aiohttp.ClientSession(raise_for_status=True) as session:
425
+ headers, _ = sign_request(hotkey, purpose="management")
426
+ path = "/lock" if lock else "/unlock"
427
+ async with session.get(
428
+ f"{miner_api.rstrip('/')}/servers/{name}{path}",
429
+ headers=headers,
430
+ ) as resp:
431
+ server = await resp.json()
432
+ print(f"Server {server['name']} lock status is now: {server['locked']}")
433
+
434
+
435
+ def lock_server(
436
+ name: str = typer.Option(..., help="Name of the server/node"),
437
+ hotkey: str = typer.Option(..., help="Path to the hotkey file for your miner"),
438
+ miner_api: str = typer.Option("http://127.0.0.1:32000", help="Miner API base URL"),
439
+ ):
440
+ """
441
+ Lock a server's deployments.
442
+ """
443
+
444
+ async def _lock_server():
445
+ nonlocal name, hotkey, miner_api
446
+ await _lock_or_unlock_server(lock=True, name=name, hotkey=hotkey, miner_api=miner_api)
447
+
448
+ asyncio.run(_lock_server())
449
+
450
+
451
+ def unlock_server(
452
+ name: str = typer.Option(..., help="Name of the server/node"),
453
+ hotkey: str = typer.Option(..., help="Path to the hotkey file for your miner"),
454
+ miner_api: str = typer.Option("http://127.0.0.1:32000", help="Miner API base URL"),
455
+ ):
456
+ """
457
+ Unlock a server's deployments.
458
+ """
459
+
460
+ async def _unlock_server():
461
+ nonlocal name, hotkey, miner_api
462
+ await _lock_or_unlock_server(lock=False, name=name, hotkey=hotkey, miner_api=miner_api)
463
+
464
+ asyncio.run(_unlock_server())
465
+
466
+
467
+ app.command(name="add-node", help="Add a new kubernetes node to your cluster")(add_node)
468
+ app.command(name="delete-node", help="Delete a kubernetes node from your cluster")(delete_node)
469
+ app.command(
470
+ name="purge-deployments",
471
+ help="Purge all deployments, allowing autoscale from scratch",
472
+ )(purge_deployments)
473
+ app.command(name="purge-deployment", help="Purge the target deployment")(purge_deployment)
474
+ app.command(name="local-inventory", help="Show local inventory")(local_inventory)
475
+ app.command(name="remote-inventory", help="Show remote inventory")(remote_inventory)
476
+ app.command(name="scorch-remote", help="Purge all GPUs/instances/etc. from validator")(
477
+ scorch_remote
478
+ )
479
+ app.command(name="delete-remote", help="Remove a single GPU from validator inventory")(
480
+ delete_remote
481
+ )
482
+ app.command(name="lock", help="Lock a server's deployments")(lock_server)
483
+ app.command(name="unlock", help="Unlock a server's deployments")(unlock_server)
484
+
485
+
486
+ if __name__ == "__main__":
487
+ app()
@@ -0,0 +1,5 @@
1
+ VALIDATOR_HEADER = "X-Chutes-Validator"
2
+ MINER_HEADER = "X-Chutes-Miner"
3
+ NONCE_HEADER = "X-Chutes-Nonce"
4
+ SIGNATURE_HEADER = "X-Chutes-Signature"
5
+ HOTKEY_HEADER = "X-Chutes-Hotkey"
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env python
2
+
3
+ import json
4
+ import hashlib
5
+ import time
6
+ from substrateinterface import Keypair
7
+ from typing import Dict, Any
8
+ from chutes_miner_cli.constants import (
9
+ VALIDATOR_HEADER,
10
+ HOTKEY_HEADER,
11
+ MINER_HEADER,
12
+ NONCE_HEADER,
13
+ SIGNATURE_HEADER,
14
+ )
15
+
16
+
17
+ def get_signing_message(
18
+ hotkey: str,
19
+ nonce: str,
20
+ payload_str: str | bytes | None,
21
+ purpose: str | None = None,
22
+ payload_hash: str | None = None,
23
+ ) -> str:
24
+ """
25
+ Get the signing message for a given hotkey, nonce, and payload.
26
+ """
27
+ if payload_str:
28
+ if isinstance(payload_str, str):
29
+ payload_str = payload_str.encode()
30
+ return f"{hotkey}:{nonce}:{hashlib.sha256(payload_str).hexdigest()}"
31
+ elif purpose:
32
+ return f"{hotkey}:{nonce}:{purpose}"
33
+ elif payload_hash:
34
+ return f"{hotkey}:{nonce}:{payload_hash}"
35
+ else:
36
+ raise ValueError("Either payload_str or purpose must be provided")
37
+
38
+
39
+ def sign_request(
40
+ hotkey: str,
41
+ payload: Dict[str, Any] | str | None = None,
42
+ purpose: str = None,
43
+ remote: bool = False,
44
+ ):
45
+ """
46
+ Generate a signed request (for miner requests to validators).
47
+ """
48
+ hotkey_data = json.loads(open(hotkey).read())
49
+ nonce = str(int(time.time()))
50
+ headers = {
51
+ MINER_HEADER: hotkey_data["ss58Address"],
52
+ NONCE_HEADER: nonce,
53
+ }
54
+ if remote:
55
+ headers[HOTKEY_HEADER] = headers.pop(MINER_HEADER)
56
+ signature_string = None
57
+ payload_string = None
58
+ if payload is not None:
59
+ if isinstance(payload, (list, dict)):
60
+ headers["Content-Type"] = "application/json"
61
+ payload_string = json.dumps(payload)
62
+ else:
63
+ payload_string = payload
64
+ signature_string = get_signing_message(
65
+ hotkey_data["ss58Address"],
66
+ nonce,
67
+ payload_str=payload_string,
68
+ purpose=None,
69
+ )
70
+ else:
71
+ signature_string = get_signing_message(
72
+ hotkey_data["ss58Address"], nonce, payload_str=None, purpose=purpose
73
+ )
74
+ if not remote:
75
+ signature_string = hotkey_data["ss58Address"] + ":" + signature_string
76
+ headers[VALIDATOR_HEADER] = headers[MINER_HEADER]
77
+ keypair = Keypair.create_from_seed(hotkey_data["secretSeed"])
78
+ headers[SIGNATURE_HEADER] = keypair.sign(signature_string.encode()).hex()
79
+ return headers, payload_string
@@ -0,0 +1,39 @@
1
+ [tool.poetry]
2
+ name = "chutes-miner-cli"
3
+ version = "0.2.0"
4
+ description = "Chutes miner CLI"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [ "Jon Durbin <jon@jondurbin.com>" ]
8
+ classifiers = [
9
+ "Development Status :: 3 - Alpha",
10
+ "Intended Audience :: Developers",
11
+ "Operating System :: POSIX :: Linux",
12
+ "Programming Language :: Python :: 3.10",
13
+ ]
14
+
15
+ [tool.poetry.dependencies]
16
+ python = "^3.12"
17
+ loguru = "^0.7.3"
18
+ pydantic = ">2.9,<3"
19
+ setuptools = ">80.0"
20
+ substrate-interface = "^1.7.11"
21
+ py-bip39-bindings = "^0.1.11"
22
+ rich = ">13.0.0"
23
+ typer = "^0.14.0"
24
+ click = ">8.1.0,<8.2.0"
25
+ aiohttp = {extras = ["speedups"], version = ">3.10,<4"}
26
+
27
+ [tool.poetry.group.dev.dependencies]
28
+ ruff = "^0.12.2"
29
+ wheel = "^0.45.1"
30
+
31
+ [tool.poetry.urls]
32
+ Homepage = "https://github.com/rayonlabs/chutes-miner/tree/main/cli"
33
+
34
+ [tool.poetry.scripts]
35
+ chutes-miner = "chutes_miner_cli.cli:app"
36
+
37
+ [build-system]
38
+ requires = ["poetry-core"]
39
+ build-backend = "poetry.core.masonry.api"