1
0
mirror of https://github.com/kellyjonbrazil/jc.git synced 2025-06-19 00:17:51 +02:00
Files
jc/jc/cli.py

473 lines
14 KiB
Python
Raw Normal View History

2019-11-07 08:04:07 -08:00
"""jc - JSON CLI output utility
JC cli module
"""
2020-06-07 12:41:50 -07:00
2019-11-07 08:04:07 -08:00
import sys
import os
import os.path
import re
2020-02-13 12:17:41 -08:00
import shlex
import importlib
2019-11-07 08:04:07 -08:00
import textwrap
import signal
import json
2020-07-09 11:27:01 -04:00
import pygments
2020-04-02 17:29:25 -07:00
from pygments import highlight
from pygments.style import Style
from pygments.token import (Name, Number, String, Keyword)
from pygments.lexers import JsonLexer
from pygments.formatters import Terminal256Formatter
2020-06-07 12:41:50 -07:00
import jc.appdirs as appdirs
2020-02-05 22:26:47 -08:00
class info():
2020-07-30 16:20:51 -07:00
version = '1.13.2'
2020-07-20 16:54:43 -07:00
description = 'JSON CLI output utility'
2020-02-05 22:26:47 -08:00
author = 'Kelly Brazil'
author_email = 'kellyjonbrazil@gmail.com'
__version__ = info.version
parsers = [
2020-03-10 20:35:52 -07:00
'airport',
2020-03-10 21:51:02 -07:00
'airport-s',
'arp',
2020-02-27 20:21:02 -08:00
'blkid',
'crontab',
'crontab-u',
2020-03-02 14:03:58 -08:00
'csv',
'df',
'dig',
2020-05-13 08:22:52 -07:00
'dmidecode',
'du',
'env',
2020-03-11 12:20:58 -07:00
'file',
'free',
'fstab',
2020-03-03 09:07:09 -08:00
'group',
2020-03-03 09:32:25 -08:00
'gshadow',
'history',
'hosts',
'id',
'ifconfig',
'ini',
'iptables',
'jobs',
2020-07-30 16:20:51 -07:00
'kv',
2020-02-27 15:14:43 -08:00
'last',
'ls',
'lsblk',
'lsmod',
'lsof',
'mount',
'netstat',
2020-03-10 14:18:55 -07:00
'ntpq',
2020-02-29 11:33:14 -08:00
'passwd',
2020-07-18 12:35:46 -07:00
'ping',
'pip-list',
'pip-show',
'ps',
'route',
2020-02-29 11:46:24 -08:00
'shadow',
'ss',
'stat',
'sysctl',
'systemctl',
'systemctl-lj',
'systemctl-ls',
'systemctl-luf',
2020-03-10 18:37:55 -07:00
'timedatectl',
2020-07-27 11:01:57 -07:00
'tracepath',
2020-07-22 12:19:27 -07:00
'traceroute',
'uname',
'uptime',
'w',
2020-03-01 16:30:04 -08:00
'who',
'xml',
'yaml'
]
# List of custom or override parsers.
# Allow any <user_data_dir>/jc/jcparsers/*.py
local_parsers = []
data_dir = appdirs.user_data_dir('jc', 'jc')
local_parsers_dir = os.path.join(data_dir, 'jcparsers')
if os.path.isdir(local_parsers_dir):
sys.path.append(data_dir)
for name in os.listdir(local_parsers_dir):
if re.match(r'\w+\.py', name) and os.path.isfile(os.path.join(local_parsers_dir, name)):
plugin_name = name[0:-3]
local_parsers.append(plugin_name)
if plugin_name not in parsers:
parsers.append(plugin_name)
# We only support 2.3.0+, pygments changed color names in 2.4.0.
# startswith is sufficient and avoids potential exceptions from split and int.
if pygments.__version__.startswith('2.3.'):
2020-07-09 11:27:01 -04:00
PYGMENT_COLOR = {
'black': '#ansiblack',
'red': '#ansidarkred',
'green': '#ansidarkgreen',
'yellow': '#ansibrown',
'blue': '#ansidarkblue',
'magenta': '#ansipurple',
'cyan': '#ansiteal',
'gray': '#ansilightgray',
'brightblack': '#ansidarkgray',
'brightred': '#ansired',
'brightgreen': '#ansigreen',
'brightyellow': '#ansiyellow',
'brightblue': '#ansiblue',
'brightmagenta': '#ansifuchsia',
'brightcyan': '#ansiturquoise',
'white': '#ansiwhite',
}
else:
PYGMENT_COLOR = {
'black': 'ansiblack',
'red': 'ansired',
'green': 'ansigreen',
'yellow': 'ansiyellow',
'blue': 'ansiblue',
'magenta': 'ansimagenta',
'cyan': 'ansicyan',
'gray': 'ansigray',
'brightblack': 'ansibrightblack',
'brightred': 'ansibrightred',
'brightgreen': 'ansibrightgreen',
'brightyellow': 'ansibrightyellow',
'brightblue': 'ansibrightblue',
'brightmagenta': 'ansibrightmagenta',
'brightcyan': 'ansibrightcyan',
'white': 'ansiwhite',
}
2020-07-09 09:59:00 -07:00
def set_env_colors(env_colors=None):
2020-04-12 12:43:51 -07:00
"""
2020-07-09 11:11:29 -07:00
Return a dictionary to be used in Pygments custom style class.
2020-04-12 13:03:09 -07:00
Grab custom colors from JC_COLORS environment variable. JC_COLORS env variable takes 4 comma
separated string values and should be in the format of:
2020-04-12 12:43:51 -07:00
2020-04-12 13:03:09 -07:00
JC_COLORS=<keyname_color>,<keyword_color>,<number_color>,<string_color>
2020-04-12 12:43:51 -07:00
Where colors are: black, red, green, yellow, blue, magenta, cyan, gray, brightblack, brightred,
brightgreen, brightyellow, brightblue, brightmagenta, brightcyan, white, default
Default colors:
2020-04-12 13:03:09 -07:00
JC_COLORS=blue,brightblack,magenta,green
2020-04-12 12:43:51 -07:00
or
2020-04-12 13:03:09 -07:00
JC_COLORS=default,default,default,default
2020-04-12 12:43:51 -07:00
"""
input_error = False
if env_colors:
color_list = env_colors.split(',')
else:
color_list = ['default', 'default', 'default', 'default']
2020-04-12 12:43:51 -07:00
if len(color_list) != 4:
2020-04-12 12:43:51 -07:00
input_error = True
for color in color_list:
2020-07-09 11:27:01 -04:00
if color != 'default' and color not in PYGMENT_COLOR:
input_error = True
2020-04-12 12:43:51 -07:00
# if there is an issue with the env variable, just set all colors to default and move on
if input_error:
2020-06-30 07:56:34 -07:00
print('jc: Warning: could not parse JC_COLORS environment variable\n', file=sys.stderr)
2020-04-12 12:43:51 -07:00
color_list = ['default', 'default', 'default', 'default']
2020-04-12 13:03:09 -07:00
# Try the color set in the JC_COLORS env variable first. If it is set to default, then fall back to default colors
return {
2020-07-09 09:59:00 -07:00
Name.Tag: f'bold {PYGMENT_COLOR[color_list[0]]}' if not color_list[0] == 'default' else f"bold {PYGMENT_COLOR['blue']}", # key names
Keyword: PYGMENT_COLOR[color_list[1]] if not color_list[1] == 'default' else PYGMENT_COLOR['brightblack'], # true, false, null
Number: PYGMENT_COLOR[color_list[2]] if not color_list[2] == 'default' else PYGMENT_COLOR['magenta'], # numbers
String: PYGMENT_COLOR[color_list[3]] if not color_list[3] == 'default' else PYGMENT_COLOR['green'] # strings
2020-04-12 12:43:51 -07:00
}
2020-04-02 17:29:25 -07:00
def piped_output():
2020-07-09 11:11:29 -07:00
"""Return False if stdout is a TTY. True if output is being piped to another program"""
2020-04-02 17:29:25 -07:00
if sys.stdout.isatty():
return False
else:
return True
2020-02-05 22:26:47 -08:00
def ctrlc(signum, frame):
"""Exit with error on SIGINT"""
2020-02-05 22:26:47 -08:00
sys.exit(1)
def parser_shortname(parser_argument):
2020-07-09 11:11:29 -07:00
"""Return short name of the parser with dashes and no -- prefix"""
return parser_argument[2:]
def parser_argument(parser):
2020-07-09 11:11:29 -07:00
"""Return short name of the parser with dashes and with -- prefix"""
return f'--{parser}'
def parser_mod_shortname(parser):
2020-07-09 11:11:29 -07:00
"""Return short name of the parser's module name (no -- prefix and dashes converted to underscores)"""
return parser.replace('--', '').replace('-', '_')
def parser_module(parser):
"""Import the module just in time and return the module object"""
shortname = parser_mod_shortname(parser)
path = ('jcparsers.' if shortname in local_parsers else 'jc.parsers.')
return importlib.import_module(path + shortname)
2019-12-13 20:01:51 -08:00
2020-02-05 13:57:34 -08:00
def parsers_text(indent=0, pad=0):
"""Return the argument and description information from each parser"""
2019-12-13 20:01:51 -08:00
ptext = ''
for parser in parsers:
parser_arg = parser_argument(parser)
parser_mod = parser_module(parser)
if hasattr(parser_mod, 'info'):
parser_desc = parser_mod.info.description
2020-02-05 11:08:47 -08:00
padding = pad - len(parser_arg)
2019-12-13 20:01:51 -08:00
padding_char = ' '
2020-02-05 13:57:34 -08:00
indent_text = padding_char * indent
2019-12-13 20:01:51 -08:00
padding_text = padding_char * padding
2020-02-05 13:57:34 -08:00
ptext += indent_text + parser_arg + padding_text + parser_desc + '\n'
2019-12-13 20:01:51 -08:00
return ptext
2019-12-14 23:15:15 -08:00
def about_jc():
"""Return jc info and the contents of each parser.info as a dictionary"""
2019-12-14 23:15:15 -08:00
parser_list = []
for parser in parsers:
parser_mod = parser_module(parser)
if hasattr(parser_mod, 'info'):
2019-12-16 09:00:16 -08:00
info_dict = {}
info_dict['name'] = parser_mod.__name__.split('.')[-1]
info_dict['argument'] = parser_argument(parser)
parser_entry = vars(parser_mod.info)
2019-12-16 09:00:16 -08:00
for k, v in parser_entry.items():
if not k.startswith('__'):
info_dict[k] = v
2019-12-16 09:00:16 -08:00
parser_list.append(info_dict)
2019-12-14 23:15:15 -08:00
2019-12-14 23:56:22 -08:00
return {
2019-12-16 09:08:47 -08:00
'name': 'jc',
2019-12-14 23:15:15 -08:00
'version': info.version,
'description': info.description,
'author': info.author,
'author_email': info.author_email,
2019-12-16 11:52:18 -08:00
'parser_count': len(parser_list),
2019-12-14 23:15:15 -08:00
'parsers': parser_list
}
2019-11-07 08:04:07 -08:00
def helptext(message):
"""Return the help text with the list of parsers"""
2020-02-05 13:57:34 -08:00
parsers_string = parsers_text(indent=12, pad=17)
2019-12-13 20:01:51 -08:00
2019-11-07 08:04:07 -08:00
helptext_string = f'''
jc: {message}
Usage: COMMAND | jc PARSER [OPTIONS]
2019-11-07 08:04:07 -08:00
or magic syntax:
2020-02-11 19:14:51 -08:00
jc [OPTIONS] COMMAND
2019-11-07 08:04:07 -08:00
Parsers:
2019-12-13 20:01:51 -08:00
{parsers_string}
2019-11-07 08:04:07 -08:00
Options:
2020-02-05 11:08:47 -08:00
-a about jc
2020-06-25 07:29:28 -07:00
-d debug - show traceback (-dd for verbose traceback)
2020-04-02 17:29:25 -07:00
-m monochrome output
2020-02-05 11:08:47 -08:00
-p pretty print output
-q quiet - suppress warnings
-r raw JSON output
2019-11-07 08:04:07 -08:00
Example:
ls -al | jc --ls -p
or using the magic syntax:
2020-02-11 19:14:51 -08:00
jc -p ls -al
2019-11-07 08:04:07 -08:00
'''
return textwrap.dedent(helptext_string)
2019-11-07 08:04:07 -08:00
def json_out(data, pretty=False, env_colors=None, mono=False, piped_out=False):
2020-07-09 11:11:29 -07:00
"""Return a JSON formatted string. String may include color codes or be pretty printed."""
2020-04-02 17:29:25 -07:00
if not mono and not piped_out:
2020-06-25 07:29:28 -07:00
# set colors
class JcStyle(Style):
styles = set_env_colors(env_colors)
2020-06-25 07:29:28 -07:00
2020-04-02 17:29:25 -07:00
if pretty:
return str(highlight(json.dumps(data, indent=2), JsonLexer(), Terminal256Formatter(style=JcStyle))[0:-1])
2020-04-02 17:29:25 -07:00
else:
return str(highlight(json.dumps(data), JsonLexer(), Terminal256Formatter(style=JcStyle))[0:-1])
2019-12-14 23:15:15 -08:00
else:
2020-04-02 17:29:25 -07:00
if pretty:
return json.dumps(data, indent=2)
2020-04-02 17:29:25 -07:00
else:
return json.dumps(data)
2019-12-14 23:15:15 -08:00
def generate_magic_command(args):
"""
2020-07-09 11:11:29 -07:00
Return a tuple with a boolean and a command, where the boolean signifies that
the command is valid, and the command is either a command string or None.
"""
2020-03-08 14:03:08 -07:00
# Parse with magic syntax: jc -p ls -al
if len(args) <= 1 or args[1].startswith('--'):
return False, None
2020-03-04 10:33:42 -08:00
# correctly parse escape characters and spaces with shlex
args_given = ' '.join(map(shlex.quote, args[1:])).split()
2020-03-04 10:33:42 -08:00
options = []
# find the options
for arg in list(args_given):
# parser found - use standard syntax
if arg.startswith('--'):
return False, None
# option found - populate option list
elif arg.startswith('-'):
options.extend(args_given.pop(0)[1:])
# command found if iterator didn't already stop - stop iterating
else:
break
# all options popped and no command found - for case like 'jc -a'
if len(args_given) == 0:
return False, None
magic_dict = {}
parser_info = about_jc()['parsers']
# create a dictionary of magic_commands to their respective parsers.
for entry in parser_info:
# Update the dict with all of the magic commands for this parser, if they exist.
magic_dict.update({mc: entry['argument'] for mc in entry.get('magic_commands', [])})
2020-03-04 10:33:42 -08:00
# find the command and parser
one_word_command = args_given[0]
two_word_command = ' '.join(args_given[0:2])
# try to get a parser for two_word_command, otherwise get one for one_word_command
found_parser = magic_dict.get(two_word_command, magic_dict.get(one_word_command))
2020-03-04 10:33:42 -08:00
# construct a new command line using the standard syntax: COMMAND | jc --PARSER -OPTIONS
run_command = ' '.join(args_given)
if found_parser:
cmd_options = ('-' + ''.join(options)) if options else ''
return True, ' '.join([run_command, '|', 'jc', found_parser, cmd_options])
else:
return False, run_command
def magic():
"""Runs the command generated by generate_magic_command() to support magic syntax"""
valid_command, run_command = generate_magic_command(sys.argv)
if valid_command:
os.system(run_command)
2020-04-14 11:10:31 -07:00
sys.exit(0)
elif run_command is None:
return
2020-03-04 10:33:42 -08:00
else:
print(helptext(f'parser not found for "{run_command}"'), file=sys.stderr)
2020-03-04 10:33:42 -08:00
sys.exit(1)
2019-11-07 08:04:07 -08:00
def main():
# break on ctrl-c keyboard interrupt
2019-11-07 08:04:07 -08:00
signal.signal(signal.SIGINT, ctrlc)
2020-04-09 13:38:33 -07:00
# break on pipe error. need try/except for windows compatibility
try:
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
except AttributeError:
pass
jc_colors = os.getenv('JC_COLORS')
# try magic syntax first: e.g. jc -p ls -al
magic()
options = []
2019-11-07 08:04:07 -08:00
# options
for opt in sys.argv:
if opt.startswith('-') and not opt.startswith('--'):
2020-03-04 10:33:42 -08:00
options.extend(opt[1:])
2019-11-07 08:04:07 -08:00
2020-03-04 10:33:42 -08:00
debug = 'd' in options
2020-06-25 07:29:28 -07:00
verbose_debug = True if options.count('d') > 1 else False
2020-04-02 17:29:25 -07:00
mono = 'm' in options
2020-03-04 10:33:42 -08:00
pretty = 'p' in options
quiet = 'q' in options
2020-03-04 12:03:40 -08:00
raw = 'r' in options
2019-11-07 08:04:07 -08:00
if verbose_debug:
import jc.tracebackplus
jc.tracebackplus.enable(context=11)
if 'a' in options:
print(json_out(about_jc(), pretty=pretty, env_colors=jc_colors, mono=mono, piped_out=piped_output()))
2020-04-14 11:10:31 -07:00
sys.exit(0)
2019-12-14 23:15:15 -08:00
2019-12-16 08:18:37 -08:00
if sys.stdin.isatty():
print(helptext('missing piped data'), file=sys.stderr)
2020-02-05 16:18:58 -08:00
sys.exit(1)
2019-12-14 23:15:15 -08:00
2019-12-16 08:18:37 -08:00
data = sys.stdin.read()
2019-12-14 23:15:15 -08:00
2019-11-07 08:23:11 -08:00
found = False
2020-06-30 11:26:09 -07:00
for arg in sys.argv:
parser_name = parser_shortname(arg)
2020-06-25 07:29:28 -07:00
2020-06-30 11:26:09 -07:00
if parser_name in parsers:
# load parser module just in time so we don't need to load all modules
parser = parser_module(arg)
try:
result = parser.parse(data, raw=raw, quiet=quiet)
2019-11-11 16:16:41 -08:00
found = True
break
2020-06-30 11:26:09 -07:00
except Exception:
if debug:
raise
else:
import jc.utils
jc.utils.error_message(
2020-06-30 11:37:33 -07:00
f'{parser_name} parser could not parse the input data. Did you use the correct parser?\n'
' For details use the -d or -dd option.')
2020-02-05 16:18:58 -08:00
sys.exit(1)
2019-11-07 08:04:07 -08:00
2019-12-16 08:18:37 -08:00
if not found:
print(helptext('missing or incorrect arguments'), file=sys.stderr)
2020-02-05 16:18:58 -08:00
sys.exit(1)
2019-11-07 08:04:07 -08:00
print(json_out(result, pretty=pretty, env_colors=jc_colors, mono=mono, piped_out=piped_output()))
2019-11-07 08:04:07 -08:00
if __name__ == '__main__':
main()