cli.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. """
  2. The ``jsonschema`` command line.
  3. """
  4. from importlib import metadata
  5. from json import JSONDecodeError
  6. from textwrap import dedent
  7. import argparse
  8. import json
  9. import sys
  10. import traceback
  11. import warnings
  12. try:
  13. from pkgutil import resolve_name
  14. except ImportError:
  15. from pkgutil_resolve_name import resolve_name # type: ignore[no-redef]
  16. from attrs import define, field
  17. from jsonschema.exceptions import SchemaError
  18. from jsonschema.validators import _RefResolver, validator_for
  19. warnings.warn(
  20. (
  21. "The jsonschema CLI is deprecated and will be removed in a future "
  22. "version. Please use check-jsonschema instead, which can be installed "
  23. "from https://pypi.org/project/check-jsonschema/"
  24. ),
  25. DeprecationWarning,
  26. stacklevel=2,
  27. )
  28. class _CannotLoadFile(Exception):
  29. pass
  30. @define
  31. class _Outputter:
  32. _formatter = field()
  33. _stdout = field()
  34. _stderr = field()
  35. @classmethod
  36. def from_arguments(cls, arguments, stdout, stderr):
  37. if arguments["output"] == "plain":
  38. formatter = _PlainFormatter(arguments["error_format"])
  39. elif arguments["output"] == "pretty":
  40. formatter = _PrettyFormatter()
  41. return cls(formatter=formatter, stdout=stdout, stderr=stderr)
  42. def load(self, path):
  43. try:
  44. file = open(path) # noqa: SIM115, PTH123
  45. except FileNotFoundError as error:
  46. self.filenotfound_error(path=path, exc_info=sys.exc_info())
  47. raise _CannotLoadFile() from error
  48. with file:
  49. try:
  50. return json.load(file)
  51. except JSONDecodeError as error:
  52. self.parsing_error(path=path, exc_info=sys.exc_info())
  53. raise _CannotLoadFile() from error
  54. def filenotfound_error(self, **kwargs):
  55. self._stderr.write(self._formatter.filenotfound_error(**kwargs))
  56. def parsing_error(self, **kwargs):
  57. self._stderr.write(self._formatter.parsing_error(**kwargs))
  58. def validation_error(self, **kwargs):
  59. self._stderr.write(self._formatter.validation_error(**kwargs))
  60. def validation_success(self, **kwargs):
  61. self._stdout.write(self._formatter.validation_success(**kwargs))
  62. @define
  63. class _PrettyFormatter:
  64. _ERROR_MSG = dedent(
  65. """\
  66. ===[{type}]===({path})===
  67. {body}
  68. -----------------------------
  69. """,
  70. )
  71. _SUCCESS_MSG = "===[SUCCESS]===({path})===\n"
  72. def filenotfound_error(self, path, exc_info):
  73. return self._ERROR_MSG.format(
  74. path=path,
  75. type="FileNotFoundError",
  76. body=f"{path!r} does not exist.",
  77. )
  78. def parsing_error(self, path, exc_info):
  79. exc_type, exc_value, exc_traceback = exc_info
  80. exc_lines = "".join(
  81. traceback.format_exception(exc_type, exc_value, exc_traceback),
  82. )
  83. return self._ERROR_MSG.format(
  84. path=path,
  85. type=exc_type.__name__,
  86. body=exc_lines,
  87. )
  88. def validation_error(self, instance_path, error):
  89. return self._ERROR_MSG.format(
  90. path=instance_path,
  91. type=error.__class__.__name__,
  92. body=error,
  93. )
  94. def validation_success(self, instance_path):
  95. return self._SUCCESS_MSG.format(path=instance_path)
  96. @define
  97. class _PlainFormatter:
  98. _error_format = field()
  99. def filenotfound_error(self, path, exc_info):
  100. return f"{path!r} does not exist.\n"
  101. def parsing_error(self, path, exc_info):
  102. return "Failed to parse {}: {}\n".format(
  103. "<stdin>" if path == "<stdin>" else repr(path),
  104. exc_info[1],
  105. )
  106. def validation_error(self, instance_path, error):
  107. return self._error_format.format(file_name=instance_path, error=error)
  108. def validation_success(self, instance_path):
  109. return ""
  110. def _resolve_name_with_default(name):
  111. if "." not in name:
  112. name = "jsonschema." + name
  113. return resolve_name(name)
  114. parser = argparse.ArgumentParser(
  115. description="JSON Schema Validation CLI",
  116. )
  117. parser.add_argument(
  118. "-i", "--instance",
  119. action="append",
  120. dest="instances",
  121. help="""
  122. a path to a JSON instance (i.e. filename.json) to validate (may
  123. be specified multiple times). If no instances are provided via this
  124. option, one will be expected on standard input.
  125. """,
  126. )
  127. parser.add_argument(
  128. "-F", "--error-format",
  129. help="""
  130. the format to use for each validation error message, specified
  131. in a form suitable for str.format. This string will be passed
  132. one formatted object named 'error' for each ValidationError.
  133. Only provide this option when using --output=plain, which is the
  134. default. If this argument is unprovided and --output=plain is
  135. used, a simple default representation will be used.
  136. """,
  137. )
  138. parser.add_argument(
  139. "-o", "--output",
  140. choices=["plain", "pretty"],
  141. default="plain",
  142. help="""
  143. an output format to use. 'plain' (default) will produce minimal
  144. text with one line for each error, while 'pretty' will produce
  145. more detailed human-readable output on multiple lines.
  146. """,
  147. )
  148. parser.add_argument(
  149. "-V", "--validator",
  150. type=_resolve_name_with_default,
  151. help="""
  152. the fully qualified object name of a validator to use, or, for
  153. validators that are registered with jsonschema, simply the name
  154. of the class.
  155. """,
  156. )
  157. parser.add_argument(
  158. "--base-uri",
  159. help="""
  160. a base URI to assign to the provided schema, even if it does not
  161. declare one (via e.g. $id). This option can be used if you wish to
  162. resolve relative references to a particular URI (or local path)
  163. """,
  164. )
  165. parser.add_argument(
  166. "--version",
  167. action="version",
  168. version=metadata.version("jsonschema"),
  169. )
  170. parser.add_argument(
  171. "schema",
  172. help="the path to a JSON Schema to validate with (i.e. schema.json)",
  173. )
  174. def parse_args(args): # noqa: D103
  175. arguments = vars(parser.parse_args(args=args or ["--help"]))
  176. if arguments["output"] != "plain" and arguments["error_format"]:
  177. raise parser.error(
  178. "--error-format can only be used with --output plain",
  179. )
  180. if arguments["output"] == "plain" and arguments["error_format"] is None:
  181. arguments["error_format"] = "{error.instance}: {error.message}\n"
  182. return arguments
  183. def _validate_instance(instance_path, instance, validator, outputter):
  184. invalid = False
  185. for error in validator.iter_errors(instance):
  186. invalid = True
  187. outputter.validation_error(instance_path=instance_path, error=error)
  188. if not invalid:
  189. outputter.validation_success(instance_path=instance_path)
  190. return invalid
  191. def main(args=sys.argv[1:]): # noqa: D103
  192. sys.exit(run(arguments=parse_args(args=args)))
  193. def run(arguments, stdout=sys.stdout, stderr=sys.stderr, stdin=sys.stdin): # noqa: D103
  194. outputter = _Outputter.from_arguments(
  195. arguments=arguments,
  196. stdout=stdout,
  197. stderr=stderr,
  198. )
  199. try:
  200. schema = outputter.load(arguments["schema"])
  201. except _CannotLoadFile:
  202. return 1
  203. Validator = arguments["validator"]
  204. if Validator is None:
  205. Validator = validator_for(schema)
  206. try:
  207. Validator.check_schema(schema)
  208. except SchemaError as error:
  209. outputter.validation_error(
  210. instance_path=arguments["schema"],
  211. error=error,
  212. )
  213. return 1
  214. if arguments["instances"]:
  215. load, instances = outputter.load, arguments["instances"]
  216. else:
  217. def load(_):
  218. try:
  219. return json.load(stdin)
  220. except JSONDecodeError as error:
  221. outputter.parsing_error(
  222. path="<stdin>", exc_info=sys.exc_info(),
  223. )
  224. raise _CannotLoadFile() from error
  225. instances = ["<stdin>"]
  226. resolver = _RefResolver(
  227. base_uri=arguments["base_uri"],
  228. referrer=schema,
  229. ) if arguments["base_uri"] is not None else None
  230. validator = Validator(schema, resolver=resolver)
  231. exit_code = 0
  232. for each in instances:
  233. try:
  234. instance = load(each)
  235. except _CannotLoadFile:
  236. exit_code = 1
  237. else:
  238. exit_code |= _validate_instance(
  239. instance_path=each,
  240. instance=instance,
  241. validator=validator,
  242. outputter=outputter,
  243. )
  244. return exit_code