cli.py 8.2 KB

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