From 9e6aebaf15adcd44d7a891053fd17b561353fbf6 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Tue, 16 Jan 2024 09:08:18 -0600 Subject: [PATCH] New CLI subcommands (WIP) --- docs/index.rst | 1 + docs/upgrading.rst | 4 + src/check_jsonschema/cli/__init__.py | 2 +- src/check_jsonschema/cli/commands/__init__.py | 23 +++ src/check_jsonschema/cli/common_options.py | 148 ++++++++++++++++++ .../cli/{main_command.py => legacy.py} | 129 ++------------- src/check_jsonschema/cli/main.py | 32 ++++ src/check_jsonschema/cli/modes.txt | 14 ++ tests/acceptance/conftest.py | 14 +- tests/unit/test_cli_annotations.py | 5 +- tests/unit/test_cli_parse.py | 67 +++++--- tests/unit/test_lazy_file_handling.py | 8 +- 12 files changed, 302 insertions(+), 145 deletions(-) create mode 100644 docs/upgrading.rst create mode 100644 src/check_jsonschema/cli/commands/__init__.py create mode 100644 src/check_jsonschema/cli/common_options.py rename src/check_jsonschema/cli/{main_command.py => legacy.py} (68%) create mode 100644 src/check_jsonschema/cli/main.py create mode 100644 src/check_jsonschema/cli/modes.txt diff --git a/docs/index.rst b/docs/index.rst index b290b1d1a..4d2996f37 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -27,3 +27,4 @@ features are tracked in the associated `issue tracker :caption: Change History: changelog + upgrading diff --git a/docs/upgrading.rst b/docs/upgrading.rst new file mode 100644 index 000000000..b9525288f --- /dev/null +++ b/docs/upgrading.rst @@ -0,0 +1,4 @@ +Upgrading +========= + +TODO: STUB! diff --git a/src/check_jsonschema/cli/__init__.py b/src/check_jsonschema/cli/__init__.py index 5b9980efe..36231d6cc 100644 --- a/src/check_jsonschema/cli/__init__.py +++ b/src/check_jsonschema/cli/__init__.py @@ -1,3 +1,3 @@ -from .main_command import main +from .main import main __all__ = ("main",) diff --git a/src/check_jsonschema/cli/commands/__init__.py b/src/check_jsonschema/cli/commands/__init__.py new file mode 100644 index 000000000..b8b010f2a --- /dev/null +++ b/src/check_jsonschema/cli/commands/__init__.py @@ -0,0 +1,23 @@ +import click + +from ..common_options import universal_opts + + +@click.group( + "check-jsonschema", + help="""\ +Check files against a JSON Schema. +Supports JSON and YAML by default, and TOML and JSON5 when parsers are available. + +Various modes of checking are supported via different subcommands. + +'check-jsonschema' supports 'format' checks with appropriate libraries installed, +including the following formats by default: + date, email, ipv4, ipv6, regex, uuid + +Use `--disable-formats` to skip specific 'format' checks or all 'format' checking. +""", +) +@universal_opts +def main_command() -> None: + raise NotImplementedError() diff --git a/src/check_jsonschema/cli/common_options.py b/src/check_jsonschema/cli/common_options.py new file mode 100644 index 000000000..0f45fc4c3 --- /dev/null +++ b/src/check_jsonschema/cli/common_options.py @@ -0,0 +1,148 @@ +import os +import typing as t + +import click + +from ..formats import KNOWN_FORMATS, RegexVariantName +from ..reporter import REPORTER_BY_NAME +from .param_types import CommaDelimitedList, ValidatorClassName + +C = t.TypeVar("C", bound=t.Union[t.Callable, click.Command]) + + +def set_color_mode(ctx: click.Context, param: str, value: str) -> None: + if "NO_COLOR" in os.environ: + ctx.color = False + else: + ctx.color = { + "auto": None, + "always": True, + "never": False, + }[value] + + +_color_opt = click.option( + "--color", + help="Force or disable colorized output. Defaults to autodetection.", + default="auto", + type=click.Choice(("auto", "always", "never")), + callback=set_color_mode, + expose_value=False, +) + + +def _verbosity_opts(cmd: C) -> C: + cmd = click.option( + "-v", + "--verbose", + help=( + "Increase output verbosity. On validation errors, this may be especially " + "useful when oneOf or anyOf is used in the schema." + ), + count=True, + )(cmd) + cmd = click.option( + "-q", + "--quiet", + help="Reduce output verbosity", + count=True, + )(cmd) + return cmd + + +_traceback_mode_opt = click.option( + "--traceback-mode", + help=( + "Set the mode of presentation for error traces. " + "Defaults to shortened tracebacks." + ), + type=click.Choice(("full", "short")), + default="short", +) + +output_format_opt = click.option( + "-o", + "--output-format", + help="Which output format to use.", + type=click.Choice(tuple(REPORTER_BY_NAME.keys()), case_sensitive=False), + default="text", +) + + +base_uri_opt = click.option( + "--base-uri", + help=( + "Override the base URI for the schema. The default behavior is to " + "follow the behavior specified by the JSON Schema spec, which is to " + "prefer an explicit '$id' and failover to the retrieval URI." + ), +) + + +def download_opts(cmd: C) -> C: + cmd = click.option( + "--no-cache", + is_flag=True, + help="Disable schema caching. Always download remote schemas.", + )(cmd) + cmd = click.option( + "--cache-filename", + help=( + "The name to use for caching a remote schema when downloaded. " + "Defaults to the last slash-delimited part of the URI." + ), + )(cmd) + return cmd + + +def validator_behavior_opts(cmd: C) -> C: + cmd = click.option( + "--fill-defaults", + help=( + "Autofill 'default' values prior to validation. " + "This may conflict with certain third-party validators used with " + "'--validator-class'" + ), + is_flag=True, + )(cmd) + cmd = click.option( + "--validator-class", + help=( + "The fully qualified name of a python validator to use in place of " + "the 'jsonschema' library validators, in the form of ':'. " + "The validator must be importable in the same environment where " + "'check-jsonschema' is run." + ), + type=ValidatorClassName(), + )(cmd) + return cmd + + +def jsonschema_format_opts(cmd: C) -> C: + cmd = click.option( + "--disable-formats", + multiple=True, + help="Disable specific format checks in the schema. " + "Pass '*' to disable all format checks.", + type=CommaDelimitedList(choices=("*", *KNOWN_FORMATS)), + metavar="{*|FORMAT,FORMAT,...}", + )(cmd) + cmd = click.option( + "--format-regex", + help=( + "Set the mode of format validation for regexes. " + "If `--disable-formats regex` is used, this option has no effect." + ), + default=RegexVariantName.default.value, + type=click.Choice([x.value for x in RegexVariantName], case_sensitive=False), + )(cmd) + return cmd + + +def universal_opts(cmd: C) -> C: + cmd = _traceback_mode_opt(cmd) + cmd = _color_opt(cmd) + cmd = _verbosity_opts(cmd) + cmd = click.help_option("-h", "--help")(cmd) + cmd = click.version_option()(cmd) + return cmd diff --git a/src/check_jsonschema/cli/main_command.py b/src/check_jsonschema/cli/legacy.py similarity index 68% rename from src/check_jsonschema/cli/main_command.py rename to src/check_jsonschema/cli/legacy.py index 4d9fc6ec0..fdcbbbf43 100644 --- a/src/check_jsonschema/cli/main_command.py +++ b/src/check_jsonschema/cli/legacy.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os import sys import textwrap import typing as t @@ -21,7 +20,15 @@ SchemaLoaderBase, ) from ..transforms import TRANSFORM_LIBRARY -from .param_types import CommaDelimitedList, LazyBinaryReadFile, ValidatorClassName +from .common_options import ( + base_uri_opt, + download_opts, + jsonschema_format_opts, + output_format_opt, + universal_opts, + validator_behavior_opts, +) +from .param_types import LazyBinaryReadFile from .parse_result import ParseResult, SchemaLoadingMode if sys.version_info >= (3, 8): @@ -37,17 +44,6 @@ ) -def set_color_mode(ctx: click.Context, param: str, value: str) -> None: - if "NO_COLOR" in os.environ: - ctx.color = False - else: - ctx.color = { - "auto": None, - "always": True, - "never": False, - }[value] - - def pretty_helptext_list(values: list[str] | tuple[str, ...]) -> str: return textwrap.indent( "\n".join( @@ -90,8 +86,8 @@ def pretty_helptext_list(values: list[str] | tuple[str, ...]) -> str: """ + pretty_helptext_list(KNOWN_FORMATS), ) -@click.help_option("-h", "--help") -@click.version_option() +@universal_opts +@base_uri_opt @click.option( "--schemafile", help=( @@ -102,14 +98,6 @@ def pretty_helptext_list(values: list[str] | tuple[str, ...]) -> str: ), metavar="[PATH|URI]", ) -@click.option( - "--base-uri", - help=( - "Override the base URI for the schema. The default behavior is to " - "follow the behavior specified by the JSON Schema spec, which is to " - "prefer an explicit '$id' and failover to the retrieval URI." - ), -) @click.option( "--builtin-schema", help="The name of an internal schema to use for '--schemafile'", @@ -124,37 +112,8 @@ def pretty_helptext_list(values: list[str] | tuple[str, ...]) -> str: "schema and validate them under their matching metaschemas." ), ) -@click.option( - "--no-cache", - is_flag=True, - help="Disable schema caching. Always download remote schemas.", -) -@click.option( - "--cache-filename", - help=( - "The name to use for caching a remote schema. " - "Defaults to the last slash-delimited part of the URI." - ), -) -@click.option( - "--disable-formats", - multiple=True, - help=( - "Disable specific format checks in the schema. " - "Pass '*' to disable all format checks." - ), - type=CommaDelimitedList(choices=("*", *KNOWN_FORMATS)), - metavar="{*|FORMAT,FORMAT,...}", -) -@click.option( - "--format-regex", - help=( - "Set the mode of format validation for regexes. " - "If `--disable-formats regex` is used, this option has no effect." - ), - default=RegexVariantName.default.value, - type=click.Choice([x.value for x in RegexVariantName], case_sensitive=False), -) +@download_opts +@jsonschema_format_opts @click.option( "--default-filetype", help="A default filetype to assume when a file's type is not detected", @@ -162,15 +121,6 @@ def pretty_helptext_list(values: list[str] | tuple[str, ...]) -> str: show_default=True, type=click.Choice(SUPPORTED_FILE_FORMATS, case_sensitive=True), ) -@click.option( - "--traceback-mode", - help=( - "Set the mode of presentation for error traces. " - "Defaults to shortened tracebacks." - ), - type=click.Choice(("full", "short")), - default="short", -) @click.option( "--data-transform", help=( @@ -179,59 +129,12 @@ def pretty_helptext_list(values: list[str] | tuple[str, ...]) -> str: ), type=click.Choice(tuple(TRANSFORM_LIBRARY.keys())), ) -@click.option( - "--fill-defaults", - help=( - "Autofill 'default' values prior to validation. " - "This may conflict with certain third-party validators used with " - "'--validator-class'" - ), - is_flag=True, -) -@click.option( - "--validator-class", - help=( - "The fully qualified name of a python validator to use in place of " - "the 'jsonschema' library validators, in the form of ':'. " - "The validator must be importable in the same environment where " - "'check-jsonschema' is run." - ), - type=ValidatorClassName(), -) -@click.option( - "-o", - "--output-format", - help="Which output format to use.", - type=click.Choice(tuple(REPORTER_BY_NAME.keys()), case_sensitive=False), - default="text", -) -@click.option( - "--color", - help="Force or disable colorized output. Defaults to autodetection.", - default="auto", - type=click.Choice(("auto", "always", "never")), - callback=set_color_mode, - expose_value=False, -) -@click.option( - "-v", - "--verbose", - help=( - "Increase output verbosity. On validation errors, this may be especially " - "useful when oneOf or anyOf is used in the schema." - ), - count=True, -) -@click.option( - "-q", - "--quiet", - help="Reduce output verbosity", - count=True, -) +@validator_behavior_opts +@output_format_opt @click.argument( "instancefiles", required=True, nargs=-1, type=LazyBinaryReadFile("rb", lazy=True) ) -def main( +def legacy_main( *, schemafile: str | None, builtin_schema: str | None, diff --git a/src/check_jsonschema/cli/main.py b/src/check_jsonschema/cli/main.py new file mode 100644 index 000000000..31a8a6002 --- /dev/null +++ b/src/check_jsonschema/cli/main.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import sys +import warnings + +from .commands import main_command +from .legacy import legacy_main + +_KNOWN_SUBCOMMANDS: frozenset[str] = frozenset( + ( + "local", + "remote", + "vendored", + "metaschema", + ) +) + + +def main(*, argv: list[str] | None = None) -> None: + if argv is None: + argv = sys.argv + + if argv[1] not in _KNOWN_SUBCOMMANDS: + warnings.warn( + "This usage was detected to use the legacy command invocation. " + "Upgrade to the new usage by following the upgrading guide: " + "https://check-jsonschema.readthedocs.io/en/stable/upgrading.html", + stacklevel=1, + ) + legacy_main(argv[1:]) + else: + main_command(argv[1:]) diff --git a/src/check_jsonschema/cli/modes.txt b/src/check_jsonschema/cli/modes.txt new file mode 100644 index 000000000..703e13595 --- /dev/null +++ b/src/check_jsonschema/cli/modes.txt @@ -0,0 +1,14 @@ +Legacy interface, no command group as the first argument. Each mode is a flag: + + check-jsonschema --schemafile foo.json instance.json + check-jsonschema --schemafile https://example.com/foo.json instance.json + check-jsonschema --builtin-schema vendor.foo instance.json + check-jsonschema --check-metaschema foo.json + +New interface, each mode of use gets a dedicated subcommand. +Also note that `--schemafile` is split into two modes: + + check-jsonschema local foo.json instance.json + check-jsonschema remote foo.json instance.json + check-jsonschema vendored foo instance.json + check-jsonschema metaschema foo.json diff --git a/tests/acceptance/conftest.py b/tests/acceptance/conftest.py index e47b088a1..42529d1a0 100644 --- a/tests/acceptance/conftest.py +++ b/tests/acceptance/conftest.py @@ -3,7 +3,8 @@ import pytest from click.testing import CliRunner -from check_jsonschema import main as cli_main +from check_jsonschema.cli.commands import main_command +from check_jsonschema.cli.legacy import legacy_main def _render_result(result): @@ -21,13 +22,20 @@ def cli_runner(): return CliRunner(mix_stderr=False) +def _get_command(legacy: bool): + if legacy: + return legacy_main + else: + return main_command + + @pytest.fixture def run_line(cli_runner): - def func(cli_args, *args, **kwargs): + def func(cli_args, legacy: bool = True, *args, **kwargs): assert cli_args[0] == "check-jsonschema" if "catch_exceptions" not in kwargs: kwargs["catch_exceptions"] = False - return cli_runner.invoke(cli_main, cli_args[1:], *args, **kwargs) + return cli_runner.invoke(_get_command(legacy), cli_args[1:], *args, **kwargs) return func diff --git a/tests/unit/test_cli_annotations.py b/tests/unit/test_cli_annotations.py index 1ab41aa51..8caca5ff5 100644 --- a/tests/unit/test_cli_annotations.py +++ b/tests/unit/test_cli_annotations.py @@ -2,7 +2,8 @@ import pytest -from check_jsonschema.cli import main as cli_main +# TODO: also test modernized commands +from check_jsonschema.cli.legacy import legacy_main click_type_test = pytest.importorskip( "click_type_test", reason="tests require 'click-type-test'" @@ -11,7 +12,7 @@ def test_annotations_match_click_params(): click_type_test.check_param_annotations( - cli_main, + legacy_main, overrides={ # don't bother with a Literal for this, since it's relatively dynamic data "builtin_schema": str | None, diff --git a/tests/unit/test_cli_parse.py b/tests/unit/test_cli_parse.py index fd376ef32..fccade5ff 100644 --- a/tests/unit/test_cli_parse.py +++ b/tests/unit/test_cli_parse.py @@ -6,7 +6,8 @@ import pytest from click.testing import CliRunner -from check_jsonschema import main as cli_main +# from check_jsonschema.cli.commands import main_command +from check_jsonschema.cli.legacy import legacy_main from check_jsonschema.cli.parse_result import ParseResult, SchemaLoadingMode @@ -27,7 +28,7 @@ def boxed_context(): @pytest.fixture def mock_parse_result(): args = ParseResult() - with mock.patch("check_jsonschema.cli.main_command.ParseResult") as m: + with mock.patch("check_jsonschema.cli.legacy.ParseResult") as m: m.return_value = args yield args @@ -37,12 +38,19 @@ def mock_cli_exec(boxed_context): def get_ctx(*args): boxed_context.ref = click.get_current_context() - with mock.patch( - "check_jsonschema.cli.main_command.execute", side_effect=get_ctx - ) as m: + with mock.patch("check_jsonschema.cli.legacy.execute", side_effect=get_ctx) as m: yield m +@pytest.fixture(params=(True, False)) +def cli_main(request) -> bool: + if request.param: + pytest.skip(reason="tests require that non-legacy behavior is implemented") + # return main_command + else: + return legacy_main + + @pytest.fixture def runner() -> CliRunner: return CliRunner(mix_stderr=False) @@ -73,12 +81,14 @@ def test_parse_result_set_schema( assert args.schema_path is None -def test_requires_some_args(runner): +def test_requires_some_args(runner, cli_main): result = runner.invoke(cli_main, []) assert result.exit_code == 2 -def test_schemafile_and_instancefile(runner, mock_parse_result, in_tmp_dir, tmp_path): +def test_schemafile_and_instancefile( + runner, mock_parse_result, in_tmp_dir, tmp_path, cli_main +): touch_files(tmp_path, "foo.json") runner.invoke(cli_main, ["--schemafile", "schema.json", "foo.json"]) assert mock_parse_result.schema_mode == SchemaLoadingMode.filepath @@ -89,23 +99,25 @@ def test_schemafile_and_instancefile(runner, mock_parse_result, in_tmp_dir, tmp_ assert tuple(f.name for f in mock_parse_result.instancefiles) == ("foo.json",) -def test_requires_at_least_one_instancefile(runner): +def test_requires_at_least_one_instancefile(runner, cli_main): result = runner.invoke(cli_main, ["--schemafile", "schema.json"]) assert result.exit_code == 2 -def test_requires_schemafile(runner, in_tmp_dir, tmp_path): +def test_requires_schemafile(runner, in_tmp_dir, tmp_path, cli_main): touch_files(tmp_path, "foo.json") result = runner.invoke(cli_main, ["foo.json"]) assert result.exit_code == 2 -def test_no_cache_defaults_false(runner, mock_parse_result): +def test_no_cache_defaults_false(runner, mock_parse_result, cli_main): runner.invoke(cli_main, ["--schemafile", "schema.json", "foo.json"]) assert mock_parse_result.disable_cache is False -def test_no_cache_flag_is_true(runner, mock_parse_result, in_tmp_dir, tmp_path): +def test_no_cache_flag_is_true( + runner, mock_parse_result, in_tmp_dir, tmp_path, cli_main +): touch_files(tmp_path, "foo.json") runner.invoke(cli_main, ["--schemafile", "schema.json", "foo.json", "--no-cache"]) assert mock_parse_result.disable_cache is True @@ -139,7 +151,7 @@ def test_no_cache_flag_is_true(runner, mock_parse_result, in_tmp_dir, tmp_path): ], ], ) -def test_mutex_schema_opts(runner, cmd_args, in_tmp_dir, tmp_path): +def test_mutex_schema_opts(runner, cmd_args, in_tmp_dir, tmp_path, cli_main): touch_files(tmp_path, "foo.json") result = runner.invoke(cli_main, cmd_args + ["foo.json"]) assert result.exit_code == 2 @@ -154,7 +166,7 @@ def test_mutex_schema_opts(runner, cmd_args, in_tmp_dir, tmp_path): ["-h"], ], ) -def test_supports_common_option(runner, cmd_args): +def test_supports_common_option(runner, cmd_args, cli_main): result = runner.invoke(cli_main, cmd_args) assert result.exit_code == 0 @@ -163,7 +175,14 @@ def test_supports_common_option(runner, cmd_args): "setting,expect_value", [(None, None), ("1", False), ("0", False)] ) def test_no_color_env_var( - runner, monkeypatch, setting, expect_value, boxed_context, in_tmp_dir, tmp_path + runner, + monkeypatch, + setting, + expect_value, + boxed_context, + in_tmp_dir, + tmp_path, + cli_main, ): if setting is None: monkeypatch.delenv("NO_COLOR", raising=False) @@ -180,7 +199,7 @@ def test_no_color_env_var( [(None, None), ("auto", None), ("always", True), ("never", False)], ) def test_color_cli_option( - runner, setting, expected_value, boxed_context, in_tmp_dir, tmp_path + runner, setting, expected_value, boxed_context, in_tmp_dir, tmp_path, cli_main ): args = ["--schemafile", "schema.json", "foo.json"] if setting: @@ -191,7 +210,7 @@ def test_color_cli_option( def test_no_color_env_var_overrides_cli_option( - runner, monkeypatch, mock_cli_exec, boxed_context, in_tmp_dir, tmp_path + runner, monkeypatch, mock_cli_exec, boxed_context, in_tmp_dir, tmp_path, cli_main ): monkeypatch.setenv("NO_COLOR", "1") touch_files(tmp_path, "foo.json") @@ -206,7 +225,7 @@ def test_no_color_env_var_overrides_cli_option( [("auto", 0), ("always", 0), ("never", 0), ("anything_else", 2)], ) def test_color_cli_option_is_choice( - runner, setting, expected_value, in_tmp_dir, tmp_path + runner, setting, expected_value, in_tmp_dir, tmp_path, cli_main ): touch_files(tmp_path, "foo.json") assert ( @@ -218,7 +237,9 @@ def test_color_cli_option_is_choice( ) -def test_formats_default_to_enabled(runner, mock_parse_result, in_tmp_dir, tmp_path): +def test_formats_default_to_enabled( + runner, mock_parse_result, in_tmp_dir, tmp_path, cli_main +): touch_files(tmp_path, "foo.json") runner.invoke(cli_main, ["--schemafile", "schema.json", "foo.json"]) assert mock_parse_result.disable_all_formats is False @@ -238,7 +259,7 @@ def test_formats_default_to_enabled(runner, mock_parse_result, in_tmp_dir, tmp_p ), ) def test_disable_selected_formats( - runner, mock_parse_result, addargs, in_tmp_dir, tmp_path + runner, mock_parse_result, addargs, in_tmp_dir, tmp_path, cli_main ): touch_files(tmp_path, "foo.json") runner.invoke( @@ -269,7 +290,9 @@ def test_disable_selected_formats( ["--disable-formats", "*,email"], ), ) -def test_disable_all_formats(runner, mock_parse_result, addargs, in_tmp_dir, tmp_path): +def test_disable_all_formats( + runner, mock_parse_result, addargs, in_tmp_dir, tmp_path, cli_main +): touch_files(tmp_path, "foo.json") # this should be an override, with or without other args runner.invoke( @@ -285,7 +308,7 @@ def test_disable_all_formats(runner, mock_parse_result, addargs, in_tmp_dir, tmp def test_can_specify_custom_validator_class( - runner, mock_parse_result, mock_module, in_tmp_dir, tmp_path + runner, mock_parse_result, mock_module, in_tmp_dir, tmp_path, cli_main ): mock_module("foo.py", "class MyValidator: pass") import foo @@ -309,7 +332,7 @@ def test_can_specify_custom_validator_class( "failmode", ("syntax", "import", "attr", "function", "non_callable") ) def test_custom_validator_class_fails( - runner, mock_parse_result, mock_module, failmode, in_tmp_dir, tmp_path + runner, mock_parse_result, mock_module, failmode, in_tmp_dir, tmp_path, cli_main ): mock_module( "foo.py", diff --git a/tests/unit/test_lazy_file_handling.py b/tests/unit/test_lazy_file_handling.py index dd69eac60..b1b0286b3 100644 --- a/tests/unit/test_lazy_file_handling.py +++ b/tests/unit/test_lazy_file_handling.py @@ -4,8 +4,8 @@ import pytest from click.testing import CliRunner -from check_jsonschema.cli.main_command import build_checker -from check_jsonschema.cli.main_command import main as cli_main +# TODO: redefine this to also test modernized commands +from check_jsonschema.cli.legacy import build_checker, legacy_main @pytest.fixture @@ -36,8 +36,8 @@ def fake_execute(argv): nonlocal checker checker = build_checker(argv) - monkeypatch.setattr("check_jsonschema.cli.main_command.execute", fake_execute) - res = runner.invoke(cli_main, args) + monkeypatch.setattr("check_jsonschema.cli.legacy.execute", fake_execute) + res = runner.invoke(legacy_main, args) assert res.exit_code == 0, res.stderr assert checker is not None