diff --git a/bittensor/cli.py b/bittensor/cli.py index b1b544b906..e0e76fb8ce 100644 --- a/bittensor/cli.py +++ b/bittensor/cli.py @@ -35,6 +35,9 @@ NewHotkeyCommand, NominateCommand, OverviewCommand, + ProfileCommand, + ProfileListCommand, + ProfileShowCommand, PowRegisterCommand, ProposalsCommand, RegenColdkeyCommand, @@ -91,6 +94,7 @@ "sudos": "sudo", "i": "info", "info": "info", + "profile": "profile", } COMMANDS = { "subnets": { @@ -189,6 +193,18 @@ "autocomplete": AutocompleteCommand, }, }, + "profile": { + "name": "profile", + "aliases": ["p"], + "help": "Commands for creating and viewing profiles.", + "commands": { + "create": ProfileCommand, + "list": ProfileListCommand, + "show": ProfileShowCommand, + # "set": ProfileSet, + # "delete": ProfileDelete, + }, + }, } @@ -226,7 +242,6 @@ def __init__( """ # Turns on console for cli. bittensor.turn_console_on() - # If no config is provided, create a new one from args. if config is None: config = cli.create_config(args) @@ -311,7 +326,6 @@ def create_config(args: List[str]) -> "bittensor.config": if len(args) == 0: parser.print_help() sys.exit() - return bittensor.config(parser, args=args) @staticmethod @@ -356,6 +370,7 @@ def run(self): # Check if command exists, if so, run the corresponding method. # If command doesn't exist, inform user and exit the program. command = self.config.command + if command in COMMANDS: command_data = COMMANDS[command] diff --git a/bittensor/commands/__init__.py b/bittensor/commands/__init__.py index 7e52f0a1ed..e5cf6cb5ab 100644 --- a/bittensor/commands/__init__.py +++ b/bittensor/commands/__init__.py @@ -38,6 +38,7 @@ }, "priority": {"max_workers": 5, "maxsize": 10}, "prometheus": {"port": 7091, "level": "INFO"}, + "profile": {"name": "default", "path": "~/.bittensor/profiles/"}, "wallet": { "name": "default", "hotkey": "default", @@ -94,6 +95,12 @@ from .metagraph import MetagraphCommand from .list import ListCommand from .misc import UpdateCommand, AutocompleteCommand +from .profile import ( + ProfileCommand, + ProfileListCommand, + ProfileShowCommand +) + from .senate import ( SenateCommand, ProposalsCommand, diff --git a/bittensor/commands/profile.py b/bittensor/commands/profile.py new file mode 100644 index 0000000000..ff5db820e1 --- /dev/null +++ b/bittensor/commands/profile.py @@ -0,0 +1,320 @@ +# Standard Library +import argparse +import os +import yaml + +# 3rd Party +from rich.prompt import Prompt +from rich.table import Table + +# Bittensor +import bittensor + +# Local +from . import defaults + +SAVED_ATTRIBUTES = { + "profile": ["name"], + "subtensor": ["network", "chain_endpoint"], + "wallet": ["name", "hotkey", "path"], + "netuid": True, +} + +class ProfileCommand: + """ + Executes the ``create`` command. + + This class provides functionality to create a profile by prompting the user to enter various attributes. + The entered attributes are then written to a profile file. + + """ + + @staticmethod + def run(cli): + try: + subtensor: "bittensor.subtensor" = bittensor.subtensor( + config=cli.config, log_verbose=False + ) + ProfileCommand._run(cli, subtensor) + finally: + if "subtensor" in locals(): + subtensor.close() + bittensor.logging.debug("closing subtensor connection") + + + @staticmethod + def _run(cli: "bittensor.cli", subtensor: "bittensor.subtensor"): + config = cli.config.copy() + for (key, attributes) in SAVED_ATTRIBUTES.items(): + #if attributes isn't a list then just raw values + if not isinstance(attributes, list): + if config.no_prompt: + continue + setattr( + config, + key, + Prompt.ask( + f"Enter {key}", + default=getattr(config, key, None) + ), + ) + continue + sub_attr = getattr(config, key, None) + for attribute in attributes: + if config.no_prompt: + continue + attr_key = f"{key}.{attribute}" + setattr( + config[key], + attribute, + Prompt.ask( + f"Enter {attr_key}", + choices=None if attribute != "network" else bittensor.__networks__, + default=getattr(sub_attr, attribute, None), + ), + ) + #Set the chain_endpoint to match the network unless user defined. + if attribute == "network" and config.subtensor.chain_endpoint is bittensor.__finney_entrypoint__: + (_, config.subtensor.chain_endpoint) = subtensor.determine_chain_endpoint_and_network(config.subtensor.network) + ProfileCommand._write_profile(config) + + @staticmethod + def _write_profile(config: "bittensor.config"): + path = os.path.expanduser(config.profile.path) + try: + os.makedirs(path, exist_ok=True) + except Exception as e: + bittensor.__console__.print( + f":cross_mark: [red]Failed to write profile[/red]:[bold white] {e}" + ) + return + + if os.path.exists(f"{path}{config.profile.name}") and not config.no_prompt: + overwrite = None + while overwrite not in ["y", "n"]: + overwrite = Prompt.ask(f"Profile {config.profile.name} already exists. Overwrite?") + if overwrite: + overwrite = overwrite.lower() + if overwrite == "n": + bittensor.__console__.print( + ":cross_mark: [red]Failed to write profile[/red]:[bold white] User denied." + ) + return + + profile = bittensor.config() + #Create a profile clone with only the saved attributes + for key in SAVED_ATTRIBUTES.keys(): + if isinstance(SAVED_ATTRIBUTES[key], list): + getattr(profile, key, None) + profile[key] = bittensor.config() + for attribute in SAVED_ATTRIBUTES[key]: + setattr(profile[key], attribute, getattr(config[key], attribute)) + else: + setattr(profile, key, getattr(config, key)) + + try: + with open(f"{path}{config.profile.name}", "w+") as f: + f.write(str(profile)) + except Exception as e: + bittensor.__console__.print( + f":cross_mark: [red]Failed to write profile[/red]:[bold white] {e}" + ) + return + + bittensor.__console__.print( + f":white_check_mark: [bold green]Profile {config.profile.name} written to {path}[/bold green]" + ) + + @staticmethod + def check_config(config: "bittensor.config"): + return config is not None + + @staticmethod + def add_args(parser: argparse.ArgumentParser): + list_parser = parser.add_parser("create", help="""Create profile""") + list_parser.set_defaults(func=ProfileCommand.run) + list_parser.add_argument( + "--profile.name", + type=str, + default=defaults.profile.name, + help="The name of the profile", + ) + list_parser.add_argument( + "--profile.path", + type=str, + default=defaults.profile.path, + help="The path to the profile directory", + ) + bittensor.subtensor.add_args(list_parser) + bittensor.wallet.add_args(list_parser) + +class ProfileListCommand: + """ + Executes the ``list`` command. + + This class provides functionality to list all profiles in the profile directory. + + """ + + @staticmethod + def run(cli): + ProfileListCommand._run(cli) + + @staticmethod + def _run(cli: "bittensor.cli"): + path = os.path.expanduser(cli.config.profile.path) + try: + os.makedirs(path, exist_ok=True) + except Exception as e: + bittensor.__console__.print( + f":cross_mark: [red]Failed to list profiles[/red]:[bold white] {e}" + ) + return + try: + profiles = os.listdir(path) + except Exception as e: + bittensor.__console__.print( + f":cross_mark: [red]Failed to list profiles[/red]:[bold white] {e}" + ) + return + if not profiles: + bittensor.__console__.print( + f":cross_mark: [red]No profiles found in {path}[/red]" + ) + return + profile_content = [] + for profile in profiles: + #load profile + try: + with open(f"{path}{profile}", "r") as f: + config_content = f.read() + except Exception as e: + continue #Not a profile + try: + config = yaml.safe_load(config_content) + except Exception as e: + continue #Not a profile + try: + profile_content.append( ( + " ", + str(config['profile']['name']), + str(config['subtensor']['network']), + str(config['netuid']), + ) + ) + except Exception as e: + continue #not a proper profile + table = Table( + show_footer=True, + width=cli.config.get("width", None), + pad_edge=True, + box=None, + show_edge=True + ) + table.title = "[white]Profiles" + table.add_column("A", style="red", justify="center", min_width=1) + table.add_column("Name", style="white", justify="center", min_width=10) + table.add_column("Network", style="white", justify="center", min_width=10) + table.add_column("Netuid", style="white", justify="center", min_width=10) + for profile in profile_content: + table.add_row(*profile) + bittensor.__console__.print(table) + + @staticmethod + def check_config(config: "bittensor.config"): + return config is not None + + @staticmethod + def add_args(parser: argparse.ArgumentParser): + list_parser = parser.add_parser("list", help="""List profiles""") + list_parser.set_defaults(func=ProfileListCommand.run) + list_parser.add_argument( + "--profile.path", + type=str, + default=defaults.profile.path, + help="The path to the profile directory", + ) + +class ProfileShowCommand: + """ + Executes the ``show`` command. + + This class provides functionality to show the content of a profile. + + """ + + @staticmethod + def run(cli): + ProfileShowCommand._run(cli) + + @staticmethod + def _run(cli: "bittensor.cli"): + config = cli.config.copy() + path = os.path.expanduser(config.profile.path) + try: + os.makedirs(path, exist_ok=True) + except Exception as e: + bittensor.__console__.print( + f":cross_mark: [red]Failed to show profile[/red]:[bold white] {e}" + ) + return + try: + profiles = os.listdir(path) + except Exception as e: + bittensor.__console__.print( + f":cross_mark: [red]Failed to show profile[/red]:[bold white] {e}" + ) + return + if not profiles: + bittensor.__console__.print( + f":cross_mark: [red]No profiles found in {path}[/red]" + ) + return + with open(f"{path}{config.profile.name}", "r") as f: + config_content = f.read() + contents = yaml.safe_load(config_content) + table = Table( + show_footer=True, + width=cli.config.get("width", None), + pad_edge=True, + box=None, + show_edge=True, + ) + table.title = f"[white]Profile [bold white]{config.profile.name}" + table.add_column("[overline white]PARAMETERS", style="bold white", justify="left", min_width=10) + table.add_column("[overline white]VALUES", style="green", justify="left", min_width=10) + for key in contents.keys(): + if isinstance(contents[key], dict): + for subkey in contents[key].keys(): + table.add_row( + f" [bold white]{key}.{subkey}", + f"[green]{contents[key][subkey]}" + ) + continue + table.add_row( + f" [bold white]{key}", + f"[green]{contents[key]}" + ) + bittensor.__console__.print(table) + + @staticmethod + def check_config(config: "bittensor.config"): + return config is not None + + def add_args(parser: argparse.ArgumentParser): + list_parser = parser.add_parser("show", help="""Show profile""") + list_parser.set_defaults(func=ProfileShowCommand.run) + list_parser.add_argument( + "--profile.name", + type=str, + help="The name of the profile", + ) + list_parser.add_argument( + "--profile.path", + type=str, + default=defaults.profile.path, + help="The path to the profile directory", + ) + + + \ No newline at end of file diff --git a/contrib/RELEASE_GUIDELINES.md b/contrib/RELEASE_GUIDELINES.md index 906a5f463f..e2f7124751 100644 --- a/contrib/RELEASE_GUIDELINES.md +++ b/contrib/RELEASE_GUIDELINES.md @@ -1,8 +1,8 @@ # Release Guidelines The release manager in charge can release a Bittensor version using two scripts: - - [./scripts/release/versioning.sh](./scripts/release/versioning.sh) - - [./scripts/release/release.sh](./scripts/release/release.sh) + - [../scripts/release/versioning.sh](../scripts/release/versioning.sh) + - [../scripts/release/release.sh](../scripts/release/release.sh) The release manager will need the right permissions for: - github.com diff --git a/tests/unit_tests/test_profile.py b/tests/unit_tests/test_profile.py new file mode 100644 index 0000000000..0b79f05f02 --- /dev/null +++ b/tests/unit_tests/test_profile.py @@ -0,0 +1,129 @@ +# Standard Library +from copy import deepcopy +from unittest.mock import patch, MagicMock, mock_open + +# 3rd Party +import pytest + +# Bittensor +from bittensor.commands.profile import ProfileCommand +from bittensor import config as bittensor_config + + +class MockDefaults: + profile = { + "name": "default", + "path": "~/.bittensor/profiles/", + } + + +@pytest.mark.parametrize( + "test_input,expected", + [ + ( + { + "name": "Alice", + "coldkey": "ckey", + "hotkey": "hkey", + "subtensor_network": "mainnet", + "active_netuid": "0", + }, + "Alice", + ), + ( + { + "name": "Bob", + "coldkey": "bckey", + "hotkey": "bhkey", + "subtensor_network": "test", + "active_netuid": "1", + }, + "Bob", + ), + ], + ids=["happy-path-Alice", "happy-path-Bob"], +) +@patch("bittensor.commands.profile.Prompt") +@patch("bittensor.commands.profile.ProfileCommand._write_profile") +@patch("bittensor.commands.profile.defaults", MockDefaults) +def test_run(mock_write_profile, mock_prompt, test_input, expected): + # Arrange + mock_cli = MagicMock() + mock_cli.config = deepcopy(bittensor_config()) + mock_cli.config.path = "/fake/path/" + for attr, value in test_input.items(): + setattr(mock_cli.config, attr, value) + mock_prompt.ask.side_effect = lambda x, default="": test_input.get( + x.split()[-1], "" + ) + + # Act + ProfileCommand.run(mock_cli) + + # Assert + mock_write_profile.assert_called_once() + assert getattr(mock_cli.config, "name") == expected + + +@pytest.mark.parametrize( + "test_input, expected", + [ + ( + bittensor_config(), + True, + ), + # Edge cases + ( + None, + False, + ), + ], +) +def test_check_config(test_input, expected): + # Arrange - In this case, all inputs are provided via test parameters, so we omit the Arrange section. + + # Act + result = ProfileCommand.check_config(test_input) + + # Assert + assert result == expected + + +def test_write_profile(): + # Create a mock config object with the necessary attributes + mock_config = type( + "Config", + (object,), + { + "path": "~/.bittensor/profiles/", + "name": "test_profile", + "coldkey": "xyz123", + "hotkey": "abc789", + "subtensor_network": "finney", + "active_netuid": "123", + }, + )() + + # Setup the mock for os.makedirs and open + with patch("os.makedirs") as mock_makedirs, patch( + "os.path.expanduser", return_value="/.bittensor/profiles/" + ), patch("builtins.open", mock_open()) as mock_file: + # Call the function with the mock config + ProfileCommand._write_profile(mock_config) + + # Assert that makedirs was called correctly + mock_makedirs.assert_called_once_with("/.bittensor/profiles/", exist_ok=True) + + # Assert that open was called correctly; construct the expected file path and contents + expected_path = "/.bittensor/profiles/test_profile" + expected_contents = ( + f"name = {mock_config.name}\n" + f"coldkey = {mock_config.coldkey}\n" + f"hotkey = {mock_config.hotkey}\n" + f"subtensor_network = {mock_config.subtensor_network}\n" + f"active_netuid = {mock_config.active_netuid}\n" + ) + + # Assert the open function was called correctly and the right contents were written + mock_file.assert_called_once_with(expected_path, "w+") + mock_file().write.assert_called_once_with(expected_contents)