Skip to content

Commit

Permalink
feat: config save --append/--remove KEY=VAL options
Browse files Browse the repository at this point in the history
This paves the way for persisting bind-mounts across runs, since this
setting will be a list.
  • Loading branch information
regisb committed Apr 28, 2023
1 parent 8112083 commit 9452c1a
Show file tree
Hide file tree
Showing 4 changed files with 93 additions and 14 deletions.
1 change: 1 addition & 0 deletions changelog.d/20230427_121619_regis_config_append.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- [Feature] Add a `config save -a/--append -A/--remove` options to conveniently append and remove values to/from list entries. (by @regisb)
11 changes: 2 additions & 9 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -351,19 +351,12 @@ Installing extra xblocks and requirements

Would you like to include custom xblocks, or extra requirements to your Open edX platform? Additional requirements can be added to the ``OPENEDX_EXTRA_PIP_REQUIREMENTS`` parameter in the :ref:`config file <configuration>` or to the ``env/build/openedx/requirements/private.txt`` file. The difference between them, is that ``private.txt`` file, even though it could be used for both, :ref:`should be used for installing extra xblocks or requirements from private repositories <extra_private_xblocks>`. For instance, to include the `polling xblock from Opencraft <https://github.com/open-craft/xblock-poll/>`_:

- add the following to the ``config.yml``::
tutor config save --append OPENEDX_EXTRA_PIP_REQUIREMENTS=git+https://github.com/open-craft/xblock-poll.git

OPENEDX_EXTRA_PIP_REQUIREMENTS:
- "git+https://github.com/open-craft/xblock-poll.git"

.. warning::
Specifying extra requirements through ``config.yml`` overwrites :ref:`the default extra requirements<openedx_extra_pip_requirements>`. You might need to add them to the list if your configuration depends on them.

- or add the dependency to ``private.txt``::
Alternatively, add the dependency to ``private.txt``::

echo "git+https://github.com/open-craft/xblock-poll.git" >> "$(tutor config printroot)/env/build/openedx/requirements/private.txt"


Then, the ``openedx`` docker image must be rebuilt::

tutor images build openedx
Expand Down
22 changes: 22 additions & 0 deletions tests/commands/test_config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import unittest

from tutor import config as tutor_config
from tests.helpers import temporary_root

from .base import TestCommandMixin
Expand Down Expand Up @@ -59,6 +60,27 @@ def test_config_printvalue(self) -> None:
self.assertEqual(0, result.exit_code)
self.assertTrue(result.output)

def test_config_append(self) -> None:
with temporary_root() as root:
self.invoke_in_root(
root, ["config", "save", "--append=TEST=value"], catch_exceptions=False
)
config1 = tutor_config.load(root)
self.invoke_in_root(
root, ["config", "save", "--append=TEST=value"], catch_exceptions=False
)
config2 = tutor_config.load(root)
self.invoke_in_root(
root, ["config", "save", "--remove=TEST=value"], catch_exceptions=False
)
config3 = tutor_config.load(root)
# Value is appended
self.assertEqual(["value"], config1["TEST"])
# Value is not appended a second time
self.assertEqual(["value"], config2["TEST"])
# Value is removed
self.assertEqual([], config3["TEST"])


class PatchesTests(unittest.TestCase, TestCommandMixin):
def test_config_patches_list(self) -> None:
Expand Down
73 changes: 68 additions & 5 deletions tutor/commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,8 @@ def shell_complete(
for key, _value in self._shell_complete_config_items(ctx, incomplete)
]

@staticmethod
def _shell_complete_config_items(
ctx: click.Context, incomplete: str
self, ctx: click.Context, incomplete: str
) -> list[tuple[str, ConfigValue]]:
# Here we want to auto-complete the name of the config key. For that we need to
# figure out the list of enabled plugins, and for that we need the project root.
Expand All @@ -48,9 +47,16 @@ def _shell_complete_config_items(
).get("root", "")
config = tutor_config.load_full(root)
return [
(key, value) for key, value in config.items() if key.startswith(incomplete)
(key, value)
for key, value in self._candidate_config_items(config)
if key.startswith(incomplete)
]

def _candidate_config_items(
self, config: Config
) -> t.Iterable[tuple[str, ConfigValue]]:
yield from config.items()


class ConfigKeyValParamType(ConfigKeyParamType):
"""
Expand Down Expand Up @@ -91,6 +97,19 @@ def shell_complete(
return []


class ConfigListKeyValParamType(ConfigKeyValParamType):
"""
Same as the parent class, but for keys of type `list`.
"""

def _candidate_config_items(
self, config: Config
) -> t.Iterable[tuple[str, ConfigValue]]:
for key, val in config.items():
if isinstance(val, list):
yield key, val


@click.command(help="Create and save configuration interactively")
@click.option("-i", "--interactive", is_flag=True, help="Run interactively")
@click.option(
Expand All @@ -102,6 +121,24 @@ def shell_complete(
metavar="KEY=VAL",
help="Set a configuration value (can be used multiple times)",
)
@click.option(
"-a",
"--append",
"append_vars",
type=ConfigListKeyValParamType(),
multiple=True,
metavar="KEY=VAL",
help="Append an item to a configuration value of type list. The value will only be added it it is not already present. (can be used multiple times)",
)
@click.option(
"-A",
"--remove",
"remove_vars",
type=ConfigListKeyValParamType(),
multiple=True,
metavar="KEY=VAL",
help="Remove an item from a configuration value of type list (can be used multiple times)",
)
@click.option(
"-U",
"--unset",
Expand All @@ -117,16 +154,42 @@ def shell_complete(
def save(
context: Context,
interactive: bool,
set_vars: Config,
set_vars: tuple[str, t.Any],
append_vars: tuple[str, t.Any],
remove_vars: tuple[str, t.Any],
unset_vars: list[str],
env_only: bool,
) -> None:
config = tutor_config.load_minimal(context.root)
if interactive:
interactive_config.ask_questions(config)
if set_vars:
for key, value in dict(set_vars).items():
for key, value in set_vars:
config[key] = env.render_unknown(config, value)
if append_vars:
for key, value in append_vars:
if key not in config:
config[key] = []
values = config[key]
if not isinstance(values, list):
raise exceptions.TutorError(
f"Could not append value to '{key}': current setting is of type '{values.__class__.__name__}', expected list."
)
if not isinstance(value, str):
raise exceptions.TutorError(
f"Could not append value to '{key}': appended value is of type '{value.__class__.__name__}', expected str."
)
if value not in values:
values.append(value)
if remove_vars:
for key, value in remove_vars:
values = config.get(key, [])
if not isinstance(values, list):
raise exceptions.TutorError(
f"Could not remove value from '{key}': current setting is of type '{values.__class__.__name__}', expected list."
)
while value in values:
values.remove(value)
for key in unset_vars:
config.pop(key, None)
if not env_only:
Expand Down

0 comments on commit 9452c1a

Please sign in to comment.