diff --git a/django_setup_configuration/management/commands/setup_configuration.py b/django_setup_configuration/management/commands/setup_configuration.py index 39abd8d..d2bd7af 100644 --- a/django_setup_configuration/management/commands/setup_configuration.py +++ b/django_setup_configuration/management/commands/setup_configuration.py @@ -1,3 +1,5 @@ +import functools +import textwrap from pathlib import Path from django.core.management import BaseCommand, CommandError @@ -6,15 +8,7 @@ from django_setup_configuration.exceptions import ValidateRequirementsFailure from django_setup_configuration.runner import SetupConfigurationRunner - -class ErrorDict(dict): - """ - small helper to display errors - """ - - def as_text(self) -> str: - output = [f"{k}: {v}" for k, v in self.items()] - return "\n".join(output) +indent = functools.partial(textwrap.indent, prefix=" " * 4) class Command(BaseCommand): @@ -57,58 +51,85 @@ def handle(self, **options): if not runner.configured_steps: raise CommandError("No steps configured, aborting.") - self.stdout.write( - "The following steps are configured:\n%s" - % "\n".join(str(step) for step in runner.configured_steps), - ) + self.stdout.write("The following steps are configured:") + for step in runner.configured_steps: + step_is_enabled = step in runner.enabled_steps + self.stdout.write( + indent( + f"{step.verbose_name} from {step.__class__}" + f" [{'enabled' if step_is_enabled else '***disabled***'}]" + ), + ) if not runner.enabled_steps: raise CommandError("No steps enabled, aborting.") - errors = ErrorDict() + if disabled_steps := runner.disabled_steps: + self.stdout.write( + "The following steps will be skipped because they are disabled:", + self.style.WARNING, + ) + for step in disabled_steps: + self.stdout.write( + indent( + f"{step.verbose_name} from {step.__class__}" + f" [{step.enable_setting} = false]" + ), + self.style.WARNING, + ) + # 1. Check prerequisites of all steps + steps_with_invalid_requirements = [] + self.stdout.write() + self.stdout.write("Validating requirements...") try: runner.validate_all_requirements() except ValidateRequirementsFailure as exc_group: for exc in exc_group.exceptions: self.stderr.write( - f"Unable to satisfy prerequisites for step:" - f" {exc.step.verbose_name}:" + self.style.ERROR( + f"Invalid configuration settings for step" + f' "{exc.step.verbose_name}":' + ) ) - errors[exc.step] = str(exc) + # Print an indented version of the validation error + self.stderr.write(indent(str(exc.validation_error)), self.style.ERROR) + self.stderr.write() + steps_with_invalid_requirements.append(exc.step) - if errors: raise CommandError( - f"Prerequisites for configuration are not fulfilled: {errors.as_text()}" + f"Failed to validate requirements for {len(exc_group.exceptions)}" + " steps" ) + self.stdout.write( + "Valid configuration settings found for all steps.", self.style.SUCCESS + ) + + # Bail out early if we're only validating if validate_only: - self.stdout.write( - self.style.SUCCESS( - "All configuration values could be successfully read from source." - ) - ) return + # 2. Execute steps + self.stdout.write() self.stdout.write("Executing steps...") - - # 2. Configure steps for result in runner.execute_all_iter(): - if not result.is_enabled: - self.stdout.write( - self.style.NOTICE( - f"Skipping step '{result.step}' because it is not enabled" - ) + if exc := result.run_exception: + self.stderr.write( + f"Error while executing step `{result.step}`", self.style.ERROR ) - continue + self.stderr.write(indent(str(exc))) - if exc := result.run_exception: raise CommandError( - f"Error while executing step `{result.step}`: {str(exc)}" - ) + "Aborting run due to a failed step. All database changes have been" + " rolled back." + ) from exc else: self.stdout.write( - self.style.SUCCESS(f"Successfully executed step: {result.step}") + indent(f"Successfully executed step: {result.step}"), + self.style.SUCCESS, ) - self.stdout.write(self.style.SUCCESS("Instance configuration completed.")) + # Done + self.stdout.write("") + self.stdout.write("Configuration completed.", self.style.SUCCESS) diff --git a/django_setup_configuration/runner.py b/django_setup_configuration/runner.py index 9f6e7e1..bc939e4 100644 --- a/django_setup_configuration/runner.py +++ b/django_setup_configuration/runner.py @@ -180,6 +180,12 @@ def enabled_steps(self) -> list[BaseConfigurationStep]: return steps + @property + def disabled_steps(self) -> list[BaseConfigurationStep]: + return [ + step for step in self.configured_steps if step not in self.enabled_steps + ] + def validate_all_requirements(self): """ Validate that the configuration models for each step can be constructed from the diff --git a/tests/test_command.py b/tests/test_command.py index 07f9b65..0fa099d 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -3,6 +3,7 @@ from django.contrib.auth.models import User from django.core.management import CommandError, call_command +import pydantic import pytest from django_setup_configuration.test_utils import build_step_config_from_sources @@ -108,7 +109,6 @@ def test_command_errors_on_bad_yaml_file(step_execute_mock): def test_command_success( - settings, yaml_file_with_valid_configuration, expected_step_config, step_execute_mock, @@ -117,25 +117,35 @@ def test_command_success( test happy flow """ assert User.objects.count() == 0 - stdout = StringIO() + stdout, stderr = StringIO(), StringIO() call_command( "setup_configuration", yaml_file=yaml_file_with_valid_configuration, stdout=stdout, + stderr=stderr, ) + assert stderr.getvalue() == "" + + # flake8: noqa: E501 output = stdout.getvalue().splitlines() expected_output = [ f"Loading config settings from {yaml_file_with_valid_configuration}", "The following steps are configured:", - "User Configuration", - "TestStep", + " User Configuration from [enabled]", + " TestStep from [enabled]", + "", + "Validating requirements...", + "Valid configuration settings found for all steps.", + "", "Executing steps...", - "Successfully executed step: User Configuration", - "Successfully executed step: TestStep", - "Instance configuration completed.", + " Successfully executed step: User Configuration", + " Successfully executed step: TestStep", + "", + "Configuration completed.", ] + # flake8: qa: E501 assert output == expected_output @@ -153,26 +163,25 @@ def test_command_success_with_validate_only_flag_does_not_run( expected_step_config, step_execute_mock, ): - """ - test happy flow - """ - assert User.objects.count() == 0 - stdout = StringIO() + stdout, stderr = StringIO(), StringIO() call_command( "setup_configuration", yaml_file=yaml_file_with_valid_configuration, - stdout=stdout, validate_only=True, + stdout=stdout, + stderr=stderr, ) output = stdout.getvalue().splitlines() expected_output = [ f"Loading config settings from {yaml_file_with_valid_configuration}", "The following steps are configured:", - "User Configuration", - "TestStep", - "All configuration values could be successfully read from source.", + " User Configuration from [enabled]", + " TestStep from [enabled]", + "", + "Validating requirements...", + "Valid configuration settings found for all steps.", ] assert output == expected_output @@ -199,17 +208,57 @@ def test_command_with_failing_requirements_reports_errors( } ) + stdout, stderr = StringIO(), StringIO() with pytest.raises(CommandError) as exc: call_command( "setup_configuration", yaml_file=yaml_path, + stdout=stdout, + stderr=stderr, ) - assert ( - "User Configuration: Failed to load config model for User Configuration" - in str(exc.value) - ) - assert "Failed to load config model for TestStep" in str(exc.value) + assert "Failed to validate requirements for 2 steps" in str(exc.value) + + output = stderr.getvalue().splitlines() + + # Strip the patch version, which is not used in the URLs + pydantic_version = ".".join(pydantic.__version__.split(".")[:2]) + # flake8: noqa: E501 + expected_output = [ + 'Invalid configuration settings for step "User Configuration":', + " 2 validation errors for ConfigSettingsSourceUser_configuration", + " user_configuration.username", + " Input should be a valid string [type=string_type, input_value=1874, input_type=int]", + f" For further information visit https://errors.pydantic.dev/{pydantic_version}/v/string_type", + " user_configuration.password", + " Field required [type=missing, input_value={'username': 1874}, input_type=dict]", + f" For further information visit https://errors.pydantic.dev/{pydantic_version}/v/missing", + "", + 'Invalid configuration settings for step "TestStep":', + " 2 validation errors for ConfigSettingsSourceTest_step", + " test_step.a_string", + " Input should be a valid string [type=string_type, input_value=42, input_type=int]", + f" For further information visit https://errors.pydantic.dev/{pydantic_version}/v/string_type", + " test_step.username", + " Input should be a valid string [type=string_type, input_value=None, input_type=NoneType]", + f" For further information visit https://errors.pydantic.dev/{pydantic_version}/v/string_type", + "", + ] + + assert output == expected_output + + output = stdout.getvalue().splitlines() + expected_output = [ + f"Loading config settings from {yaml_path}", + "The following steps are configured:", + " User Configuration from [enabled]", + " TestStep from [enabled]", + "", + "Validating requirements...", + ] + # flake8: qa: E501 + + assert output == expected_output assert User.objects.count() == 0 step_execute_mock.assert_not_called() @@ -233,18 +282,55 @@ def test_command_with_failing_requirements_and_validate_reports_errors( } ) + stdout, stderr = StringIO(), StringIO() with pytest.raises(CommandError) as exc: call_command( "setup_configuration", yaml_file=yaml_path, validate_only=False, + stdout=stdout, + stderr=stderr, ) - assert ( - "User Configuration: Failed to load config model for User Configuration" - in str(exc.value) - ) - assert "Failed to load config model for TestStep" in str(exc.value) + output = stderr.getvalue().splitlines() + + # Strip the patch version, which is not used in the URLs + pydantic_version = ".".join(pydantic.__version__.split(".")[:2]) + # flake8: noqa: E501 + expected_output = [ + 'Invalid configuration settings for step "User Configuration":', + " 2 validation errors for ConfigSettingsSourceUser_configuration", + " user_configuration.username", + " Input should be a valid string [type=string_type, input_value=1874, input_type=int]", + f" For further information visit https://errors.pydantic.dev/{pydantic_version}/v/string_type", + " user_configuration.password", + " Field required [type=missing, input_value={'username': 1874}, input_type=dict]", + f" For further information visit https://errors.pydantic.dev/{pydantic_version}/v/missing", + "", + 'Invalid configuration settings for step "TestStep":', + " 2 validation errors for ConfigSettingsSourceTest_step", + " test_step.a_string", + " Input should be a valid string [type=string_type, input_value=42, input_type=int]", + f" For further information visit https://errors.pydantic.dev/{pydantic_version}/v/string_type", + " test_step.username", + " Input should be a valid string [type=string_type, input_value=None, input_type=NoneType]", + f" For further information visit https://errors.pydantic.dev/{pydantic_version}/v/string_type", + "", + ] + + assert output == expected_output + + output = stdout.getvalue().splitlines() + expected_output = [ + f"Loading config settings from {yaml_path}", + "The following steps are configured:", + " User Configuration from [enabled]", + " TestStep from [enabled]", + "", + "Validating requirements...", + ] + assert output == expected_output + # flake8: qa: E501 assert User.objects.count() == 0 step_execute_mock.assert_not_called() @@ -255,27 +341,43 @@ def test_command_with_failing_execute_reports_errors( ): step_execute_mock.side_effect = ValueError("Something went wrong") - stdout = StringIO() + stdout, stderr = StringIO(), StringIO() with pytest.raises(CommandError) as exc: call_command( "setup_configuration", - stdout=stdout, yaml_file=yaml_file_with_valid_configuration, + stdout=stdout, + stderr=stderr, ) + # flake8: noqa: E501 assert ( - str(exc.value) == "Error while executing step `TestStep`: Something went wrong" + str(exc.value) + == "Aborting run due to a failed step. All database changes have been rolled back." ) output = stdout.getvalue().splitlines() expected_output = [ f"Loading config settings from {yaml_file_with_valid_configuration}", "The following steps are configured:", - "User Configuration", - "TestStep", + " User Configuration from [enabled]", + " TestStep from [enabled]", + "", + "Validating requirements...", + "Valid configuration settings found for all steps.", + "", "Executing steps...", - "Successfully executed step: User Configuration", + " Successfully executed step: User Configuration", + ] + # flake8: qa: E501 + + assert output == expected_output + + output = stderr.getvalue().splitlines() + expected_output = [ + "Error while executing step `TestStep`", + " Something went wrong", ] assert output == expected_output @@ -292,3 +394,89 @@ def test_load_step_config_from_source_returns_correct_model( ) assert model == user_config_model + + +def test_command_aborts_on_no_enabled_steps(step_execute_mock, yaml_file_factory): + yaml_path = yaml_file_factory( + { + "user_configuration_enabled": False, + "test_step_is_enabled": False, + } + ) + + stdout, stderr = StringIO(), StringIO() + with pytest.raises(CommandError) as exc: + call_command( + "setup_configuration", + yaml_file=yaml_path, + stdout=stdout, + stderr=stderr, + ) + + assert str(exc.value) == "No steps enabled, aborting." + + output = stdout.getvalue().splitlines() + # flake8: noqa: E501 + expected_output = [ + f"Loading config settings from {yaml_path}", + "The following steps are configured:", + " User Configuration from [***disabled***]", + " TestStep from [***disabled***]", + ] + # flake8: qa: E501 + + assert output == expected_output + + assert stderr.getvalue() == "" + + assert User.objects.count() == 0 + step_execute_mock.assert_not_called() + + +def test_command_with_disabled_and_enabled_steps_lists_the_disabled_steps( + step_execute_mock, yaml_file_factory +): + yaml_path = yaml_file_factory( + { + "user_configuration_enabled": True, + "user_configuration": { + "username": "alice", + "password": "secret", + }, + "test_step_is_enabled": False, + } + ) + + stdout, stderr = StringIO(), StringIO() + call_command( + "setup_configuration", + yaml_file=yaml_path, + stdout=stdout, + stderr=stderr, + ) + + # flake8: noqa: E501 + output = stdout.getvalue().splitlines() + expected_output = [ + f"Loading config settings from {yaml_path}", + "The following steps are configured:", + " User Configuration from [enabled]", + " TestStep from [***disabled***]", + "The following steps will be skipped because they are disabled:", + " TestStep from [test_step_is_enabled = false]", + "", + "Validating requirements...", + "Valid configuration settings found for all steps.", + "", + "Executing steps...", + " Successfully executed step: User Configuration", + "", + "Configuration completed.", + ] + # flake8: qa: E501 + + assert output == expected_output + assert stderr.getvalue() == "" + + assert User.objects.count() == 1 + step_execute_mock.assert_not_called()