diff --git a/redbot/setup.py b/redbot/setup.py index 57baccb70..f3363d4d1 100644 --- a/redbot/setup.py +++ b/redbot/setup.py @@ -55,10 +55,13 @@ def save_config(name, data, remove=False): json.dump(_config, fs, indent=4) -def get_data_dir(instance_name: str): +def get_data_dir(*, instance_name: str, data_path: Optional[Path], interactive: bool) -> str: + if data_path is not None: + return str(data_path.resolve()) data_path = Path(appdir.user_data_dir) / "data" / instance_name + if not interactive: + return str(data_path.resolve()) - print() print( "We've attempted to figure out a sane default data location which is printed below." " If you don't want to change this default please press [ENTER]," @@ -99,15 +102,23 @@ def get_data_dir(instance_name: str): return str(data_path.resolve()) -def get_storage_type(): - storage_dict = {1: "JSON", 2: "PostgreSQL"} +def get_storage_type(backend: Optional[str], *, interactive: bool): + if backend: + return get_target_backend(backend) + if not interactive: + return BackendType.JSON + storage_dict = {1: BackendType.JSON, 2: BackendType.POSTGRES} storage = None while storage is None: print() - print("Please choose your storage backend (if you're unsure, just choose 1).") + print("Please choose your storage backend.") print("1. JSON (file storage, requires no database).") print("2. PostgreSQL (Requires a database server)") + print("If you're unsure, press [ENTER] to use the recommended default - JSON.") + storage = input("> ") + if not storage: + return BackendType.JSON try: storage = int(storage) except ValueError: @@ -115,11 +126,20 @@ def get_storage_type(): else: if storage not in storage_dict: storage = None - return storage + return storage_dict[storage] -def get_name() -> str: - name = "" +def get_name(name: str) -> str: + INSTANCE_NAME_RE = re.compile(r"[A-Za-z0-9_\.\-]*") + if name: + if INSTANCE_NAME_RE.fullmatch(name) is None: + print( + "ERROR: Instance names can only include characters A-z, numbers, " + "underscores (_) and periods (.)." + ) + sys.exit(1) + return name + while len(name) == 0: print( "Please enter a name for your instance," @@ -128,7 +148,7 @@ def get_name() -> str: " A-z, numbers, underscores (_) and periods (.)." ) name = input("> ") - if re.fullmatch(r"[A-Za-z0-9_\.\-]*", name) is None: + if INSTANCE_NAME_RE.fullmatch(name) is None: print( "ERROR: Instance names can only include characters A-z, numbers, " "underscores (_) and periods (.)." @@ -144,54 +164,84 @@ def get_name() -> str: return name -def basic_setup(): +def basic_setup( + *, + name: str, + data_path: Optional[Path], + backend: Optional[str], + interactive: bool, + overwrite_existing_instance: bool, +): """ Creates the data storage folder. :return: """ + if not interactive and not name: + print( + "Providing instance name through --instance-name is required" + " when using non-interactive mode." + ) + sys.exit(1) - print( - "Hello! Before we begin, we need to gather some initial information for the new instance." + if interactive: + print( + "Hello! Before we begin, we need to gather some initial information" + " for the new instance." + ) + name = get_name(name) + + default_data_dir = get_data_dir( + instance_name=name, data_path=data_path, interactive=interactive ) - name = get_name() - - default_data_dir = get_data_dir(name) default_dirs = deepcopy(data_manager.basic_config_default) default_dirs["DATA_PATH"] = default_data_dir - storage = get_storage_type() + storage_type = get_storage_type(backend, interactive=interactive) - storage_dict = {1: BackendType.JSON, 2: BackendType.POSTGRES} - storage_type: BackendType = storage_dict.get(storage, BackendType.JSON) default_dirs["STORAGE_TYPE"] = storage_type.value driver_cls = drivers.get_driver_class(storage_type) default_dirs["STORAGE_DETAILS"] = driver_cls.get_config_details() if name in instance_data: - print( - "WARNING: An instance already exists with this name. " - "Continuing will overwrite the existing instance config." - ) - if not click.confirm("Are you absolutely certain you want to continue?", default=False): - print("Not continuing") - sys.exit(0) + if overwrite_existing_instance: + pass + elif interactive: + print( + "WARNING: An instance already exists with this name. " + "Continuing will overwrite the existing instance config." + ) + if not click.confirm( + "Are you absolutely certain you want to continue?", default=False + ): + print("Not continuing") + sys.exit(0) + else: + print( + "An instance with this name already exists.\n" + "If you want to remove the existing instance and replace it with this one," + " run this command with --overwrite-existing-instance flag." + ) + sys.exit(1) save_config(name, default_dirs) - print() - print( - "Your basic configuration has been saved. Please run `redbot ` to" - " continue your setup process and to run the bot.\n\n" - "First time? Read the quickstart guide:\n" - "https://docs.discord.red/en/stable/getting_started.html" - ) + if interactive: + print() + print( + f"Your basic configuration has been saved. Please run `redbot {name}` to" + " continue your setup process and to run the bot.\n\n" + "First time? Read the quickstart guide:\n" + "https://docs.discord.red/en/stable/getting_started.html" + ) + else: + print("Your basic configuration has been saved.") -def get_current_backend(instance) -> BackendType: +def get_current_backend(instance: str) -> BackendType: return BackendType(instance_data[instance]["STORAGE_TYPE"]) -def get_target_backend(backend) -> BackendType: +def get_target_backend(backend: str) -> BackendType: if backend == "json": return BackendType.JSON elif backend == "postgres": @@ -234,13 +284,13 @@ async def create_backup(instance: str, destination_folder: Path = Path.home()) - async def remove_instance( - instance, + instance: str, interactive: bool = False, delete_data: Optional[bool] = None, _create_backup: Optional[bool] = None, drop_db: Optional[bool] = None, remove_datapath: Optional[bool] = None, -): +) -> None: data_manager.load_basic_configuration(instance) backend = get_current_backend(instance) @@ -277,10 +327,10 @@ async def remove_instance( safe_delete(data_path) save_config(instance, {}, remove=True) - print("The instance {} has been removed\n".format(instance)) + print("The instance {} has been removed.".format(instance)) -async def remove_instance_interaction(): +async def remove_instance_interaction() -> None: if not instance_list: print("No instances have been set up!") return @@ -303,8 +353,54 @@ async def remove_instance_interaction(): @click.group(invoke_without_command=True) @click.option("--debug", type=bool) +@click.option( + "--no-prompt", + "interactive", + type=bool, + is_flag=True, + default=True, + help=( + "Don't ask for user input during the process (non-interactive mode)." + " This makes `--instance-name` required." + ), +) +@click.option( + "--instance-name", + type=str, + default="", + help="Name of the new instance. Required if --no-prompt is passed.", +) +@click.option( + "--data-path", + type=click.Path(exists=False, dir_okay=True, file_okay=False, writable=True, path_type=Path), + default=None, + help=( + "Data path of the new instance. If this option and --no-prompt are omitted," + " you will be asked for this." + ), +) +@click.option( + "--backend", + type=click.Choice(["json", "postgres"]), + default=None, + help=( + "Choose a backend type for the new instance." + " If this option is omitted, you will be asked for this." + " Defaults to JSON in non-interactive mode.\n" + "Note: Choosing PostgreSQL will prevent the setup from being completely non-interactive." + ), +) +@click.option("--overwrite-existing-instance", type=bool, is_flag=True) @click.pass_context -def cli(ctx, debug): +def cli( + ctx: click.Context, + debug: bool, + interactive: bool, + instance_name: str, + data_path: Optional[Path], + backend: Optional[str], + overwrite_existing_instance: bool, +) -> None: """Create a new instance.""" level = logging.DEBUG if debug else logging.INFO base_logger = logging.getLogger("red") @@ -317,7 +413,13 @@ def cli(ctx, debug): base_logger.addHandler(stdout_handler) if ctx.invoked_subcommand is None: - basic_setup() + basic_setup( + name=instance_name, + data_path=data_path, + backend=backend, + overwrite_existing_instance=overwrite_existing_instance, + interactive=interactive, + ) @cli.command() @@ -376,7 +478,7 @@ def delete( _create_backup: Optional[bool], drop_db: Optional[bool], remove_datapath: Optional[bool], -): +) -> None: """Removes an instance.""" asyncio.run( remove_instance( @@ -388,7 +490,7 @@ def delete( @cli.command() @click.argument("instance", type=click.Choice(instance_list), metavar="") @click.argument("backend", type=click.Choice(["json", "postgres"])) -def convert(instance, backend): +def convert(instance: str, backend: str) -> None: """Convert data backend of an instance.""" current_backend = get_current_backend(instance) target = get_target_backend(backend) @@ -418,13 +520,13 @@ def convert(instance, backend): @click.argument( "destination_folder", type=click.Path( - exists=False, dir_okay=True, file_okay=False, resolve_path=True, writable=True + dir_okay=True, file_okay=False, resolve_path=True, writable=True, path_type=Path ), default=Path.home(), ) -def backup(instance: str, destination_folder: Union[str, Path]) -> None: +def backup(instance: str, destination_folder: Path) -> None: """Backup instance's data.""" - asyncio.run(create_backup(instance, Path(destination_folder))) + asyncio.run(create_backup(instance, destination_folder)) def run_cli():