6
0
mirror of https://github.com/ChristianLight/tutor.git synced 2024-12-04 19:03:39 +00:00

refactor: add code coverage, cover CLI commands with tests

This commit is contained in:
alex.soh 2021-11-23 16:25:09 +08:00 committed by Régis Behmo
parent dbb79c0fa0
commit 72843c06f9
36 changed files with 881 additions and 92 deletions

42
.coveragerc Normal file
View File

@ -0,0 +1,42 @@
# .coveragerc to control coverage.py
[run]
branch = True
source =
./tutor
./bin
omit =
*/templates/*
[report]
# Regexes for lines to exclude from consideration
exclude_lines =
# Have to re-enable the standard pragma
pragma: no cover
# Don't complain about missing debug-only code:
def __repr__
if self\.debug
# Don't complain if tests don't hit defensive assertion code:
raise AssertionError
raise NotImplementedError
# Don't complain if non-runnable code isn't run:
if 0:
if __name__ == .__main__.:
# Don't complain about abstract methods, they aren't run:
@(abc\.)?abstractmethod
ignore_errors = True
show_missing = True
skip_empty = True
precision = 2
[html]
skip_empty = True
show_contexts = True
[json]
pretty_print = True
show_contexts = True

4
.gitignore vendored
View File

@ -7,3 +7,7 @@ __pycache__
/build/ /build/
/dist/ /dist/
/release_description.md /release_description.md
# Unit test/ coverage reports
.coverage
/htmlcov/

View File

@ -1,6 +1,6 @@
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.PHONY: docs .PHONY: docs
SRC_DIRS = ./tutor ./tests ./bin SRC_DIRS = ./tutor ./tests ./bin ./docs
BLACK_OPTS = --exclude templates ${SRC_DIRS} BLACK_OPTS = --exclude templates ${SRC_DIRS}
###### Development ###### Development
@ -53,6 +53,23 @@ bootstrap-dev: ## Install dev requirements
bootstrap-dev-plugins: bootstrap-dev ## Install dev requirement and all supported plugins bootstrap-dev-plugins: bootstrap-dev ## Install dev requirement and all supported plugins
pip install -r requirements/plugins.txt pip install -r requirements/plugins.txt
###### Code coverage
coverage: ## Run unit-tests before analyzing code coverage and generate report
$(MAKE) --keep-going coverage-tests coverage-report
coverage-tests: ## Run unit-tests and analyze code coverage
coverage run -m unittest discover
coverage-report: ## Generate CLI report for the code coverage
coverage report
coverage-html: coverage-report ## Generate HTML report for the code coverage
coverage html
coverage-browse-report: coverage-html ## Open the HTML report in the browser
sensible-browser htmlcov/index.html
###### Deployment ###### Deployment
bundle: ## Bundle the tutor package in a single "dist/tutor" executable bundle: ## Bundle the tutor package in a single "dist/tutor" executable

View File

@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from tutor.plugins import OfficialPlugin from tutor.plugins import OfficialPlugin
from tutor.commands.cli import main
# Manually install plugins (this is for creating the bundle) # Manually install plugins (this is for creating the bundle)
for plugin_name in [ for plugin_name in [
@ -20,6 +21,5 @@ for plugin_name in [
except ImportError: except ImportError:
pass pass
from tutor.commands.cli import main if __name__ == "__main__":
main()
main()

View File

@ -1,6 +1,7 @@
import io import io
import os import os
import sys import sys
from typing import Any, Dict, List
import docutils import docutils
import docutils.parsers.rst import docutils.parsers.rst
@ -28,7 +29,7 @@ pygments_style = None
# -- Sphinx-Click configuration # -- Sphinx-Click configuration
# https://sphinx-click.readthedocs.io/ # https://sphinx-click.readthedocs.io/
extensions.append('sphinx_click') extensions.append("sphinx_click")
# This is to avoid the addition of the local username to the docs # This is to avoid the addition of the local username to the docs
os.environ["HOME"] = "~" os.environ["HOME"] = "~"
# Make sure that sphinx-click can find the tutor module # Make sure that sphinx-click can find the tutor module
@ -63,7 +64,7 @@ html_show_copyright = False
# Custom variables # Custom variables
here = os.path.abspath(os.path.dirname(__file__)) here = os.path.abspath(os.path.dirname(__file__))
about = {} about: Dict[str, str] = {}
with io.open( with io.open(
os.path.join(here, "..", "tutor", "__about__.py"), "rt", encoding="utf-8" os.path.join(here, "..", "tutor", "__about__.py"), "rt", encoding="utf-8"
) as f: ) as f:
@ -77,17 +78,17 @@ rst_prolog = """
# Custom directives # Custom directives
def youtube( def youtube(
_name, _name: Any,
_args, _args: Any,
_options, _options: Any,
content, content: List[str],
_lineno, _lineno: Any,
_contentOffset, _contentOffset: Any,
_blockText, _blockText: Any,
_state, _state: Any,
_stateMachine, _stateMachine: Any,
): ) -> Any:
""" Restructured text extension for inserting youtube embedded videos """ """Restructured text extension for inserting youtube embedded videos"""
if not content: if not content:
return [] return []
video_id = content[0] video_id = content[0]

View File

@ -4,6 +4,7 @@ pip-tools
pylint pylint
pyinstaller pyinstaller
twine twine
coverage
# Types packages # Types packages
types-PyYAML types-PyYAML

View File

@ -36,6 +36,8 @@ click==8.0.3
# pip-tools # pip-tools
colorama==0.4.4 colorama==0.4.4
# via twine # via twine
coverage==6.2
# via -r requirements/dev.in
cryptography==35.0.0 cryptography==35.0.0
# via secretstorage # via secretstorage
docutils==0.17.1 docutils==0.17.1

View File

@ -1,11 +1,12 @@
import io import io
import os import os
from setuptools import find_packages, setup from setuptools import find_packages, setup
from typing import Dict, List
HERE = os.path.abspath(os.path.dirname(__file__)) HERE = os.path.abspath(os.path.dirname(__file__))
def load_readme(): def load_readme() -> str:
with io.open(os.path.join(HERE, "README.rst"), "rt", encoding="utf8") as f: with io.open(os.path.join(HERE, "README.rst"), "rt", encoding="utf8") as f:
readme = f.read() readme = f.read()
# Replace img src for publication on pypi # Replace img src for publication on pypi
@ -14,8 +15,8 @@ def load_readme():
) )
def load_about(): def load_about() -> Dict[str, str]:
about = {} about: Dict[str, str] = {}
with io.open( with io.open(
os.path.join(HERE, "tutor", "__about__.py"), "rt", encoding="utf-8" os.path.join(HERE, "tutor", "__about__.py"), "rt", encoding="utf-8"
) as f: ) as f:
@ -23,14 +24,13 @@ def load_about():
return about return about
def load_requirements(filename: str): def load_requirements(filename: str) -> List[str]:
with io.open( with io.open(
os.path.join(HERE, "requirements", filename), "rt", encoding="utf-8" os.path.join(HERE, "requirements", filename), "rt", encoding="utf-8"
) as f: ) as f:
return [line.strip() for line in f if is_requirement(line)] return [line.strip() for line in f if is_requirement(line)]
def is_requirement(line: str) -> bool:
def is_requirement(line):
return not (line.strip() == "" or line.startswith("#")) return not (line.strip() == "" or line.startswith("#"))
@ -72,4 +72,5 @@ setup(
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
], ],
test_suite="tests",
) )

View File

View File

@ -0,0 +1,26 @@
import unittest
from click.testing import CliRunner
from tutor.commands.cli import cli, print_help
class CliTests(unittest.TestCase):
def test_help(self) -> None:
runner = CliRunner()
result = runner.invoke(print_help)
self.assertEqual(0, result.exit_code)
self.assertIsNone(result.exception)
def test_cli_help(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--help"])
self.assertEqual(0, result.exit_code)
self.assertIsNone(result.exception)
def test_cli_version(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--version"])
self.assertEqual(0, result.exit_code)
self.assertIsNone(result.exception)
self.assertRegex(result.output, r"cli, version \d+.\d+.\d+\n")

View File

@ -0,0 +1,116 @@
import os
import tempfile
import unittest
from click.testing import CliRunner
from tests.helpers import TestContext, temporary_root
from tutor import config as tutor_config
from tutor.commands.config import config_command
class ConfigTests(unittest.TestCase):
def test_config_help(self) -> None:
runner = CliRunner()
result = runner.invoke(config_command, ["--help"])
self.assertEqual(0, result.exit_code)
self.assertFalse(result.exception)
def test_config_save(self) -> None:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
result = runner.invoke(config_command, ["save"], obj=context)
self.assertEqual(0, result.exit_code)
self.assertFalse(result.exception)
def test_config_save_interactive(self) -> None:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
result = runner.invoke(config_command, ["save", "-i"], obj=context)
self.assertEqual(0, result.exit_code)
self.assertFalse(result.exception)
def test_config_save_skip_update(self) -> None:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
result = runner.invoke(config_command, ["save", "-e"], obj=context)
self.assertEqual(0, result.exit_code)
self.assertFalse(result.exception)
def test_config_save_set_value(self) -> None:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
result = runner.invoke(
config_command, ["save", "-s", "key=value"], obj=context
)
self.assertEqual(0, result.exit_code)
self.assertFalse(result.exception)
result = runner.invoke(config_command, ["printvalue", "key"], obj=context)
self.assertIn("value", result.output)
def test_config_save_unset_value(self) -> None:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
result = runner.invoke(config_command, ["save", "-U", "key"], obj=context)
self.assertEqual(0, result.exit_code)
self.assertFalse(result.exception)
result = runner.invoke(config_command, ["printvalue", "key"], obj=context)
self.assertEqual(1, result.exit_code)
def test_config_printroot(self) -> None:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
result = runner.invoke(config_command, ["printroot"], obj=context)
self.assertEqual(0, result.exit_code)
self.assertFalse(result.exception)
self.assertIn(context.root, result.output)
def test_config_printvalue(self) -> None:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
runner.invoke(config_command, ["save"], obj=context)
result = runner.invoke(
config_command, ["printvalue", "MYSQL_ROOT_PASSWORD"], obj=context
)
self.assertEqual(0, result.exit_code)
self.assertFalse(result.exception)
self.assertTrue(result.output)
def test_config_render(self) -> None:
with tempfile.TemporaryDirectory() as dest:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
runner.invoke(config_command, ["save"], obj=context)
result = runner.invoke(
config_command, ["render", context.root, dest], obj=context
)
self.assertEqual(0, result.exit_code)
self.assertFalse(result.exception)
def test_config_render_with_extra_configs(self) -> None:
with tempfile.TemporaryDirectory() as dest:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
runner.invoke(config_command, ["save"], obj=context)
result = runner.invoke(
config_command,
[
"render",
"-x",
os.path.join(context.root, tutor_config.CONFIG_FILENAME),
context.root,
dest,
],
obj=context,
)
self.assertEqual(0, result.exit_code)
self.assertFalse(result.exception)

View File

@ -0,0 +1,18 @@
import os
import unittest
from tests.helpers import TestContext, TestJobRunner, temporary_root
from tutor import config as tutor_config
class TestContextTests(unittest.TestCase):
def test_create_testcontext(self) -> None:
with temporary_root() as root:
context = TestContext(root)
config = tutor_config.load_full(root)
runner = context.job_runner(config)
self.assertTrue(os.path.exists(context.root))
self.assertFalse(
os.path.exists(os.path.join(context.root, tutor_config.CONFIG_FILENAME))
)
self.assertTrue(isinstance(runner, TestJobRunner))

View File

@ -0,0 +1,20 @@
import unittest
from click.testing import CliRunner
from tutor.commands.compose import bindmount_command
from tutor.commands.dev import dev
class DevTests(unittest.TestCase):
def test_dev_help(self) -> None:
runner = CliRunner()
result = runner.invoke(dev, ["--help"])
self.assertEqual(0, result.exit_code)
self.assertIsNone(result.exception)
def test_dev_bindmount(self) -> None:
runner = CliRunner()
result = runner.invoke(bindmount_command, ["--help"])
self.assertEqual(0, result.exit_code)
self.assertIsNone(result.exception)

View File

@ -0,0 +1,176 @@
import unittest
from unittest.mock import Mock, patch
from click.testing import CliRunner
from tests.helpers import TestContext, temporary_root
from tutor import images, plugins
from tutor.commands.config import config_command
from tutor.commands.images import ImageNotFoundError, images_command
class ImagesTests(unittest.TestCase):
def test_images_help(self) -> None:
runner = CliRunner()
result = runner.invoke(images_command, ["--help"])
self.assertEqual(0, result.exit_code)
self.assertIsNone(result.exception)
def test_images_pull_image(self) -> None:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
result = runner.invoke(images_command, ["pull"], obj=context)
self.assertEqual(0, result.exit_code)
self.assertIsNone(result.exception)
def test_images_pull_plugin_invalid_plugin_should_throw_error(self) -> None:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
result = runner.invoke(images_command, ["pull", "plugin"], obj=context)
self.assertEqual(1, result.exit_code)
self.assertEqual(ImageNotFoundError, type(result.exception))
@patch.object(plugins.BasePlugin, "iter_installed", return_value=[])
@patch.object(
plugins.Plugins,
"iter_hooks",
return_value=[
(
"dev-plugins",
{"plugin": "plugin:dev-1.0.0", "plugin2": "plugin2:dev-1.0.0"},
)
],
)
@patch.object(images, "pull", return_value=None)
def test_images_pull_plugin(
self, _image_pull: Mock, iter_hooks: Mock, iter_installed: Mock
) -> None:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
result = runner.invoke(images_command, ["pull", "plugin"], obj=context)
self.assertEqual(0, result.exit_code)
self.assertIsNone(result.exception)
iter_hooks.assert_called_once_with("remote-image")
_image_pull.assert_called_once_with("plugin:dev-1.0.0")
iter_installed.assert_called()
def test_images_printtag_image(self) -> None:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
result = runner.invoke(images_command, ["printtag", "openedx"], obj=context)
self.assertEqual(0, result.exit_code)
self.assertIsNone(result.exception)
self.assertRegex(
result.output, r"docker.io/overhangio/openedx:\d+.\d+.\d+\n"
)
@patch.object(plugins.BasePlugin, "iter_installed", return_value=[])
@patch.object(
plugins.Plugins,
"iter_hooks",
return_value=[
(
"dev-plugins",
{"plugin": "plugin:dev-1.0.0", "plugin2": "plugin2:dev-1.0.0"},
)
],
)
def test_images_printtag_plugin(
self, iter_hooks: Mock, iter_installed: Mock
) -> None:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
result = runner.invoke(images_command, ["printtag", "plugin"], obj=context)
self.assertEqual(0, result.exit_code)
self.assertIsNone(result.exception)
iter_hooks.assert_called_once_with("build-image")
iter_installed.assert_called()
self.assertEqual(result.output, "plugin:dev-1.0.0\n")
@patch.object(plugins.BasePlugin, "iter_installed", return_value=[])
@patch.object(
plugins.Plugins,
"iter_hooks",
return_value=[
(
"dev-plugins",
{"plugin": "plugin:dev-1.0.0", "plugin2": "plugin2:dev-1.0.0"},
)
],
)
@patch.object(images, "build", return_value=None)
def test_images_build_plugin(
self, image_build: Mock, iter_hooks: Mock, iter_installed: Mock
) -> None:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
runner.invoke(config_command, ["save"], obj=context)
result = runner.invoke(images_command, ["build", "plugin"], obj=context)
self.assertIsNone(result.exception)
self.assertEqual(0, result.exit_code)
image_build.assert_called()
iter_hooks.assert_called_once_with("build-image")
iter_installed.assert_called()
self.assertIn("plugin:dev-1.0.0", image_build.call_args[0])
@patch.object(plugins.BasePlugin, "iter_installed", return_value=[])
@patch.object(
plugins.Plugins,
"iter_hooks",
return_value=[
(
"dev-plugins",
{"plugin": "plugin:dev-1.0.0", "plugin2": "plugin2:dev-1.0.0"},
)
],
)
@patch.object(images, "build", return_value=None)
def test_images_build_plugin_with_args(
self, image_build: Mock, iter_hooks: Mock, iter_installed: Mock
) -> None:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
runner.invoke(config_command, ["save"], obj=context)
args = [
"build",
"--no-cache",
"-a",
"myarg=value",
"--add-host",
"host",
"--target",
"target",
"-d",
"docker_args",
"plugin",
]
result = runner.invoke(
images_command,
args,
obj=context,
)
self.assertEqual(0, result.exit_code)
self.assertIsNone(result.exception)
iter_hooks.assert_called_once_with("build-image")
iter_installed.assert_called()
image_build.assert_called()
self.assertIn("plugin:dev-1.0.0", image_build.call_args[0])
for arg in image_build.call_args[0][2:]:
if arg == "--build-arg":
continue
self.assertIn(arg, args)
def test_images_push(self) -> None:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
result = runner.invoke(images_command, ["push"], obj=context)
self.assertEqual(0, result.exit_code)
self.assertIsNone(result.exception)

View File

@ -0,0 +1,13 @@
import unittest
from click.testing import CliRunner
from tutor.commands.k8s import k8s
class K8sTests(unittest.TestCase):
def test_k8s_help(self) -> None:
runner = CliRunner()
result = runner.invoke(k8s, ["--help"])
self.assertEqual(0, result.exit_code)
self.assertIsNone(result.exception)

View File

@ -0,0 +1,25 @@
import unittest
from click.testing import CliRunner
from tutor.commands.local import local, quickstart, upgrade
class LocalTests(unittest.TestCase):
def test_local_help(self) -> None:
runner = CliRunner()
result = runner.invoke(local, ["--help"])
self.assertEqual(0, result.exit_code)
self.assertIsNone(result.exception)
def test_local_quickstart_help(self) -> None:
runner = CliRunner()
result = runner.invoke(quickstart, ["--help"])
self.assertEqual(0, result.exit_code)
self.assertIsNone(result.exception)
def test_local_upgrade_help(self) -> None:
runner = CliRunner()
result = runner.invoke(upgrade, ["--help"])
self.assertEqual(0, result.exit_code)
self.assertIsNone(result.exception)

View File

@ -0,0 +1,64 @@
import unittest
from unittest.mock import Mock, patch
from click.testing import CliRunner
from tests.helpers import TestContext, temporary_root
from tutor import plugins
from tutor.commands.plugins import plugins_command
class PluginsTests(unittest.TestCase):
def test_plugins_help(self) -> None:
runner = CliRunner()
result = runner.invoke(plugins_command, ["--help"])
self.assertEqual(0, result.exit_code)
self.assertIsNone(result.exception)
def test_plugins_printroot(self) -> None:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
result = runner.invoke(plugins_command, ["printroot"], obj=context)
self.assertEqual(0, result.exit_code)
self.assertIsNone(result.exception)
self.assertTrue(result.output)
@patch.object(plugins.BasePlugin, "iter_installed", return_value=[])
def test_plugins_list(self, _iter_installed: Mock) -> None:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
result = runner.invoke(plugins_command, ["list"], obj=context)
self.assertEqual(0, result.exit_code)
self.assertIsNone(result.exception)
self.assertFalse(result.output)
_iter_installed.assert_called()
def test_plugins_install_not_found_plugin(self) -> None:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
result = runner.invoke(
plugins_command, ["install", "notFound"], obj=context
)
self.assertEqual(1, result.exit_code)
self.assertTrue(result.exception)
def test_plugins_enable_not_installed_plugin(self) -> None:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
result = runner.invoke(plugins_command, ["enable", "notFound"], obj=context)
self.assertEqual(1, result.exit_code)
self.assertTrue(result.exception)
def test_plugins_disable_not_installed_plugin(self) -> None:
with temporary_root() as root:
context = TestContext(root)
runner = CliRunner()
result = runner.invoke(
plugins_command, ["disable", "notFound"], obj=context
)
self.assertEqual(0, result.exit_code)
self.assertFalse(result.exception)

45
tests/helpers.py Normal file
View File

@ -0,0 +1,45 @@
import os
import tempfile
from tutor.commands.context import BaseJobContext
from tutor.jobs import BaseJobRunner
from tutor.types import Config
class TestJobRunner(BaseJobRunner):
def __init__(self, root: str, config: Config):
"""
Mock job runner for unit testing.
This runner does nothing except print the service name and command,
separated by dashes.
"""
super().__init__(root, config)
def run_job(self, service: str, command: str) -> int:
print(
os.linesep.join(["Service: {}".format(service), "-----", command, "----- "])
)
return 0
def temporary_root() -> "tempfile.TemporaryDirectory[str]":
"""
Context manager to handle temporary test root.
This function can be used as follows:
with temporary_root() as root:
config = tutor_config.load_full(root)
...
"""
return tempfile.TemporaryDirectory(prefix="tutor-test-root-")
class TestContext(BaseJobContext):
"""
Click context that will use only test job runners.
"""
def job_runner(self, config: Config) -> TestJobRunner:
return TestJobRunner(self.root, config)

View File

@ -1,12 +1,14 @@
import json
import os
import unittest import unittest
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import tempfile
import click import click
from tests.helpers import temporary_root
from tutor import config as tutor_config from tutor import config as tutor_config
from tutor import interactive from tutor import interactive
from tutor.types import get_typed, Config from tutor.types import Config, get_typed
class ConfigTests(unittest.TestCase): class ConfigTests(unittest.TestCase):
@ -30,8 +32,8 @@ class ConfigTests(unittest.TestCase):
self.assertNotEqual("abcd", config["MYSQL_ROOT_PASSWORD"]) self.assertNotEqual("abcd", config["MYSQL_ROOT_PASSWORD"])
@patch.object(tutor_config.fmt, "echo") @patch.object(tutor_config.fmt, "echo")
def test_save_load(self, _: Mock) -> None: def test_update_twice_should_return_same_config(self, _: Mock) -> None:
with tempfile.TemporaryDirectory() as root: with temporary_root() as root:
config1 = tutor_config.load_minimal(root) config1 = tutor_config.load_minimal(root)
tutor_config.save_config_file(root, config1) tutor_config.save_config_file(root, config1)
config2 = tutor_config.load_minimal(root) config2 = tutor_config.load_minimal(root)
@ -40,7 +42,7 @@ class ConfigTests(unittest.TestCase):
@patch.object(tutor_config.fmt, "echo") @patch.object(tutor_config.fmt, "echo")
def test_removed_entry_is_added_on_save(self, _: Mock) -> None: def test_removed_entry_is_added_on_save(self, _: Mock) -> None:
with tempfile.TemporaryDirectory() as root: with temporary_root() as root:
with patch.object( with patch.object(
tutor_config.utils, "random_string" tutor_config.utils, "random_string"
) as mock_random_string: ) as mock_random_string:
@ -62,7 +64,7 @@ class ConfigTests(unittest.TestCase):
def mock_prompt(*_args: None, **kwargs: str) -> str: def mock_prompt(*_args: None, **kwargs: str) -> str:
return kwargs["default"] return kwargs["default"]
with tempfile.TemporaryDirectory() as rootdir: with temporary_root() as rootdir:
with patch.object(click, "prompt", new=mock_prompt): with patch.object(click, "prompt", new=mock_prompt):
with patch.object(click, "confirm", new=mock_prompt): with patch.object(click, "confirm", new=mock_prompt):
config = interactive.load_user_config(rootdir, interactive=True) config = interactive.load_user_config(rootdir, interactive=True)
@ -74,6 +76,27 @@ class ConfigTests(unittest.TestCase):
def test_is_service_activated(self) -> None: def test_is_service_activated(self) -> None:
config: Config = {"RUN_SERVICE1": True, "RUN_SERVICE2": False} config: Config = {"RUN_SERVICE1": True, "RUN_SERVICE2": False}
self.assertTrue(tutor_config.is_service_activated(config, "service1")) self.assertTrue(tutor_config.is_service_activated(config, "service1"))
self.assertFalse(tutor_config.is_service_activated(config, "service2")) self.assertFalse(tutor_config.is_service_activated(config, "service2"))
@patch.object(tutor_config.fmt, "echo")
def test_json_config_is_overwritten_by_yaml(self, _: Mock) -> None:
with temporary_root() as root:
# Create config from scratch
config_yml_path = os.path.join(root, tutor_config.CONFIG_FILENAME)
config_json_path = os.path.join(
root, tutor_config.CONFIG_FILENAME.replace("yml", "json")
)
config = tutor_config.load_full(root)
# Save config to json
with open(config_json_path, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=4)
self.assertFalse(os.path.exists(config_yml_path))
self.assertTrue(os.path.exists(config_json_path))
# Reload and compare
current = tutor_config.load_full(root)
self.assertTrue(os.path.exists(config_yml_path))
self.assertFalse(os.path.exists(config_json_path))
self.assertEqual(config, current)

View File

@ -1,12 +1,10 @@
import os import os
import tempfile import tempfile
import unittest import unittest
from unittest.mock import patch, Mock from unittest.mock import Mock, patch
from tutor import config as tutor_config from tutor import config as tutor_config
from tutor import env from tutor import env, exceptions, fmt
from tutor import fmt
from tutor import exceptions
from tutor.types import Config from tutor.types import Config
@ -32,10 +30,10 @@ class EnvTests(unittest.TestCase):
self.assertTrue(os.path.exists(path)) self.assertTrue(os.path.exists(path))
def test_pathjoin(self) -> None: def test_pathjoin(self) -> None:
with tempfile.TemporaryDirectory() as root:
self.assertEqual( self.assertEqual(
"/tmp/env/target/dummy", env.pathjoin("/tmp", "target", "dummy") os.path.join(root, "env", "dummy"), env.pathjoin(root, "dummy")
) )
self.assertEqual("/tmp/env/dummy", env.pathjoin("/tmp", "dummy"))
def test_render_str(self) -> None: def test_render_str(self) -> None:
self.assertEqual( self.assertEqual(

View File

@ -1,4 +1,5 @@
import unittest import unittest
from tutor import images from tutor import images
from tutor.types import Config from tutor.types import Config

91
tests/test_jobs.py Normal file
View File

@ -0,0 +1,91 @@
import re
import unittest
from io import StringIO
from unittest.mock import patch
from tests.helpers import TestContext, temporary_root
from tutor import config as tutor_config
from tutor import jobs
class JobsTests(unittest.TestCase):
@patch("sys.stdout", new_callable=StringIO)
def test_initialise(self, mock_stdout: StringIO) -> None:
with temporary_root() as root:
context = TestContext(root)
config = tutor_config.load_full(root)
runner = context.job_runner(config)
jobs.initialise(runner)
output = mock_stdout.getvalue().strip()
service = re.search(r"Service: (\w*)", output)
commands = re.search(r"(-----)([\S\s]+)(-----)", output)
assert service is not None
assert commands is not None
self.assertTrue(output.startswith("Initialising all services..."))
self.assertTrue(output.endswith("All services initialised."))
self.assertEqual(service.group(1), "mysql")
self.assertTrue(commands.group(2))
def test_create_user_command_without_staff(self) -> None:
command = jobs.create_user_command("superuser", False, "username", "email")
self.assertNotIn("--staff", command)
def test_create_user_command_with_staff(self) -> None:
command = jobs.create_user_command("superuser", True, "username", "email")
self.assertIn("--staff", command)
def test_create_user_command_with_staff_with_password(self) -> None:
command = jobs.create_user_command(
"superuser", True, "username", "email", "command"
)
self.assertIn("set_password", command)
@patch("sys.stdout", new_callable=StringIO)
def test_import_demo_course(self, mock_stdout: StringIO) -> None:
with temporary_root() as root:
context = TestContext(root)
config = tutor_config.load_full(root)
runner = context.job_runner(config)
jobs.import_demo_course(runner)
output = mock_stdout.getvalue()
service = re.search(r"Service: (\w*)", output)
commands = re.search(r"(-----)([\S\s]+)(-----)", output)
assert service is not None
assert commands is not None
self.assertEqual(service.group(1), "cms")
self.assertTrue(
commands.group(2)
.strip()
.startswith('echo "Loading settings $DJANGO_SETTINGS_MODULE"')
)
@patch("sys.stdout", new_callable=StringIO)
def test_set_theme(self, mock_stdout: StringIO) -> None:
with temporary_root() as root:
context = TestContext(root)
config = tutor_config.load_full(root)
runner = context.job_runner(config)
jobs.set_theme("sample_theme", ["domain1", "domain2"], runner)
output = mock_stdout.getvalue()
service = re.search(r"Service: (\w*)", output)
commands = re.search(r"(-----)([\S\s]+)(-----)", output)
assert service is not None
assert commands is not None
self.assertEqual(service.group(1), "lms")
self.assertTrue(
commands.group(2)
.strip()
.startswith(
"export DJANGO_SETTINGS_MODULE=$SERVICE_VARIANT.envs.$SETTINGS"
)
)
def test_get_all_openedx_domains(self) -> None:
with temporary_root() as root:
config = tutor_config.load_full(root)
domains = jobs.get_all_openedx_domains(config)
self.assertTrue(domains)
self.assertEqual(6, len(domains))

View File

@ -2,10 +2,8 @@ import unittest
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from tutor import config as tutor_config from tutor import config as tutor_config
from tutor import exceptions from tutor import exceptions, fmt, plugins
from tutor import fmt from tutor.types import Config, get_typed
from tutor import plugins
from tutor.types import get_typed, Config
class PluginsTests(unittest.TestCase): class PluginsTests(unittest.TestCase):
@ -13,15 +11,16 @@ class PluginsTests(unittest.TestCase):
plugins.Plugins.clear() plugins.Plugins.clear()
@patch.object(plugins.DictPlugin, "iter_installed", return_value=[]) @patch.object(plugins.DictPlugin, "iter_installed", return_value=[])
def test_iter_installed(self, _dict_plugin_iter_installed: Mock) -> None: def test_iter_installed(self, dict_plugin_iter_installed: Mock) -> None:
with patch.object(plugins.pkg_resources, "iter_entry_points", return_value=[]): # type: ignore with patch.object(plugins.pkg_resources, "iter_entry_points", return_value=[]): # type: ignore
self.assertEqual([], list(plugins.iter_installed())) self.assertEqual([], list(plugins.iter_installed()))
dict_plugin_iter_installed.assert_called_once()
def test_is_installed(self) -> None: def test_is_installed(self) -> None:
self.assertFalse(plugins.is_installed("dummy")) self.assertFalse(plugins.is_installed("dummy"))
@patch.object(plugins.DictPlugin, "iter_installed", return_value=[]) @patch.object(plugins.DictPlugin, "iter_installed", return_value=[])
def test_official_plugins(self, _dict_plugin_iter_installed: Mock) -> None: def test_official_plugins(self, dict_plugin_iter_installed: Mock) -> None:
with patch.object(plugins.importlib, "import_module", return_value=42): # type: ignore with patch.object(plugins.importlib, "import_module", return_value=42): # type: ignore
plugin1 = plugins.OfficialPlugin.load("plugin1") plugin1 = plugins.OfficialPlugin.load("plugin1")
with patch.object(plugins.importlib, "import_module", return_value=43): # type: ignore with patch.object(plugins.importlib, "import_module", return_value=43): # type: ignore
@ -35,6 +34,7 @@ class PluginsTests(unittest.TestCase):
[plugin1, plugin2], [plugin1, plugin2],
list(plugins.iter_installed()), list(plugins.iter_installed()),
) )
dict_plugin_iter_installed.assert_called_once()
def test_enable(self) -> None: def test_enable(self) -> None:
config: Config = {plugins.CONFIG_KEY: []} config: Config = {plugins.CONFIG_KEY: []}

View File

@ -1,7 +1,12 @@
import base64 import base64
import os
import sys
import tempfile
import unittest import unittest
from io import StringIO
from unittest.mock import MagicMock, patch
from tutor import utils from tutor import exceptions, utils
class UtilsTests(unittest.TestCase): class UtilsTests(unittest.TestCase):
@ -24,7 +29,7 @@ class UtilsTests(unittest.TestCase):
def test_list_if(self) -> None: def test_list_if(self) -> None:
self.assertEqual('["cms"]', utils.list_if([("lms", False), ("cms", True)])) self.assertEqual('["cms"]', utils.list_if([("lms", False), ("cms", True)]))
def test_encrypt_decrypt(self) -> None: def test_encrypt_success(self) -> None:
password = "passw0rd" password = "passw0rd"
encrypted1 = utils.encrypt(password) encrypted1 = utils.encrypt(password)
encrypted2 = utils.encrypt(password) encrypted2 = utils.encrypt(password)
@ -32,6 +37,24 @@ class UtilsTests(unittest.TestCase):
self.assertTrue(utils.verify_encrypted(encrypted1, password)) self.assertTrue(utils.verify_encrypted(encrypted1, password))
self.assertTrue(utils.verify_encrypted(encrypted2, password)) self.assertTrue(utils.verify_encrypted(encrypted2, password))
def test_encrypt_fail(self) -> None:
password = "passw0rd"
self.assertFalse(utils.verify_encrypted(password, password))
def test_ensure_file_directory_exists(self) -> None:
with tempfile.TemporaryDirectory() as root:
tempPath = os.path.join(root, "tempDir", "tempFile")
utils.ensure_file_directory_exists(tempPath)
self.assertTrue(os.path.exists(os.path.dirname(tempPath)))
def test_ensure_file_directory_exists_dirExists(self) -> None:
with tempfile.TemporaryDirectory() as root:
tempPath = os.path.join(root, "tempDir")
os.makedirs(tempPath)
self.assertRaises(
exceptions.TutorError, utils.ensure_file_directory_exists, tempPath
)
def test_long_to_base64(self) -> None: def test_long_to_base64(self) -> None:
self.assertEqual( self.assertEqual(
b"\x00", base64.urlsafe_b64decode(utils.long_to_base64(0) + "==") b"\x00", base64.urlsafe_b64decode(utils.long_to_base64(0) + "==")
@ -45,3 +68,93 @@ class UtilsTests(unittest.TestCase):
self.assertIsNotNone(imported.n) self.assertIsNotNone(imported.n)
self.assertIsNotNone(imported.p) self.assertIsNotNone(imported.p)
self.assertIsNotNone(imported.q) self.assertIsNotNone(imported.q)
def test_is_root(self) -> None:
result = utils.is_root()
self.assertFalse(result)
@patch("sys.platform", "win32")
def test_is_root_win32(self) -> None:
result = utils.is_root()
self.assertFalse(result)
def test_get_user_id(self) -> None:
result = utils.get_user_id()
if sys.platform == "win32":
self.assertEqual(0, result)
else:
self.assertNotEqual(0, result)
@patch("sys.platform", "win32")
def test_get_user_id_win32(self) -> None:
result = utils.get_user_id()
self.assertEqual(0, result)
@patch("sys.stdout", new_callable=StringIO)
@patch("subprocess.Popen", autospec=True)
def test_execute_exit_without_error(
self, mock_popen: MagicMock, mock_stdout: StringIO
) -> None:
process = mock_popen.return_value
mock_popen.return_value.__enter__.return_value = process
process.wait.return_value = 0
process.communicate.return_value = ("output", "error")
result = utils.execute("echo", "")
self.assertEqual(0, result)
self.assertEqual("echo \n", mock_stdout.getvalue())
self.assertEqual(1, process.wait.call_count)
process.kill.assert_not_called()
@patch("sys.stdout", new_callable=StringIO)
@patch("subprocess.Popen", autospec=True)
def test_execute_exit_with_error(
self, mock_popen: MagicMock, mock_stdout: StringIO
) -> None:
process = mock_popen.return_value
mock_popen.return_value.__enter__.return_value = process
process.wait.return_value = 1
process.communicate.return_value = ("output", "error")
self.assertRaises(exceptions.TutorError, utils.execute, "echo", "")
self.assertEqual("echo \n", mock_stdout.getvalue())
self.assertEqual(1, process.wait.call_count)
process.kill.assert_not_called()
@patch("sys.stdout", new_callable=StringIO)
@patch("subprocess.Popen", autospec=True)
def test_execute_throw_exception(
self, mock_popen: MagicMock, mock_stdout: StringIO
) -> None:
process = mock_popen.return_value
mock_popen.return_value.__enter__.return_value = process
process.wait.side_effect = ZeroDivisionError("Exception occurred.")
self.assertRaises(ZeroDivisionError, utils.execute, "echo", "")
self.assertEqual("echo \n", mock_stdout.getvalue())
self.assertEqual(2, process.wait.call_count)
process.kill.assert_called_once()
@patch("sys.stdout", new_callable=StringIO)
@patch("subprocess.Popen", autospec=True)
def test_execute_keyboard_interrupt(
self, mock_popen: MagicMock, mock_stdout: StringIO
) -> None:
process = mock_popen.return_value
mock_popen.return_value.__enter__.return_value = process
process.wait.side_effect = KeyboardInterrupt()
with self.assertRaises(KeyboardInterrupt):
utils.execute("echo", "")
output = mock_stdout.getvalue()
self.assertIn("echo", output)
self.assertEqual(2, process.wait.call_count)
process.kill.assert_called_once()
@patch("sys.platform", "win32")
def test_check_macos_memory_win32_should_skip(self) -> None:
utils.check_macos_memory()
@patch("sys.platform", "darwin")
def test_check_macos_memory_darwin_filenotfound(self) -> None:
self.assertRaises(exceptions.TutorError, utils.check_macos_memory)

View File

@ -1,20 +1,17 @@
#! /usr/bin/env python3
import sys import sys
import appdirs import appdirs
import click import click
from .. import exceptions, fmt, utils
from ..__about__ import __app__, __version__
from .config import config_command from .config import config_command
from .context import Context from .context import Context
from .dev import dev from .dev import dev
from .images import images_command from .images import images_command
from .k8s import k8s from .k8s import k8s
from .local import local from .local import local
from .plugins import plugins_command, add_plugin_commands from .plugins import add_plugin_commands, plugins_command
from ..__about__ import __version__, __app__
from .. import exceptions
from .. import fmt
from .. import utils
def main() -> None: def main() -> None:
@ -35,7 +32,10 @@ def main() -> None:
sys.exit(1) sys.exit(1)
@click.group(context_settings={"help_option_names": ["-h", "--help", "help"]}) @click.group(
context_settings={"help_option_names": ["-h", "--help", "help"]},
help="Tutor is the Docker-based Open edX distribution designed for peace of mind.",
)
@click.version_option(version=__version__) @click.version_option(version=__version__)
@click.option( @click.option(
"-r", "-r",

View File

@ -6,11 +6,9 @@ import click
from .. import bindmounts from .. import bindmounts
from .. import config as tutor_config from .. import config as tutor_config
from .. import env as tutor_env from .. import env as tutor_env
from .. import fmt, jobs, utils
from ..exceptions import TutorError from ..exceptions import TutorError
from .. import fmt
from .. import jobs
from ..types import Config from ..types import Config
from .. import utils
from .context import BaseJobContext from .context import BaseJobContext

View File

@ -3,9 +3,7 @@ from typing import List
import click import click
from .. import config as tutor_config from .. import config as tutor_config
from .. import env from .. import env, exceptions, fmt
from .. import exceptions
from .. import fmt
from .. import interactive as interactive_config from .. import interactive as interactive_config
from .. import serialize from .. import serialize
from ..types import Config from ..types import Config

View File

@ -9,7 +9,7 @@ class Context:
The project `root` is passed to all subcommands of `tutor`; that's because The project `root` is passed to all subcommands of `tutor`; that's because
it is defined as an argument of the top-level command. For instance: it is defined as an argument of the top-level command. For instance:
tutor --root=... local run ... $ tutor --root=... local run ...
""" """
def __init__(self, root: str) -> None: def __init__(self, root: str) -> None:

View File

@ -4,9 +4,7 @@ import click
from .. import config as tutor_config from .. import config as tutor_config
from .. import env as tutor_env from .. import env as tutor_env
from .. import exceptions from .. import exceptions, images, plugins
from .. import images
from .. import plugins
from ..types import Config from ..types import Config
from .context import Context from .context import Context
@ -88,7 +86,7 @@ def build(
@click.argument("image_names", metavar="image", nargs=-1) @click.argument("image_names", metavar="image", nargs=-1)
@click.pass_obj @click.pass_obj
def pull(context: Context, image_names: List[str]) -> None: def pull(context: Context, image_names: List[str]) -> None:
config = tutor_config.load(context.root) config = tutor_config.load_full(context.root)
for image in image_names: for image in image_names:
pull_image(config, image) pull_image(config, image)
@ -97,7 +95,7 @@ def pull(context: Context, image_names: List[str]) -> None:
@click.argument("image_names", metavar="image", nargs=-1) @click.argument("image_names", metavar="image", nargs=-1)
@click.pass_obj @click.pass_obj
def push(context: Context, image_names: List[str]) -> None: def push(context: Context, image_names: List[str]) -> None:
config = tutor_config.load(context.root) config = tutor_config.load_full(context.root)
for image in image_names: for image in image_names:
push_image(config, image) push_image(config, image)
@ -106,7 +104,7 @@ def push(context: Context, image_names: List[str]) -> None:
@click.argument("image_names", metavar="image", nargs=-1) @click.argument("image_names", metavar="image", nargs=-1)
@click.pass_obj @click.pass_obj
def printtag(context: Context, image_names: List[str]) -> None: def printtag(context: Context, image_names: List[str]) -> None:
config = tutor_config.load(context.root) config = tutor_config.load_full(context.root)
for image in image_names: for image in image_names:
to_print = [] to_print = []
for _img, tag in iter_images(config, image, BASE_IMAGE_NAMES): for _img, tag in iter_images(config, image, BASE_IMAGE_NAMES):

View File

@ -6,12 +6,8 @@ import click
from .. import config as tutor_config from .. import config as tutor_config
from .. import env as tutor_env from .. import env as tutor_env
from .. import exceptions from .. import exceptions, fmt, jobs, serialize, utils
from .. import fmt
from .. import jobs
from .. import serialize
from ..types import Config, get_typed from ..types import Config, get_typed
from .. import utils
from .config import save as config_save_command from .config import save as config_save_command
from .context import Context from .context import Context

View File

@ -4,10 +4,8 @@ import click
from .. import config as tutor_config from .. import config as tutor_config
from .. import env as tutor_env from .. import env as tutor_env
from .. import fmt from .. import exceptions, fmt, utils
from ..types import Config, get_typed from ..types import Config, get_typed
from .. import utils
from .. import exceptions
from . import compose from . import compose
from .config import save as config_save_command from .config import save as config_save_command

View File

@ -1,15 +1,13 @@
import os import os
import shutil import shutil
from typing import List
import urllib.request import urllib.request
from typing import List
import click import click
from .. import config as tutor_config from .. import config as tutor_config
from .. import env as tutor_env from .. import env as tutor_env
from .. import exceptions from .. import exceptions, fmt, plugins
from .. import fmt
from .. import plugins
from .context import Context from .context import Context

View File

@ -3,6 +3,8 @@ import os
from . import env, exceptions, fmt, plugins, serialize, utils from . import env, exceptions, fmt, plugins, serialize, utils
from .types import Config, cast_config from .types import Config, cast_config
CONFIG_FILENAME = "config.yml"
def load(root: str) -> Config: def load(root: str) -> Config:
""" """
@ -37,6 +39,11 @@ def load_minimal(root: str) -> Config:
def load_full(root: str) -> Config: def load_full(root: str) -> Config:
""" """
Load a full configuration, with user, base and defaults. Load a full configuration, with user, base and defaults.
Return:
current (dict): params currently saved in config.yml
defaults (dict): default values of params which might be missing from the
current config
""" """
config = get_user(root) config = get_user(root)
update_with_base(config) update_with_base(config)
@ -234,15 +241,13 @@ def convert_json2yml(root: str) -> None:
return return
if os.path.exists(config_path(root)): if os.path.exists(config_path(root)):
raise exceptions.TutorError( raise exceptions.TutorError(
"Both config.json and config.yml exist in {}: only one of these files must exist to continue".format( f"Both config.json and {CONFIG_FILENAME} exist in {root}: only one of these files must exist to continue"
root
)
) )
config = get_yaml_file(json_path) config = get_yaml_file(json_path)
save_config_file(root, config) save_config_file(root, config)
os.remove(json_path) os.remove(json_path)
fmt.echo_info( fmt.echo_info(
"File config.json detected in {} and converted to config.yml".format(root) f"File config.json detected in {root} and converted to {CONFIG_FILENAME}"
) )
@ -251,8 +256,8 @@ def save_config_file(root: str, config: Config) -> None:
utils.ensure_file_directory_exists(path) utils.ensure_file_directory_exists(path)
with open(path, "w") as of: with open(path, "w") as of:
serialize.dump(config, of) serialize.dump(config, of)
fmt.echo_info("Configuration saved to {}".format(path)) fmt.echo_info(f"Configuration saved to {path}")
def config_path(root: str) -> str: def config_path(root: str) -> str:
return os.path.join(root, "config.yml") return os.path.join(root, CONFIG_FILENAME)

View File

@ -349,7 +349,8 @@ def current_version(root: str) -> str:
path = pathjoin(root, VERSION_FILENAME) path = pathjoin(root, VERSION_FILENAME)
if not os.path.exists(path): if not os.path.exists(path):
return "0.0.0" return "0.0.0"
return open(path).read().strip() with open(path) as f:
return f.read().strip()
def read_template_file(*path: str) -> str: def read_template_file(*path: str) -> str:

View File

@ -8,8 +8,8 @@ import appdirs
import click import click
import pkg_resources import pkg_resources
from .__about__ import __app__
from . import exceptions, fmt, serialize from . import exceptions, fmt, serialize
from .__about__ import __app__
from .types import Config, get_typed from .types import Config, get_typed
CONFIG_KEY = "PLUGINS" CONFIG_KEY = "PLUGINS"

View File

@ -10,7 +10,6 @@ import sys
from typing import List, Tuple from typing import List, Tuple
import click import click
from Crypto.Protocol.KDF import bcrypt, bcrypt_check from Crypto.Protocol.KDF import bcrypt, bcrypt_check
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from Crypto.PublicKey.RSA import RsaKey from Crypto.PublicKey.RSA import RsaKey
@ -145,11 +144,12 @@ def get_user_id() -> int:
""" """
Portable way to get user ID. Note: I have no idea if it actually works on windows... Portable way to get user ID. Note: I have no idea if it actually works on windows...
""" """
if sys.platform == "win32": if sys.platform != "win32":
# Don't even try
return 0
return os.getuid() return os.getuid()
# Don't even try for windows
return 0
def docker_run(*command: str) -> int: def docker_run(*command: str) -> int:
args = ["run", "--rm"] args = ["run", "--rm"]
@ -187,7 +187,7 @@ def is_a_tty() -> bool:
Return True if stdin is able to allocate a tty. Tty allocation sometimes cannot be Return True if stdin is able to allocate a tty. Tty allocation sometimes cannot be
enabled, for instance in cron jobs enabled, for instance in cron jobs
""" """
return os.isatty(sys.stdin.fileno()) return sys.stdin.isatty()
def execute(*command: str) -> int: def execute(*command: str) -> int: