From d63b9aced2ab71648755ffaf44dd04aba45ee8c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Behmo?= Date: Mon, 21 Feb 2022 11:06:33 +0100 Subject: [PATCH 1/8] docs: clarify how to run commands in k8s containers This problem emerged here: https://discuss.overhang.io/t/discovery-user-creation-in-kubernetes-deployment/2442 --- docs/k8s.rst | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/k8s.rst b/docs/k8s.rst index 0316b0b..abdd603 100644 --- a/docs/k8s.rst +++ b/docs/k8s.rst @@ -44,12 +44,12 @@ Use this external IP to configure your DNS records. Once the DNS records are con If, for some reason, you would like to deploy your own load balancer, you should set ``ENABLE_WEB_PROXY=false`` just like in the :ref:`local installation `. Then, point your load balancer at the "caddy" service, which will be a `ClusterIP `__. -S3-like object storage with `MinIO `_ -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +S3-like object storage with `MinIO `__ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Like many web applications, Open edX needs to persist data. In particular, it needs to persist files uploaded by students and course designers. In the local installation, these files are persisted to disk, on the host filesystem. But on Kubernetes, it is difficult to share a single filesystem between different pods. This would require persistent volume claims with `ReadWriteMany` access mode, and these are difficult to setup. -Luckily, there is another solution: at `edx.org `_, uploaded files are persisted on AWS S3: Open edX is compatible out-of-the-box with the S3 API for storing user-generated files. The problem with S3 is that it introduces a dependency on AWS. To solve this problem, Tutor comes with a plugin that emulates the S3 API but stores files on premises. This is achieved thanks to `MinIO `_. If you want to deploy a production platform to Kubernetes, you will most certainly need to enable the ``minio`` plugin:: +Luckily, there is another solution: at `edx.org `_, uploaded files are persisted on AWS S3: Open edX is compatible out-of-the-box with the S3 API for storing user-generated files. The problem with S3 is that it introduces a dependency on AWS. To solve this problem, Tutor comes with a plugin that emulates the S3 API but stores files on premises. This is achieved thanks to `MinIO `__. If you want to deploy a production platform to Kubernetes, you will most certainly need to enable the ``minio`` plugin:: tutor plugins enable minio @@ -58,7 +58,7 @@ The "minio.LMS_HOST" domain name will have to point to your Kubernetes cluster. Kubernetes dashboard ~~~~~~~~~~~~~~~~~~~~ -This is not a requirement per se, but it's very convenient to have a visual interface of the Kubernetes cluster. We suggest the official `Kubernetes dashboard `_. Depending on your Kubernetes provider, you may need to install a dashboard yourself. There are generic instructions on the `project's README `_. AWS provides `specific instructions `_. +This is not a requirement per se, but it's very convenient to have a visual interface of the Kubernetes cluster. We suggest the official `Kubernetes dashboard `__. Depending on your Kubernetes provider, you may need to install a dashboard yourself. There are generic instructions on the `project's README `__. AWS provides `specific instructions `__. On Minikube, the dashboard is already installed. To access the dashboard, run:: @@ -112,6 +112,15 @@ All non-persisting data will be deleted, and then re-created. Common tasks ------------ +Executing commands inside service pods +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The Tutor and plugin documentation usually often instructions to execute some ``tutor local run ...`` commands. These commands are only valid when running Tutor locally with docker-compose, and will not work on Kubernetes. Instead, you should run ``tutor k8s exec ...`` commands. Arguments and options should be identical. + +For instance, to run a Python shell in the lms container, run:: + + tutor k8s exec lms ./manage.py lms shell + Running a custom "openedx" Docker image ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From a8d60d753b05b20184fb8f1c76da5c4c8e9d9a3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Behmo?= Date: Mon, 21 Feb 2022 12:07:57 +0100 Subject: [PATCH 2/8] chore: resolve "deprecated django-admin.py is deprecated" warning See: https://docs.djangoproject.com/en/dev/internals/deprecation/#deprecation-removed-in-4-0 --- tutor/templates/build/openedx/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tutor/templates/build/openedx/Dockerfile b/tutor/templates/build/openedx/Dockerfile index 46b0438..73cf526 100644 --- a/tutor/templates/build/openedx/Dockerfile +++ b/tutor/templates/build/openedx/Dockerfile @@ -155,7 +155,7 @@ COPY --chown=app:app settings/cms/*.py ./cms/envs/tutor/ RUN mkdir /openedx/locale/user COPY --chown=app:app ./locale/ /openedx/locale/user/locale/ RUN cd /openedx/locale/user && \ - django-admin.py compilemessages -v1 + django-admin compilemessages -v1 # Compile i18n strings: in some cases, js locales are not properly compiled out of the box # and we need to do a pass ourselves. Also, we need to compile the djangojs.js files for From 79f14b7e7e2e8f025e28513f99225138f6b18e01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Behmo?= Date: Mon, 21 Feb 2022 11:52:46 +0100 Subject: [PATCH 3/8] chore: fix various linting warnings in f-strings and docs --- docs/k8s.rst | 6 +++--- tutor/commands/compose.py | 15 ++++++--------- tutor/utils.py | 29 ++++++++++------------------- 3 files changed, 19 insertions(+), 31 deletions(-) diff --git a/docs/k8s.rst b/docs/k8s.rst index abdd603..7864ceb 100644 --- a/docs/k8s.rst +++ b/docs/k8s.rst @@ -20,7 +20,7 @@ Tutor was tested with server version 1.14.1 and client 1.14.3. Memory ~~~~~~ -In the following, we assume you have access to a working Kubernetes cluster. `kubectl` should use your cluster configuration by default. To launch a cluster locally, you may try out Minikube. Just follow the `official installation instructions `_. +In the following, we assume you have access to a working Kubernetes cluster. ``kubectl`` should use your cluster configuration by default. To launch a cluster locally, you may try out Minikube. Just follow the `official installation instructions `__. The Kubernetes cluster should have at least 4Gb of RAM on each node. When running Minikube, the virtual machine should have that much allocated memory. See below for an example with VirtualBox: @@ -102,7 +102,7 @@ As with the :ref:`local installation `, there are multiple commands to ru tutor k8s -h -In particular, the `tutor k8s start` command restarts and reconfigures all services by running ``kubectl apply``. That means that you can delete containers, deployments or just any other kind of resources, and Tutor will re-create them automatically. You should just beware of not deleting any persistent data stored in persistent volume claims. For instance, to restart from a "blank slate", run:: +In particular, the ``tutor k8s start`` command restarts and reconfigures all services by running ``kubectl apply``. That means that you can delete containers, deployments or just any other kind of resources, and Tutor will re-create them automatically. You should just beware of not deleting any persistent data stored in persistent volume claims. For instance, to restart from a "blank slate", run:: tutor k8s stop tutor k8s start @@ -133,6 +133,6 @@ Some Tutor plugins and customization procedures require that the "openedx" image Updating docker images ~~~~~~~~~~~~~~~~~~~~~~ -Kubernetes does not provide a single command for updating docker images out of the box. A `commonly used trick `_ is to modify an innocuous label on all resources:: +Kubernetes does not provide a single command for updating docker images out of the box. A `commonly used trick `__ is to modify an innocuous label on all resources:: kubectl patch -k "$(tutor config printroot)/env" --patch "{\"spec\": {\"template\": {\"metadata\": {\"labels\": {\"date\": \"`date +'%Y%m%d-%H%M%S'`\"}}}}}" diff --git a/tutor/commands/compose.py b/tutor/commands/compose.py index f7c220e..ebc2652 100644 --- a/tutor/commands/compose.py +++ b/tutor/commands/compose.py @@ -44,7 +44,7 @@ class ComposeJobRunner(jobs.BaseComposeJobRunner): run_command += ["run", "--rm"] if not utils.is_a_tty(): run_command += ["-T"] - job_service_name = "{}-job".format(service) + job_service_name = f"{service}-job" return self.docker_compose( *run_command, job_service_name, @@ -224,9 +224,8 @@ def bindmount_command(context: BaseComposeContext, service: str, path: str) -> N config = tutor_config.load(context.root) host_path = bindmounts.create(context.job_runner(config), service, path) fmt.echo_info( - "Bind-mount volume created at {}. You can now use it in all `local` and `dev` commands with the `--volume={}` option.".format( - host_path, path - ) + f"Bind-mount volume created at {host_path}. You can now use it in all `local` and `dev` " + f"commands with the `--volume={path}` option." ) @@ -286,12 +285,10 @@ def dc_command(context: BaseComposeContext, command: str, args: List[str]) -> No host_bind_path = bindmounts.get_path(context.root, volume_arg) if not os.path.exists(host_bind_path): raise TutorError( - ( - "Bind-mount volume directory {} does not exist. It must first be created" - " with the '{}' command." - ).format(host_bind_path, bindmount_command.name) + f"Bind-mount volume directory {host_bind_path} does not exist. It must first be created " + f"with the '{bindmount_command.name}' command." ) - volume_arg = "{}:{}".format(host_bind_path, volume_arg) + volume_arg = f"{host_bind_path}:{volume_arg}" volume_args += ["--volume", volume_arg] context.job_runner(config).docker_compose(command, *volume_args, *non_volume_args) diff --git a/tutor/utils.py b/tutor/utils.py index 4a59778..6175e6a 100644 --- a/tutor/utils.py +++ b/tutor/utils.py @@ -45,15 +45,11 @@ def ensure_file_directory_exists(path: str) -> None: directory = os.path.dirname(path) if os.path.isfile(directory): raise exceptions.TutorError( - "Attempting to create a directory, but a file with the same name already exists: {}".format( - directory - ) + f"Attempting to create a directory, but a file with the same name already exists: {directory}" ) if os.path.isdir(path): raise exceptions.TutorError( - "Attempting to write to a file, but a directory with the same name already exists: {}".format( - directory - ) + f"Attempting to write to a file, but a directory with the same name already exists: {directory}" ) if not os.path.exists(directory): os.makedirs(directory) @@ -123,7 +119,7 @@ def long_to_base64(n: int) -> str: return _bytes bys = long2intarr(n) - data = struct.pack("%sB" % len(bys), *bys) + data = struct.pack(f"{len(bys)}B", *bys) if not data: data = b"\x00" s = base64.urlsafe_b64encode(data).rstrip(b"=") @@ -202,24 +198,21 @@ def execute(*command: str) -> int: except Exception as e: p.kill() p.wait() - raise exceptions.TutorError( - "Command failed: {}".format(" ".join(command)) - ) from e + raise exceptions.TutorError(f"Command failed: {' '.join(command)}") from e if result > 0: raise exceptions.TutorError( - "Command failed with status {}: {}".format(result, " ".join(command)) + f"Command failed with status {result}: {' '.join(command)}" ) return result def check_output(*command: str) -> bytes: - click.echo(fmt.command(" ".join(command))) + literal_command = " ".join(command) + click.echo(fmt.command(literal_command)) try: return subprocess.check_output(command) except Exception as e: - raise exceptions.TutorError( - "Command failed: {}".format(" ".join(command)) - ) from e + raise exceptions.TutorError(f"Command failed: {literal_command}") from e def check_macos_docker_memory() -> None: @@ -237,7 +230,7 @@ def check_macos_docker_memory() -> None: ) try: - with open(settings_path) as fp: + with open(settings_path, encoding="utf-8") as fp: data = json.load(fp) memory_mib = int(data["memoryMiB"]) except OSError as e: @@ -264,7 +257,5 @@ def check_macos_docker_memory() -> None: if memory_mib < 4096: raise exceptions.TutorError( - "Docker is configured to allocate {} MiB RAM, less than the recommended {} MiB".format( - memory_mib, 4096 - ) + f"Docker is configured to allocate {memory_mib} MiB RAM, less than the recommended {4096} MiB" ) From 2d20a043638c4467c63eafc2b22966890797838e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Behmo?= Date: Mon, 21 Feb 2022 11:53:17 +0100 Subject: [PATCH 4/8] feat: add a convenient `tutor k8s apply` command This is convenient to check k8s template validity. --- CHANGELOG.md | 2 ++ Makefile | 3 +++ tutor/commands/k8s.py | 30 ++++++++++++++++++++++-------- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6912611..c629b70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Note: Breaking changes between versions are indicated by "💥". ## Unreleased +- [Feature] Add `tutor k8s apply` comand, which is a direct interface with `kubectl apply`. + ## v13.1.5 (2022-02-14) - [Improvement] Upgrade all services to open-release/maple.2. diff --git a/Makefile b/Makefile index 7d2efcf..2eb30e8 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,9 @@ test-types: ## Check type definitions test-pythonpackage: build-pythonpackage ## Test that package can be uploaded to pypi twine check dist/tutor-$(shell make version).tar.gz +test-k8s: ## Validate the k8s format with kubectl. Not part of the standard test suite. + tutor k8s apply --dry-run=client --validate=true + format: ## Format code automatically black $(BLACK_OPTS) diff --git a/tutor/commands/k8s.py b/tutor/commands/k8s.py index dd40ea0..a0f669d 100644 --- a/tutor/commands/k8s.py +++ b/tutor/commands/k8s.py @@ -112,10 +112,7 @@ class K8sJobRunner(jobs.BaseJobRunner): serialize.dump(job, job_file) # We cannot use the k8s API to create the job: configMap and volume names need # to be found with the right suffixes. - utils.kubectl( - "apply", - "--kustomize", - tutor_env.pathjoin(self.root), + kubectl_apply( "--selector", f"app.kubernetes.io/name={job_name}", ) @@ -225,10 +222,7 @@ def start(context: Context, names: List[str]) -> None: fmt.echo_info("Namespace already exists: skipping creation.") except exceptions.TutorError: fmt.echo_info("Namespace does not exist: now creating it...") - utils.kubectl( - "apply", - "--kustomize", - tutor_env.pathjoin(context.root), + kubectl_apply( "--wait", "--selector", "app.kubernetes.io/component=namespace", @@ -475,6 +469,25 @@ def upgrade(context: click.Context, from_release: Optional[str]) -> None: context.invoke(config_save_command) +@click.command( + short_help="Direct interface to `kubectl apply`.", + help=( + "Direct interface to `kubnectl-apply`. This is a wrapper around `kubectl apply`. A;; options and" + " arguments passed to this command will be forwarded as-is to `kubectl apply`." + ), + context_settings={"ignore_unknown_options": True}, + name="apply", +) +@click.argument("args", nargs=-1) +@click.pass_obj +def apply_command(context: Context, args: List[str]) -> None: + kubectl_apply(context.root, *args) + + +def kubectl_apply(root: str, *args: str) -> None: + utils.kubectl("apply", "--kustomize", tutor_env.pathjoin(root), *args) + + def kubectl_exec( config: Config, service: str, command: str, attach: bool = False ) -> int: @@ -551,3 +564,4 @@ k8s.add_command(exec_command) k8s.add_command(logs) k8s.add_command(wait) k8s.add_command(upgrade) +k8s.add_command(apply_command) From bb888a8af5825a36a131a555a55ebb142493f6ae Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Tue, 11 Jan 2022 08:57:28 -0500 Subject: [PATCH 5/8] docs: change "Reference" heading to "CLI Reference" because it only contains CLI reference information currently. The folder structure implies that eventually there will be more reference material, so the name of 'reference.rst' was *not* changed to 'cli-reference.rst'. --- docs/reference.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference.rst b/docs/reference.rst index d1f6131..006044b 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -1,5 +1,5 @@ -Reference -========= +CLI Reference +============= .. toctree:: :maxdepth: 2 From 87f8348a016e7f47d980a7e8593bae65f82455df Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Tue, 11 Jan 2022 16:33:02 -0500 Subject: [PATCH 6/8] docs: clarify YAML vs. Python plugins & CLI customization I found the existing docs a bit light on the particulars of how the YAML and Python plugin APIs relate. I was able to figure it out (there's a nice congruence between them) but I think these tweaks should it make it more immediately obvious to readers how the Python API is a essentially a superset of the YAML API that allows for dynamic behavior. --- docs/plugins/api.rst | 36 ++++++++++++++++++++++++++++----- docs/plugins/gettingstarted.rst | 27 +++++++++++++++++++++---- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/docs/plugins/api.rst b/docs/plugins/api.rst index c43cab6..38923a7 100644 --- a/docs/plugins/api.rst +++ b/docs/plugins/api.rst @@ -1,7 +1,20 @@ Plugin API ========== -Plugins can affect the behaviour of Tutor at multiple levels. First, plugins can define new services with their Docker images, settings and the right initialisation commands. To do so you will have to define custom :ref:`config `, :ref:`patches `, :ref:`hooks ` and :ref:`templates `. Then, plugins can also extend the CLI by defining their own :ref:`commands `. +A Tutor plugin is defined either by a YAML file or a Python package. +Plugins can affect the behaviour of Tutor at multiple levels: +they can define Docker images, introduce new services, modify the settings of existing services, specify intialization processes, and more. +Plugins can achieve all this using four extension points that Tutor exposes to both YAML and Python plugins: + +* :ref:`config `, +* :ref:`patches `, +* :ref:`hooks ` and +* :ref:`templates `. + +Additionally, Python plugins can extend Tutor's command line interface using one futher extension point: + +* :ref:`command `. + .. _plugin_config: @@ -60,7 +73,8 @@ Example:: This will add a Redis instance to the services run with ``tutor local`` commands. .. note:: - The ``patches`` attribute can be a callable function instead of a static dict value. + In Python plugins, remember that ``patches`` can be a callable function instead of a static dict value. + One can use this to dynamically load a list of patch files from a folder. .. _plugin_hooks: @@ -176,7 +190,9 @@ When saving the environment, template files that are stored in a template root w command ~~~~~~~ -A plugin can provide custom command line commands. Commands are assumed to be `click.Command `__ objects, and you typically implement them using the `click.command `__ decorator. +Python plugins can provide a custom command line interface. +The ``command`` attribute is assumed to be a `click.Command `__ object, +and you typically implement them using the `click.command `__ decorator. You may also use the `click.pass_obj `__ decorator to pass the CLI `context `__, such as when you want to access Tutor configuration settings from your command. @@ -207,11 +223,21 @@ You can even define subcommands by creating `command groups `__ tracking code to your Open edX platform. We need to add the ``GOOGLE_ANALYTICS_ACCOUNT`` and ``GOOGLE_ANALYTICS_TRACKING_ID`` settings to both the LMS and the CMS settings. To do so, we will only have to create the ``openedx-common-settings`` patch, which is shared by the development and the production settings both for the LMS and the CMS. First, create the plugin directory:: @@ -58,17 +60,34 @@ That's it! And it's very easy to share your plugins. Just upload them to your Gi Python package ~~~~~~~~~~~~~~ -Creating a plugin as a Python package allows you to define more complex logic and to store your patches in a more structured way. Python Tutor plugins are regular Python packages that define a specific entrypoint: ``tutor.plugin.v0``. +Creating a plugin as a Python package allows you to define more complex logic and to store your patches in a more structured way. Python Tutor plugins are regular Python packages that define an entrypoint within the ``tutor.plugin.v0`` group: Example:: from setuptools import setup setup( ... - entry_points={"tutor.plugin.v0": ["myplugin = myplugin.plugin"]}, + entry_points={ + "tutor.plugin.v0": ["myplugin = myplugin.plugin"] + }, ) -The ``myplugin.plugin`` python module should then declare the ``config``, ``hooks``, etc. attributes that will define its behaviour. +The ``myplugin/plugin.py`` Python module can then define the attributes ``config``, ``patches``, ``hooks``, and ``templates`` to specify the plugin's behavior. The attributes may be defined either as dictionaries or as zero-argument callables returning dictionaries; in the latter case, the callable will be evaluated upon plugin load. Finally, the ``command`` attribute can be defined as an instance of ``click.Command`` to define the plugin's command line interface. + +Example:: + + import click + + templates = pkg_resources.resource_filename(...) + config = {...} + hooks = {...} + + def patches(): + ... + + @click.command(...) + def command(): + ... To get started on the right foot, it is strongly recommended to create your first plugin with the `tutor plugin cookiecutter `__:: From 0d2d6c58e8426adcb9f6619749f2b449982be4cd Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Fri, 4 Mar 2022 16:48:47 -0500 Subject: [PATCH 7/8] squash: docs: address reveiw comments --- docs/plugins/api.rst | 16 ++++------------ docs/plugins/gettingstarted.rst | 4 +--- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/docs/plugins/api.rst b/docs/plugins/api.rst index 38923a7..1c49e89 100644 --- a/docs/plugins/api.rst +++ b/docs/plugins/api.rst @@ -1,22 +1,14 @@ Plugin API ========== -A Tutor plugin is defined either by a YAML file or a Python package. Plugins can affect the behaviour of Tutor at multiple levels: -they can define Docker images, introduce new services, modify the settings of existing services, specify intialization processes, and more. -Plugins can achieve all this using four extension points that Tutor exposes to both YAML and Python plugins: -* :ref:`config `, -* :ref:`patches `, -* :ref:`hooks ` and -* :ref:`templates `. +* Add new settings or modify existing ones in the Tutor configuration (see :ref:`config `). +* Add new templates to the Tutor project environment or modify existing ones (see :ref:`patches `, :ref:`templates ` and :ref:`hooks `). +* Add custom commands to the Tutor CLI (see :ref:`command `). -Additionally, Python plugins can extend Tutor's command line interface using one futher extension point: +There exists two different APIs to create Tutor plugins: either with YAML files or Python packages. YAML files are more simple to create, but are limited to just configuration and template patches. -* :ref:`command `. - - -.. _plugin_config: config ~~~~~~ diff --git a/docs/plugins/gettingstarted.rst b/docs/plugins/gettingstarted.rst index d695c9e..f9718e7 100644 --- a/docs/plugins/gettingstarted.rst +++ b/docs/plugins/gettingstarted.rst @@ -14,9 +14,7 @@ YAML files that are stored in the tutor plugins root folder will be automaticall On Linux, this points to ``~/.local/share/tutor-plugins``. The location of the plugin root folder can be modified by setting the ``TUTOR_PLUGINS_ROOT`` environment variable. -YAML plugins must define two special top-level keys: ``name`` and ``version``. -Then, YAML plugins may use four top-level keys to customize Tutor's behavior: ``config``, ``patches``, ``hooks``, and ``templates``. -Custom CLI commands are not supported by YAML plugins. +YAML plugins must define two special top-level keys: ``name`` and ``version``. Then, YAML plugins may use two more top-level keys to customize Tutor's behavior: ``config`` and ``patches``. Custom CLI commands, templates, and hooks are not supported by YAML plugins. Let's create a simple plugin that adds your own `Google Analytics `__ tracking code to your Open edX platform. We need to add the ``GOOGLE_ANALYTICS_ACCOUNT`` and ``GOOGLE_ANALYTICS_TRACKING_ID`` settings to both the LMS and the CMS settings. To do so, we will only have to create the ``openedx-common-settings`` patch, which is shared by the development and the production settings both for the LMS and the CMS. First, create the plugin directory:: From 38a67e6c64feae4d315008af6d2646308d3acde4 Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Fri, 4 Mar 2022 17:11:14 -0500 Subject: [PATCH 8/8] squash: docs: grammar, typos --- docs/plugins/api.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/plugins/api.rst b/docs/plugins/api.rst index 1c49e89..edb549f 100644 --- a/docs/plugins/api.rst +++ b/docs/plugins/api.rst @@ -1,7 +1,7 @@ Plugin API ========== -Plugins can affect the behaviour of Tutor at multiple levels: +Plugins can affect the behaviour of Tutor at multiple levels. They can: * Add new settings or modify existing ones in the Tutor configuration (see :ref:`config `). * Add new templates to the Tutor project environment or modify existing ones (see :ref:`patches `, :ref:`templates ` and :ref:`hooks `). @@ -9,6 +9,7 @@ Plugins can affect the behaviour of Tutor at multiple levels: There exists two different APIs to create Tutor plugins: either with YAML files or Python packages. YAML files are more simple to create, but are limited to just configuration and template patches. +.. _plugin_config: config ~~~~~~ @@ -222,7 +223,7 @@ You can even define subcommands by creating `command groups