diff --git a/CHANGELOG.md b/CHANGELOG.md index c484cbd..2b8d7bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Note: Breaking changes between versions are indicated by "💥". ## Unreleased +- [Improvement] Move ``-r/--root`` option to parent command level - [Bugfix] Fix course about page visibility - [Improvement] Print gunicorn access logs in the console - 💥[Improvement] Get rid of the `indexcourses` and `portainer` command (#269) diff --git a/docs/local.rst b/docs/local.rst index 4a87e52..5a1334a 100644 --- a/docs/local.rst +++ b/docs/local.rst @@ -5,7 +5,12 @@ Local deployment This method is for deploying Open edX locally on a single server, where docker images are orchestrated with `docker-compose `_. -In the following, environment and data files will be generated in a user-specific project folder which will be referred to as the "**project root**". On Linux, the default project root is ``~/.local/share/tutor``. An alternative project root can be defined by passing the ``--root=...`` option to most commands, or define the ``TUTOR_ROOT=...`` environment variable. +In the following, environment and data files will be generated in a user-specific project folder which will be referred to as the "**project root**". On Linux, the default project root is ``~/.local/share/tutor``. An alternative project root can be defined by passing the ``--root=...`` option to the ``tutor`` command, or define the ``TUTOR_ROOT=...`` environment variable:: + + tutor --root=/path/to/tutorroot run ... + # Or equivalently: + export TUTOR_ROOT=/path/to/tutorroot + tutor run ... Main commands ------------- diff --git a/tutor/commands/android.py b/tutor/commands/android.py index dbf047a..97b4adf 100644 --- a/tutor/commands/android.py +++ b/tutor/commands/android.py @@ -3,7 +3,6 @@ import click from .. import config as tutor_config from .. import env as tutor_env from .. import fmt -from .. import opts from .. import utils @@ -18,31 +17,31 @@ def build(): @click.command(help="Build the application in debug mode") -@opts.root -def debug(root): - docker_run(root) +@click.pass_obj +def debug(context): + docker_run(context.root) fmt.echo_info( "The debuggable APK file is available in {}".format( - tutor_env.data_path(root, "android") + tutor_env.data_path(context.root, "android") ) ) @click.command(help="Build the application in release mode") -@opts.root -def release(root): - docker_run(root, "./gradlew", "assembleProdRelease") +@click.pass_obj +def release(context): + docker_run(context.root, "./gradlew", "assembleProdRelease") fmt.echo_info( "The production APK file is available in {}".format( - tutor_env.data_path(root, "android") + tutor_env.data_path(context.root, "android") ) ) @click.command(help="Pull the docker image") -@opts.root -def pullimage(root): - config = tutor_config.load(root) +@click.pass_obj +def pullimage(context): + config = tutor_config.load(context.root) utils.execute("docker", "pull", config["DOCKER_IMAGE_ANDROID"]) diff --git a/tutor/commands/cli.py b/tutor/commands/cli.py index 8769ea8..9f02e50 100755 --- a/tutor/commands/cli.py +++ b/tutor/commands/cli.py @@ -1,6 +1,7 @@ #! /usr/bin/env python3 import sys +import appdirs import click import click_repl @@ -18,9 +19,15 @@ from .. import exceptions from .. import fmt +# pylint: disable=too-few-public-methods +class Context: + def __init__(self, root): + self.root = root + + def main(): try: - cli() + cli() # pylint: disable=no-value-for-parameter except exceptions.TutorError as e: fmt.echo_error("Error: {}".format(e.args[0])) sys.exit(1) @@ -28,8 +35,18 @@ def main(): @click.group(context_settings={"help_option_names": ["-h", "--help", "help"]}) @click.version_option(version=__version__) -def cli(): - pass +@click.option( + "-r", + "--root", + envvar="TUTOR_ROOT", + default=appdirs.user_data_dir(appname="tutor"), + show_default=True, + type=click.Path(resolve_path=True), + help="Root project directory (environment variable: TUTOR_ROOT)", +) +@click.pass_context +def cli(context, root): + context.obj = Context(root) @click.command(help="Print this help", name="help") diff --git a/tutor/commands/config.py b/tutor/commands/config.py index b0b6ae5..3270272 100644 --- a/tutor/commands/config.py +++ b/tutor/commands/config.py @@ -5,7 +5,7 @@ from .. import env from .. import exceptions from .. import fmt from .. import interactive as interactive_config -from .. import opts +from .. import serialize @click.group( @@ -17,14 +17,24 @@ def config_command(): pass +class YamlParamType(click.ParamType): + name = "yaml" + + def convert(self, value, param, ctx): + try: + k, v = value.split("=") + except ValueError: + self.fail("'{}' is not of the form 'key=value'.".format(value), param, ctx) + return k, serialize.parse(v) + + @click.command(help="Create and save configuration interactively") -@opts.root @click.option("-i", "--interactive", is_flag=True, help="Run interactively") @click.option( "-s", "--set", "set_", - type=opts.YamlParamType(), + type=YamlParamType(), multiple=True, metavar="KEY=VAL", help="Set a configuration value (can be used multiple times)", @@ -35,28 +45,31 @@ def config_command(): multiple=True, help="Remove a configuration value (can be used multiple times)", ) -def save(root, interactive, set_, unset): - config, defaults = interactive_config.load_all(root, interactive=interactive) +@click.pass_obj +def save(context, interactive, set_, unset): + config, defaults = interactive_config.load_all( + context.root, interactive=interactive + ) if set_: tutor_config.merge(config, dict(set_), force=True) for key in unset: config.pop(key, None) - tutor_config.save(root, config) + tutor_config.save(context.root, config) tutor_config.merge(config, defaults) - env.save(root, config) + env.save(context.root, config) @click.command(help="Print the project root") -@opts.root -def printroot(root): - click.echo(root) +@click.pass_obj +def printroot(context): + click.echo(context.root) @click.command(help="Print a configuration value") -@opts.root @click.argument("key") -def printvalue(root, key): - config = tutor_config.load(root) +@click.pass_obj +def printvalue(context, key): + config = tutor_config.load(context.root) try: fmt.echo(config[key]) except KeyError: diff --git a/tutor/commands/dev.py b/tutor/commands/dev.py index 32f6857..901f867 100644 --- a/tutor/commands/dev.py +++ b/tutor/commands/dev.py @@ -2,7 +2,6 @@ import click from .. import env as tutor_env from .. import fmt -from .. import opts from .. import utils @@ -11,53 +10,72 @@ def dev(): pass +edx_platform_path_option = click.option( + "-P", + "--edx-platform-path", + envvar="TUTOR_EDX_PLATFORM_PATH", + type=click.Path(exists=True, dir_okay=True, resolve_path=True), + help="Mount a local edx-platform from the host (environment variable: TUTOR_EDX_PLATFORM_PATH)", +) + +edx_platform_development_settings_option = click.option( + "-S", + "--edx-platform-settings", + envvar="TUTOR_EDX_PLATFORM_SETTINGS", + default="tutor.development", + help="Mount a local edx-platform from the host (environment variable: TUTOR_EDX_PLATFORM_PATH)", +) + + @click.command( help="Run a command in one of the containers", context_settings={"ignore_unknown_options": True}, ) -@opts.root -@opts.edx_platform_path -@opts.edx_platform_development_settings +@edx_platform_path_option +@edx_platform_development_settings_option @click.argument("service") @click.argument("command", default=None, required=False) @click.argument("args", nargs=-1) -def run(root, edx_platform_path, edx_platform_settings, service, command, args): +@click.pass_obj +def run(context, edx_platform_path, edx_platform_settings, service, command, args): run_command = [service] if command: run_command.append(command) if args: run_command += args - docker_compose_run(root, edx_platform_path, edx_platform_settings, *run_command) + docker_compose_run( + context.root, edx_platform_path, edx_platform_settings, *run_command + ) @click.command( help="Exec a command in a running container", context_settings={"ignore_unknown_options": True}, ) -@opts.root @click.argument("service") @click.argument("command") @click.argument("args", nargs=-1) -def execute(root, service, command, args): +@click.pass_obj +def execute(context, service, command, args): exec_command = ["exec", service, command] if args: exec_command += args - docker_compose(root, *exec_command) + docker_compose(context.root, *exec_command) @click.command(help="Run a development server") -@opts.root -@opts.edx_platform_path -@opts.edx_platform_development_settings +@edx_platform_path_option +@edx_platform_development_settings_option @click.argument("service", type=click.Choice(["lms", "cms"])) -def runserver(root, edx_platform_path, edx_platform_settings, service): +@click.pass_obj +def runserver(context, edx_platform_path, edx_platform_settings, service): port = service_port(service) fmt.echo_info( "The {} service will be available at http://localhost:{}".format(service, port) ) docker_compose_run( - root, + context.root, edx_platform_path, edx_platform_settings, "-p", @@ -71,9 +89,9 @@ def runserver(root, edx_platform_path, edx_platform_settings, service): @click.command(help="Stop a running development platform") -@opts.root -def stop(root): - docker_compose(root, "rm", "--stop", "--force") +@click.pass_obj +def stop(context): + docker_compose(context.root, "rm", "--stop", "--force") def docker_compose_run(root, edx_platform_path, edx_platform_settings, *command): diff --git a/tutor/commands/images.py b/tutor/commands/images.py index a92b84b..67a61f4 100644 --- a/tutor/commands/images.py +++ b/tutor/commands/images.py @@ -5,7 +5,6 @@ import click from .. import config as tutor_config from .. import env as tutor_env from .. import images -from .. import opts from .. import plugins BASE_IMAGE_NAMES = ["openedx", "forum", "android"] @@ -21,7 +20,6 @@ def images_command(): short_help="Build docker images", help="Build the docker images necessary for an Open edX platform.", ) -@opts.root @click.argument("image", nargs=-1) @click.option( "--no-cache", is_flag=True, help="Do not use cache when building the image" @@ -32,10 +30,11 @@ def images_command(): multiple=True, help="Set build-time docker ARGS in the form 'myarg=value'. This option may be specified multiple times.", ) -def build(root, image, no_cache, build_arg): - config = tutor_config.load(root) +@click.pass_obj +def build(context, image, no_cache, build_arg): + config = tutor_config.load(context.root) for i in image: - build_image(root, config, i, no_cache, build_arg) + build_image(context.root, config, i, no_cache, build_arg) def build_image(root, config, image, no_cache, build_arg): @@ -77,10 +76,10 @@ def build_image(root, config, image, no_cache, build_arg): @click.command(short_help="Pull images from the Docker registry") -@opts.root @click.argument("image", nargs=-1) -def pull(root, image): - config = tutor_config.load(root) +@click.pass_obj +def pull(context, image): + config = tutor_config.load(context.root) for i in image: pull_image(config, i) @@ -101,15 +100,15 @@ def pull_image(config, image): @click.command(short_help="Push images to the Docker registry") -@opts.root @click.argument("image", nargs=-1) -def push(root, image): - config = tutor_config.load(root) +@click.pass_obj +def push(context, image): + config = tutor_config.load(context.root) for i in image: - push_image(root, config, i) + push_image(config, i) -def push_image(root, config, image): +def push_image(config, image): # Push base images for img in BASE_IMAGE_NAMES: if image in [img, "all"]: diff --git a/tutor/commands/k8s.py b/tutor/commands/k8s.py index 2cbd33d..4c8258e 100644 --- a/tutor/commands/k8s.py +++ b/tutor/commands/k8s.py @@ -4,7 +4,6 @@ from .. import config as tutor_config from .. import env as tutor_env from .. import fmt from .. import interactive as interactive_config -from .. import opts from .. import scripts from .. import utils @@ -15,11 +14,11 @@ def k8s(): @click.command(help="Configure and run Open edX from scratch") -@opts.root @click.option("-I", "--non-interactive", is_flag=True, help="Run non-interactively") -def quickstart(root, non_interactive): +@click.pass_obj +def quickstart(context, non_interactive): click.echo(fmt.title("Interactive platform configuration")) - config = interactive_config.update(root, interactive=(not non_interactive)) + config = interactive_config.update(context.root, interactive=(not non_interactive)) if config["ACTIVATE_HTTPS"] and not config["WEB_PROXY"]: fmt.echo_alert( "Potentially invalid configuration: ACTIVATE_HTTPS=true WEB_PROXY=false\n" @@ -28,21 +27,21 @@ def quickstart(root, non_interactive): " more information." ) click.echo(fmt.title("Updating the current environment")) - tutor_env.save(root, config) + tutor_env.save(context, config) click.echo(fmt.title("Starting the platform")) - start.callback(root) + start.callback() click.echo(fmt.title("Database creation and migrations")) - init.callback(root) + init.callback() @click.command(help="Run all configured Open edX services") -@opts.root -def start(root): +@click.pass_obj +def start(context): # Create namespace utils.kubectl( "apply", "--kustomize", - tutor_env.pathjoin(root), + tutor_env.pathjoin(context.root), "--wait", "--selector", "app.kubernetes.io/component=namespace", @@ -51,29 +50,29 @@ def start(root): utils.kubectl( "apply", "--kustomize", - tutor_env.pathjoin(root), + tutor_env.pathjoin(context.root), "--wait", "--selector", "app.kubernetes.io/component=volume", ) # Create everything else - utils.kubectl("apply", "--kustomize", tutor_env.pathjoin(root)) + utils.kubectl("apply", "--kustomize", tutor_env.pathjoin(context.root)) @click.command(help="Stop a running platform") -@opts.root -def stop(root): - config = tutor_config.load(root) +@click.pass_obj +def stop(context): + config = tutor_config.load(context.root) utils.kubectl( "delete", *resource_selector(config), "deployments,services,ingress,configmaps" ) @click.command(help="Reboot an existing platform") -@opts.root -def reboot(root): - stop.callback(root) - start.callback(root) +@click.pass_obj +def reboot(context): + stop.callback() + start.callback() def resource_selector(config, *selectors): @@ -87,24 +86,28 @@ def resource_selector(config, *selectors): @click.command(help="Completely delete an existing platform") -@opts.root @click.option("-y", "--yes", is_flag=True, help="Do not ask for confirmation") -def delete(root, yes): +@click.pass_obj +def delete(context, yes): if not yes: click.confirm( "Are you sure you want to delete the platform? All data will be removed.", abort=True, ) utils.kubectl( - "delete", "-k", tutor_env.pathjoin(root), "--ignore-not-found=true", "--wait" + "delete", + "-k", + tutor_env.pathjoin(context.root), + "--ignore-not-found=true", + "--wait", ) @click.command(help="Initialise all applications") -@opts.root -def init(root): - config = tutor_config.load(root) - runner = K8sScriptRunner(root, config) +@click.pass_obj +def init(context): + config = tutor_config.load(context.root) + runner = K8sScriptRunner(context.root, config) for service in ["mysql", "elasticsearch", "mongodb"]: if runner.is_activated(service): wait_for_pod_ready(config, service) @@ -112,7 +115,6 @@ def init(root): @click.command(help="Create an Open edX user and interactively set their password") -@opts.root @click.option("--superuser", is_flag=True, help="Make superuser") @click.option("--staff", is_flag=True, help="Make staff user") @click.option( @@ -122,9 +124,10 @@ def init(root): ) @click.argument("name") @click.argument("email") -def createuser(root, superuser, staff, password, name, email): - config = tutor_config.load(root) - runner = K8sScriptRunner(root, config) +@click.pass_obj +def createuser(context, superuser, staff, password, name, email): + config = tutor_config.load(context.root) + runner = K8sScriptRunner(context.root, config) runner.check_service_is_activated("lms") command = scripts.create_user_command( superuser, staff, name, email, password=password @@ -133,31 +136,31 @@ def createuser(root, superuser, staff, password, name, email): @click.command(help="Import the demo course") -@opts.root -def importdemocourse(root): +@click.pass_obj +def importdemocourse(context): fmt.echo_info("Importing demo course") - config = tutor_config.load(root) - runner = K8sScriptRunner(root, config) + config = tutor_config.load(context.root) + runner = K8sScriptRunner(context.root, config) scripts.import_demo_course(runner) @click.command(name="exec", help="Execute a command in a pod of the given application") -@opts.root @click.argument("service") @click.argument("command") -def exec_command(root, service, command): - config = tutor_config.load(root) +@click.pass_obj +def exec_command(context, service, command): + config = tutor_config.load(context.root) kubectl_exec(config, service, command, attach=True) @click.command(help="View output from containers") -@opts.root @click.option("-c", "--container", help="Print the logs of this specific container") @click.option("-f", "--follow", is_flag=True, help="Follow log output") @click.option("--tail", type=int, help="Number of lines to show from each container") @click.argument("service") -def logs(root, container, follow, tail, service): - config = tutor_config.load(root) +@click.pass_obj +def logs(context, container, follow, tail, service): + config = tutor_config.load(context.root) command = ["logs"] selectors = ["app.kubernetes.io/name=" + service] if service else [] diff --git a/tutor/commands/local.py b/tutor/commands/local.py index 82ba132..b766f34 100644 --- a/tutor/commands/local.py +++ b/tutor/commands/local.py @@ -6,7 +6,6 @@ from .. import config as tutor_config from .. import env as tutor_env from .. import fmt from .. import interactive as interactive_config -from .. import opts from .. import scripts from .. import utils @@ -20,48 +19,48 @@ def local(): @click.command(help="Configure and run Open edX from scratch") -@opts.root @click.option("-I", "--non-interactive", is_flag=True, help="Run non-interactively") @click.option( "-p", "--pullimages", "pullimages_", is_flag=True, help="Update docker images" ) -def quickstart(root, non_interactive, pullimages_): +@click.pass_obj +def quickstart(context, non_interactive, pullimages_): click.echo(fmt.title("Interactive platform configuration")) - config = interactive_config.update(root, interactive=(not non_interactive)) + config = interactive_config.update(context.root, interactive=(not non_interactive)) click.echo(fmt.title("Updating the current environment")) - tutor_env.save(root, config) + tutor_env.save(context.root, config) click.echo(fmt.title("Stopping any existing platform")) - stop.callback(root, []) + stop.callback([]) click.echo(fmt.title("HTTPS certificates generation")) - https_create.callback(root) + https_create.callback() if pullimages_: click.echo(fmt.title("Docker image updates")) - pullimages.callback(root) + pullimages.callback() click.echo(fmt.title("Starting the platform in detached mode")) - start.callback(root, True, []) + start.callback(True, []) click.echo(fmt.title("Database creation and migrations")) - init.callback(root) + init.callback() echo_platform_info(config) @click.command(help="Update docker images") -@opts.root -def pullimages(root): - config = tutor_config.load(root) - docker_compose(root, config, "pull") +@click.pass_obj +def pullimages(context): + config = tutor_config.load(context.root) + docker_compose(context.root, config, "pull") @click.command(help="Run all or a selection of configured Open edX services") -@opts.root @click.option("-d", "--detach", is_flag=True, help="Start in daemon mode") @click.argument("services", metavar="service", nargs=-1) -def start(root, detach, services): +@click.pass_obj +def start(context, detach, services): command = ["up", "--remove-orphans"] if detach: command.append("-d") - config = tutor_config.load(root) - docker_compose(root, config, *command, *services) + config = tutor_config.load(context.root) + docker_compose(context.root, config, *command, *services) def echo_platform_info(config): @@ -82,23 +81,22 @@ def echo_platform_info(config): @click.command(help="Stop a running platform") -@opts.root @click.argument("services", metavar="service", nargs=-1) -def stop(root, services): - config = tutor_config.load(root) - docker_compose(root, config, "rm", "--stop", "--force", *services) +@click.pass_obj +def stop(context, services): + config = tutor_config.load(context.root) + docker_compose(context.root, config, "rm", "--stop", "--force", *services) @click.command( short_help="Reboot an existing platform", help="This is more than just a restart: with reboot, the platform is fully stopped before being restarted again", ) -@opts.root @click.option("-d", "--detach", is_flag=True, help="Start in daemon mode") @click.argument("services", metavar="service", nargs=-1) -def reboot(root, detach, services): - stop.callback(root, services) - start.callback(root, detach, services) +def reboot(detach, services): + stop.callback(services) + start.callback(detach, services) @click.command( @@ -108,10 +106,10 @@ restart all services. Note that this performs a 'docker-compose restart', so new may not be taken into account. It is useful for reloading settings, for instance. To fully stop the platform, use the 'reboot' command.""", ) -@opts.root @click.argument("service") -def restart(root, service): - config = tutor_config.load(root) +@click.pass_obj +def restart(context, service): + config = tutor_config.load(context.root) command = ["restart"] if service == "openedx": if config["ACTIVATE_LMS"]: @@ -120,19 +118,19 @@ def restart(root, service): command += ["cms", "cms_worker"] elif service != "all": command += [service] - docker_compose(root, config, *command) + docker_compose(context.root, config, *command) @click.command( help="Run a command in one of the containers", context_settings={"ignore_unknown_options": True}, ) -@opts.root @click.option("--entrypoint", help="Override the entrypoint of the image") @click.argument("service") @click.argument("command", default=None, required=False) @click.argument("args", nargs=-1) -def run(root, entrypoint, service, command, args): +@click.pass_obj +def run(context, entrypoint, service, command, args): run_command = ["run", "--rm"] if entrypoint: run_command += ["--entrypoint", entrypoint] @@ -141,31 +139,31 @@ def run(root, entrypoint, service, command, args): run_command.append(command) if args: run_command += args - config = tutor_config.load(root) - docker_compose(root, config, *run_command) + config = tutor_config.load(context.root) + docker_compose(context.root, config, *run_command) @click.command( help="Exec a command in a running container", context_settings={"ignore_unknown_options": True}, ) -@opts.root @click.argument("service") @click.argument("command") @click.argument("args", nargs=-1) -def execute(root, service, command, args): +@click.pass_obj +def execute(context, service, command, args): exec_command = ["exec", service, command] if args: exec_command += args - config = tutor_config.load(root) - docker_compose(root, config, *exec_command) + config = tutor_config.load(context.root) + docker_compose(context.root, config, *exec_command) @click.command(help="Initialise all applications") -@opts.root -def init(root): - config = tutor_config.load(root) - runner = ScriptRunner(root, config) +@click.pass_obj +def init(context): + config = tutor_config.load(context.root) + runner = ScriptRunner(context.root, config) scripts.initialise(runner) @@ -179,12 +177,12 @@ that is convenient when developing new plugins. Ex: tutor local hook discovery discovery hooks discovery init""", name="hook", ) -@opts.root @click.argument("service") @click.argument("path", nargs=-1) -def run_hook(root, service, path): - config = tutor_config.load(root) - runner = ScriptRunner(root, config) +@click.pass_obj +def run_hook(context, service, path): + config = tutor_config.load(context.root) + runner = ScriptRunner(context.root, config) fmt.echo_info( "Running '{}' hook in '{}' container...".format(".".join(path), service) ) @@ -197,8 +195,8 @@ def https(): @click.command(help="Create https certificates", name="create") -@opts.root -def https_create(root): +@click.pass_obj +def https_create(context): """ Note: there are a couple issues with https certificate generation. 1. Certificates are generated and renewed by using port 80, which is not necessarily open. @@ -206,8 +204,8 @@ def https_create(root): b. It may be occupied by an external web server 2. On certificate renewal, nginx is not reloaded """ - config = tutor_config.load(root) - runner = ScriptRunner(root, config) + config = tutor_config.load(context.root) + runner = ScriptRunner(context.root, config) if not config["ACTIVATE_HTTPS"]: fmt.echo_info("HTTPS is not activated: certificate generation skipped") return @@ -230,7 +228,7 @@ See the official certbot documentation for your platform: https://certbot.eff.or utils.docker_run( "--volume", - "{}:/etc/letsencrypt/".format(tutor_env.data_path(root, "letsencrypt")), + "{}:/etc/letsencrypt/".format(tutor_env.data_path(context.root, "letsencrypt")), "-p", "80:80", "--entrypoint=sh", @@ -242,9 +240,9 @@ See the official certbot documentation for your platform: https://certbot.eff.or @click.command(help="Renew https certificates", name="renew") -@opts.root -def https_renew(root): - config = tutor_config.load(root) +@click.pass_obj +def https_renew(context): + config = tutor_config.load(context.root) if not config["ACTIVATE_HTTPS"]: fmt.echo_info("HTTPS is not activated: certificate renewal skipped") return @@ -261,7 +259,7 @@ See the official certbot documentation for your platform: https://certbot.eff.or return docker_run = [ "--volume", - "{}:/etc/letsencrypt/".format(tutor_env.data_path(root, "letsencrypt")), + "{}:/etc/letsencrypt/".format(tutor_env.data_path(context.root, "letsencrypt")), "-p", "80:80", "certbot/certbot:latest", @@ -271,23 +269,22 @@ See the official certbot documentation for your platform: https://certbot.eff.or @click.command(help="View output from containers") -@opts.root @click.option("-f", "--follow", is_flag=True, help="Follow log output") @click.option("--tail", type=int, help="Number of lines to show from each container") @click.argument("service", nargs=-1) -def logs(root, follow, tail, service): +@click.pass_obj +def logs(context, follow, tail, service): command = ["logs"] if follow: command += ["--follow"] if tail is not None: command += ["--tail", str(tail)] command += service - config = tutor_config.load(root) - docker_compose(root, config, *command) + config = tutor_config.load(context.root) + docker_compose(context.root, config, *command) @click.command(help="Create an Open edX user and interactively set their password") -@opts.root @click.option("--superuser", is_flag=True, help="Make superuser") @click.option("--staff", is_flag=True, help="Make staff user") @click.option( @@ -297,9 +294,10 @@ def logs(root, follow, tail, service): ) @click.argument("name") @click.argument("email") -def createuser(root, superuser, staff, password, name, email): - config = tutor_config.load(root) - runner = ScriptRunner(root, config) +@click.pass_obj +def createuser(context, superuser, staff, password, name, email): + config = tutor_config.load(context.root) + runner = ScriptRunner(context.root, config) runner.check_service_is_activated("lms") command = scripts.create_user_command( superuser, staff, name, email, password=password @@ -308,10 +306,10 @@ def createuser(root, superuser, staff, password, name, email): @click.command(help="Import the demo course") -@opts.root -def importdemocourse(root): - config = tutor_config.load(root) - runner = ScriptRunner(root, config) +@click.pass_obj +def importdemocourse(context): + config = tutor_config.load(context.root) + runner = ScriptRunner(context.root, config) fmt.echo_info("Importing demo course") scripts.import_demo_course(runner) diff --git a/tutor/commands/plugins.py b/tutor/commands/plugins.py index f15bee7..c0806a9 100644 --- a/tutor/commands/plugins.py +++ b/tutor/commands/plugins.py @@ -5,7 +5,6 @@ import click from .. import config as tutor_config from .. import env as tutor_env from .. import fmt -from .. import opts from .. import plugins @@ -22,47 +21,57 @@ def plugins_command(): @click.command(name="list", help="List installed plugins") -@opts.root -def list_command(root): - config = tutor_config.load_user(root) +@click.pass_obj +def list_command(context): + config = tutor_config.load_user(context.root) for name, _ in plugins.iter_installed(): status = "" if plugins.is_enabled(config, name) else " (disabled)" print("{plugin}{status}".format(plugin=name, status=status)) @click.command(help="Enable a plugin") -@opts.root @click.argument("plugin_names", metavar="plugin", nargs=-1) -def enable(root, plugin_names): - config = tutor_config.load_user(root) +@click.pass_obj +def enable(context, plugin_names): + config = tutor_config.load_user(context.root) for plugin in plugin_names: plugins.enable(config, plugin) fmt.echo_info("Plugin {} enabled".format(plugin)) - tutor_config.save(root, config) + tutor_config.save(context.root, config) fmt.echo_info( "You should now re-generate your environment with `tutor config save`." ) @click.command(help="Disable a plugin") -@opts.root @click.argument("plugin_names", metavar="plugin", nargs=-1) -def disable(root, plugin_names): - config = tutor_config.load_user(root) +@click.pass_obj +def disable(context, plugin_names): + config = tutor_config.load_user(context.root) for plugin in plugin_names: plugins.disable(config, plugin) - plugin_dir = tutor_env.pathjoin(root, "plugins", plugin) + plugin_dir = tutor_env.pathjoin(context.root, "plugins", plugin) if os.path.exists(plugin_dir): shutil.rmtree(plugin_dir) fmt.echo_info("Plugin {} disabled".format(plugin)) - tutor_config.save(root, config) + tutor_config.save(context.root, config) fmt.echo_info( "You should now re-generate your environment with `tutor config save`." ) +def iter_extra_plugin_commands(): + """ + TODO document this. Merge with plugins.iter_commands? It's good to keepo + click-related stuff outside of the plugins module. + """ + for plugin_name, command in plugins.iter_commands(): + command.name = plugin_name + yield command + + plugins_command.add_command(list_command) plugins_command.add_command(enable) plugins_command.add_command(disable) diff --git a/tutor/commands/webui.py b/tutor/commands/webui.py index 9a016ef..c3a2f2b 100644 --- a/tutor/commands/webui.py +++ b/tutor/commands/webui.py @@ -11,7 +11,6 @@ import click # Note: it is important that this module does not depend on config, such that # the web ui can be launched even where there is no configuration. from .. import fmt -from .. import opts from .. import env as tutor_env from .. import serialize @@ -24,7 +23,6 @@ def webui(): @click.command(help="Start the web UI") -@opts.root @click.option( "-p", "--port", @@ -36,15 +34,16 @@ def webui(): @click.option( "-h", "--host", default="0.0.0.0", show_default=True, help="Host address to listen" ) -def start(root, port, host): - check_gotty_binary(root) +@click.pass_obj +def start(context, port, host): + check_gotty_binary(context.root) fmt.echo_info("Access the Tutor web UI at http://{}:{}".format(host, port)) while True: - config = load_config(root) + config = load_config(context.root) user = config["user"] password = config["password"] command = [ - gotty_path(root), + gotty_path(context.root), "--permit-write", "--address", host, @@ -66,7 +65,7 @@ def start(root, port, host): try: p.wait(timeout=2) except subprocess.TimeoutExpired: - new_config = load_config(root) + new_config = load_config(context.root) if new_config != config: click.echo( "WARNING configuration changed. Tutor web UI is now going to restart. Reload this page to continue." @@ -77,7 +76,6 @@ def start(root, port, host): @click.command(help="Configure authentication") -@opts.root @click.option("-u", "--user", prompt="User name", help="Authentication user name") @click.option( "-p", @@ -87,12 +85,13 @@ def start(root, port, host): confirmation_prompt=True, help="Authentication password", ) -def configure(root, user, password): - save_config(root, {"user": user, "password": password}) +@click.pass_obj +def configure(context, user, password): + save_config(context.root, {"user": user, "password": password}) fmt.echo_info( "The web UI configuration has been updated. " "If at any point you wish to reset your username and password, " - "just delete the following file:\n\n {}".format(config_path(root)) + "just delete the following file:\n\n {}".format(config_path(context.root)) ) diff --git a/tutor/config.py b/tutor/config.py index 7c2a224..04688d7 100644 --- a/tutor/config.py +++ b/tutor/config.py @@ -185,7 +185,9 @@ def check_existing_config(root): """ if not os.path.exists(config_path(root)): raise exceptions.TutorError( - "Project root does not exist. Make sure to generate the initial configuration with `tutor config save --interactive` or `tutor config quickstart` prior to running other commands." + "Project root does not exist. Make sure to generate the initial " + "configuration with `tutor config save --interactive` or `tutor config " + "quickstart` prior to running other commands." ) env.check_is_up_to_date(root) diff --git a/tutor/opts.py b/tutor/opts.py deleted file mode 100644 index 09015a5..0000000 --- a/tutor/opts.py +++ /dev/null @@ -1,42 +0,0 @@ -import appdirs -import click - -from . import serialize - - -root = click.option( - "-r", - "--root", - envvar="TUTOR_ROOT", - default=appdirs.user_data_dir(appname="tutor"), - show_default=True, - type=click.Path(resolve_path=True), - help="Root project directory (environment variable: TUTOR_ROOT)", -) - -edx_platform_path = click.option( - "-P", - "--edx-platform-path", - envvar="TUTOR_EDX_PLATFORM_PATH", - type=click.Path(exists=True, dir_okay=True, resolve_path=True), - help="Mount a local edx-platform from the host (environment variable: TUTOR_EDX_PLATFORM_PATH)", -) - -edx_platform_development_settings = click.option( - "-S", - "--edx-platform-settings", - envvar="TUTOR_EDX_PLATFORM_SETTINGS", - default="tutor.development", - help="Mount a local edx-platform from the host (environment variable: TUTOR_EDX_PLATFORM_PATH)", -) - - -class YamlParamType(click.ParamType): - name = "yaml" - - def convert(self, value, param, ctx): - try: - k, v = value.split("=") - except ValueError: - self.fail("'{}' is not of the form 'key=value'.".format(value), param, ctx) - return k, serialize.parse(v) diff --git a/tutor/plugins.py b/tutor/plugins.py index 0448b07..79b6515 100644 --- a/tutor/plugins.py +++ b/tutor/plugins.py @@ -156,3 +156,15 @@ def iter_hooks(config, hook_name): def iter_templates(config): yield from Plugins.instance(config).iter_templates() + + +def iter_commands(): + """ + TODO doesn't this slow down the cli? (we need to import all plugins) + Also, do we really need the `config` argument? Do we want to make it possible for disabled plugins to be loaded? + TODO document this + """ + for plugin_name, plugin in iter_installed(): + command = getattr(plugin, "command", None) + if command: + yield plugin_name, command