Add non-interactive mode to redbot-setup (#5448)

* Simplify `redbot-setup backup` thanks to Click 8.0

* Add some of the missing type hints

* Fix unnecessary new lines in `redbot-setup` and `redbot-setup delete`

* Add default value for storage backend

* Add non-interactive mode to `redbot-setup`
This commit is contained in:
jack1142 2021-12-31 02:08:18 +01:00 committed by GitHub
parent ff7c146b62
commit 8cc004f70f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

@ -55,10 +55,13 @@ def save_config(name, data, remove=False):
json.dump(_config, fs, indent=4) 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 data_path = Path(appdir.user_data_dir) / "data" / instance_name
if not interactive:
return str(data_path.resolve())
print()
print( print(
"We've attempted to figure out a sane default data location which is printed below." "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]," " 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()) return str(data_path.resolve())
def get_storage_type(): def get_storage_type(backend: Optional[str], *, interactive: bool):
storage_dict = {1: "JSON", 2: "PostgreSQL"} if backend:
return get_target_backend(backend)
if not interactive:
return BackendType.JSON
storage_dict = {1: BackendType.JSON, 2: BackendType.POSTGRES}
storage = None storage = None
while storage is None: while storage is None:
print() 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("1. JSON (file storage, requires no database).")
print("2. PostgreSQL (Requires a database server)") print("2. PostgreSQL (Requires a database server)")
print("If you're unsure, press [ENTER] to use the recommended default - JSON.")
storage = input("> ") storage = input("> ")
if not storage:
return BackendType.JSON
try: try:
storage = int(storage) storage = int(storage)
except ValueError: except ValueError:
@ -115,11 +126,20 @@ def get_storage_type():
else: else:
if storage not in storage_dict: if storage not in storage_dict:
storage = None storage = None
return storage return storage_dict[storage]
def get_name() -> str: def get_name(name: str) -> str:
name = "" 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: while len(name) == 0:
print( print(
"Please enter a name for your instance," "Please enter a name for your instance,"
@ -128,7 +148,7 @@ def get_name() -> str:
" A-z, numbers, underscores (_) and periods (.)." " A-z, numbers, underscores (_) and periods (.)."
) )
name = input("> ") name = input("> ")
if re.fullmatch(r"[A-Za-z0-9_\.\-]*", name) is None: if INSTANCE_NAME_RE.fullmatch(name) is None:
print( print(
"ERROR: Instance names can only include characters A-z, numbers, " "ERROR: Instance names can only include characters A-z, numbers, "
"underscores (_) and periods (.)." "underscores (_) and periods (.)."
@ -144,54 +164,84 @@ def get_name() -> str:
return name 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. Creates the data storage folder.
:return: :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( if interactive:
"Hello! Before we begin, we need to gather some initial information for the new instance." 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 = deepcopy(data_manager.basic_config_default)
default_dirs["DATA_PATH"] = default_data_dir 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 default_dirs["STORAGE_TYPE"] = storage_type.value
driver_cls = drivers.get_driver_class(storage_type) driver_cls = drivers.get_driver_class(storage_type)
default_dirs["STORAGE_DETAILS"] = driver_cls.get_config_details() default_dirs["STORAGE_DETAILS"] = driver_cls.get_config_details()
if name in instance_data: if name in instance_data:
print( if overwrite_existing_instance:
"WARNING: An instance already exists with this name. " pass
"Continuing will overwrite the existing instance config." elif interactive:
) print(
if not click.confirm("Are you absolutely certain you want to continue?", default=False): "WARNING: An instance already exists with this name. "
print("Not continuing") "Continuing will overwrite the existing instance config."
sys.exit(0) )
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) save_config(name, default_dirs)
print() if interactive:
print( print()
"Your basic configuration has been saved. Please run `redbot <name>` to" print(
" continue your setup process and to run the bot.\n\n" f"Your basic configuration has been saved. Please run `redbot {name}` to"
"First time? Read the quickstart guide:\n" " continue your setup process and to run the bot.\n\n"
"https://docs.discord.red/en/stable/getting_started.html" "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"]) return BackendType(instance_data[instance]["STORAGE_TYPE"])
def get_target_backend(backend) -> BackendType: def get_target_backend(backend: str) -> BackendType:
if backend == "json": if backend == "json":
return BackendType.JSON return BackendType.JSON
elif backend == "postgres": elif backend == "postgres":
@ -234,13 +284,13 @@ async def create_backup(instance: str, destination_folder: Path = Path.home()) -
async def remove_instance( async def remove_instance(
instance, instance: str,
interactive: bool = False, interactive: bool = False,
delete_data: Optional[bool] = None, delete_data: Optional[bool] = None,
_create_backup: Optional[bool] = None, _create_backup: Optional[bool] = None,
drop_db: Optional[bool] = None, drop_db: Optional[bool] = None,
remove_datapath: Optional[bool] = None, remove_datapath: Optional[bool] = None,
): ) -> None:
data_manager.load_basic_configuration(instance) data_manager.load_basic_configuration(instance)
backend = get_current_backend(instance) backend = get_current_backend(instance)
@ -277,10 +327,10 @@ async def remove_instance(
safe_delete(data_path) safe_delete(data_path)
save_config(instance, {}, remove=True) 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: if not instance_list:
print("No instances have been set up!") print("No instances have been set up!")
return return
@ -303,8 +353,54 @@ async def remove_instance_interaction():
@click.group(invoke_without_command=True) @click.group(invoke_without_command=True)
@click.option("--debug", type=bool) @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 @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.""" """Create a new instance."""
level = logging.DEBUG if debug else logging.INFO level = logging.DEBUG if debug else logging.INFO
base_logger = logging.getLogger("red") base_logger = logging.getLogger("red")
@ -317,7 +413,13 @@ def cli(ctx, debug):
base_logger.addHandler(stdout_handler) base_logger.addHandler(stdout_handler)
if ctx.invoked_subcommand is None: 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() @cli.command()
@ -376,7 +478,7 @@ def delete(
_create_backup: Optional[bool], _create_backup: Optional[bool],
drop_db: Optional[bool], drop_db: Optional[bool],
remove_datapath: Optional[bool], remove_datapath: Optional[bool],
): ) -> None:
"""Removes an instance.""" """Removes an instance."""
asyncio.run( asyncio.run(
remove_instance( remove_instance(
@ -388,7 +490,7 @@ def delete(
@cli.command() @cli.command()
@click.argument("instance", type=click.Choice(instance_list), metavar="<INSTANCE_NAME>") @click.argument("instance", type=click.Choice(instance_list), metavar="<INSTANCE_NAME>")
@click.argument("backend", type=click.Choice(["json", "postgres"])) @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.""" """Convert data backend of an instance."""
current_backend = get_current_backend(instance) current_backend = get_current_backend(instance)
target = get_target_backend(backend) target = get_target_backend(backend)
@ -418,13 +520,13 @@ def convert(instance, backend):
@click.argument( @click.argument(
"destination_folder", "destination_folder",
type=click.Path( 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(), 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.""" """Backup instance's data."""
asyncio.run(create_backup(instance, Path(destination_folder))) asyncio.run(create_backup(instance, destination_folder))
def run_cli(): def run_cli():