diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 34d1c769f..5756cb651 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -231,6 +231,7 @@ repos: - astroid == 2.9.0 - ansible-runner - jinja2 + - jsonschema - libtmux - onigurumacffi - pytest diff --git a/docs/changelog-fragments.d/1093.feature.md b/docs/changelog-fragments.d/1093.feature.md new file mode 100644 index 000000000..f58abe7d0 --- /dev/null +++ b/docs/changelog-fragments.d/1093.feature.md @@ -0,0 +1,7 @@ +Added the ability to produce a json schema file for the settings file. + +```bash +ansible-navigator settings --schema +``` + +-- by {user}`cidrblock` diff --git a/mypy.ini b/mypy.ini index 123c1ad9f..8af13bb81 100644 --- a/mypy.ini +++ b/mypy.ini @@ -23,6 +23,10 @@ ignore_missing_imports = true # No type hints as of version 2.1.2 ignore_missing_imports = true +[mypy-jsonschema.*] +# No type hints as of version 4.4.0 +ignore_missing_imports = true + [mypy-libtmux] # No type hints as of version 0.10.3 ignore_missing_imports = true diff --git a/src/ansible_navigator/actions/settings.py b/src/ansible_navigator/actions/settings.py index 21658948e..6f7d0c591 100644 --- a/src/ansible_navigator/actions/settings.py +++ b/src/ansible_navigator/actions/settings.py @@ -3,12 +3,14 @@ from dataclasses import asdict from typing import Tuple +from ansible_navigator.configuration_subsystem.definitions import Constants from ..action_base import ActionBase from ..action_defs import RunStdoutReturn from ..app_public import AppPublic from ..configuration_subsystem import PresentableSettingsEntries from ..configuration_subsystem import PresentableSettingsEntry from ..configuration_subsystem import to_presentable +from ..configuration_subsystem import to_schema from ..content_defs import ContentView from ..content_defs import SerializationFormat from ..steps import StepType @@ -116,6 +118,12 @@ def run_stdout(self) -> RunStdoutReturn: :returns: RunStdoutReturn """ self._logger.debug("settings requested in stdout mode") + if self._args.entry("settings_schema").value.source is not Constants.DEFAULT_CFG: + if self._args.settings_schema == "json": + schema = to_schema(self._args) + print(schema) + return RunStdoutReturn(message="", return_code=0) + self._settings = to_presentable(self._args) info_dump = serialize( content=list(self._settings), diff --git a/src/ansible_navigator/configuration_subsystem/__init__.py b/src/ansible_navigator/configuration_subsystem/__init__.py index a8e593edd..022218bf3 100644 --- a/src/ansible_navigator/configuration_subsystem/__init__.py +++ b/src/ansible_navigator/configuration_subsystem/__init__.py @@ -8,6 +8,7 @@ from .defs_presentable import PresentableSettingsEntry from .navigator_configuration import NavigatorConfiguration from .transform import to_presentable +from .transform import to_schema __all__ = ( @@ -19,4 +20,5 @@ "PresentableSettingsEntries", "SettingsEntry", "to_presentable", + "to_schema", ) diff --git a/src/ansible_navigator/configuration_subsystem/definitions.py b/src/ansible_navigator/configuration_subsystem/definitions.py index 7c6d4708d..a492958b9 100644 --- a/src/ansible_navigator/configuration_subsystem/definitions.py +++ b/src/ansible_navigator/configuration_subsystem/definitions.py @@ -54,6 +54,7 @@ class CliParameters: """An object to hold the CLI parameters.""" action: Optional[str] = None + const: Optional[Union[bool, str]] = None long_override: Optional[str] = None nargs: Optional[str] = None positional: bool = False diff --git a/src/ansible_navigator/configuration_subsystem/navigator_configuration.py b/src/ansible_navigator/configuration_subsystem/navigator_configuration.py index 0fa7aa25e..17f8e1d1b 100644 --- a/src/ansible_navigator/configuration_subsystem/navigator_configuration.py +++ b/src/ansible_navigator/configuration_subsystem/navigator_configuration.py @@ -520,6 +520,22 @@ class Internals: ), value=SettingsEntryValue(), ), + SettingsEntry( + name="settings_schema", + choices=["json"], + cli_parameters=CliParameters( + short="--ss", + long_override="--schema", + const="json", + nargs="?", + ), + settings_file_path_override="settings.schema", + short_description=( + "Generate a schema for the settings file. ('json'= draft-07 JSON Schema)" + ), + subcommands=["settings"], + value=SettingsEntryValue(default="json"), + ), SettingsEntry( name="time_zone", cli_parameters=CliParameters(short="--tz"), diff --git a/src/ansible_navigator/configuration_subsystem/navigator_post_processor.py b/src/ansible_navigator/configuration_subsystem/navigator_post_processor.py index 777fd4df9..bd9342d6d 100644 --- a/src/ansible_navigator/configuration_subsystem/navigator_post_processor.py +++ b/src/ansible_navigator/configuration_subsystem/navigator_post_processor.py @@ -430,13 +430,13 @@ def exec_shell( return self._true_or_false(entry, config) @_post_processor - def _help_for_command( + def _forced_stdout( self, entry: SettingsEntry, config: ApplicationConfiguration, subcommand: str, ) -> PostProcessorReturn: - """Post process help_xxxx + """Force mode stdout for a settings parameter. :param entry: The current settings entry :param config: The full application configuration @@ -452,11 +452,11 @@ def _help_for_command( messages.append(LogMessage(level=logging.DEBUG, message=message)) return messages, exit_messages - help_builder = partialmethod(_help_for_command, subcommand="builder") - help_config = partialmethod(_help_for_command, subcommand="config") - help_doc = partialmethod(_help_for_command, subcommand="doc") - help_inventory = partialmethod(_help_for_command, subcommand="inventory") - help_playbook = partialmethod(_help_for_command, subcommand="run") + help_builder = partialmethod(_forced_stdout, subcommand="builder") + help_config = partialmethod(_forced_stdout, subcommand="config") + help_doc = partialmethod(_forced_stdout, subcommand="doc") + help_inventory = partialmethod(_forced_stdout, subcommand="inventory") + help_playbook = partialmethod(_forced_stdout, subcommand="run") @staticmethod @_post_processor @@ -766,6 +766,28 @@ def pull_arguments( entry.value.current = flatten_list(entry.value.current) return messages, exit_messages + @_post_processor + def settings_schema( + self, + entry: SettingsEntry, + config: ApplicationConfiguration, + ) -> PostProcessorReturn: + """Force mode stdout for schema parameter. + + :param entry: The current settings entry + :param config: The full application configuration + :returns: An instance of the standard post process return object + """ + messages: List[LogMessage] = [] + exit_messages: List[ExitMessage] = [] + + if entry.value.source is not C.DEFAULT_CFG and config.app == "settings": + mode = Mode.STDOUT + self._requested_mode.append(ModeChangeRequest(entry=entry.name, mode=mode)) + message = message = f"`{entry.name} requesting mode {mode.value}" + messages.append(LogMessage(level=logging.DEBUG, message=message)) + return messages, exit_messages + @staticmethod @_post_processor def set_environment_variable( diff --git a/src/ansible_navigator/configuration_subsystem/parser.py b/src/ansible_navigator/configuration_subsystem/parser.py index f4f421c6f..d7234b462 100644 --- a/src/ansible_navigator/configuration_subsystem/parser.py +++ b/src/ansible_navigator/configuration_subsystem/parser.py @@ -53,6 +53,8 @@ def generate_argument(entry) -> Tuple[Any, Any, Dict[str, Any]]: kwargs["dest"] = entry.name if entry.cli_parameters.nargs is not None: kwargs["nargs"] = entry.cli_parameters.nargs + if entry.cli_parameters.const is not None: + kwargs["const"] = entry.cli_parameters.const if entry.cli_parameters.metavar is not None: kwargs["metavar"] = entry.cli_parameters.metavar diff --git a/src/ansible_navigator/configuration_subsystem/schema.py b/src/ansible_navigator/configuration_subsystem/schema.py new file mode 100644 index 000000000..764cccb52 --- /dev/null +++ b/src/ansible_navigator/configuration_subsystem/schema.py @@ -0,0 +1,232 @@ +"""Partial json schema for settings.""" +from typing import Dict + + +PARTIAL_SCHEMA: Dict = { + "$schema": "http://json-schema.org/draft-07/schema", + "additionalProperties": False, + "properties": { + "ansible-navigator": { + "properties": { + "ansible": { + "additionalProperties": False, + "properties": { + "cmdline": { + "type": "string", + }, + "config": { + "type": "string", + }, + "inventories": { + "items": {"type": "string"}, + "type": "array", + }, + "playbook": {"type": "string"}, + }, + "type": "object", + }, + "ansible-builder": { + "additionalProperties": False, + "type": "object", + "properties": { + "workdir": { + "type": "string", + }, + }, + }, + "ansible-runner": { + "additionalProperties": False, + "properties": { + "artifact-dir": { + "type": "string", + }, + "rotate-artifacts-count": { + "type": "integer", + }, + "timeout": { + "type": "integer", + }, + }, + "type": "object", + }, + "app": { + "type": "string", + }, + "collection-doc-cache-path": { + "type": "string", + }, + "color": { + "additionalProperties": False, + "properties": { + "enable": { + "type": "boolean", + }, + "osc4": { + "type": "boolean", + }, + }, + "type": "object", + }, + "documentation": { + "additionalProperties": False, + "properties": { + "plugin": { + "additionalProperties": False, + "properties": { + "name": { + "type": "string", + }, + "type": { + "type": "string", + }, + }, + "type": "object", + }, + }, + "type": "object", + }, + "editor": { + "additionalProperties": False, + "properties": { + "command": { + "type": "string", + }, + "console": { + "type": "boolean", + }, + }, + "type": "object", + }, + "exec": { + "additionalProperties": False, + "properties": { + "command": { + "type": "string", + }, + "shell": { + "type": "boolean", + }, + }, + "type": "object", + }, + "execution-environment": { + "additionalProperties": False, + "properties": { + "container-engine": { + "type": "string", + }, + "container-options": { + "items": {"type": "string"}, + "type": "array", + }, + "enabled": { + "type": "boolean", + }, + "environment-variables": { + "additionalProperties": False, + "properties": { + "pass": { + "items": {"type": "string"}, + "type": "array", + }, + "set": { + "type": "object", + }, + }, + "type": "object", + }, + "image": { + "type": "string", + }, + "pull": { + "additionalProperties": False, + "properties": { + "arguments": { + "items": {"type": "string"}, + "type": "array", + }, + "policy": { + "type": "string", + }, + }, + }, + "volume-mounts": { + "additionalProperties": False, + "properties": { + "dest": {"type": "string"}, + "label": {"type": "string"}, + "options": {"type": "string"}, + }, + "required": ["src", "dest"], + "type": "array", + }, + }, + "type": "object", + }, + "help-builder": { + "type": "boolean", + }, + "help-config": { + "type": "boolean", + }, + "help-doc": { + "type": "boolean", + }, + "help-inventory": { + "type": "boolean", + }, + "help-playbook": { + "type": "boolean", + }, + "inventory-columns": { + "items": {"type": "string"}, + "type": "array", + }, + "logging": { + "additionalProperties": False, + "properties": { + "append": { + "type": "boolean", + }, + "file": { + "type": "string", + }, + "level": { + "type": "string", + }, + }, + "type": "object", + }, + "mode": { + "type": "string", + }, + "playbook-artifact": { + "additionalProperties": False, + "properties": { + "enable": { + "type": "boolean", + }, + "replay": { + "type": "string", + }, + "save-as": { + "type": "string", + }, + }, + "type": "object", + }, + "time-zone": { + "type": "string", + }, + "settings": { + "additionalProperties": False, + "properties": {"schema": {"type": "string"}}, + }, + }, + "additionalProperties": False, + }, + }, + "required": ["ansible-navigator"], + "title": "ansible-navigator settings file schema", + "type": "object", +} diff --git a/src/ansible_navigator/configuration_subsystem/transform.py b/src/ansible_navigator/configuration_subsystem/transform.py index ef68ca562..a5f19c606 100644 --- a/src/ansible_navigator/configuration_subsystem/transform.py +++ b/src/ansible_navigator/configuration_subsystem/transform.py @@ -1,8 +1,15 @@ """Methods of transforming the settings.""" +from typing import Dict + +from ..content_defs import ContentView +from ..utils.serialize import SerializationFormat +from ..utils.serialize import serialize from .definitions import ApplicationConfiguration +from .definitions import Constants from .defs_presentable import PresentableSettingsEntries from .defs_presentable import PresentableSettingsEntry +from .schema import PARTIAL_SCHEMA def to_presentable(settings: ApplicationConfiguration) -> PresentableSettingsEntries: @@ -34,3 +41,27 @@ def to_presentable(settings: ApplicationConfiguration) -> PresentableSettingsEnt settings_list.sort() return PresentableSettingsEntries(tuple(settings_list)) + + +def to_schema(settings: ApplicationConfiguration) -> str: + """Build a json schema from the settings using the stub schema. + + :param settings: The application settings + :returns: The json schema + """ + for entry in settings.entries: + subschema: Dict = PARTIAL_SCHEMA["properties"] + dot_parts = entry.settings_file_path(prefix=settings.application_name_dashed).split(".") + for part in dot_parts[:-1]: + if isinstance(subschema, dict): + subschema = subschema.get(part, {}).get("properties") + subschema[dot_parts[-1]]["description"] = entry.short_description + if entry.choices: + subschema[dot_parts[-1]]["enum"] = entry.choices + if entry.value.default is not Constants.NOT_SET: + subschema[dot_parts[-1]]["default"] = entry.value.default + return serialize( + content=PARTIAL_SCHEMA, + content_view=ContentView.NORMAL, + serialization_format=SerializationFormat.JSON, + ) diff --git a/test-requirements.txt b/test-requirements.txt index b398a7f2e..0acff07a8 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -2,6 +2,7 @@ ansible-core darglint flake8-docstrings flake8-quotes +jsonschema libtmux lxml pre-commit diff --git a/tests/fixtures/integration/actions/settings/test_stdout_tmux.py/test/0.json b/tests/fixtures/integration/actions/settings/test_stdout_tmux.py/test/0.json index 71ec85d8d..ba000a31b 100644 --- a/tests/fixtures/integration/actions/settings/test_stdout_tmux.py/test/0.json +++ b/tests/fixtures/integration/actions/settings/test_stdout_tmux.py/test/0.json @@ -20,9 +20,9 @@ " long: --workdir", " short: --bwd", " current_settings_file: None", - " current_value: /home/user/code/ansible-navigator", + " current_value: /home/user/github/ansible-navigator", " default: true", - " default_value: /home/user/code/ansible-navigator", + " default_value: /home/user/github/ansible-navigator", " description: Specify the path that contains ansible-builder manifest files", " env_var: ANSIBLE_NAVIGATOR_WORKDIR", " name: Workdir", diff --git a/tests/fixtures/integration/actions/settings/test_stdout_tmux.py/test/1.json b/tests/fixtures/integration/actions/settings/test_stdout_tmux.py/test/1.json index 762e01dfb..c567bc1a8 100644 --- a/tests/fixtures/integration/actions/settings/test_stdout_tmux.py/test/1.json +++ b/tests/fixtures/integration/actions/settings/test_stdout_tmux.py/test/1.json @@ -20,9 +20,9 @@ " long: --workdir", " short: --bwd", " current_settings_file: None", - " current_value: /home/user/code/ansible-navigator", + " current_value: /home/user/github/ansible-navigator", " default: true", - " default_value: /home/user/code/ansible-navigator", + " default_value: /home/user/github/ansible-navigator", " description: Specify the path that contains ansible-builder manifest files", " env_var: ANSIBLE_NAVIGATOR_WORKDIR", " name: Workdir", diff --git a/tests/fixtures/integration/actions/settings/test_stdout_tmux.py/test/2.json b/tests/fixtures/integration/actions/settings/test_stdout_tmux.py/test/2.json new file mode 100644 index 000000000..64f6a6a15 --- /dev/null +++ b/tests/fixtures/integration/actions/settings/test_stdout_tmux.py/test/2.json @@ -0,0 +1,39 @@ +{ + "name": "test[print json schema to stdout, default json, mode auto clear && ansible-navigator settings --schema --ee False --ll debug --mode interactive]", + "index": 2, + "comment": "print json schema to stdout, default json, mode auto", + "additional_information": { + "present": [ + "ansible-navigator settings file schema" + ], + "absent": [], + "compared_fixture": false + }, + "output": [ + " \"schema\": {", + " \"default\": \"json\",", + " \"description\": \"Generate a schema for the settings file\",", + " \"enum\": [", + " \"json\"", + " ],", + " \"type\": \"str\"", + " }", + " }", + " },", + " \"time-zone\": {", + " \"default\": \"UTC\",", + " \"description\": \"Specify the IANA time zone to use or 'local' to use the system time zone.\",", + " \"type\": \"string\"", + " }", + " }", + " }", + " },", + " \"required\": [", + " \"ansible-navigator\"", + " ],", + " \"title\": \"ansible-navigator settings file schema\",", + " \"type\": \"object\"", + "}", + "(venv) bash-5.1$" + ] +} diff --git a/tests/fixtures/integration/actions/settings/test_stdout_tmux.py/test/3.json b/tests/fixtures/integration/actions/settings/test_stdout_tmux.py/test/3.json new file mode 100644 index 000000000..b7effbbb7 --- /dev/null +++ b/tests/fixtures/integration/actions/settings/test_stdout_tmux.py/test/3.json @@ -0,0 +1,39 @@ +{ + "name": "test[print json schema to stdout, specify json, mode auto clear && ansible-navigator settings --schema json --ee False --ll debug --mode interactive]", + "index": 3, + "comment": "print json schema to stdout, specify json, mode auto", + "additional_information": { + "present": [ + "ansible-navigator settings file schema" + ], + "absent": [], + "compared_fixture": false + }, + "output": [ + " \"schema\": {", + " \"default\": \"json\",", + " \"description\": \"Generate a schema for the settings file\",", + " \"enum\": [", + " \"json\"", + " ],", + " \"type\": \"str\"", + " }", + " }", + " },", + " \"time-zone\": {", + " \"default\": \"UTC\",", + " \"description\": \"Specify the IANA time zone to use or 'local' to use the system time zone.\",", + " \"type\": \"string\"", + " }", + " }", + " }", + " },", + " \"required\": [", + " \"ansible-navigator\"", + " ],", + " \"title\": \"ansible-navigator settings file schema\",", + " \"type\": \"object\"", + "}", + "(venv) bash-5.1$" + ] +} diff --git a/tests/fixtures/unit/configuration_subsystem/ansible-navigator.yml b/tests/fixtures/unit/configuration_subsystem/ansible-navigator.yml index 174c556dc..44ab69d53 100644 --- a/tests/fixtures/unit/configuration_subsystem/ansible-navigator.yml +++ b/tests/fixtures/unit/configuration_subsystem/ansible-navigator.yml @@ -69,4 +69,6 @@ ansible-navigator: enable: True replay: /tmp/test_artifact.json save-as: /tmp/test_artifact.json + settings: + schema: json time-zone: Japan diff --git a/tests/fixtures/unit/configuration_subsystem/ansible-navigator_no_app.yml b/tests/fixtures/unit/configuration_subsystem/ansible-navigator_no_app.yml new file mode 100644 index 000000000..8b269b46a --- /dev/null +++ b/tests/fixtures/unit/configuration_subsystem/ansible-navigator_no_app.yml @@ -0,0 +1,3 @@ +--- +ansible-navigator: + app: non_app diff --git a/tests/integration/actions/settings/test_stdout_tmux.py b/tests/integration/actions/settings/test_stdout_tmux.py index 6921825b7..4c68f0cfd 100644 --- a/tests/integration/actions/settings/test_stdout_tmux.py +++ b/tests/integration/actions/settings/test_stdout_tmux.py @@ -40,6 +40,22 @@ class ShellCommand(UiTestStep): ).join(), present=["workdir"], ), + ShellCommand( + comment="print json schema to stdout, default json, mode auto", + user_input=StdoutCommand( + cmdline="--schema", + execution_environment=False, + ).join(), + present=["ansible-navigator settings file schema"], + ), + ShellCommand( + comment="print json schema to stdout, specify json, mode auto", + user_input=StdoutCommand( + cmdline="--schema json", + execution_environment=False, + ).join(), + present=["ansible-navigator settings file schema"], + ), ) steps = add_indices(stdout_tests) diff --git a/tests/unit/configuration_subsystem/data.py b/tests/unit/configuration_subsystem/data.py index 19d970b7c..1643a8156 100644 --- a/tests/unit/configuration_subsystem/data.py +++ b/tests/unit/configuration_subsystem/data.py @@ -250,6 +250,7 @@ def cli_data(): ("pull_arguments", "--tls-verify=false", ["--tls-verify=false"]), ("pull_policy", "never", "never"), ("set_environment_variable", "T1=A,T2=B,T3=C", {"T1": "A", "T2": "B", "T3": "C"}), + ("settings_schema", "json", "json"), ("time_zone", "Japan", "Japan"), ("workdir", "/tmp/", "/tmp/"), ] diff --git a/tests/unit/configuration_subsystem/test_json_schema.py b/tests/unit/configuration_subsystem/test_json_schema.py new file mode 100644 index 000000000..96385de8b --- /dev/null +++ b/tests/unit/configuration_subsystem/test_json_schema.py @@ -0,0 +1,112 @@ +"""Tests for the transformation of settings to a json schema.""" + +import json + +from pathlib import Path +from typing import Any +from typing import Dict + +import pytest + +from jsonschema import validate +from jsonschema.exceptions import ValidationError + +from ansible_navigator.configuration_subsystem import NavigatorConfiguration +from ansible_navigator.configuration_subsystem import to_schema +from ansible_navigator.utils.serialize import Loader +from ansible_navigator.utils.serialize import yaml +from .defaults import TEST_FIXTURE_DIR + + +@pytest.fixture(name="schema_dict") +def _schema_dict(): + settings = NavigatorConfiguration + schema = to_schema(settings) + as_dict = json.loads(schema) + return as_dict + + +def test_basic(schema_dict: Dict[str, Any]): + """Simple test to ensure an exception isn't raised. + + :param schema_dict: The json schema as a dictionary + """ + assert schema_dict["$schema"] == "http://json-schema.org/draft-07/schema" + assert isinstance(schema_dict["properties"]["ansible-navigator"]["properties"], dict) + assert len(schema_dict["properties"]["ansible-navigator"]["properties"]) >= 21 + + +def test_additional_properties(schema_dict: Dict[str, Any]): + """Ensure additional properties are forbidden throughout the schema. + + :param schema_dict: The json schema as a dictionary + """ + + def property_dive(subschema: Dict[str, Any]): + if "properties" in subschema: + assert subschema["additionalProperties"] is False + for value in subschema["properties"].values(): + property_dive(subschema=value) + + property_dive(schema_dict) + + +def test_no_extras(schema_dict: Dict[str, Any]): + """Ensure no extras exist in either settings or schema. + + :param schema_dict: The json schema as a dictionary + """ + settings = NavigatorConfiguration + all_paths = [ + setting.settings_file_path(prefix=settings.application_name_dashed) + for setting in settings.entries + ] + + json_paths = [] + + def dive(subschema, path=""): + if "properties" in subschema: + for name, prop in subschema["properties"].items(): + if path: + dive(prop, f"{path}.{name}") + else: + dive(prop, name) + else: + json_paths.append(path) + + dive(schema_dict) + + # The difference below are because we do not have settings entries for the individual + # keys but instead the full dict + only_in_json = [p for p in json_paths if p not in all_paths] + assert only_in_json == [ + "ansible-navigator.execution-environment.volume-mounts.dest", + "ansible-navigator.execution-environment.volume-mounts.label", + "ansible-navigator.execution-environment.volume-mounts.options", + ] + only_in_settings = [p for p in all_paths if p not in json_paths] + assert only_in_settings == ["ansible-navigator.execution-environment.volume-mounts"] + + +def test_schema_sample_full(schema_dict: Dict[str, Any]): + """Check the full settings file against the schema. + + :param schema_dict: The json schema as a dictionary + """ + settings_file = Path(TEST_FIXTURE_DIR, "ansible-navigator.yml") + with settings_file.open(encoding="utf-8") as fh: + settings_contents = yaml.load(fh, Loader=Loader) + validate(instance=settings_contents, schema=schema_dict) + + +def test_schema_sample_wrong(schema_dict: Dict[str, Any]): + """Check the broken settings file against the schema. + + :param schema_dict: The json schema as a dictionary + """ + settings_file = Path(TEST_FIXTURE_DIR, "ansible-navigator_no_app.yml") + with settings_file.open(encoding="utf-8") as fh: + settings_contents = yaml.load(fh, Loader=Loader) + with pytest.raises(ValidationError) as exc: + validate(instance=settings_contents, schema=schema_dict) + assert "'non_app' is not one of ['builder'" in str(exc)