Skip to content

Commit 907db34

Browse files
authored
Merge pull request #332 from python-jsonschema/support-stdin
Support reading schemafiles and instancefiles from stdin
2 parents 38d28db + ac5ad1d commit 907db34

File tree

11 files changed

+206
-79
lines changed

11 files changed

+206
-79
lines changed

src/check_jsonschema/checker.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def _fail(self, msg: str, err: Exception | None = None) -> t.NoReturn:
4747
raise _Exit(1)
4848

4949
def get_validator(
50-
self, path: pathlib.Path, doc: dict[str, t.Any]
50+
self, path: pathlib.Path | str, doc: dict[str, t.Any]
5151
) -> jsonschema.protocols.Validator:
5252
try:
5353
return self._schema_loader.get_validator(

src/check_jsonschema/cli/main_command.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
import textwrap
5+
import typing as t
56

67
import click
78
import jsonschema
@@ -90,8 +91,10 @@ def pretty_helptext_list(values: list[str] | tuple[str, ...]) -> str:
9091
help=(
9192
"The path to a file containing the JSON Schema to use or an "
9293
"HTTP(S) URI for the schema. If a remote file is used, "
93-
"it will be downloaded and cached locally based on mtime."
94+
"it will be downloaded and cached locally based on mtime. "
95+
"Use '-' for stdin."
9496
),
97+
metavar="[PATH|URI]",
9598
)
9699
@click.option(
97100
"--base-uri",
@@ -217,7 +220,7 @@ def pretty_helptext_list(values: list[str] | tuple[str, ...]) -> str:
217220
help="Reduce output verbosity",
218221
count=True,
219222
)
220-
@click.argument("instancefiles", required=True, nargs=-1)
223+
@click.argument("instancefiles", required=True, nargs=-1, type=click.File("rb"))
221224
def main(
222225
*,
223226
schemafile: str | None,
@@ -236,7 +239,7 @@ def main(
236239
output_format: str,
237240
verbose: int,
238241
quiet: int,
239-
instancefiles: tuple[str, ...],
242+
instancefiles: tuple[t.BinaryIO, ...],
240243
) -> None:
241244
args = ParseResult()
242245

src/check_jsonschema/cli/parse_result.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import enum
4+
import typing as t
45

56
import click
67
import jsonschema
@@ -21,7 +22,7 @@ def __init__(self) -> None:
2122
self.schema_mode: SchemaLoadingMode = SchemaLoadingMode.filepath
2223
self.schema_path: str | None = None
2324
self.base_uri: str | None = None
24-
self.instancefiles: tuple[str, ...] = ()
25+
self.instancefiles: tuple[t.BinaryIO, ...] = ()
2526
# cache controls
2627
self.disable_cache: bool = False
2728
self.cache_filename: str | None = None
Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
import pathlib
3+
import io
44
import typing as t
55

66
from .parsers import ParseError, ParserSet
@@ -10,11 +10,11 @@
1010
class InstanceLoader:
1111
def __init__(
1212
self,
13-
filenames: t.Sequence[str],
13+
files: t.Sequence[t.BinaryIO],
1414
default_filetype: str = "json",
1515
data_transform: Transform | None = None,
1616
) -> None:
17-
self._filenames = filenames
17+
self._files = files
1818
self._default_filetype = default_filetype
1919
self._data_transform = (
2020
data_transform if data_transform is not None else Transform()
@@ -24,13 +24,23 @@ def __init__(
2424
modify_yaml_implementation=self._data_transform.modify_yaml_implementation
2525
)
2626

27-
def iter_files(self) -> t.Iterator[tuple[pathlib.Path, ParseError | t.Any]]:
28-
for fn in self._filenames:
29-
path = pathlib.Path(fn)
27+
def iter_files(self) -> t.Iterator[tuple[str, ParseError | t.Any]]:
28+
for file in self._files:
29+
if hasattr(file, "name"):
30+
name = file.name
31+
# allowing for BytesIO to be special-cased here is useful for
32+
# simpler test setup, since this is what tests will pass and we naturally
33+
# support it here
34+
elif isinstance(file, io.BytesIO) or file.fileno() == 0:
35+
name = "<stdin>"
36+
else:
37+
raise ValueError(f"File {file} has no name attribute")
3038
try:
31-
data: t.Any = self._parsers.parse_file(path, self._default_filetype)
39+
data: t.Any = self._parsers.parse_data_with_path(
40+
file, name, self._default_filetype
41+
)
3242
except ParseError as err:
3343
data = err
3444
else:
3545
data = self._data_transform(data)
36-
yield (path, data)
46+
yield (name, data)

src/check_jsonschema/result.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
import pathlib
24

35
import jsonschema
@@ -15,18 +17,18 @@ def __init__(self) -> None:
1517
def success(self) -> bool:
1618
return not (bool(self.parse_errors) or bool(self.validation_errors))
1719

18-
def record_validation_success(self, path: pathlib.Path) -> None:
20+
def record_validation_success(self, path: pathlib.Path | str) -> None:
1921
self.successes.append(str(path))
2022

2123
def record_validation_error(
22-
self, path: pathlib.Path, err: jsonschema.ValidationError
24+
self, path: pathlib.Path | str, err: jsonschema.ValidationError
2325
) -> None:
2426
filename = str(path)
2527
if filename not in self.validation_errors:
2628
self.validation_errors[filename] = []
2729
self.validation_errors[filename].append(err)
2830

29-
def record_parse_error(self, path: pathlib.Path, err: ParseError) -> None:
31+
def record_parse_error(self, path: pathlib.Path | str, err: ParseError) -> None:
3032
filename = str(path)
3133
if filename not in self.parse_errors:
3234
self.parse_errors[filename] = []

src/check_jsonschema/schema_loader/main.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from ..parsers import ParserSet
1313
from ..utils import is_url_ish
1414
from .errors import UnsupportedUrlScheme
15-
from .readers import HttpSchemaReader, LocalSchemaReader
15+
from .readers import HttpSchemaReader, LocalSchemaReader, StdinSchemaReader
1616
from .resolver import make_reference_registry
1717

1818

@@ -47,7 +47,7 @@ def set_defaults_then_validate(
4747
class SchemaLoaderBase:
4848
def get_validator(
4949
self,
50-
path: pathlib.Path,
50+
path: pathlib.Path | str,
5151
instance_doc: dict[str, t.Any],
5252
format_opts: FormatOptions,
5353
fill_defaults: bool,
@@ -82,15 +82,22 @@ def __init__(
8282
self._parsers = ParserSet()
8383

8484
# setup a schema reader lazily, when needed
85-
self._reader: LocalSchemaReader | HttpSchemaReader | None = None
85+
self._reader: LocalSchemaReader | HttpSchemaReader | StdinSchemaReader | None = (
86+
None
87+
)
8688

8789
@property
88-
def reader(self) -> LocalSchemaReader | HttpSchemaReader:
90+
def reader(self) -> LocalSchemaReader | HttpSchemaReader | StdinSchemaReader:
8991
if self._reader is None:
9092
self._reader = self._get_schema_reader()
9193
return self._reader
9294

93-
def _get_schema_reader(self) -> LocalSchemaReader | HttpSchemaReader:
95+
def _get_schema_reader(
96+
self,
97+
) -> LocalSchemaReader | HttpSchemaReader | StdinSchemaReader:
98+
if self.schemafile == "-":
99+
return StdinSchemaReader()
100+
94101
if self.url_info is None or self.url_info.scheme in ("file", ""):
95102
return LocalSchemaReader(self.schemafile)
96103

@@ -117,7 +124,7 @@ def get_schema(self) -> dict[str, t.Any]:
117124

118125
def get_validator(
119126
self,
120-
path: pathlib.Path,
127+
path: pathlib.Path | str,
121128
instance_doc: dict[str, t.Any],
122129
format_opts: FormatOptions,
123130
fill_defaults: bool,
@@ -189,7 +196,7 @@ def __init__(self, base_uri: str | None = None) -> None:
189196

190197
def get_validator(
191198
self,
192-
path: pathlib.Path,
199+
path: pathlib.Path | str,
193200
instance_doc: dict[str, t.Any],
194201
format_opts: FormatOptions,
195202
fill_defaults: bool,

src/check_jsonschema/schema_loader/readers.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from __future__ import annotations
22

33
import io
4+
import json
5+
import sys
46
import typing as t
57

68
import ruamel.yaml
79

810
from ..cachedownloader import CacheDownloader
9-
from ..parsers import ParserSet
11+
from ..parsers import ParseError, ParserSet
1012
from ..utils import filename2path
1113
from .errors import SchemaParseError
1214

@@ -30,7 +32,7 @@ def __init__(self, filename: str) -> None:
3032
self.filename = str(self.path)
3133
self.parsers = ParserSet()
3234

33-
def get_retrieval_uri(self) -> str:
35+
def get_retrieval_uri(self) -> str | None:
3436
return self.path.as_uri()
3537

3638
def _read_impl(self) -> t.Any:
@@ -40,6 +42,20 @@ def read_schema(self) -> dict:
4042
return _run_load_callback(self.filename, self._read_impl)
4143

4244

45+
class StdinSchemaReader:
46+
def __init__(self) -> None:
47+
self.parsers = ParserSet()
48+
49+
def get_retrieval_uri(self) -> str | None:
50+
return None
51+
52+
def read_schema(self) -> dict:
53+
try:
54+
return json.load(sys.stdin)
55+
except ValueError as e:
56+
raise ParseError("Failed to parse JSON from stdin") from e
57+
58+
4359
class HttpSchemaReader:
4460
def __init__(
4561
self,
@@ -64,7 +80,7 @@ def _parse(self, schema_bytes: bytes) -> t.Any:
6480
)
6581
return self._parsed_schema
6682

67-
def get_retrieval_uri(self) -> str:
83+
def get_retrieval_uri(self) -> str | None:
6884
return self.url
6985

7086
def _read_impl(self) -> t.Any:

tests/acceptance/test_special_filetypes.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,37 @@ def _fake_compute_default_cache_dir(self):
114114
assert result.exit_code == 0
115115
else:
116116
assert result.exit_code == 1
117+
118+
119+
@pytest.mark.parametrize("check_passes", (True, False))
120+
@pytest.mark.parametrize("using_stdin", ("schema", "instance"))
121+
def test_schema_or_instance_from_stdin(
122+
run_line, check_passes, tmp_path, monkeypatch, using_stdin
123+
):
124+
"""
125+
a "remote schema" (meaning HTTPS) with bad data, therefore requiring that a retry
126+
fires in order to parse
127+
"""
128+
if using_stdin == "schema":
129+
instance_path = tmp_path / "instance.json"
130+
instance_path.write_text("42" if check_passes else '"foo"')
131+
132+
result = run_line(
133+
["check-jsonschema", "--schemafile", "-", str(instance_path)],
134+
input='{"type": "integer"}',
135+
)
136+
elif using_stdin == "instance":
137+
schema_path = tmp_path / "schema.json"
138+
schema_path.write_text('{"type": "integer"}')
139+
instance = "42" if check_passes else '"foo"'
140+
141+
result = run_line(
142+
["check-jsonschema", "--schemafile", schema_path, "-"],
143+
input=instance,
144+
)
145+
else:
146+
raise NotImplementedError
147+
if check_passes:
148+
assert result.exit_code == 0
149+
else:
150+
assert result.exit_code == 1

tests/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
import pathlib
23
import sys
34

@@ -38,3 +39,10 @@ def func(path, text):
3839
for name in all_names_to_clear:
3940
if name in sys.modules:
4041
del sys.modules[name]
42+
43+
44+
@pytest.fixture
45+
def in_tmp_dir(request, tmp_path):
46+
os.chdir(str(tmp_path))
47+
yield
48+
os.chdir(request.config.invocation_dir)

0 commit comments

Comments
 (0)