1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260
|
"""
Entry point for the Wizard CLI tool.
"""
import argparse
import os
import platform
import sys
import textwrap
from gettext import gettext as _
from json import JSONDecodeError
from pathlib import Path
from typing import TextIO, Optional
from .schema import PyCodeGenerator
from ..__version__ import __version__
# Define the top-level parser
parser: argparse.ArgumentParser
def main(args=None):
"""
A companion CLI tool for the Dataclass Wizard, which simplifies
interaction with the Python `dataclasses` module.
"""
setup_parser()
args = parser.parse_args(args)
try:
args.func(args)
except AttributeError:
# A sub-command is not provided.
parser.print_help()
parser.exit(0)
def setup_parser():
"""Sets up the Wizard CLI parser."""
global parser
desc = main.__doc__
py_version = sys.version.split(" ", 1)[0]
# create the top-level parser
parser = argparse.ArgumentParser(description=desc)
# define global flags for the CLI tool
parser.add_argument('-V', '--version', action='version',
version=f'%(prog)s-cli/{__version__} '
f'Python/{py_version} '
f'{platform.system()}/{platform.release()}',
help='Display the version of this tool.')
# Commenting these out for now, as they are all currently a "no-op".
# parser.add_argument('-v', '--verbose', action='store_true',
# help='Enable verbose output')
# parser.add_argument('-q', '--quiet', action='store_true')
# Add the sub-commands here.
subparsers = parser.add_subparsers(help='Supported sub-commands')
# create the parser for the "gs" command
gs_parser = subparsers.add_parser(
'gen-schema', aliases=['gs'],
help='Generates a Python dataclass schema, given a JSON input.')
gs_parser.add_argument('in_file', metavar='in-file',
nargs='?',
type=FileTypeWithExt('r', ext='.json'),
help="Path to JSON file. The default assumes the "
"input is piped from stdin or '-'",
default=sys.stdin)
gs_parser.add_argument('out_file', metavar='out-file',
nargs='?',
type=FileTypeWithExt('w', ext='.py'),
help="Path to new Python file. The default is to "
"print the output to stdout or '-'",
default=sys.stdout)
gs_parser.add_argument("-n", "--no-json-file", action="store_true",
help='Do not create a separate JSON file. Note '
'this only applies when the JSON input is '
'piped in to stdin.')
gs_parser.add_argument("-f", "--force-strings", action="store_true",
help='Force-resolve strings to inferred Python types. '
'For example, a string appearing as "TRUE" will '
'resolve to a `bool` type, instead of the '
'default `Union[bool, str]`.')
gs_parser.add_argument("-x", "--experimental", action="store_true",
help='Enable experimental features via a __future__ '
'import, which allows PEP-585 and PEP-604 '
'style annotations in Python 3.7+')
gs_parser.set_defaults(func=gen_py_schema)
class FileTypeWithExt(argparse.FileType):
"""
Extends :class:`argparse.FileType` to add a default file extension if the
provided file name is missing one.
"""
def __init__(self, mode='r', ext=None,
bufsize=-1, encoding=None, errors='ignore'):
super().__init__(mode, bufsize, encoding, errors)
self._ext = ext
def __call__(self, string):
# the special argument "-" means sys.std{in,out}
if string == '-':
if 'r' in self._mode:
return sys.stdin
elif 'w' in self._mode: # pragma: no branch
return sys.stdout
else: # pragma: no cover
msg = _('argument "-" with mode %r') % self._mode
raise ValueError(msg)
# all other arguments are used as file names
ext = os.path.splitext(string)[-1].lower()
# Add the file extension, if needed
if not ext and self._ext:
string += self._ext
try:
return open(string, self._mode, self._bufsize, self._encoding,
self._errors)
except OSError as e:
message = _("can't open '%s': %s")
raise argparse.ArgumentTypeError(message % (string, e))
def get_div(out_file: TextIO, char='_', line_width=50):
"""
Returns a formatted line divider to print.
"""
if out_file.isatty():
try:
w = os.get_terminal_size(out_file.fileno()).columns - 2
if w > 0:
line_width = w
except (ValueError, OSError):
# Perhaps not a real terminal after all
pass
return char * line_width
def gen_py_schema(args):
"""
Entry point for the `wiz gen-schema (gs)` command.
"""
in_file: TextIO = args.in_file
out_file: TextIO = args.out_file
no_json_file: bool = args.no_json_file
force_strings: bool = args.force_strings
experimental: bool = args.experimental
# Currently these arguments are unused
# verbose, quiet = args.verbose, args.quiet
# Check if input is piped from stdin.
is_stdin: bool = in_file.name == '<stdin>'
# Check if output should be displayed to the terminal.
is_stdout: bool = out_file.name == '<stdout>'
# Read in contents of the JSON string, from stdin or a local file.
json_string: str = in_file.read()
try:
code_gen = PyCodeGenerator(file_contents=json_string,
force_strings=force_strings,
experimental=experimental)
except JSONDecodeError as e:
msg = str(e).lower()
if is_stdin and ('double quotes' in msg or 'extra data' in msg):
# We can provide a more helpful error message in this case.
msg = """\
Confirm that double quotes are properly applied. For example, the following syntax is invalid:
echo "{"key": "value"}" | wiz gs
Instead, wrap the string with single quotes as shown below:
echo \'{"key": "value"}\' | wiz gs
"""
_exit_with_error(out_file, msg=msg)
_exit_with_error(out_file, e)
except Exception as e:
_exit_with_error(out_file, e)
else:
print('Successfully generated the Python code for the JSON schema.')
print(get_div(out_file))
print()
if not is_stdout:
out_path = Path(out_file.name)
# Only create the JSON file if we are piped the input, and the
# `--no-json-file / -n` option is not passed in.
add_json_file: bool = is_stdin and not no_json_file
print(f'Wrote out the Python Code to: {out_path.absolute()}')
if add_json_file:
json_loc = out_path.with_suffix('.json')
json_loc.write_text(json_string)
print(f'Saved the JSON Input to: {json_loc.absolute()}')
out_file.write(code_gen.py_code)
def _exit_with_error(out_file: TextIO,
e: Optional[Exception] = None,
msg: Optional[str] = None,
line_width=70,
indent=' '):
"""
Prints the error message from an error `e` or an error message `msg`
and exits the program.
"""
msg_header = ('An error{err_cls}was encountered while parsing the JSON '
'input:')
if not msg:
msg = str(e)
error_lines = [
msg_header.format(err_cls=f' ({type(e).__name__}) ' if e else ' '),
get_div(out_file)
]
error_lines.extend(
textwrap.wrap(
textwrap.dedent(msg),
width=line_width,
initial_indent=indent,
subsequent_indent=indent,
drop_whitespace=False,
replace_whitespace=False,
)
)
sys.exit('\n'.join(error_lines))
if __name__ == "__main__":
sys.exit(main())
|