diff --git a/CHANGELOG b/CHANGELOG index 5a997b6e8..7706e9f4e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,14 @@ jc changelog +20240212 v1.25.1 +- Fix for crash when optional libraries are not installed (e.g. xmltodict) +- Fix for `ini` parser crashing with some keys with no values +- Fix `xrandr` parser to extract more EDID data +- Enhance `uptime` parser to support output with no user information +- Enhance `--quiet` CLI option to cover more warning messages +- Add tests for missing optional libraries +- Documentation updates + 20240204 v1.25.0 - Add `--slurp` functionality to wrap output from multiple lines into a single array. Note, this only works with single-line input parsers. (e.g. `date`, `ip-address`, `url`, etc.) diff --git a/docs/parsers/ini.md b/docs/parsers/ini.md index 3c0d2aa67..d5976a20f 100644 --- a/docs/parsers/ini.md +++ b/docs/parsers/ini.md @@ -98,4 +98,4 @@ Compatibility: linux, darwin, cygwin, win32, aix, freebsd Source: [`jc/parsers/ini.py`](https://github.com/kellyjonbrazil/jc/blob/master/jc/parsers/ini.py) -Version 2.1 by Kelly Brazil (kellyjonbrazil@gmail.com) +Version 2.2 by Kelly Brazil (kellyjonbrazil@gmail.com) diff --git a/docs/parsers/plist.md b/docs/parsers/plist.md index 87f63aa7c..116d99c74 100644 --- a/docs/parsers/plist.md +++ b/docs/parsers/plist.md @@ -76,4 +76,4 @@ Compatibility: linux, darwin, cygwin, win32, aix, freebsd Source: [`jc/parsers/plist.py`](https://github.com/kellyjonbrazil/jc/blob/master/jc/parsers/plist.py) -Version 1.1 by Kelly Brazil (kellyjonbrazil@gmail.com) +Version 1.2 by Kelly Brazil (kellyjonbrazil@gmail.com) diff --git a/docs/parsers/proc.md b/docs/parsers/proc.md index dcc2bc8d0..24dcdacbe 100644 --- a/docs/parsers/proc.md +++ b/docs/parsers/proc.md @@ -52,11 +52,11 @@ Specific Proc file parser names can be found with `jc -hh` or `jc -a`. Schemas can also be found online at: - https://kellyjonbrazil.github.io/jc/docs/parsers/proc_ +https://kellyjonbrazil.github.io/jc/docs/parsers/proc_ For example: - https://kellyjonbrazil.github.io/jc/docs/parsers/proc_meminfo +https://kellyjonbrazil.github.io/jc/docs/parsers/proc_meminfo Examples: diff --git a/docs/parsers/uptime.md b/docs/parsers/uptime.md index aeecc5d15..d26cddbf5 100644 --- a/docs/parsers/uptime.md +++ b/docs/parsers/uptime.md @@ -92,4 +92,4 @@ Source: [`jc/parsers/uptime.py`](https://github.com/kellyjonbrazil/jc/blob/maste This parser can be used with the `--slurp` command-line option. -Version 1.8 by Kelly Brazil (kellyjonbrazil@gmail.com) +Version 1.9 by Kelly Brazil (kellyjonbrazil@gmail.com) diff --git a/docs/parsers/xml.md b/docs/parsers/xml.md index cfcd27cda..cefc63835 100644 --- a/docs/parsers/xml.md +++ b/docs/parsers/xml.md @@ -100,4 +100,4 @@ Compatibility: linux, darwin, cygwin, win32, aix, freebsd Source: [`jc/parsers/xml.py`](https://github.com/kellyjonbrazil/jc/blob/master/jc/parsers/xml.py) -Version 1.9 by Kelly Brazil (kellyjonbrazil@gmail.com) +Version 1.10 by Kelly Brazil (kellyjonbrazil@gmail.com) diff --git a/docs/parsers/xrandr.md b/docs/parsers/xrandr.md index 43259d19e..bd85056f6 100644 --- a/docs/parsers/xrandr.md +++ b/docs/parsers/xrandr.md @@ -33,7 +33,7 @@ Schema: "maximum_height": integer, "devices": [ { - "modes": [ + "resolution_modes": [ { "resolution_width": integer, "resolution_height": integer, @@ -82,7 +82,7 @@ Examples: "maximum_height": 32767, "devices": [ { - "modes": [ + "resolution_modes": [ { "resolution_width": 1920, "resolution_height": 1080, @@ -143,7 +143,7 @@ Examples: "maximum_height": 32767, "devices": [ { - "modes": [ + "resolution_modes": [ { "resolution_width": 1920, "resolution_height": 1080, @@ -199,7 +199,7 @@ Examples: ### parse ```python -def parse(data: str, raw: bool = False, quiet: bool = False) -> Dict +def parse(data: str, raw: bool = False, quiet: bool = False) -> Response ``` Main text parsing function @@ -219,4 +219,4 @@ Compatibility: linux, darwin, cygwin, aix, freebsd Source: [`jc/parsers/xrandr.py`](https://github.com/kellyjonbrazil/jc/blob/master/jc/parsers/xrandr.py) -Version 1.4 by Kevin Lyter (code (at) lyterk.com) +Version 2.0 by Kevin Lyter (code (at) lyterk.com) diff --git a/jc/cli.py b/jc/cli.py index ce64cf65e..41c8358da 100644 --- a/jc/cli.py +++ b/jc/cli.py @@ -408,7 +408,7 @@ def json_out(self) -> str: ensure_ascii=self.ascii_only ) - if not self.mono: + if not self.mono and PYGMENTS_INSTALLED: class JcStyle(Style): styles: CustomColorType = self.custom_colors @@ -540,13 +540,12 @@ def do_magic(self) -> None: if self.magic_run_command_str.startswith('/proc'): try: self.magic_found_parser = 'proc' + filelist = shlex.split(self.magic_run_command_str) # multiple proc files detected - if ' ' in self.magic_run_command_str: + if len(filelist) > 1: self.slurp = True multi_out: List[str] = [] - filelist = self.magic_run_command_str.split() - filelist = [x.strip() for x in filelist] self.inputlist = filelist for file in self.inputlist: @@ -557,7 +556,7 @@ def do_magic(self) -> None: # single proc file else: - file = self.magic_run_command_str + file = filelist[0] # self.magic_stdout = self.open_text_file('/Users/kelly/temp' + file) self.magic_stdout = self.open_text_file(file) @@ -861,6 +860,9 @@ def _run(self) -> None: self.set_mono() self.set_custom_colors() + if self.quiet: + utils.CLI_QUIET = True + if self.verbose_debug: tracebackplus.enable(context=11) # type: ignore diff --git a/jc/cli_data.py b/jc/cli_data.py index 07cc91bc5..bfe49e551 100644 --- a/jc/cli_data.py +++ b/jc/cli_data.py @@ -101,8 +101,8 @@ $ jc --pretty /proc/meminfo Line Slicing: - $ $ cat output.txt | jc 4:15 --parser # Parse from line 4 to 14 - with parser (zero-based) + $ cat output.txt | jc 4:15 --parser # Parse from line 4 to 14 + with parser (zero-based) Parser Documentation: $ jc --help --dig diff --git a/jc/lib.py b/jc/lib.py index e1bdcf7ae..cf2429dbe 100644 --- a/jc/lib.py +++ b/jc/lib.py @@ -10,7 +10,7 @@ from jc import utils -__version__ = '1.25.0' +__version__ = '1.25.1' parsers: List[str] = [ 'acpi', @@ -251,7 +251,8 @@ def _is_valid_parser_plugin(name: str, local_parsers_dir: str) -> bool: else: utils.warning_message([f'Not installing invalid parser plugin "{parser_mod_name}" at {local_parsers_dir}']) return False - except Exception: + except Exception as e: + utils.warning_message([f'Not installing parser plugin "{parser_mod_name}" at {local_parsers_dir} due to error: {e}']) return False return False @@ -324,7 +325,16 @@ def _get_parser(parser_mod_name: str) -> ModuleType: parser_mod_name = _cliname_to_modname(parser_mod_name) parser_cli_name = _modname_to_cliname(parser_mod_name) modpath: str = 'jcparsers.' if parser_cli_name in local_parsers else 'jc.parsers.' - return importlib.import_module(f'{modpath}{parser_mod_name}') + mod = None + + try: + mod = importlib.import_module(f'{modpath}{parser_mod_name}') + except Exception as e: + mod = importlib.import_module(f'jc.parsers.disabled_parser') + mod.__name__ = parser_mod_name + utils.warning_message([f'"{parser_mod_name}" parser disabled due to error: {e}']) + + return mod def _parser_is_slurpable(parser: ModuleType) -> bool: """ diff --git a/jc/parsers/broken_parser.py b/jc/parsers/broken_parser.py new file mode 100644 index 000000000..ded316ed6 --- /dev/null +++ b/jc/parsers/broken_parser.py @@ -0,0 +1,23 @@ +"""jc - JSON Convert broken parser - for testing purposes only""" +import non_existent_library + +class info(): + """Provides parser metadata (version, author, etc.)""" + version = '1.0' + description = 'broken parser' + author = 'N/A' + author_email = 'N/A' + compatible = ['linux', 'darwin', 'cygwin', 'win32', 'aix', 'freebsd'] + hidden = True + + +__version__ = info.version + + +def parse( + data: str, + raw: bool = False, + quiet: bool = False +) -> dict: + """Main text parsing function""" + return {} diff --git a/jc/parsers/disabled_parser.py b/jc/parsers/disabled_parser.py new file mode 100644 index 000000000..1ed79a890 --- /dev/null +++ b/jc/parsers/disabled_parser.py @@ -0,0 +1,26 @@ +"""jc - JSON Convert disabled parser + +This parser has been disabled due to an error in the parser code. +""" +from jc.exceptions import ParseError + +class info(): + """Provides parser metadata (version, author, etc.)""" + version = '1.0' + description = 'Disabled parser' + author = 'N/A' + author_email = 'N/A' + compatible = ['linux', 'darwin', 'cygwin', 'win32', 'aix', 'freebsd'] + hidden = True + + +__version__ = info.version + + +def parse( + data: str, + raw: bool = False, + quiet: bool = False +) -> dict: + """Main text parsing function""" + raise ParseError('This parser is disabled.') diff --git a/jc/parsers/ini.py b/jc/parsers/ini.py index 64d5ae665..21ce3cf4a 100644 --- a/jc/parsers/ini.py +++ b/jc/parsers/ini.py @@ -75,7 +75,7 @@ class info(): """Provides parser metadata (version, author, etc.)""" - version = '2.1' + version = '2.2' description = 'INI file parser' author = 'Kelly Brazil' author_email = 'kellyjonbrazil@gmail.com' @@ -87,14 +87,10 @@ class info(): __version__ = info.version -class MyDict(dict): - def __setitem__(self, key, value): - # convert None values to empty string - if value is None: - self[key] = '' - - else: - super().__setitem__(key, value) +def _none_to_empty_string(data): + if data is None: + return '' + return data def _process(proc_data): @@ -110,13 +106,18 @@ def _process(proc_data): Dictionary representing the INI file. """ # remove quotation marks from beginning and end of values + # and convert None to empty string for k, v in proc_data.items(): if isinstance(v, dict): for key, value in v.items(): - v[key] = jc.utils.remove_quotes(value) + value = _none_to_empty_string(value) + value = jc.utils.remove_quotes(value) + v[key] = value continue - proc_data[k] = jc.utils.remove_quotes(v) + v = _none_to_empty_string(v) + v = jc.utils.remove_quotes(v) + proc_data[k] = v return proc_data @@ -143,7 +144,6 @@ def parse(data, raw=False, quiet=False): if jc.utils.has_data(data): ini_parser = configparser.ConfigParser( - dict_type = MyDict, allow_no_value=True, interpolation=None, default_section=None, @@ -175,4 +175,3 @@ def parse(data, raw=False, quiet=False): raw_output.update(temp_dict) return raw_output if raw else _process(raw_output) - diff --git a/jc/parsers/plist.py b/jc/parsers/plist.py index c1ec872a1..f7cd55142 100644 --- a/jc/parsers/plist.py +++ b/jc/parsers/plist.py @@ -44,6 +44,12 @@ ... } """ +import sys + +# ugly hack because I accidentally shadowed the xml module from the +# standard library with the xml parser. :( +sys.path = [x for x in sys.path if 'jc/jc/parsers' not in x] + from typing import Dict, Union import plistlib import binascii @@ -53,7 +59,7 @@ class info(): """Provides parser metadata (version, author, etc.)""" - version = '1.1' + version = '1.2' description = 'PLIST file parser' author = 'Kelly Brazil' author_email = 'kellyjonbrazil@gmail.com' diff --git a/jc/parsers/proc.py b/jc/parsers/proc.py index e22e7d121..bfa1ad3d4 100644 --- a/jc/parsers/proc.py +++ b/jc/parsers/proc.py @@ -47,11 +47,11 @@ Schemas can also be found online at: - https://kellyjonbrazil.github.io/jc/docs/parsers/proc_ +https://kellyjonbrazil.github.io/jc/docs/parsers/proc_ For example: - https://kellyjonbrazil.github.io/jc/docs/parsers/proc_meminfo +https://kellyjonbrazil.github.io/jc/docs/parsers/proc_meminfo Examples: diff --git a/jc/parsers/pyedid/edid.py b/jc/parsers/pyedid/edid.py index fafe9c2c9..dbed2f0af 100755 --- a/jc/parsers/pyedid/edid.py +++ b/jc/parsers/pyedid/edid.py @@ -8,6 +8,16 @@ __all__ = ["Edid"] +# EDID: +# 00ffffffffffff004ca3523100000000 +# 0014010380221378eac8959e57549226 +# 0f505400000001010101010101010101 +# 010101010101381d56d4500016303020 +# 250058c2100000190000000f00000000 +# 000000000025d9066a00000000fe0053 +# 414d53554e470a204ca34154000000fe +# 004c544e313536415432343430310018 + class Edid: """Edid class @@ -64,36 +74,39 @@ class Edid: _ASPECT_RATIOS = { 0b00: (16, 10), - 0b01: ( 4, 3), - 0b10: ( 5, 4), - 0b11: (16, 9), + 0b01: (4, 3), + 0b10: (5, 4), + 0b11: (16, 9), } - _RawEdid = namedtuple("RawEdid", - ("header", - "manu_id", - "prod_id", - "serial_no", - "manu_week", - "manu_year", - "edid_version", - "edid_revision", - "input_type", - "width", - "height", - "gamma", - "features", - "color", - "timings_supported", - "timings_reserved", - "timings_edid", - "timing_1", - "timing_2", - "timing_3", - "timing_4", - "extension", - "checksum") - ) + _RawEdid = namedtuple( + "RawEdid", + ( + "header", + "manu_id", + "prod_id", + "serial_no", + "manu_week", + "manu_year", + "edid_version", + "edid_revision", + "input_type", + "width", + "height", + "gamma", + "features", + "color", + "timings_supported", + "timings_reserved", + "timings_edid", + "timing_1", + "timing_2", + "timing_3", + "timing_4", + "extension", + "checksum", + ), + ) def __init__(self, edid: ByteString): self._parse_edid(edid) @@ -109,18 +122,20 @@ def _parse_edid(self, edid: ByteString): unpacked = struct.unpack(self._STRUCT_FORMAT, edid) raw_edid = self._RawEdid(*unpacked) - if raw_edid.header != b'\x00\xff\xff\xff\xff\xff\xff\x00': + if raw_edid.header != b"\x00\xff\xff\xff\xff\xff\xff\x00": raise ValueError("Invalid header.") self.raw = edid self.manufacturer_id = raw_edid.manu_id self.product = raw_edid.prod_id self.year = raw_edid.manu_year + 1990 - self.edid_version = "{:d}.{:d}".format(raw_edid.edid_version, raw_edid.edid_revision) + self.edid_version = "{:d}.{:d}".format( + raw_edid.edid_version, raw_edid.edid_revision + ) self.type = "digital" if (raw_edid.input_type & 0xFF) else "analog" self.width = float(raw_edid.width) self.height = float(raw_edid.height) - self.gamma = (raw_edid.gamma+100)/100 + self.gamma = (raw_edid.gamma + 100) / 100 self.dpms_standby = bool(raw_edid.features & 0xFF) self.dpms_suspend = bool(raw_edid.features & 0x7F) self.dpms_activeoff = bool(raw_edid.features & 0x3F) @@ -132,22 +147,27 @@ def _parse_edid(self, edid: ByteString): self.resolutions.append(self._TIMINGS[i]) for i in range(8): - bytes_data = raw_edid.timings_edid[2*i:2*i+2] - if bytes_data == b'\x01\x01': + bytes_data = raw_edid.timings_edid[2 * i : 2 * i + 2] + if bytes_data == b"\x01\x01": continue byte1, byte2 = bytes_data - x_res = 8*(int(byte1)+31) - aspect_ratio = self._ASPECT_RATIOS[(byte2>>6) & 0b11] - y_res = int(x_res * aspect_ratio[1]/aspect_ratio[0]) + x_res = 8 * (int(byte1) + 31) + aspect_ratio = self._ASPECT_RATIOS[(byte2 >> 6) & 0b11] + y_res = int(x_res * aspect_ratio[1] / aspect_ratio[0]) rate = (int(byte2) & 0b00111111) + 60.0 self.resolutions.append((x_res, y_res, rate)) self.name = None self.serial = None - for timing_bytes in (raw_edid.timing_1, raw_edid.timing_2, raw_edid.timing_3, raw_edid.timing_4): + for timing_bytes in ( + raw_edid.timing_1, + raw_edid.timing_2, + raw_edid.timing_3, + raw_edid.timing_4, + ): # "other" descriptor - if timing_bytes[0:2] == b'\x00\x00': + if timing_bytes[0:2] == b"\x00\x00": timing_type = timing_bytes[3] if timing_type in (0xFF, 0xFE, 0xFC): buffer = timing_bytes[5:] diff --git a/jc/parsers/uptime.py b/jc/parsers/uptime.py index de4e7b5e1..39d639068 100644 --- a/jc/parsers/uptime.py +++ b/jc/parsers/uptime.py @@ -65,7 +65,7 @@ class info(): """Provides parser metadata (version, author, etc.)""" - version = '1.8' + version = '1.9' description = '`uptime` command parser' author = 'Kelly Brazil' author_email = 'kellyjonbrazil@gmail.com' @@ -160,19 +160,27 @@ def parse(data, raw=False, quiet=False): jc.utils.input_type_check(data) raw_output = {} - cleandata = data.splitlines() if jc.utils.has_data(data): - time, _, *uptime, users, _, _, _, load_1m, load_5m, load_15m = cleandata[0].split() - - raw_output['time'] = time - raw_output['uptime'] = ' '.join(uptime).rstrip(',') - raw_output['users'] = users - raw_output['load_1m'] = load_1m.rstrip(',') - raw_output['load_5m'] = load_5m.rstrip(',') - raw_output['load_15m'] = load_15m - - if raw: - return raw_output - else: - return _process(raw_output) + if 'users' in data: + # standard uptime output + time, _, *uptime, users, _, _, _, load_1m, load_5m, load_15m = data.split() + + raw_output['time'] = time + raw_output['uptime'] = ' '.join(uptime).rstrip(',') + raw_output['users'] = users + raw_output['load_1m'] = load_1m.rstrip(',') + raw_output['load_5m'] = load_5m.rstrip(',') + raw_output['load_15m'] = load_15m + + else: + # users information missing (e.g. busybox) + time, _, *uptime, _, _, load_1m, load_5m, load_15m = data.split() + + raw_output['time'] = time + raw_output['uptime'] = ' '.join(uptime).rstrip(',') + raw_output['load_1m'] = load_1m.rstrip(',') + raw_output['load_5m'] = load_5m.rstrip(',') + raw_output['load_15m'] = load_15m + + return raw_output if raw else _process(raw_output) diff --git a/jc/parsers/xml.py b/jc/parsers/xml.py index 903d96803..3a5fa7182 100644 --- a/jc/parsers/xml.py +++ b/jc/parsers/xml.py @@ -73,15 +73,10 @@ import jc.utils from jc.exceptions import LibraryNotInstalled -try: - import xmltodict -except Exception: - raise LibraryNotInstalled('The xmltodict library is not installed.') - class info(): """Provides parser metadata (version, author, etc.)""" - version = '1.9' + version = '1.10' description = 'XML file parser' author = 'Kelly Brazil' author_email = 'kellyjonbrazil@gmail.com' @@ -93,7 +88,7 @@ class info(): __version__ = info.version -def _process(proc_data, has_data=False): +def _process(proc_data, has_data=False, xml_mod=None): """ Final processing to conform to the schema. @@ -105,16 +100,19 @@ def _process(proc_data, has_data=False): Dictionary representing an XML document. """ + if not xml_mod: + raise LibraryNotInstalled('The xmltodict library is not installed.') + proc_output = [] if has_data: # standard output with @ prefix for attributes try: - proc_output = xmltodict.parse(proc_data, + proc_output = xml_mod.parse(proc_data, dict_constructor=dict, process_comments=True) except (ValueError, TypeError): - proc_output = xmltodict.parse(proc_data, dict_constructor=dict) + proc_output = xml_mod.parse(proc_data, dict_constructor=dict) return proc_output @@ -133,6 +131,12 @@ def parse(data, raw=False, quiet=False): Dictionary. Raw or processed structured data. """ + xmltodict = None + try: + import xmltodict + except Exception: + raise LibraryNotInstalled('The xmltodict library is not installed.') + jc.utils.compatibility(__name__, info.compatible, quiet) jc.utils.input_type_check(data) @@ -156,4 +160,4 @@ def parse(data, raw=False, quiet=False): return raw_output - return _process(data, has_data) + return _process(data, has_data, xml_mod=xmltodict) diff --git a/jc/parsers/xrandr.py b/jc/parsers/xrandr.py index 2a5791741..3d9e40213 100644 --- a/jc/parsers/xrandr.py +++ b/jc/parsers/xrandr.py @@ -28,7 +28,7 @@ "maximum_height": integer, "devices": [ { - "modes": [ + "resolution_modes": [ { "resolution_width": integer, "resolution_height": integer, @@ -77,7 +77,7 @@ "maximum_height": 32767, "devices": [ { - "modes": [ + "resolution_modes": [ { "resolution_width": 1920, "resolution_height": 1080, @@ -138,7 +138,7 @@ "maximum_height": 32767, "devices": [ { - "modes": [ + "resolution_modes": [ { "resolution_width": 1920, "resolution_height": 1080, @@ -189,16 +189,27 @@ ] } """ +from collections import defaultdict +from enum import Enum import re -from typing import Dict, List, Optional, Union +from typing import Dict, List, Tuple, Union + import jc.utils from jc.parsers.pyedid.edid import Edid from jc.parsers.pyedid.helpers.edid_helper import EdidHelper +Match = None +try: + # Added Python 3.7 + Match = re.Match +except AttributeError: + Match = type(re.match("", "")) + class info: """Provides parser metadata (version, author, etc.)""" - version = "1.4" + + version = "2.0" description = "`xrandr` command parser" author = "Kevin Lyter" author_email = "code (at) lyterk.com" @@ -210,36 +221,10 @@ class info: __version__ = info.version -# keep parsing state so we know which parsers have already tried the line -# Structure is: -# { -# : [ -# -# ] -# } -# -# Where is the xrandr output line to be checked and -# can contain "screen", "device", or "model" -parse_state: Dict[str, List] = {} - - -def _was_parsed(line: str, parser: str) -> bool: - """ - Check if entered parser has already parsed. If so return True. - If not, return false and add the parser to the list for the line entry. - """ - if line in parse_state: - if parser in parse_state[line]: - return True - - parse_state[line].append(parser) - return False - - parse_state[line] = [parser] - return False - - +# NOTE: When developing, comment out the try statement and catch block to get +# TypedDict type hints and valid type errors. try: + # Added in Python 3.8 from typing import TypedDict Frequency = TypedDict( @@ -250,8 +235,8 @@ def _was_parsed(line: str, parser: str) -> bool: "is_preferred": bool, }, ) - Mode = TypedDict( - "Mode", + ResolutionMode = TypedDict( + "ResolutionMode", { "resolution_width": int, "resolution_height": int, @@ -259,14 +244,15 @@ def _was_parsed(line: str, parser: str) -> bool: "frequencies": List[Frequency], }, ) - Model = TypedDict( - "Model", + EdidModel = TypedDict( + "EdidModel", { "name": str, "product_id": str, "serial_number": str, }, ) + Props = Dict[str, Union[List[str], EdidModel]] Device = TypedDict( "Device", { @@ -282,7 +268,8 @@ def _was_parsed(line: str, parser: str) -> bool: "offset_height": int, "dimension_width": int, "dimension_height": int, - "modes": List[Mode], + "props": Props, + "resolution_modes": List[ResolutionMode], "rotation": str, "reflection": str, }, @@ -307,12 +294,13 @@ def _was_parsed(line: str, parser: str) -> bool: }, ) except ImportError: - Screen = Dict[str, Union[int, str]] - Device = Dict[str, Union[str, int, bool]] + EdidModel = Dict[str, str] + Props = Dict[str, Union[List[str], EdidModel]] Frequency = Dict[str, Union[float, bool]] - Mode = Dict[str, Union[int, bool, List[Frequency]]] - Model = Dict[str, str] - Response = Dict[str, Union[Device, Mode, Screen]] + ResolutionMode = Dict[str, Union[int, bool, List[Frequency]]] + Device = Dict[str, Union[str, int, bool, List[ResolutionMode]]] + Screen = Dict[str, Union[int, List[Device]]] + Response = Dict[str, Screen] _screen_pattern = ( @@ -323,33 +311,6 @@ def _was_parsed(line: str, parser: str) -> bool: ) -def _parse_screen(next_lines: List[str]) -> Optional[Screen]: - next_line = next_lines.pop() - - if _was_parsed(next_line, 'screen'): - return None - - result = re.match(_screen_pattern, next_line) - if not result: - next_lines.append(next_line) - return None - - raw_matches = result.groupdict() - - screen: Screen = {"devices": []} - for k, v in raw_matches.items(): - screen[k] = int(v) - - while next_lines: - device: Optional[Device] = _parse_device(next_lines) - if not device: - break - else: - screen["devices"].append(device) - - return screen - - # eDP1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) # 310mm x 170mm # regex101 demo link @@ -365,25 +326,106 @@ def _parse_screen(next_lines: List[str]) -> Optional[Screen]: + r"( ?((?P\d+)mm x (?P\d+)mm)?)?" ) +# 1920x1080i 60.03*+ 59.93 +# 1920x1080 60.00 + 50.00 59.94 +_resolution_mode_pattern = r"\s*(?P\d+)x(?P\d+)(?Pi)?\s+(?P.*)" +_frequencies_pattern = r"(((?P\d+\.\d+)(?P\*| |)(?P\+?)?)+)" -def _parse_device(next_lines: List[str], quiet: bool = False) -> Optional[Device]: - if not next_lines: - return None - next_line = next_lines.pop() +# Values sometimes appear on the same lines as the keys (CscMatrix), sometimes on the line +# below (as with EDIDs), and sometimes both (CTM). +# Capture the key line that way. +# +# CTM: 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 +# 0 1 +# CscMatrix: 65536 0 0 0 0 65536 0 0 0 0 65536 0 +# EDID: +# 00ffffffffffff0010ac33424c303541 +# 0f210104b53c22783eee95a3544c9926 +_prop_key_pattern = r"\s+(?P[\w| |\-|_]+):\s?(?P.*)" + + +class LineType(Enum): + Screen = 1 + Device = 2 + ResolutionMode = 3 + PropKey = 4 + PropValue = 5 + Invalid = 6 + + +class _Line: + """Provide metadata about line to make handling it more simple across fn boundaries""" + + def __init__(self, s: str, t: LineType, m: Match): + self.s = s + self.t = t + self.m = m + + @classmethod + def categorize(cls, line: str) -> "_Line": + """Iterate through line char by char to see what type of line it is. Apply regexes for more distinctness. Save the regexes and return them for later processing.""" + i = 0 + tab_count = 0 + while True: + try: + c = line[i] + except: + # Really shouldn't be getting to the end of the line + raise Exception(f"Reached end of line unexpectedly: '{line}'") + + if not c.isspace(): + if tab_count == 0: + screen_match = re.match(_screen_pattern, line) + if screen_match: + return cls(line, LineType.Screen, screen_match) + + device_match = re.match(_device_pattern, line) + if device_match: + return cls(line, LineType.Device, device_match) + else: + break + elif tab_count == 1: + match = re.match(_prop_key_pattern, line) + if match: + return cls(line, LineType.PropKey, match) + else: + break + else: + match = re.match(r"\s+(.*)\s+", line) + if match: + return cls(line, LineType.PropValue, match) + else: + break + else: + if c == " ": + match = re.match(_resolution_mode_pattern, line) + if match: + return cls(line, LineType.ResolutionMode, match) + else: + break + elif c == "\t": + tab_count += 1 + i += 1 + raise Exception(f"Line could not be categorized: '{line}'") + + +def _parse_screen(line: _Line) -> Screen: + d = line.m.groupdict() + + screen: Screen = {"devices": []} # type: ignore # Will be populated, but not immediately. + for k, v in d.items(): + screen[k] = int(v) - if _was_parsed(next_line, 'device'): - return None + return screen - result = re.match(_device_pattern, next_line) - if not result: - next_lines.append(next_line) - return None - matches = result.groupdict() +def _parse_device(line: _Line) -> Device: + matches = line.m.groupdict() device: Device = { - "modes": [], + "props": defaultdict(list), + "resolution_modes": [], "is_connected": matches["is_connected"] == "connected", "is_primary": matches["is_primary"] is not None and len(matches["is_primary"]) > 0, @@ -403,97 +445,20 @@ def _parse_device(next_lines: List[str], quiet: bool = False) -> Optional[Device if v: device[k] = int(v) except ValueError: - if not quiet: - jc.utils.warning_message( - [f"{next_line} : {k} - {v} is not int-able"] - ) - - model: Optional[Model] = _parse_model(next_lines, quiet) - if model: - device["model_name"] = model["name"] - device["product_id"] = model["product_id"] - device["serial_number"] = model["serial_number"] - - while next_lines: - next_line = next_lines.pop() - next_mode: Optional[Mode] = _parse_mode(next_line) - if next_mode: - device["modes"].append(next_mode) - else: - if re.match(_device_pattern, next_line): - next_lines.append(next_line) - break - return device - - -# EDID: -# 00ffffffffffff004ca3523100000000 -# 0014010380221378eac8959e57549226 -# 0f505400000001010101010101010101 -# 010101010101381d56d4500016303020 -# 250058c2100000190000000f00000000 -# 000000000025d9066a00000000fe0053 -# 414d53554e470a204ca34154000000fe -# 004c544e313536415432343430310018 -_edid_head_pattern = r"\s*EDID:\s*" -_edid_line_pattern = r"\s*(?P[0-9a-fA-F]{32})\s*" - - -def _parse_model(next_lines: List[str], quiet: bool = False) -> Optional[Model]: - if not next_lines: - return None - - next_line = next_lines.pop() - - if _was_parsed(next_line, 'model'): - return None - - if not re.match(_edid_head_pattern, next_line): - next_lines.append(next_line) - return None - - edid_hex_value = "" - - while next_lines: - next_line = next_lines.pop() - result = re.match(_edid_line_pattern, next_line) - - if not result: - next_lines.append(next_line) - break - - matches = result.groupdict() - edid_hex_value += matches["edid_line"] + raise Exception([f"{line.s} : {k} - {v} is not int-able"]) - edid = Edid(EdidHelper.hex2bytes(edid_hex_value)) - - model: Model = { - "name": edid.name or "Generic", - "product_id": str(edid.product), - "serial_number": str(edid.serial), - } - return model - - -# 1920x1080i 60.03*+ 59.93 -# 1920x1080 60.00 + 50.00 59.94 -_mode_pattern = r"\s*(?P\d+)x(?P\d+)(?Pi)?\s+(?P.*)" -_frequencies_pattern = r"(((?P\d+\.\d+)(?P\*| |)(?P\+?)?)+)" + return device -def _parse_mode(line: str) -> Optional[Mode]: - result = re.match(_mode_pattern, line) +def _parse_resolution_mode(line: _Line) -> ResolutionMode: frequencies: List[Frequency] = [] - if not result: - return None - - d = result.groupdict() + d = line.m.groupdict() resolution_width = int(d["resolution_width"]) resolution_height = int(d["resolution_height"]) is_high_resolution = d["is_high_resolution"] is not None - mode: Mode = { + mode: ResolutionMode = { "resolution_width": resolution_width, "resolution_height": resolution_height, "is_high_resolution": is_high_resolution, @@ -518,7 +483,45 @@ def _parse_mode(line: str) -> Optional[Mode]: return mode -def parse(data: str, raw: bool = False, quiet: bool = False) -> Dict: +def _parse_props(index: int, line: _Line, lines: List[str]) -> Tuple[int, Props]: + tmp_props: Dict[str, List[str]] = {} + key = "" + while index <= len(lines): + if line.t == LineType.PropKey: + d = line.m.groupdict() + # See _prop_key_pattern + key = d["key"] + maybe_value = d["maybe_value"] + if not maybe_value: + tmp_props[key] = [] + else: + tmp_props[key] = [maybe_value] + elif line.t == LineType.PropValue: + tmp_props[key].append(line.s.strip()) + else: + # We've gone past our props and need to ascend + index = index - 1 + break + index += 1 + try: + line = _Line.categorize(lines[index]) + except: + pass + + props: Props = {} + if "EDID" in tmp_props: + edid = Edid(EdidHelper.hex2bytes("".join(tmp_props["EDID"]))) + model: EdidModel = { + "name": edid.name or "Generic", + "product_id": str(edid.product), + "serial_number": str(edid.serial), + } + props["EdidModel"] = model + + return index, {**tmp_props, **props} + + +def parse(data: str, raw: bool = False, quiet: bool = False) -> Response: """ Main text parsing function @@ -535,15 +538,34 @@ def parse(data: str, raw: bool = False, quiet: bool = False) -> Dict: jc.utils.compatibility(__name__, info.compatible, quiet) jc.utils.input_type_check(data) - linedata = data.splitlines() - linedata.reverse() # For popping - result: Dict = {} + index = 0 + lines = data.splitlines() + screen, device = None, None + result: Response = {"screens": []} if jc.utils.has_data(data): - result = {"screens": []} - while linedata: - screen = _parse_screen(linedata) - if screen: + while index < len(lines): + line = _Line.categorize(lines[index]) + if line.t == LineType.Screen: + screen = _parse_screen(line) result["screens"].append(screen) + elif line.t == LineType.Device: + device = _parse_device(line) + if not screen: + raise Exception("There should be an identifiable screen") + screen["devices"].append(device) + elif line.t == LineType.ResolutionMode: + resolution_mode = _parse_resolution_mode(line) + if not device: + raise Exception("Undefined device") + device["resolution_modes"].append(resolution_mode) + elif line.t == LineType.PropKey: + # Props needs to be state aware, it owns the index. + ix, props = _parse_props(index, line, lines) + index = ix + if not device: + raise Exception("Undefined device") + device["props"] = props + index += 1 return result diff --git a/jc/utils.py b/jc/utils.py index 9ee5e7657..9771af1f8 100644 --- a/jc/utils.py +++ b/jc/utils.py @@ -12,6 +12,7 @@ from typing import Any, List, Dict, Iterable, Union, Optional, TextIO from .jc_types import TimeStampFormatType +CLI_QUIET = False def _asciify(string: str) -> str: """ @@ -62,6 +63,9 @@ def warning_message(message_lines: List[str]) -> None: None - just prints output to STDERR """ + if CLI_QUIET: + return + # this is for backwards compatibility with existing custom parsers if isinstance(message_lines, str): message_lines = [message_lines] diff --git a/man/jc.1 b/man/jc.1 index d9ebbaa47..aebe3e688 100644 --- a/man/jc.1 +++ b/man/jc.1 @@ -1,4 +1,4 @@ -.TH jc 1 2024-02-05 1.25.0 "JSON Convert" +.TH jc 1 2024-02-12 1.25.1 "JSON Convert" .SH NAME \fBjc\fP \- JSON Convert JSONifies the output of many CLI tools, file-types, and strings diff --git a/runtests-missing-libs.sh b/runtests-missing-libs.sh new file mode 100755 index 000000000..c64959334 --- /dev/null +++ b/runtests-missing-libs.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# system should be in "America/Los_Angeles" timezone for all tests to pass +# ensure no local plugin parsers are installed for all tests to pass + +pip uninstall pygments ruamel.yaml xmltodict --yes +python3 -m unittest -v +pip install pygments ruamel.yaml xmltodict diff --git a/setup.py b/setup.py index 6d535b22e..de0ccd3ee 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name='jc', - version='1.25.0', + version='1.25.1', author='Kelly Brazil', author_email='kellyjonbrazil@gmail.com', description='Converts the output of popular command-line tools and file-types to JSON.', diff --git a/tests/fixtures/generic/ini-dup-mariadb.json b/tests/fixtures/generic/ini-dup-mariadb.json new file mode 100644 index 000000000..299f89081 --- /dev/null +++ b/tests/fixtures/generic/ini-dup-mariadb.json @@ -0,0 +1 @@ +{"server":{},"mysqld":{"user":["mysql"],"pid_file":["/var/run/mysqld/mysqld.pid"],"port":["3306"],"basedir":["/usr"],"datadir":["/var/lib/mysql"],"tmpdir":["/tmp"],"lc_messages_dir":["/usr/share/mysql"],"skip_external_locking":[""],"bind_address":["127.0.0.1"],"key_buffer_size":["16M"],"max_allowed_packet":["64M"],"thread_stack":["192K"],"thread_cache_size":["8"],"myisam_recover_options":["BACKUP"],"max_connections":["80"],"max_user_connections":["0"],"table_cache":["64"],"thread_concurrency":["10"],"open_files_limit":["122880"],"table_open_cache":["6000"],"tmp_table_size":["32M"],"join_buffer_size":["8M"],"max_heap_table_size":["32M"],"query_cache_type":["0"],"query_cache_limit":["0"],"query_cache_size":["0"],"log_error":["/var/log/mysql/error.log"],"expire_logs_days":["10"],"max_binlog_size":["100M"],"innodb_buffer_pool_size":["1G"],"innodb_log_file_size":["256M"],"character_set_server":["utf8mb4"],"collation_server":["utf8mb4_general_ci"],"ignore_db_dir":["lost+found"]},"embedded":{},"mariadb":{"performance_schema":["ON"],"performance_schema_instrument":["stage/%=ON"],"performance_schema_consumer_events_stages_current":["ON"],"performance_schema_consumer_events_stages_history":["ON"],"performance_schema_consumer_events_stages_history_long":["ON"]},"mariadb-10.1":{}} diff --git a/tests/fixtures/generic/ini-mariadb.ini b/tests/fixtures/generic/ini-mariadb.ini new file mode 100644 index 000000000..0050bf522 --- /dev/null +++ b/tests/fixtures/generic/ini-mariadb.ini @@ -0,0 +1,151 @@ +# Ansible managed +# These groups are read by MariaDB server. +# Use it for options that only the server (but not clients) should see +# +# See the examples of server my.cnf files in /usr/share/mysql/ +# + +# this is read by the standalone daemon and embedded servers +[server] + +# this is only for the mysqld standalone daemon +[mysqld] + +# +# * Basic Settings +# +user = mysql +pid_file = /var/run/mysqld/mysqld.pid +port = 3306 +basedir = /usr +datadir = /var/lib/mysql +tmpdir = /tmp +lc_messages_dir = /usr/share/mysql +skip_external_locking + +# Instead of skip-networking the default is now to listen only on +# localhost which is more compatible and is not less secure. +bind_address = 127.0.0.1 + +# +# * Fine Tuning +# +key_buffer_size = 16M +max_allowed_packet = 64M +thread_stack = 192K +thread_cache_size = 8 +# This replaces the startup script and checks MyISAM tables if needed +# the first time they are touched +myisam_recover_options = BACKUP +max_connections = 80 +max_user_connections = 0 +table_cache = 64 +thread_concurrency = 10 +open_files_limit = 122880 +table_open_cache = 6000 +tmp_table_size = 32M +join_buffer_size = 8M +max_heap_table_size = 32M + +# +# * Query Cache Configuration +# +# Disabled by default in MariaDB >= 10.1.7 see: +# https://mariadb.com/kb/en/query-cache/ +query_cache_type = 0 +query_cache_limit = 0 +query_cache_size = 0 + +# +# * Logging and Replication +# +# Both location gets rotated by the cronjob. +# Be aware that this log type is a performance killer. +# As of 5.1 you can enable the log at runtime! +#general_log_file = /var/log/mysql/mysql.log +#general_log = 1 +# +# Error log - should be very few entries. +# +log_error = /var/log/mysql/error.log +# +# Enable the slow query log to see queries with especially long duration +#slow_query_log_file = /var/log/mysql/mariadb-slow.log +#long_query_time = 10 +#log_slow_rate_limit = 1000 +#log_slow_verbosity = query_plan +#log-queries-not-using-indexes +# +# The following can be used as easy to replay backup logs or for replication. +# note: if you are setting up a replication slave, see README.Debian about +# other settings you may need to change. +#server-id = 1 +#log_bin = /var/log/mysql/mysql-bin.log +expire_logs_days = 10 +max_binlog_size = 100M +#binlog_do_db = include_database_name +#binlog_ignore_db = exclude_database_name + +# +# * InnoDB +# +# InnoDB is enabled by default with a 10MB datafile in /var/lib/mysql/. +# Read the manual for more InnoDB related options. There are many! +innodb_buffer_pool_size = 1G +innodb_log_file_size = 256M + +# * Security Features +# +# Read the manual, too, if you want chroot! +# chroot = /var/lib/mysql/ +# +# For generating SSL certificates you can use for example the GUI tool "tinyca". +# +# ssl-ca=/etc/mysql/cacert.pem +# ssl-cert=/etc/mysql/server-cert.pem +# ssl-key=/etc/mysql/server-key.pem +# +# Accept only connections using the latest and most secure TLS protocol version. +# ..when MariaDB is compiled with OpenSSL: +# ssl-cipher=TLSv1.2 +# ..when MariaDB is compiled with YaSSL (default in Debian): +# ssl=on + +# +# * Character sets +# +# MySQL/MariaDB default is Latin1, but in Debian we rather default to the full +# utf8 4-byte character set. See also client.cnf +# +character_set_server = utf8mb4 +collation_server = utf8mb4_general_ci +ignore_db_dir = lost+found + +# +# * Unix socket authentication plugin is built-in since 10.0.22-6 +# +# Needed so the root database user can authenticate without a password but +# only when running as the unix root user. +# +# Also available for other users if required. +# See https://mariadb.com/kb/en/unix_socket-authentication-plugin/ + +# this is only for embedded server +[embedded] + +# This group is only read by MariaDB servers, not by MySQL. +# If you use the same .cnf file for MySQL and MariaDB, +# you can put MariaDB-only options here +[mariadb] +# https://mariadb.com/kb/en/library/performance-schema-overview/ +performance_schema=ON +performance_schema_instrument='stage/%=ON' +performance_schema_consumer_events_stages_current=ON +performance_schema_consumer_events_stages_history=ON +performance_schema_consumer_events_stages_history_long=ON + +# This group is only read by MariaDB-10.1 servers. +# If you use the same .cnf file for MariaDB of different versions, +# use this group for options that older servers don't understand +[mariadb-10.1] +# vim: syntax=dosini diff --git a/tests/fixtures/generic/ini-mariadb.json b/tests/fixtures/generic/ini-mariadb.json new file mode 100644 index 000000000..85bb0b1ad --- /dev/null +++ b/tests/fixtures/generic/ini-mariadb.json @@ -0,0 +1 @@ +{"server":{},"mysqld":{"user":"mysql","pid_file":"/var/run/mysqld/mysqld.pid","port":"3306","basedir":"/usr","datadir":"/var/lib/mysql","tmpdir":"/tmp","lc_messages_dir":"/usr/share/mysql","skip_external_locking":"","bind_address":"127.0.0.1","key_buffer_size":"16M","max_allowed_packet":"64M","thread_stack":"192K","thread_cache_size":"8","myisam_recover_options":"BACKUP","max_connections":"80","max_user_connections":"0","table_cache":"64","thread_concurrency":"10","open_files_limit":"122880","table_open_cache":"6000","tmp_table_size":"32M","join_buffer_size":"8M","max_heap_table_size":"32M","query_cache_type":"0","query_cache_limit":"0","query_cache_size":"0","log_error":"/var/log/mysql/error.log","expire_logs_days":"10","max_binlog_size":"100M","innodb_buffer_pool_size":"1G","innodb_log_file_size":"256M","character_set_server":"utf8mb4","collation_server":"utf8mb4_general_ci","ignore_db_dir":"lost+found"},"embedded":{},"mariadb":{"performance_schema":"ON","performance_schema_instrument":"stage/%=ON","performance_schema_consumer_events_stages_current":"ON","performance_schema_consumer_events_stages_history":"ON","performance_schema_consumer_events_stages_history_long":"ON"},"mariadb-10.1":{}} diff --git a/tests/fixtures/generic/xrandr_issue_525.out b/tests/fixtures/generic/xrandr_issue_525.out new file mode 100644 index 000000000..05e6c7cd0 --- /dev/null +++ b/tests/fixtures/generic/xrandr_issue_525.out @@ -0,0 +1,138 @@ +Screen 0: minimum 320 x 200, current 1920 x 1080, maximum 16384 x 16384 +eDP-1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 309mm x 174mm + EDID: + 00ffffffffffff0006af3d5700000000 + 001c0104a51f1178022285a5544d9a27 + 0e505400000001010101010101010101 + 010101010101b43780a070383e401010 + 350035ae100000180000000f00000000 + 00000000000000000020000000fe0041 + 554f0a202020202020202020000000fe + 004231343048414e30352e37200a0070 + scaling mode: Full aspect + supported: Full, Center, Full aspect + Colorspace: Default + supported: Default, RGB_Wide_Gamut_Fixed_Point, RGB_Wide_Gamut_Floating_Point, opRGB, DCI-P3_RGB_D65, BT2020_RGB, BT601_YCC, BT709_YCC, XVYCC_601, XVYCC_709, SYCC_601, opYCC_601, BT2020_CYCC, BT2020_YCC + max bpc: 12 + range: (6, 12) + Broadcast RGB: Automatic + supported: Automatic, Full, Limited 16:235 + panel orientation: Normal + supported: Normal, Upside Down, Left Side Up, Right Side Up + link-status: Good + supported: Good, Bad + CTM: 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 + 0 1 + CONNECTOR_ID: 95 + supported: 95 + non-desktop: 0 + range: (0, 1) + 1920x1080 60.03*+ 60.01 59.97 59.96 59.93 + 1680x1050 59.95 59.88 + 1400x1050 59.98 + 1600x900 59.99 59.94 59.95 59.82 + 1280x1024 60.02 + 1400x900 59.96 59.88 + 1280x960 60.00 + 1440x810 60.00 59.97 + 1368x768 59.88 59.85 + 1280x800 59.99 59.97 59.81 59.91 + 1280x720 60.00 59.99 59.86 59.74 + 1024x768 60.04 60.00 + 960x720 60.00 + 928x696 60.05 + 896x672 60.01 + 1024x576 59.95 59.96 59.90 59.82 + 960x600 59.93 60.00 + 960x540 59.96 59.99 59.63 59.82 + 800x600 60.00 60.32 56.25 + 840x525 60.01 59.88 + 864x486 59.92 59.57 + 700x525 59.98 + 800x450 59.95 59.82 + 640x512 60.02 + 700x450 59.96 59.88 + 640x480 60.00 59.94 + 720x405 59.51 58.99 + 684x384 59.88 59.85 + 640x400 59.88 59.98 + 640x360 59.86 59.83 59.84 59.32 + 512x384 60.00 + 512x288 60.00 59.92 + 480x270 59.63 59.82 + 400x300 60.32 56.34 + 432x243 59.92 59.57 + 320x240 60.05 + 360x202 59.51 59.13 + 320x180 59.84 59.32 +DP-1 disconnected (normal left inverted right x axis y axis) + HDCP Content Type: HDCP Type0 + supported: HDCP Type0, HDCP Type1 + Content Protection: Undesired + supported: Undesired, Desired, Enabled + Colorspace: Default + supported: Default, RGB_Wide_Gamut_Fixed_Point, RGB_Wide_Gamut_Floating_Point, opRGB, DCI-P3_RGB_D65, BT2020_RGB, BT601_YCC, BT709_YCC, XVYCC_601, XVYCC_709, SYCC_601, opYCC_601, BT2020_CYCC, BT2020_YCC + max bpc: 12 + range: (6, 12) + Broadcast RGB: Automatic + supported: Automatic, Full, Limited 16:235 + audio: auto + supported: force-dvi, off, auto, on + subconnector: Unknown + supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native + link-status: Good + supported: Good, Bad + CTM: 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 + 0 1 + CONNECTOR_ID: 103 + supported: 103 + non-desktop: 0 + range: (0, 1) +HDMI-1 disconnected (normal left inverted right x axis y axis) + HDCP Content Type: HDCP Type0 + supported: HDCP Type0, HDCP Type1 + Content Protection: Undesired + supported: Undesired, Desired, Enabled + max bpc: 12 + range: (8, 12) + content type: No Data + supported: No Data, Graphics, Photo, Cinema, Game + Colorspace: Default + supported: Default, SMPTE_170M_YCC, BT709_YCC, XVYCC_601, XVYCC_709, SYCC_601, opYCC_601, opRGB, BT2020_CYCC, BT2020_RGB, BT2020_YCC, DCI-P3_RGB_D65, DCI-P3_RGB_Theater + aspect ratio: Automatic + supported: Automatic, 4:3, 16:9 + Broadcast RGB: Automatic + supported: Automatic, Full, Limited 16:235 + audio: auto + supported: force-dvi, off, auto, on + link-status: Good + supported: Good, Bad + CTM: 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 + 0 1 + CONNECTOR_ID: 113 + supported: 113 + non-desktop: 0 + range: (0, 1) +DP-2 disconnected (normal left inverted right x axis y axis) + HDCP Content Type: HDCP Type0 + supported: HDCP Type0, HDCP Type1 + Content Protection: Undesired + supported: Undesired, Desired, Enabled + Colorspace: Default + supported: Default, RGB_Wide_Gamut_Fixed_Point, RGB_Wide_Gamut_Floating_Point, opRGB, DCI-P3_RGB_D65, BT2020_RGB, BT601_YCC, BT709_YCC, XVYCC_601, XVYCC_709, SYCC_601, opYCC_601, BT2020_CYCC, BT2020_YCC + max bpc: 12 + range: (6, 12) + Broadcast RGB: Automatic + supported: Automatic, Full, Limited 16:235 + audio: auto + supported: force-dvi, off, auto, on + subconnector: Unknown + supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native + link-status: Good + supported: Good, Bad + CTM: 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 + 0 1 + CONNECTOR_ID: 119 + supported: 119 + non-desktop: 0 + range: (0, 1) diff --git a/tests/fixtures/generic/xrandr_properties_1.out b/tests/fixtures/generic/xrandr_properties_1.out new file mode 100644 index 000000000..526434c7d --- /dev/null +++ b/tests/fixtures/generic/xrandr_properties_1.out @@ -0,0 +1,138 @@ +Screen 0: minimum 320 x 200, current 1920 x 1080, maximum 16384 x 16384 +eDP-1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 309mm x 174mm + EDID: + 00ffffffffffff0006af3d5700000000 + 001c0104a51f1178022285a5544d9a27 + 0e505400000001010101010101010101 + 010101010101b43780a070383e401010 + 350035ae100000180000000f00000000 + 00000000000000000020000000fe0041 + 554f0a202020202020202020000000fe + 004231343048414e30352e37200a0070 + scaling mode: Full aspect + supported: Full, Center, Full aspect + Colorspace: Default + supported: Default, RGB_Wide_Gamut_Fixed_Point, RGB_Wide_Gamut_Floating_Point, opRGB, DCI-P3_RGB_D65, BT2020_RGB, BT601_YCC, BT709_YCC, XVYCC_601, XVYCC_709, SYCC_601, opYCC_601, BT2020_CYCC, BT2020_YCC + max bpc: 12 + range: (6, 12) + Broadcast RGB: Automatic + supported: Automatic, Full, Limited 16:235 + panel orientation: Normal + supported: Normal, Upside Down, Left Side Up, Right Side Up + link-status: Good + supported: Good, Bad + CTM: 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 + 0 1 + CONNECTOR_ID: 95 + supported: 95 + non-desktop: 0 + range: (0, 1) + 1920x1080 60.03*+ 60.01 59.97 59.96 59.93 + 1680x1050 59.95 59.88 + 1400x1050 59.98 + 1600x900 59.99 59.94 59.95 59.82 + 1280x1024 60.02 + 1400x900 59.96 59.88 + 1280x960 60.00 + 1440x810 60.00 59.97 + 1368x768 59.88 59.85 + 1280x800 59.99 59.97 59.81 59.91 + 1280x720 60.00 59.99 59.86 59.74 + 1024x768 60.04 60.00 + 960x720 60.00 + 928x696 60.05 + 896x672 60.01 + 1024x576 59.95 59.96 59.90 59.82 + 960x600 59.93 60.00 + 960x540 59.96 59.99 59.63 59.82 + 800x600 60.00 60.32 56.25 + 840x525 60.01 59.88 + 864x486 59.92 59.57 + 700x525 59.98 + 800x450 59.95 59.82 + 640x512 60.02 + 700x450 59.96 59.88 + 640x480 60.00 59.94 + 720x405 59.51 58.99 + 684x384 59.88 59.85 + 640x400 59.88 59.98 + 640x360 59.86 59.83 59.84 59.32 + 512x384 60.00 + 512x288 60.00 59.92 + 480x270 59.63 59.82 + 400x300 60.32 56.34 + 432x243 59.92 59.57 + 320x240 60.05 + 360x202 59.51 59.13 + 320x180 59.84 59.32 +DP-1 disconnected (normal left inverted right x axis y axis) + HDCP Content Type: HDCP Type0 + supported: HDCP Type0, HDCP Type1 + Content Protection: Undesired + supported: Undesired, Desired, Enabled + Colorspace: Default + supported: Default, RGB_Wide_Gamut_Fixed_Point, RGB_Wide_Gamut_Floating_Point, opRGB, DCI-P3_RGB_D65, BT2020_RGB, BT601_YCC, BT709_YCC, XVYCC_601, XVYCC_709, SYCC_601, opYCC_601, BT2020_CYCC, BT2020_YCC + max bpc: 12 + range: (6, 12) + Broadcast RGB: Automatic + supported: Automatic, Full, Limited 16:235 + audio: auto + supported: force-dvi, off, auto, on + subconnector: Unknown + supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native + link-status: Good + supported: Good, Bad + CTM: 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 + 0 1 + CONNECTOR_ID: 103 + supported: 103 + non-desktop: 0 + range: (0, 1) +HDMI-1 disconnected (normal left inverted right x axis y axis) + HDCP Content Type: HDCP Type0 + supported: HDCP Type0, HDCP Type1 + Content Protection: Undesired + supported: Undesired, Desired, Enabled + max bpc: 12 + range: (8, 12) + content type: No Data + supported: No Data, Graphics, Photo, Cinema, Game + Colorspace: Default + supported: Default, SMPTE_170M_YCC, BT709_YCC, XVYCC_601, XVYCC_709, SYCC_601, opYCC_601, opRGB, BT2020_CYCC, BT2020_RGB, BT2020_YCC, DCI-P3_RGB_D65, DCI-P3_RGB_Theater + aspect ratio: Automatic + supported: Automatic, 4:3, 16:9 + Broadcast RGB: Automatic + supported: Automatic, Full, Limited 16:235 + audio: auto + supported: force-dvi, off, auto, on + link-status: Good + supported: Good, Bad + CTM: 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 + 0 1 + CONNECTOR_ID: 113 + supported: 113 + non-desktop: 0 + range: (0, 1) +DP-2 disconnected (normal left inverted right x axis y axis) + HDCP Content Type: HDCP Type0 + supported: HDCP Type0, HDCP Type1 + Content Protection: Undesired + supported: Undesired, Desired, Enabled + Colorspace: Default + supported: Default, RGB_Wide_Gamut_Fixed_Point, RGB_Wide_Gamut_Floating_Point, opRGB, DCI-P3_RGB_D65, BT2020_RGB, BT601_YCC, BT709_YCC, XVYCC_601, XVYCC_709, SYCC_601, opYCC_601, BT2020_CYCC, BT2020_YCC + max bpc: 12 + range: (6, 12) + Broadcast RGB: Automatic + supported: Automatic, Full, Limited 16:235 + audio: auto + supported: force-dvi, off, auto, on + subconnector: Unknown + supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native + link-status: Good + supported: Good, Bad + CTM: 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 + 0 1 + CONNECTOR_ID: 119 + supported: 119 + non-desktop: 0 + range: (0, 1) diff --git a/tests/test_ini.py b/tests/test_ini.py index 43362faaf..c2e9c0d3f 100644 --- a/tests/test_ini.py +++ b/tests/test_ini.py @@ -21,6 +21,9 @@ class MyTests(unittest.TestCase): with open(os.path.join(THIS_DIR, os.pardir, 'tests/fixtures/generic/ini-single-quote.ini'), 'r', encoding='utf-8') as f: generic_ini_single_quote = f.read() + with open(os.path.join(THIS_DIR, os.pardir, 'tests/fixtures/generic/ini-mariadb.ini'), 'r', encoding='utf-8') as f: + generic_ini_mariadb = f.read() + # output with open(os.path.join(THIS_DIR, os.pardir, 'tests/fixtures/generic/ini-test.json'), 'r', encoding='utf-8') as f: generic_ini_test_json = json.loads(f.read()) @@ -34,6 +37,9 @@ class MyTests(unittest.TestCase): with open(os.path.join(THIS_DIR, os.pardir, 'tests/fixtures/generic/ini-single-quote.json'), 'r', encoding='utf-8') as f: generic_ini_single_quote_json = json.loads(f.read()) + with open(os.path.join(THIS_DIR, os.pardir, 'tests/fixtures/generic/ini-mariadb.json'), 'r', encoding='utf-8') as f: + generic_ini_mariadb_json = json.loads(f.read()) + def test_ini_nodata(self): """ @@ -53,6 +59,12 @@ def test_ini_iptelserver(self): """ self.assertEqual(jc.parsers.ini.parse(self.generic_ini_iptelserver, quiet=True), self.generic_ini_iptelserver_json) + def test_ini_mariadb(self): + """ + Test the mariadb ini file + """ + self.assertEqual(jc.parsers.ini.parse(self.generic_ini_mariadb, quiet=True), self.generic_ini_mariadb_json) + def test_ini_duplicate_keys(self): """ Test input that contains duplicate keys. Only the last value should be used. @@ -104,6 +116,15 @@ def test_ini_singlequote(self): """ self.assertEqual(jc.parsers.ini.parse(self.generic_ini_single_quote, quiet=True), self.generic_ini_single_quote_json) + def test_ini_single_key_no_value(self): + """ + Test ini file with a single item with no value. This caused issues in jc v.1.25.0 + """ + data = '''[data] +novalue +''' + expected = {"data":{"novalue":""}} + self.assertEqual(jc.parsers.ini.parse(data, quiet=True), expected) if __name__ == '__main__': diff --git a/tests/test_ini_dup.py b/tests/test_ini_dup.py index 751524663..739d87f31 100644 --- a/tests/test_ini_dup.py +++ b/tests/test_ini_dup.py @@ -21,6 +21,9 @@ class MyTests(unittest.TestCase): with open(os.path.join(THIS_DIR, os.pardir, 'tests/fixtures/generic/ini-single-quote.ini'), 'r', encoding='utf-8') as f: generic_ini_single_quote = f.read() + with open(os.path.join(THIS_DIR, os.pardir, 'tests/fixtures/generic/ini-mariadb.ini'), 'r', encoding='utf-8') as f: + generic_ini_mariadb = f.read() + # output with open(os.path.join(THIS_DIR, os.pardir, 'tests/fixtures/generic/ini-dup-test.json'), 'r', encoding='utf-8') as f: generic_ini_dup_test_json = json.loads(f.read()) @@ -34,6 +37,9 @@ class MyTests(unittest.TestCase): with open(os.path.join(THIS_DIR, os.pardir, 'tests/fixtures/generic/ini-dup-single-quote.json'), 'r', encoding='utf-8') as f: generic_ini_dup_single_quote_json = json.loads(f.read()) + with open(os.path.join(THIS_DIR, os.pardir, 'tests/fixtures/generic/ini-dup-mariadb.json'), 'r', encoding='utf-8') as f: + generic_ini_dup_mariadb_json = json.loads(f.read()) + def test_ini_dup_nodata(self): """ @@ -53,6 +59,12 @@ def test_ini_dup_iptelserver(self): """ self.assertEqual(jc.parsers.ini_dup.parse(self.generic_ini_iptelserver, quiet=True), self.generic_ini_dup_iptelserver_json) + def test_ini_dup_mariadb(self): + """ + Test the mariadb ini file + """ + self.assertEqual(jc.parsers.ini_dup.parse(self.generic_ini_mariadb, quiet=True), self.generic_ini_dup_mariadb_json) + def test_ini_dup_duplicate_keys(self): """ Test input that contains duplicate keys. @@ -94,7 +106,15 @@ def test_ini_dup_singlequote(self): """ self.assertEqual(jc.parsers.ini_dup.parse(self.generic_ini_single_quote, quiet=True), self.generic_ini_dup_single_quote_json) - + def test_ini_dup_single_key_no_value(self): + """ + Test ini file with a single item with no value. + """ + data = '''[data] +novalue +''' + expected = {"data":{"novalue":[""]}} + self.assertEqual(jc.parsers.ini_dup.parse(data, quiet=True), expected) if __name__ == '__main__': unittest.main() diff --git a/tests/test_jc_cli.py b/tests/test_jc_cli.py index 3a3c5d350..f5eab6b54 100644 --- a/tests/test_jc_cli.py +++ b/tests/test_jc_cli.py @@ -1,12 +1,20 @@ import os import unittest from datetime import datetime, timezone + try: import pygments from pygments.token import (Name, Number, String, Keyword) PYGMENTS_INSTALLED=True -except ModuleNotFoundError: +except: PYGMENTS_INSTALLED=False + +try: + import ruamel.yaml + RUAMELYAML_INSTALLED = True +except: + RUAMELYAML_INSTALLED = False + from jc.cli import JcCli import jc.parsers.url as url_parser import jc.parsers.proc as proc_parser @@ -47,164 +55,165 @@ def test_cli_magic_parser(self): resulting_attributes = (cli.magic_found_parser, cli.magic_options, cli.magic_run_command) self.assertEqual(expected, resulting_attributes) + @unittest.skipIf(not PYGMENTS_INSTALLED, 'pygments library not installed') def test_cli_set_env_colors(self): - if PYGMENTS_INSTALLED: - if pygments.__version__.startswith('2.3.'): - env = { - '': { - Name.Tag: 'bold #ansidarkblue', - Keyword: '#ansidarkgray', - Number: '#ansipurple', - String: '#ansidarkgreen' - }, - ' ': { - Name.Tag: 'bold #ansidarkblue', - Keyword: '#ansidarkgray', - Number: '#ansipurple', - String: '#ansidarkgreen' - }, - 'default,default,default,default': { - Name.Tag: 'bold #ansidarkblue', - Keyword: '#ansidarkgray', - Number: '#ansipurple', - String: '#ansidarkgreen' - }, - 'red,red,red,red': { - Name.Tag: 'bold #ansidarkred', - Keyword: '#ansidarkred', - Number: '#ansidarkred', - String: '#ansidarkred' - }, - 'red,red,yada,red': { - Name.Tag: 'bold #ansidarkblue', - Keyword: '#ansidarkgray', - Number: '#ansipurple', - String: '#ansidarkgreen' - }, - 'red,red,red': { - Name.Tag: 'bold #ansidarkblue', - Keyword: '#ansidarkgray', - Number: '#ansipurple', - String: '#ansidarkgreen' - }, - 'red,red,red,red,red,red': { - Name.Tag: 'bold #ansidarkblue', - Keyword: '#ansidarkgray', - Number: '#ansipurple', - String: '#ansidarkgreen' - } + if pygments.__version__.startswith('2.3.'): + env = { + '': { + Name.Tag: 'bold #ansidarkblue', + Keyword: '#ansidarkgray', + Number: '#ansipurple', + String: '#ansidarkgreen' + }, + ' ': { + Name.Tag: 'bold #ansidarkblue', + Keyword: '#ansidarkgray', + Number: '#ansipurple', + String: '#ansidarkgreen' + }, + 'default,default,default,default': { + Name.Tag: 'bold #ansidarkblue', + Keyword: '#ansidarkgray', + Number: '#ansipurple', + String: '#ansidarkgreen' + }, + 'red,red,red,red': { + Name.Tag: 'bold #ansidarkred', + Keyword: '#ansidarkred', + Number: '#ansidarkred', + String: '#ansidarkred' + }, + 'red,red,yada,red': { + Name.Tag: 'bold #ansidarkblue', + Keyword: '#ansidarkgray', + Number: '#ansipurple', + String: '#ansidarkgreen' + }, + 'red,red,red': { + Name.Tag: 'bold #ansidarkblue', + Keyword: '#ansidarkgray', + Number: '#ansipurple', + String: '#ansidarkgreen' + }, + 'red,red,red,red,red,red': { + Name.Tag: 'bold #ansidarkblue', + Keyword: '#ansidarkgray', + Number: '#ansipurple', + String: '#ansidarkgreen' } - else: - env = { - '': { - Name.Tag: 'bold ansiblue', - Keyword: 'ansibrightblack', - Number: 'ansimagenta', - String: 'ansigreen' - }, - ' ': { - Name.Tag: 'bold ansiblue', - Keyword: 'ansibrightblack', - Number: 'ansimagenta', - String: 'ansigreen' - }, - 'default,default,default,default': { - Name.Tag: 'bold ansiblue', - Keyword: 'ansibrightblack', - Number: 'ansimagenta', - String: 'ansigreen' - }, - 'red,red,red,red': { - Name.Tag: 'bold ansired', - Keyword: 'ansired', - Number: 'ansired', - String: 'ansired' - }, - 'red,red,yada,red': { - Name.Tag: 'bold ansiblue', - Keyword: 'ansibrightblack', - Number: 'ansimagenta', - String: 'ansigreen' - }, - 'red,red,red': { - Name.Tag: 'bold ansiblue', - Keyword: 'ansibrightblack', - Number: 'ansimagenta', - String: 'ansigreen' - }, - 'red,red,red,red,red,red': { - Name.Tag: 'bold ansiblue', - Keyword: 'ansibrightblack', - Number: 'ansimagenta', - String: 'ansigreen' - } + } + else: + env = { + '': { + Name.Tag: 'bold ansiblue', + Keyword: 'ansibrightblack', + Number: 'ansimagenta', + String: 'ansigreen' + }, + ' ': { + Name.Tag: 'bold ansiblue', + Keyword: 'ansibrightblack', + Number: 'ansimagenta', + String: 'ansigreen' + }, + 'default,default,default,default': { + Name.Tag: 'bold ansiblue', + Keyword: 'ansibrightblack', + Number: 'ansimagenta', + String: 'ansigreen' + }, + 'red,red,red,red': { + Name.Tag: 'bold ansired', + Keyword: 'ansired', + Number: 'ansired', + String: 'ansired' + }, + 'red,red,yada,red': { + Name.Tag: 'bold ansiblue', + Keyword: 'ansibrightblack', + Number: 'ansimagenta', + String: 'ansigreen' + }, + 'red,red,red': { + Name.Tag: 'bold ansiblue', + Keyword: 'ansibrightblack', + Number: 'ansimagenta', + String: 'ansigreen' + }, + 'red,red,red,red,red,red': { + Name.Tag: 'bold ansiblue', + Keyword: 'ansibrightblack', + Number: 'ansimagenta', + String: 'ansigreen' } + } - for jc_colors, expected_colors in env.items(): - cli = JcCli() - os.environ["JC_COLORS"] = jc_colors - cli.set_custom_colors() - self.assertEqual(cli.custom_colors, expected_colors) + for jc_colors, expected_colors in env.items(): + cli = JcCli() + os.environ["JC_COLORS"] = jc_colors + cli.set_custom_colors() + self.assertEqual(cli.custom_colors, expected_colors) + @unittest.skipIf(not PYGMENTS_INSTALLED, 'pygments library not installed') def test_cli_json_out(self): - if PYGMENTS_INSTALLED: - test_input = [ - None, - {}, - [], - '', - {"key1": "value1", "key2": 2, "key3": None, "key4": 3.14, "key5": True}, - ] - - if pygments.__version__.startswith('2.3.'): - expected_output = [ - '\x1b[30;01mnull\x1b[39;00m', - '{}', - '[]', - '\x1b[32m""\x1b[39m', - '{\x1b[34;01m"key1"\x1b[39;00m:\x1b[32m"value1"\x1b[39m,\x1b[34;01m"key2"\x1b[39;00m:\x1b[35m2\x1b[39m,\x1b[34;01m"key3"\x1b[39;00m:\x1b[30;01mnull\x1b[39;00m,\x1b[34;01m"key4"\x1b[39;00m:\x1b[35m3.14\x1b[39m,\x1b[34;01m"key5"\x1b[39;00m:\x1b[30;01mtrue\x1b[39;00m}' - ] - else: - expected_output = [ - '\x1b[90mnull\x1b[39m', - '{}', - '[]', - '\x1b[32m""\x1b[39m', - '{\x1b[34;01m"key1"\x1b[39;00m:\x1b[32m"value1"\x1b[39m,\x1b[34;01m"key2"\x1b[39;00m:\x1b[35m2\x1b[39m,\x1b[34;01m"key3"\x1b[39;00m:\x1b[90mnull\x1b[39m,\x1b[34;01m"key4"\x1b[39;00m:\x1b[35m3.14\x1b[39m,\x1b[34;01m"key5"\x1b[39;00m:\x1b[90mtrue\x1b[39m}' - ] - - for test_dict, expected_json in zip(test_input, expected_output): - cli = JcCli() - os.environ["JC_COLORS"] = "default,default,default,default" - cli.set_custom_colors() - cli.data_out = test_dict - self.assertEqual(cli.json_out(), expected_json) + test_input = [ + None, + {}, + [], + '', + {"key1": "value1", "key2": 2, "key3": None, "key4": 3.14, "key5": True}, + ] - def test_cli_json_out_mono(self): - if PYGMENTS_INSTALLED: - test_input = [ - None, - {}, - [], - '', - {"key1": "value1", "key2": 2, "key3": None, "key4": 3.14, "key5": True}, + if pygments.__version__.startswith('2.3.'): + expected_output = [ + '\x1b[30;01mnull\x1b[39;00m', + '{}', + '[]', + '\x1b[32m""\x1b[39m', + '{\x1b[34;01m"key1"\x1b[39;00m:\x1b[32m"value1"\x1b[39m,\x1b[34;01m"key2"\x1b[39;00m:\x1b[35m2\x1b[39m,\x1b[34;01m"key3"\x1b[39;00m:\x1b[30;01mnull\x1b[39;00m,\x1b[34;01m"key4"\x1b[39;00m:\x1b[35m3.14\x1b[39m,\x1b[34;01m"key5"\x1b[39;00m:\x1b[30;01mtrue\x1b[39;00m}' ] - + else: expected_output = [ - 'null', + '\x1b[90mnull\x1b[39m', '{}', '[]', - '""', - '{"key1":"value1","key2":2,"key3":null,"key4":3.14,"key5":true}' + '\x1b[32m""\x1b[39m', + '{\x1b[34;01m"key1"\x1b[39;00m:\x1b[32m"value1"\x1b[39m,\x1b[34;01m"key2"\x1b[39;00m:\x1b[35m2\x1b[39m,\x1b[34;01m"key3"\x1b[39;00m:\x1b[90mnull\x1b[39m,\x1b[34;01m"key4"\x1b[39;00m:\x1b[35m3.14\x1b[39m,\x1b[34;01m"key5"\x1b[39;00m:\x1b[90mtrue\x1b[39m}' ] - for test_dict, expected_json in zip(test_input, expected_output): - cli = JcCli() - cli.set_custom_colors() - cli.mono = True - cli.data_out = test_dict - self.assertEqual(cli.json_out(), expected_json) + for test_dict, expected_json in zip(test_input, expected_output): + cli = JcCli() + os.environ["JC_COLORS"] = "default,default,default,default" + cli.set_custom_colors() + cli.data_out = test_dict + self.assertEqual(cli.json_out(), expected_json) + @unittest.skipIf(not PYGMENTS_INSTALLED, 'pygments library not installed') + def test_cli_json_out_mono(self): + test_input = [ + None, + {}, + [], + '', + {"key1": "value1", "key2": 2, "key3": None, "key4": 3.14, "key5": True}, + ] + + expected_output = [ + 'null', + '{}', + '[]', + '""', + '{"key1":"value1","key2":2,"key3":null,"key4":3.14,"key5":true}' + ] + + for test_dict, expected_json in zip(test_input, expected_output): + cli = JcCli() + cli.set_custom_colors() + cli.mono = True + cli.data_out = test_dict + self.assertEqual(cli.json_out(), expected_json) + + @unittest.skipIf(not PYGMENTS_INSTALLED, 'pygments library not installed') def test_cli_json_out_pretty(self): test_input = [ {"key1": "value1", "key2": 2, "key3": None, "key4": 3.14, "key5": True}, @@ -229,40 +238,60 @@ def test_cli_json_out_pretty(self): cli.data_out = test_dict self.assertEqual(cli.json_out(), expected_json) + @unittest.skipIf(PYGMENTS_INSTALLED, 'pygments library installed') + def test_cli_json_out_pretty_no_pygments(self): + test_input = [ + {"key1": "value1", "key2": 2, "key3": None, "key4": 3.14, "key5": True}, + {"key1": [{"subkey1": "subvalue1"}, {"subkey2": [1, 2, 3]}], "key2": True} + ] + + expected_output = [ + '{\n "key1": "value1",\n "key2": 2,\n "key3": null,\n "key4": 3.14,\n "key5": true\n}', + '{\n "key1": [\n {\n "subkey1": "subvalue1"\n },\n {\n "subkey2": [\n 1,\n 2,\n 3\n ]\n }\n ],\n "key2": true\n}' + ] + + for test_dict, expected_json in zip(test_input, expected_output): + cli = JcCli() + cli.pretty = True + cli.set_custom_colors() + cli.data_out = test_dict + self.assertEqual(cli.json_out(), expected_json) + + @unittest.skipIf(not PYGMENTS_INSTALLED, 'pygments library not installed') def test_cli_yaml_out(self): - if PYGMENTS_INSTALLED: - test_input = [ - None, - {}, - [], - '', - {"key1": "value1", "key2": 2, "key3": None, "key4": 3.14, "key5": True}, + test_input = [ + None, + {}, + [], + '', + {"key1": "value1", "key2": 2, "key3": None, "key4": 3.14, "key5": True}, + ] + + if pygments.__version__.startswith('2.3.'): + expected_output = [ + '---\n...', + '--- {}', + '--- []', + "--- \x1b[32m'\x1b[39m\x1b[32m'\x1b[39m", + '---\nkey1: value1\nkey2: 2\nkey3:\nkey4: 3.14\nkey5: true' + ] + else: + expected_output = [ + '---\n...', + '--- {}', + '--- []', + "--- \x1b[32m'\x1b[39m\x1b[32m'\x1b[39m", + '---\n\x1b[34;01mkey1\x1b[39;00m: value1\n\x1b[34;01mkey2\x1b[39;00m: 2\n\x1b[34;01mkey3\x1b[39;00m:\n\x1b[34;01mkey4\x1b[39;00m: 3.14\n\x1b[34;01mkey5\x1b[39;00m: true' ] - if pygments.__version__.startswith('2.3.'): - expected_output = [ - '---\n...', - '--- {}', - '--- []', - "--- \x1b[32m'\x1b[39m\x1b[32m'\x1b[39m", - '---\nkey1: value1\nkey2: 2\nkey3:\nkey4: 3.14\nkey5: true' - ] - else: - expected_output = [ - '---\n...', - '--- {}', - '--- []', - "--- \x1b[32m'\x1b[39m\x1b[32m'\x1b[39m", - '---\n\x1b[34;01mkey1\x1b[39;00m: value1\n\x1b[34;01mkey2\x1b[39;00m: 2\n\x1b[34;01mkey3\x1b[39;00m:\n\x1b[34;01mkey4\x1b[39;00m: 3.14\n\x1b[34;01mkey5\x1b[39;00m: true' - ] - - for test_dict, expected_json in zip(test_input, expected_output): - cli = JcCli() - os.environ["JC_COLORS"] = "default,default,default,default" - cli.set_custom_colors() - cli.data_out = test_dict - self.assertEqual(cli.yaml_out(), expected_json) + for test_dict, expected_json in zip(test_input, expected_output): + cli = JcCli() + os.environ["JC_COLORS"] = "default,default,default,default" + cli.set_custom_colors() + cli.data_out = test_dict + self.assertEqual(cli.yaml_out(), expected_json) + @unittest.skipIf(not RUAMELYAML_INSTALLED, 'ruamel.yaml library not installed') def test_cli_yaml_out_mono(self): test_input = [ None, @@ -295,6 +324,10 @@ def test_cli_about_jc(self): self.assertGreaterEqual(cli.about_jc()['parser_count'], 55) self.assertEqual(cli.about_jc()['parser_count'], len(cli.about_jc()['parsers'])) + def test_cli_parsers_text(self): + cli = JcCli() + self.assertIsNot(cli.parsers_text, '') + def test_add_meta_to_simple_dict(self): cli = JcCli() cli.data_out = {'a': 1, 'b': 2} diff --git a/tests/test_jc_lib.py b/tests/test_jc_lib.py index f98921601..6f9d95e8d 100644 --- a/tests/test_jc_lib.py +++ b/tests/test_jc_lib.py @@ -11,6 +11,12 @@ def test_lib_get_parser_string(self): p = jc.lib.get_parser('arp') self.assertIsInstance(p, ModuleType) + def test_lib_get_parser_broken_parser(self): + """get_parser substitutes the disabled_parser if a parser is broken""" + broken = jc.lib.get_parser('broken_parser') + disabled = jc.lib.get_parser('disabled_parser') + self.assertIs(broken, disabled) + def test_lib_get_parser_module(self): p = jc.lib.get_parser(csv_parser) self.assertIsInstance(p, ModuleType) diff --git a/tests/test_uptime.py b/tests/test_uptime.py index cf8a75fdf..25c6d8100 100644 --- a/tests/test_uptime.py +++ b/tests/test_uptime.py @@ -65,6 +65,14 @@ def test_uptime_osx_10_14_6(self): """ self.assertEqual(jc.parsers.uptime.parse(self.osx_10_14_6_uptime, quiet=True), self.osx_10_14_6_uptime_json) + def test_uptime_busybox(self): + """ + Test 'uptime' on busybox with no user information + """ + data = '00:03:32 up 3 min, load average: 0.00, 0.00, 0.00' + expected = {"time":"00:03:32","uptime":"3 min","load_1m":0.0,"load_5m":0.0,"load_15m":0.0,"time_hour":0,"time_minute":3,"time_second":32,"uptime_days":0,"uptime_hours":0,"uptime_minutes":3,"uptime_total_seconds":180} + self.assertEqual(jc.parsers.uptime.parse(data, quiet=True), expected) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_xml.py b/tests/test_xml.py index bbe269392..b73cfe254 100644 --- a/tests/test_xml.py +++ b/tests/test_xml.py @@ -2,7 +2,6 @@ import unittest import json import jc.parsers.xml -import xmltodict # fix for whether tests are run directly or via runtests.sh try: @@ -10,10 +9,18 @@ except: from _vendor.packaging import version # type: ignore -THIS_DIR = os.path.dirname(os.path.abspath(__file__)) -XMLTODICT_0_13_0_OR_HIGHER = version.parse(xmltodict.__version__) >= version.parse('0.13.0') +# check the version of installed xmltodict library +try: + import xmltodict + XMLTODICT_INSTALLED = True + XMLTODICT_0_13_0_OR_HIGHER = version.parse(xmltodict.__version__) >= version.parse('0.13.0') # type: ignore +except: + XMLTODICT_INSTALLED = False +THIS_DIR = os.path.dirname(os.path.abspath(__file__)) + +@unittest.skipIf(not XMLTODICT_INSTALLED, 'xmltodict library not installed') class MyTests(unittest.TestCase): # input diff --git a/tests/test_xrandr.py b/tests/test_xrandr.py index 14248015c..91b358baf 100644 --- a/tests/test_xrandr.py +++ b/tests/test_xrandr.py @@ -1,36 +1,33 @@ +import pprint import re import unittest from typing import Optional from jc.parsers.xrandr import ( - _parse_screen, - _parse_device, - _parse_mode, - _parse_model, + Device, + Edid, + _Line, + LineType, + ResolutionMode, + Response, + Screen, _device_pattern, - _screen_pattern, - _mode_pattern, _frequencies_pattern, - _edid_head_pattern, - _edid_line_pattern, + _parse_device, + _parse_resolution_mode, + _parse_screen, + _resolution_mode_pattern, + _screen_pattern, parse, - Mode, - Model, - Device, - Screen ) -import jc.parsers.xrandr class XrandrTests(unittest.TestCase): - def setUp(self): - jc.parsers.xrandr.parse_state = {} - def test_xrandr_nodata(self): """ Test 'xrandr' with no data """ - self.assertEqual(parse("", quiet=True), {}) + self.assertEqual(parse("", quiet=True), {"screens": []}) def test_regexes(self): devices = [ @@ -61,37 +58,30 @@ def test_regexes(self): "1400x900 59.96 59.88", ] for mode in modes: - match = re.match(_mode_pattern, mode) + match = re.match(_resolution_mode_pattern, mode) self.assertIsNotNone(match) if match: rest = match.groupdict()["rest"] self.assertIsNotNone(re.match(_frequencies_pattern, rest)) - edid_lines = [ - " EDID: ", - " 00ffffffffffff000469d41901010101 ", - " 2011010308291a78ea8585a6574a9c26 ", - " 125054bfef80714f8100810f81408180 ", - " 9500950f01019a29a0d0518422305098 ", - " 360098ff1000001c000000fd00374b1e ", - " 530f000a202020202020000000fc0041 ", - " 535553205657313933530a20000000ff ", - " 0037384c383032313130370a20200077 ", - ] + def test_line_categorize(self): + base = "eDP-1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 309mm x 174mm" + resolution_mode = " 320x240 60.05" + prop_key = " EDID:" + prop_value = " 00ffffffffffff0006af3d5700000000" + invalid = "" - for i in range(len(edid_lines)): - line = edid_lines[i] - if i == 0: - match = re.match(_edid_head_pattern, line) - else: - match = re.match(_edid_line_pattern, line) - - self.assertIsNotNone(match) + self.assertEqual(LineType.Device, _Line.categorize(base).t) + self.assertEqual(LineType.ResolutionMode, _Line.categorize(resolution_mode).t) + self.assertEqual(LineType.PropKey, _Line.categorize(prop_key).t) + self.assertEqual(LineType.PropValue, _Line.categorize(prop_value).t) + with self.assertRaises(Exception): + _Line.categorize(invalid) def test_screens(self): sample = "Screen 0: minimum 8 x 8, current 1920 x 1080, maximum 32767 x 32767" - - actual: Optional[Screen] = _parse_screen([sample]) + line = _Line.categorize(sample) + actual: Optional[Screen] = _parse_screen(line) self.assertIsNotNone(actual) expected = { @@ -110,7 +100,8 @@ def test_screens(self): sample = ( "Screen 0: minimum 320 x 200, current 1920 x 1080, maximum 16384 x 16384" ) - actual = _parse_screen([sample]) + line = _Line.categorize(sample) + actual = _parse_screen(line) if actual: self.assertEqual(320, actual["minimum_width"]) else: @@ -119,7 +110,8 @@ def test_screens(self): def test_device(self): # regex101 sample link for tests/edits https://regex101.com/r/3cHMv3/1 sample = "eDP1 connected primary 1920x1080+0+0 left (normal left inverted right x axis y axis) 310mm x 170mm" - actual: Optional[Device] = _parse_device([sample]) + line = _Line.categorize(sample) + actual: Optional[Device] = _parse_device(line) expected = { "device_name": "eDP1", @@ -140,17 +132,19 @@ def test_device(self): for k, v in expected.items(): self.assertEqual(v, actual[k], f"Devices regex failed on {k}") - with open("tests/fixtures/generic/xrandr_device.out", "r") as f: - extended_sample = f.read().splitlines() - extended_sample.reverse() + # with open("tests/fixtures/generic/xrandr_device.out", "r") as f: + # extended_sample = f.read().splitlines() - device = _parse_device(extended_sample) - if device: - self.assertEqual(59.94, device["modes"][12]["frequencies"][4]["frequency"]) + # device = _parse_device(extended_sample) + # if device: + # self.assertEqual( + # 59.94, device["resolution_modes"][12]["frequencies"][4]["frequency"] + # ) def test_device_with_reflect(self): sample = "VGA-1 connected primary 1920x1080+0+0 left X and Y axis (normal left inverted right x axis y axis) 310mm x 170mm" - actual: Optional[Device] = _parse_device([sample]) + line = _Line.categorize(sample) + actual: Optional[Device] = _parse_device(line) expected = { "device_name": "VGA-1", @@ -173,7 +167,7 @@ def test_device_with_reflect(self): self.assertEqual(v, actual[k], f"Devices regex failed on {k}") def test_mode(self): - sample_1 = "1920x1080 60.03*+ 59.93" + sample_1 = " 1920x1080 60.03*+ 59.93" expected = { "frequencies": [ {"frequency": 60.03, "is_current": True, "is_preferred": True}, @@ -183,7 +177,8 @@ def test_mode(self): "resolution_height": 1080, "is_high_resolution": False, } - actual: Optional[Mode] = _parse_mode(sample_1) + line = _Line.categorize(sample_1) + actual: Optional[ResolutionMode] = _parse_resolution_mode(line) self.assertIsNotNone(actual) @@ -191,8 +186,9 @@ def test_mode(self): for k, v in expected.items(): self.assertEqual(v, actual[k], f"mode regex failed on {k}") - sample_2 = " 1920x1080i 60.00 50.00 59.94" - actual: Optional[Mode] = _parse_mode(sample_2) + sample_2 = " 1920x1080i 60.00 50.00 59.94" + line = _Line.categorize(sample_2) + actual: Optional[ResolutionMode] = _parse_resolution_mode(line) self.assertIsNotNone(actual) if actual: self.assertEqual(True, actual["is_high_resolution"]) @@ -205,7 +201,9 @@ def test_complete_1(self): actual = parse(txt, quiet=True) self.assertEqual(1, len(actual["screens"])) - self.assertEqual(18, len(actual["screens"][0]["devices"][0]["modes"])) + self.assertEqual( + 18, len(actual["screens"][0]["devices"][0]["resolution_modes"]) + ) def test_complete_2(self): with open("tests/fixtures/generic/xrandr_2.out", "r") as f: @@ -213,7 +211,9 @@ def test_complete_2(self): actual = parse(txt, quiet=True) self.assertEqual(1, len(actual["screens"])) - self.assertEqual(38, len(actual["screens"][0]["devices"][0]["modes"])) + self.assertEqual( + 38, len(actual["screens"][0]["devices"][0]["resolution_modes"]) + ) def test_complete_3(self): with open("tests/fixtures/generic/xrandr_3.out", "r") as f: @@ -232,84 +232,119 @@ def test_complete_4(self): actual = parse(txt, quiet=True) self.assertEqual(1, len(actual["screens"])) - self.assertEqual(2, len(actual["screens"][0]["devices"][0]["modes"])) + self.assertEqual(2, len(actual["screens"][0]["devices"][0]["resolution_modes"])) def test_complete_5(self): - with open("tests/fixtures/generic/xrandr_properties.out", "r") as f: + with open("tests/fixtures/generic/xrandr_properties_1.out", "r") as f: txt = f.read() actual = parse(txt, quiet=True) self.assertEqual(1, len(actual["screens"])) - self.assertEqual(29, len(actual["screens"][0]["devices"][0]["modes"])) - - def test_model(self): - asus_edid = [ - " EDID: ", - " 00ffffffffffff000469d41901010101", - " 2011010308291a78ea8585a6574a9c26", - " 125054bfef80714f8100810f81408180", - " 9500950f01019a29a0d0518422305098", - " 360098ff1000001c000000fd00374b1e", - " 530f000a202020202020000000fc0041", - " 535553205657313933530a20000000ff", - " 0037384c383032313130370a20200077", - ] - asus_edid.reverse() - - expected = { - "name": "ASUS VW193S", - "product_id": "6612", - "serial_number": "78L8021107", - } - - actual: Optional[Model] = _parse_model(asus_edid) - self.assertIsNotNone(actual) - - if actual: - for k, v in expected.items(): - self.assertEqual(v, actual[k], f"mode regex failed on {k}") - - generic_edid = [ - " EDID: ", - " 00ffffffffffff004ca3523100000000", - " 0014010380221378eac8959e57549226", - " 0f505400000001010101010101010101", - " 010101010101381d56d4500016303020", - " 250058c2100000190000000f00000000", - " 000000000025d9066a00000000fe0053", - " 414d53554e470a204ca34154000000fe", - " 004c544e313536415432343430310018", - ] - generic_edid.reverse() - - expected = { - "name": "Generic", - "product_id": "12626", - "serial_number": "0", - } - - jc.parsers.xrandr.parse_state = {} - actual: Optional[Model] = _parse_model(generic_edid) - self.assertIsNotNone(actual) - - if actual: - for k, v in expected.items(): - self.assertEqual(v, actual[k], f"mode regex failed on {k}") - - empty_edid = [""] - actual: Optional[Model] = _parse_model(empty_edid) - self.assertIsNone(actual) + self.assertEqual( + 38, len(actual["screens"][0]["devices"][0]["resolution_modes"]) + ) + # def test_model(self): + # asus_edid = [ + # " EDID: ", + # " 00ffffffffffff000469d41901010101", + # " 2011010308291a78ea8585a6574a9c26", + # " 125054bfef80714f8100810f81408180", + # " 9500950f01019a29a0d0518422305098", + # " 360098ff1000001c000000fd00374b1e", + # " 530f000a202020202020000000fc0041", + # " 535553205657313933530a20000000ff", + # " 0037384c383032313130370a20200077", + # ] + # asus_edid.reverse() + + # expected = { + # "name": "ASUS VW193S", + # "product_id": "6612", + # "serial_number": "78L8021107", + # } + + # actual: Optional[EdidModel] = _parse_model(asus_edid) + # self.assertIsNotNone(actual) + + # if actual: + # for k, v in expected.items(): + # self.assertEqual(v, actual[k], f"mode regex failed on {k}") + + # generic_edid = [ + # " EDID: ", + # " 00ffffffffffff004ca3523100000000", + # " 0014010380221378eac8959e57549226", + # " 0f505400000001010101010101010101", + # " 010101010101381d56d4500016303020", + # " 250058c2100000190000000f00000000", + # " 000000000025d9066a00000000fe0053", + # " 414d53554e470a204ca34154000000fe", + # " 004c544e313536415432343430310018", + # ] + # generic_edid.reverse() + + # expected = { + # "name": "Generic", + # "product_id": "12626", + # "serial_number": "0", + # } + + # jc.parsers.xrandr.parse_state = {} + # actual: Optional[EdidModel] = _parse_model(generic_edid) + # self.assertIsNotNone(actual) + + # if actual: + # for k, v in expected.items(): + # self.assertEqual(v, actual[k], f"mode regex failed on {k}") + + # empty_edid = [""] + # actual: Optional[EdidModel] = _parse_model(empty_edid) + # self.assertIsNone(actual) def test_issue_490(self): """test for issue 490: https://github.com/kellyjonbrazil/jc/issues/490""" - data_in = '''\ + data_in = """\ Screen 0: minimum 1024 x 600, current 1024 x 600, maximum 1024 x 600 default connected 1024x600+0+0 0mm x 0mm 1024x600 0.00* -''' - expected = {"screens":[{"devices":[{"modes":[{"resolution_width":1024,"resolution_height":600,"is_high_resolution":False,"frequencies":[{"frequency":0.0,"is_current":True,"is_preferred":False}]}],"is_connected":True,"is_primary":False,"device_name":"default","rotation":"normal","reflection":"normal","resolution_width":1024,"resolution_height":600,"offset_width":0,"offset_height":0,"dimension_width":0,"dimension_height":0}],"screen_number":0,"minimum_width":1024,"minimum_height":600,"current_width":1024,"current_height":600,"maximum_width":1024,"maximum_height":600}]} - self.assertEqual(jc.parsers.xrandr.parse(data_in), expected) +""" + actual: Response = parse(data_in) + self.maxDiff = None + self.assertEqual(1024, actual["screens"][0]["devices"][0]["resolution_width"]) + + def test_issue_525(self): + self.maxDiff = None + with open("tests/fixtures/generic/xrandr_issue_525.out", "r") as f: + txt = f.read() + actual = parse(txt, quiet=True) + dp4 = actual["screens"][0]["devices"][0]["props"]["Broadcast RGB"][1] # type: ignore + # pprint.pprint(actual) + self.assertEqual("supported: Automatic, Full, Limited 16:235", dp4) + edp1_expected_keys = { + "EDID", + "EdidModel", + "scaling mode", + "Colorspace", + "max bpc", + "Broadcast RGB", + "panel orientation", + "link-status", + "CTM", + "CONNECTOR_ID", + "non-desktop", + } + actual_keys = set(actual["screens"][0]["devices"][0]["props"].keys()) + self.assertSetEqual(edp1_expected_keys, actual_keys) + expected_edid_model = { + "name": "Generic", + "product_id": "22333", + "serial_number": "0", + } + self.assertDictEqual( + expected_edid_model, + actual["screens"][0]["devices"][0]["props"]["EdidModel"], # type: ignore + ) if __name__ == "__main__": diff --git a/tests/test_yaml.py b/tests/test_yaml.py index 92079cba4..b61150d7d 100644 --- a/tests/test_yaml.py +++ b/tests/test_yaml.py @@ -5,7 +5,14 @@ THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +try: + import ruamel.yaml + RUAMELYAML_INSTALLED = True +except: + RUAMELYAML_INSTALLED = False + +@unittest.skipIf(not RUAMELYAML_INSTALLED, 'ruamel.yaml library not installed') class MyTests(unittest.TestCase): # input