From 88b0781f428a26ca740240022461207e45b7e49e Mon Sep 17 00:00:00 2001 From: Dimitar Valov Date: Mon, 27 Nov 2023 23:41:39 +0200 Subject: [PATCH 1/6] add logging configuration as dict --- kapitan/__init__.py | 53 ++++++++++---------- kapitan/cli.py | 79 +++++++++++++++++++++++++++--- kapitan/dependency_manager/base.py | 35 ++++++++++--- kapitan/remoteinventory/fetch.py | 10 +++- kapitan/targets.py | 40 ++++++++++++--- tests/test_compile.py | 11 ++--- 6 files changed, 175 insertions(+), 53 deletions(-) diff --git a/kapitan/__init__.py b/kapitan/__init__.py index cdc55f2c1..6ad3394bf 100755 --- a/kapitan/__init__.py +++ b/kapitan/__init__.py @@ -7,32 +7,35 @@ import os import sys -import logging - -def setup_logging(name=None, level=logging.INFO, force=False): - "setup logging and deal with logging behaviours in MacOS python 3.8 and below" - # default opts - kwopts = {"format": "%(message)s", "level": level} - - if level == logging.DEBUG: - kwopts["format"] = "%(asctime)s %(name)-12s %(levelname)-8s %(message)s" - - if sys.version_info >= (3, 8) and force: - kwopts["force"] = True - - logging.basicConfig(**kwopts) - - if sys.version_info < (3, 8) and force: - logging.getLogger(name).setLevel(level) - - -# XXX in MacOS, updating logging level in __main__ doesn't work for python3.8+ -# XXX this is a hack that seems to work -if "-v" in sys.argv or "--verbose" in sys.argv: - setup_logging(level=logging.DEBUG) -else: - setup_logging() +# this dict is used to confgiure in various places, such as setup spawned processes +logging_config = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "brief": {"format": "%(message)s"}, + "extended": {"format": "%(asctime)s %(name)-12s %(levelname)-8s %(message)s"}, + }, + "handlers": { + "brief": { + "class": "logging.StreamHandler", + "level": "INFO", + "formatter": "brief", + "stream": "ext://sys.stdout", + }, + "extended": { + "class": "logging.StreamHandler", + "level": "INFO", + "formatter": "extended", + "stream": "ext://sys.stdout", + }, + }, + "loggers": { + "kapitan": {"level": "INFO", "propagate": True}, + "reclass": {"level": "INFO", "propagate": True}, + }, + "root": {"level": "ERROR", "handlers": ["brief"]}, +} # Adding reclass to PYTHONPATH sys.path.insert(0, os.path.dirname(__file__) + "/reclass") diff --git a/kapitan/cli.py b/kapitan/cli.py index d1d81ee8b..60bdc4b56 100644 --- a/kapitan/cli.py +++ b/kapitan/cli.py @@ -11,14 +11,14 @@ import argparse import json -import logging +import logging.config import multiprocessing import os import sys import yaml -from kapitan import cached, defaults, setup_logging +from kapitan import cached, defaults, logging_config from kapitan.initialiser import initialise_skeleton from kapitan.inputs.jsonnet import jsonnet_file from kapitan.lint import start_lint @@ -97,6 +97,16 @@ def trigger_compile(args): ) +def add_logging_argument(parser): + parser.add_argument( + "--logging-config", + metavar="FILE", + action=LoggingConfigAction, + # default=from_dot_kapitan # TODO + help="python logging configuration", + ) + + def build_parser(): parser = argparse.ArgumentParser(prog=PROJECT_NAME, description=DESCRIPTION) parser.add_argument("--version", action="version", version=VERSION) @@ -306,6 +316,8 @@ def build_parser(): help="dumps all none-type entries as empty, default is dumping as 'null'", ) + add_logging_argument(compile_parser) + compile_selector_parser = compile_parser.add_mutually_exclusive_group() compile_selector_parser.add_argument( "--targets", @@ -379,6 +391,7 @@ def build_parser(): default=from_dot_kapitan("inventory", "multiline-string-style", "double-quotes"), help="set multiline string style to STYLE, default is 'double-quotes'", ) + add_logging_argument(inventory_parser) searchvar_parser = subparser.add_parser( "searchvar", aliases=["sv"], help="show all inventory files where var is declared" @@ -622,6 +635,63 @@ def build_parser(): return parser +# def is_valid_file(parser, arg): +# if not os.path.exists(arg): +# parser.error("The file %s does not exist!" % arg) +# else: +# return open(arg, "r") +class LoggingConfigAction(argparse.Action): + """LoggingConfigAction configures Python's logging.""" + + def __init__(self, option_strings, dest, nargs=None, **kwargs): + super().__init__(option_strings, dest, **kwargs) + + def __call__( + self, + parser, + namespace, + values, + option_string, + ): + print("values:", values) + # if isinstance(values, list): + # for logger_value in values: + # if "=" not in logger: + # continue + + # name, level = logger_value.split("=") + # log = logging.getLogger(name) + # log.setLevel(level.upper()) + + +def setup_logging(args=None): # level=None, force=False + """Setups logging using the args + The default configuration is: + kapitan and reclass on INFO as they are for regular output to users + + The logging-config option takes precedence over all. It reads a yaml file with based on the https://docs.python.org/3/library/logging.config.html#logging-config-dictschema + + The verbose option option takes precedence over the quiet one. When they are used + """ + if hasattr(args, "logging_config") and args.logging_config: + print("TODO") + else: + if hasattr(args, "verbose") and args.verbose: + set_common_loggers("DEBUG", "extended") + elif hasattr(args, "quiet") and args.quiet: + set_common_loggers("CRITICAL", "extended") + + logging.config.dictConfig(logging_config) + + +def set_common_loggers(level, stream): + logging_config["loggers"] = { + "kapitan": {"level": level, "propagate": True}, + "reclass": {"level": level, "propagate": True}, + } + logging_config["root"] = {"level": level, "handlers": [stream]} + + def main(): """main function for command line usage""" try: @@ -649,10 +719,7 @@ def main(): assert "name" in args, "All cli commands must have provided default name" cached.args[args.name] = args - if hasattr(args, "verbose") and args.verbose: - setup_logging(level=logging.DEBUG, force=True) - elif hasattr(args, "quiet") and args.quiet: - setup_logging(level=logging.CRITICAL, force=True) + setup_logging(args) # call chosen command args.func(args) diff --git a/kapitan/dependency_manager/base.py b/kapitan/dependency_manager/base.py index 52c9fc698..f773fc24d 100644 --- a/kapitan/dependency_manager/base.py +++ b/kapitan/dependency_manager/base.py @@ -4,6 +4,7 @@ import hashlib import logging +import logging.config import multiprocessing import os from collections import defaultdict, namedtuple @@ -14,6 +15,7 @@ from git import GitCommandError from git import Repo +from kapitan import logging_config from kapitan.errors import GitSubdirNotFoundError, GitFetchingError, HelmFetchingError from kapitan.helm_cli import helm_cli from kapitan.utils import ( @@ -82,20 +84,37 @@ def fetch_dependencies(output_path, target_objs, save_dir, force, pool): ) continue - git_worker = partial(fetch_git_dependency, save_dir=save_dir, force=force) - http_worker = partial(fetch_http_dependency, save_dir=save_dir, force=force) - helm_worker = partial(fetch_helm_chart, save_dir=save_dir, force=force) + git_worker = partial( + fetch_git_dependency, + save_dir=save_dir, + force=force, + logging_config_dict=logging_config, + ) + http_worker = partial( + fetch_http_dependency, + save_dir=save_dir, + force=force, + logging_config_dict=logging_config, + ) + helm_worker = partial( + fetch_helm_chart, + save_dir=save_dir, + force=force, + logging_config_dict=logging_config, + ) [p.get() for p in pool.imap_unordered(http_worker, http_deps.items()) if p] [p.get() for p in pool.imap_unordered(git_worker, git_deps.items()) if p] [p.get() for p in pool.imap_unordered(helm_worker, helm_deps.items()) if p] -def fetch_git_dependency(dep_mapping, save_dir, force, item_type="Dependency"): +def fetch_git_dependency(dep_mapping, save_dir, force, item_type="Dependency", logging_config_dict=None): """ fetches a git repository at source into save_dir, and copy the repository into output_path stored in dep_mapping. ref is used to checkout if exists, fetches master branch by default. only subdir is copied into output_path if specified. """ + if logging_config_dict is not None: + logging.config.dictConfig(logging_config_dict) source, deps = dep_mapping # to avoid collisions between basename(source) path_hash = hashlib.sha256(os.path.dirname(source).encode()).hexdigest()[:8] @@ -152,11 +171,13 @@ def fetch_git_source(source, save_dir, item_type): raise GitFetchingError("{} {}: fetching unsuccessful\n{}".format(item_type, source, e.stderr)) -def fetch_http_dependency(dep_mapping, save_dir, force, item_type="Dependency"): +def fetch_http_dependency(dep_mapping, save_dir, force, item_type="Dependency", logging_config_dict=None): """ fetches a http[s] file at source and saves into save_dir, after which it is copied into the output_path stored in dep_mapping """ + if logging_config_dict is not None: + logging.config.dictConfig(logging_config_dict) source, deps = dep_mapping # to avoid collisions between basename(source) path_hash = hashlib.sha256(os.path.dirname(source).encode()).hexdigest()[:8] @@ -221,10 +242,12 @@ def fetch_http_source(source, save_path, item_type): return None -def fetch_helm_chart(dep_mapping, save_dir, force): +def fetch_helm_chart(dep_mapping, save_dir, force, logging_config_dict=None): """ downloads a helm chart and its subcharts from source then untars and moves it to save_dir """ + if logging_config_dict is not None: + logging.config.dictConfig(logging_config_dict) source, deps = dep_mapping # to avoid collisions between source.chart_name/source.version diff --git a/kapitan/remoteinventory/fetch.py b/kapitan/remoteinventory/fetch.py index 38569eba5..f833649ca 100644 --- a/kapitan/remoteinventory/fetch.py +++ b/kapitan/remoteinventory/fetch.py @@ -4,7 +4,7 @@ from collections import defaultdict from functools import partial -from kapitan import cached +from kapitan import cached, logging_config from kapitan.dependency_manager.base import fetch_git_dependency, fetch_http_dependency from kapitan.utils import normalise_join_path @@ -69,7 +69,13 @@ def fetch_inventories(inventory_path, target_objs, save_dir, force, pool): logger.debug("Target object %s has no inventory key", target_obj["vars"]["target"]) continue - git_worker = partial(fetch_git_dependency, save_dir=save_dir, force=force, item_type="Inventory") + git_worker = partial( + fetch_git_dependency, + save_dir=save_dir, + force=force, + item_type="Inventory", + logging_config_dict=logging_config, + ) http_worker = partial(fetch_http_dependency, save_dir=save_dir, force=force, item_type="Inventory") [p.get() for p in pool.imap_unordered(git_worker, git_inventories.items()) if p] [p.get() for p in pool.imap_unordered(http_worker, http_inventories.items()) if p] diff --git a/kapitan/targets.py b/kapitan/targets.py index c5e18e36f..ebc85f36c 100644 --- a/kapitan/targets.py +++ b/kapitan/targets.py @@ -8,6 +8,7 @@ "kapitan targets" import json import logging +import logging.config import multiprocessing import os import shutil @@ -21,7 +22,7 @@ import yaml from reclass.errors import NotFoundError, ReclassException -from kapitan import cached, defaults +from kapitan import cached, defaults, logging_config from kapitan.dependency_manager.base import fetch_dependencies from kapitan.errors import CompileError, InventoryError, KapitanError from kapitan.inputs.copy import Copy @@ -158,6 +159,7 @@ def compile_targets( ref_controller=ref_controller, inventory_path=inventory_path, globals_cached=cached.as_dict(), + logging_config_dict=logging_config, **kwargs, ) @@ -187,10 +189,13 @@ def compile_targets( # validate the compiled outputs if kwargs.get("validate", False): - validate_map = create_validate_mapping(target_objs, compile_path) + validate_map = create_validate_mapping( + target_objs, compile_path, logging_config_dict=logging_config + ) worker = partial( schema_validate_kubernetes_output, cache_dir=kwargs.get("schemas_path", "./schemas"), + logging_config_dict=logging_config, ) [p.get() for p in pool.imap_unordered(worker, validate_map.items()) if p] @@ -455,8 +460,19 @@ def search_targets(inventory_path, targets, labels): return targets_found -def compile_target(target_obj, search_paths, compile_path, ref_controller, globals_cached=None, **kwargs): +def compile_target( + target_obj, + search_paths, + compile_path, + ref_controller, + globals_cached=None, + logging_config_dict=None, + **kwargs, +): """Compiles target_obj and writes to compile_path""" + + if logging_config_dict is not None: + logging.config.dictConfig(logging_config_dict) start = time.time() compile_objs = target_obj["compile"] ext_vars = target_obj["vars"] @@ -776,12 +792,18 @@ def schema_validate_compiled(args): os.makedirs(args.schemas_path) logger.info("created schema-cache-path at %s", args.schemas_path) - worker = partial(schema_validate_kubernetes_output, cache_dir=args.schemas_path) + worker = partial( + schema_validate_kubernetes_output, + cache_dir=args.schemas_path, + logging_config_dict=logging_config, + ) pool = multiprocessing.Pool(args.parallelism) try: target_objs = load_target_inventory(args.inventory_path, args.targets) - validate_map = create_validate_mapping(target_objs, args.compiled_path) + validate_map = create_validate_mapping( + target_objs, args.compiled_path, logging_config_dict=logging_config + ) [p.get() for p in pool.imap_unordered(worker, validate_map.items()) if p] pool.close() @@ -807,11 +829,13 @@ def schema_validate_compiled(args): pool.join() -def create_validate_mapping(target_objs, compiled_path): +def create_validate_mapping(target_objs, compiled_path, logging_config_dict=None): """ creates mapping of (kind, version) tuple to output_paths across different targets this is required to avoid redundant schema fetch when multiple targets use the same schema for validation """ + if logging_config_dict is not None: + logging.config.dictConfig(logging_config_dict) validate_files_map = defaultdict(list) for target_obj in target_objs: target_name = target_obj["vars"]["target"] @@ -843,11 +867,13 @@ def create_validate_mapping(target_objs, compiled_path): return validate_files_map -def schema_validate_kubernetes_output(validate_data, cache_dir): +def schema_validate_kubernetes_output(validate_data, cache_dir, logging_config_dict=None): """ validates given files according to kubernetes manifest schemas schemas are cached from/to cache_dir validate_data must be of structure ((kind, version), validate_files) """ (kind, version), validate_files = validate_data + if logging_config_dict is not None: + logging.config.dictConfig(logging_config_dict) KubernetesManifestValidator(cache_dir).validate(validate_files, kind=kind, version=version) diff --git a/tests/test_compile.py b/tests/test_compile.py index 5196ae99b..7ad6889a9 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -16,7 +16,7 @@ import shutil import yaml import toml -from kapitan.cli import main +from kapitan.cli import main, setup_logging from kapitan.utils import directory_hash from kapitan.cached import reset_cache from kapitan.targets import validate_matching_target_name @@ -152,11 +152,8 @@ def test_compile_not_enough_args(self): self.assertEqual(cm.exception.code, 1) def test_compile_not_matching_targets(self): - with self.assertLogs(logger="kapitan.targets", level="ERROR") as cm, contextlib.redirect_stdout( - io.StringIO() - ): - # as of now, we cannot capture stdout with contextlib.redirect_stdout - # since we only do logger.error(e) in targets.py before exiting + setup_logging() + with contextlib.redirect_stdout(io.StringIO()) as captured_out: with self.assertRaises(SystemExit) as ca: unmatched_filename = "inventory/targets/minikube-es-fake.yml" correct_filename = "inventory/targets/minikube-es.yml" @@ -170,7 +167,7 @@ def test_compile_not_matching_targets(self): if os.path.exists(unmatched_filename): os.rename(src=unmatched_filename, dst=correct_filename) error_message_substr = "is missing the corresponding yml file" - self.assertTrue(" ".join(cm.output).find(error_message_substr) != -1) + self.assertTrue(captured_out.getvalue().find(error_message_substr) != -1) def test_compile_vars_target_missing(self): inventory_path = "inventory" From c595194230f3509e00ba5e3d39a62c2dbdb8194e Mon Sep 17 00:00:00 2001 From: Dimitar Valov Date: Sun, 3 Dec 2023 16:13:09 +0200 Subject: [PATCH 2/6] add default options --- kapitan/cli.py | 86 ++++++++++++++++++++------------------------------ 1 file changed, 35 insertions(+), 51 deletions(-) diff --git a/kapitan/cli.py b/kapitan/cli.py index 60bdc4b56..b0e2630dd 100644 --- a/kapitan/cli.py +++ b/kapitan/cli.py @@ -97,16 +97,6 @@ def trigger_compile(args): ) -def add_logging_argument(parser): - parser.add_argument( - "--logging-config", - metavar="FILE", - action=LoggingConfigAction, - # default=from_dot_kapitan # TODO - help="python logging configuration", - ) - - def build_parser(): parser = argparse.ArgumentParser(prog=PROJECT_NAME, description=DESCRIPTION) parser.add_argument("--version", action="version", version=VERSION) @@ -315,8 +305,7 @@ def build_parser(): action="store_true", help="dumps all none-type entries as empty, default is dumping as 'null'", ) - - add_logging_argument(compile_parser) + add_logging_argument("compile", compile_parser) compile_selector_parser = compile_parser.add_mutually_exclusive_group() compile_selector_parser.add_argument( @@ -391,7 +380,7 @@ def build_parser(): default=from_dot_kapitan("inventory", "multiline-string-style", "double-quotes"), help="set multiline string style to STYLE, default is 'double-quotes'", ) - add_logging_argument(inventory_parser) + add_logging_argument("inventory", inventory_parser) searchvar_parser = subparser.add_parser( "searchvar", aliases=["sv"], help="show all inventory files where var is declared" @@ -635,56 +624,51 @@ def build_parser(): return parser -# def is_valid_file(parser, arg): -# if not os.path.exists(arg): -# parser.error("The file %s does not exist!" % arg) -# else: -# return open(arg, "r") -class LoggingConfigAction(argparse.Action): - """LoggingConfigAction configures Python's logging.""" - - def __init__(self, option_strings, dest, nargs=None, **kwargs): - super().__init__(option_strings, dest, **kwargs) - - def __call__( - self, - parser, - namespace, - values, - option_string, - ): - print("values:", values) - # if isinstance(values, list): - # for logger_value in values: - # if "=" not in logger: - # continue - - # name, level = logger_value.split("=") - # log = logging.getLogger(name) - # log.setLevel(level.upper()) - - -def setup_logging(args=None): # level=None, force=False +def add_logging_argument(command, parser): + """Adds logging argument for command to a parser. + + Args: + command (str): the command like compile, inventory + parser (ArgumentParser): the argument parser to which to add the argument + """ + parser.add_argument( + "--logging-config", + metavar="FILE", + default=from_dot_kapitan(command, "logging", ""), + help="python logging configuration", + ) + + +def setup_logging(args=None): """Setups logging using the args The default configuration is: kapitan and reclass on INFO as they are for regular output to users - The logging-config option takes precedence over all. It reads a yaml file with based on the https://docs.python.org/3/library/logging.config.html#logging-config-dictschema + The logging-config option takes precedence over all. It reads a yaml file with based + on the https://docs.python.org/3/library/logging.config.html#logging-config-dictschema. - The verbose option option takes precedence over the quiet one. When they are used + The verbose option option takes precedence over the quiet one when they are used. """ - if hasattr(args, "logging_config") and args.logging_config: - print("TODO") + if hasattr(args, "logging_config") and args.logging_config != "": + with open(args.logging_config, "r", encoding="ascii") as f: + logging_config.update(yaml.safe_load(f)) + logging.config.fileConfig(args.logging_config) else: if hasattr(args, "verbose") and args.verbose: - set_common_loggers("DEBUG", "extended") + set_kapitan_loggers("DEBUG", "extended") elif hasattr(args, "quiet") and args.quiet: - set_common_loggers("CRITICAL", "extended") + set_kapitan_loggers("CRITICAL", "extended") - logging.config.dictConfig(logging_config) + logging.config.dictConfig(logging_config) -def set_common_loggers(level, stream): +def set_kapitan_loggers(level, stream): + """Set logging level of kapitan and reclass loggers. + + Args: + level (str): the log level to use + stream (str): the stream to use + """ logging_config["loggers"] = { "kapitan": {"level": level, "propagate": True}, "reclass": {"level": level, "propagate": True}, From 8181e592e6e411161bd8abfb2a60f69e31ce1738 Mon Sep 17 00:00:00 2001 From: Dimitar Valov Date: Thu, 7 Dec 2023 17:45:35 +0200 Subject: [PATCH 3/6] refine comment --- kapitan/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kapitan/__init__.py b/kapitan/__init__.py index 6ad3394bf..92ae2eb04 100755 --- a/kapitan/__init__.py +++ b/kapitan/__init__.py @@ -8,7 +8,7 @@ import os import sys -# this dict is used to confgiure in various places, such as setup spawned processes +# this dict is used to confgiure the python logging in various places, such as setup spawned processes logging_config = { "version": 1, "disable_existing_loggers": False, From 66293197371ed184cdf1e3ac05a357dd65832c9f Mon Sep 17 00:00:00 2001 From: dav9 Date: Fri, 8 Dec 2023 14:22:19 +0200 Subject: [PATCH 4/6] fix test_kubernetes_validator.py --- tests/test_kubernetes_validator.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_kubernetes_validator.py b/tests/test_kubernetes_validator.py index 3326319f9..d853cc388 100644 --- a/tests/test_kubernetes_validator.py +++ b/tests/test_kubernetes_validator.py @@ -5,6 +5,8 @@ # # SPDX-License-Identifier: Apache-2.0 +import contextlib +import io import os import sys import tempfile @@ -84,14 +86,16 @@ def test_validate_command_fail(self): yaml.dump(d, fp, default_flow_style=False) sys.argv = ["kapitan", "validate", "--schemas-path", self.cache_dir] - with self.assertRaises(SystemExit), self.assertLogs(logger="kapitan.targets", level="ERROR") as log: + with contextlib.redirect_stdout(io.StringIO()) as captured_out, self.assertRaises( + SystemExit + ), self.assertLogs(logger="kapitan.targets", level="ERROR") as log: try: main() finally: # copy back the original file copyfile(copied_file, original_file) os.remove(copied_file) - self.assertTrue(" ".join(log.output).find("invalid '{}' manifest".format(wrong_manifest_kind)) != -1) + self.assertTrue(captured_out.getvalue().find("invalid '{}' manifest".format(wrong_manifest_kind)) != -1) def test_validate_after_compile(self): sys.argv = [ From 2ceb2bfa8df1bc5c442fbfad0a37146aa33928b1 Mon Sep 17 00:00:00 2001 From: dav9 Date: Fri, 8 Dec 2023 16:20:05 +0200 Subject: [PATCH 5/6] remove usage of logging.config.fileConfig --- kapitan/cli.py | 1 - 1 file changed, 1 deletion(-) diff --git a/kapitan/cli.py b/kapitan/cli.py index b0e2630dd..9f9c9e174 100644 --- a/kapitan/cli.py +++ b/kapitan/cli.py @@ -652,7 +652,6 @@ def setup_logging(args=None): if hasattr(args, "logging_config") and args.logging_config != "": with open(args.logging_config, "r", encoding="ascii") as f: logging_config.update(yaml.safe_load(f)) - logging.config.fileConfig(args.logging_config) else: if hasattr(args, "verbose") and args.verbose: set_kapitan_loggers("DEBUG", "extended") From 0ca57582f72ac2305214a176da96720843977215 Mon Sep 17 00:00:00 2001 From: dav9 Date: Fri, 15 Dec 2023 09:00:27 +0200 Subject: [PATCH 6/6] format codestyle --- tests/test_kubernetes_validator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_kubernetes_validator.py b/tests/test_kubernetes_validator.py index d853cc388..24d77d483 100644 --- a/tests/test_kubernetes_validator.py +++ b/tests/test_kubernetes_validator.py @@ -95,7 +95,9 @@ def test_validate_command_fail(self): # copy back the original file copyfile(copied_file, original_file) os.remove(copied_file) - self.assertTrue(captured_out.getvalue().find("invalid '{}' manifest".format(wrong_manifest_kind)) != -1) + self.assertTrue( + captured_out.getvalue().find("invalid '{}' manifest".format(wrong_manifest_kind)) != -1 + ) def test_validate_after_compile(self): sys.argv = [