diff --git a/jc/lib.py b/jc/lib.py index 2c95985c..bbf3a43b 100644 --- a/jc/lib.py +++ b/jc/lib.py @@ -97,6 +97,7 @@ parsers = [ 'wc', 'who', 'xml', + 'xrandr', 'yaml', 'zipinfo' ] diff --git a/jc/parsers/xrandr.py b/jc/parsers/xrandr.py new file mode 100644 index 00000000..b8c2669c --- /dev/null +++ b/jc/parsers/xrandr.py @@ -0,0 +1,320 @@ +"""jc - JSON CLI output utility `xrandr` command output parser + +Options supported: + +Usage (module): + + import jc.parsers.xrandr + result = jc.parsers.xrandr.parse(xrandr_command_output) + +Schema: + { + "screens": [ + { + "screen_number": 0, + "minimum_width": 8, + "minimum_height": 8, + "current_width": 1920, + "current_height": 1080, + "maximum_width": 32767, + "maximum_height": 32767, + "associated_device": { + "associated_modes": [ + { + "resolution_width": 1920, + "resolution_height": 1080, + "is_high_resolution": false, + "frequencies": [ + { + "frequency": 60.03, + "is_current": true, + "is_preferred": true + }, + { + "frequency": 59.93, + "is_current": false, + "is_preferred": false + } + ] + }, + { + "resolution_width": 1680, + "resolution_height": 1050, + "is_high_resolution": false, + "frequencies": [ + { + "frequency": 59.88, + "is_current": false, + "is_preferred": false + } + ] + } + ], + "is_connected": true, + "is_primary": true, + "device_name": "eDP1", + "resolution_width": 1920, + "resolution_height": 1080, + "offset_width": 0, + "offset_height": 0, + "dimension_width": 310, + "dimension_height": 170 + } + } + ], + "unassociated_devices": [] + } +Translated from: + Screen 0: minimum 8 x 8, current 1920 x 1080, maximum 32767 x 32767 + eDP1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 310mm x 170mm + 1920x1080 60.03*+ 59.93 + 1680x1050 59.88 +Examples: + + $ xrandr | jc --xrandr +""" +import re +from typing import Dict, List, Optional, Union + +import jc.utils + + +class info: + """Provides parser metadata (version, author, etc.)""" + + version = "1.9" + description = "`xrandr` command parser" + author = "Kevin Lyter" + author_email = "lyter_git at sent.com" + + # compatible options: linux, darwin, cygwin, win32, aix, freebsd + compatible = ["linux", "darwin", "cygwin", "aix", "freebsd"] + magic_commands = ["xrandr"] + + +__version__ = info.version + +try: + from typing import TypedDict + + Frequency = TypedDict( + "Frequency", + { + "frequency": float, + "is_current": bool, + "is_preferred": bool, + }, + ) + Mode = TypedDict( + "Mode", + { + "resolution_width": int, + "resolution_height": int, + "is_high_resolution": bool, + "frequencies": List[Frequency], + }, + ) + Device = TypedDict( + "Device", + { + "device_name": str, + "is_connected": bool, + "is_primary": bool, + "resolution_width": int, + "resolution_height": int, + "offset_width": int, + "offset_height": int, + "dimension_width": int, + "dimension_height": int, + "associated_modes": List[Mode], + }, + ) + Screen = TypedDict( + "Screen", + { + "screen_number": int, + "minimum_width": int, + "minimum_height": int, + "current_width": int, + "current_height": int, + "maximum_width": int, + "maximum_height": int, + "associated_device": Device, + }, + ) + Response = TypedDict( + "Response", + { + "screens": List[Screen], + "unassociated_devices": List[Device], + }, + ) +except ImportError: + Screen = Dict[str, Union[int, str]] + Device = Dict[str, Union[str, int, bool]] + Frequency = Dict[str, Union[float, bool]] + Mode = Dict[str, Union[int, bool, List[Frequency]]] + Response = Dict[str, Union[Device, Mode, Screen]] + + +_screen_pattern = ( + r"Screen (?P\d+): " + + "minimum (?P\d+) x (?P\d+), " + + "current (?P\d+) x (?P\d+), " + + "maximum (?P\d+) x (?P\d+)" +) + + +def _parse_screen(next_lines: List[str]) -> Optional[Screen]: + next_line = next_lines.pop() + result = re.match(_screen_pattern, next_line) + if not result: + next_lines.append(next_line) + return None + + raw_matches = result.groupdict() + screen: Screen = {} + for k, v in raw_matches.items(): + screen[k] = int(v) + + if next_lines: + device: Optional[Device] = _parse_device(next_lines) + if device: + screen["associated_device"] = device + + return screen + + +# eDP1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) +# 310mm x 170mm +# regex101 demo link +_device_pattern = ( + r"(?P.+) " + + "(?P(connected|disconnected)) ?" + + "(?P primary)? ?" + + "((?P\d+)x(?P\d+)" + + "\+(?P\d+)\+(?P\d+))? " + + "\(normal left inverted right x axis y axis\)" + + "( ((?P\d+)mm x (?P\d+)mm)?)?" +) + + +def _parse_device(next_lines: List[str]) -> Optional[Device]: + if not next_lines: + return None + + next_line = next_lines.pop() + result = re.match(_device_pattern, next_line) + if not result: + next_lines.append(next_line) + return None + + matches = result.groupdict() + + device: Device = { + "associated_modes": [], + "is_connected": matches["is_connected"] == "connected", + "is_primary": matches["is_primary"] is not None + and len(matches["is_primary"]) > 0, + "device_name": matches["device_name"], + } + for k, v in matches.items(): + if k not in {"is_connected", "is_primary", "device_name"}: + try: + if v: + device[k] = int(v) + except ValueError: + print(f"Error: {next_line} : {k} - {v} is not int-able") + + while next_lines: + next_line = next_lines.pop() + next_mode: Optional[Mode] = _parse_mode(next_line) + if next_mode: + device["associated_modes"].append(next_mode) + else: + next_lines.append(next_line) + break + return device + + +# 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\+?)?)+)" + + +def _parse_mode(line: str) -> Optional[Mode]: + result = re.match(_mode_pattern, line) + frequencies: List[Frequency] = [] + if not result: + return None + + d = result.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 = { + "resolution_width": resolution_width, + "resolution_height": resolution_height, + "is_high_resolution": is_high_resolution, + "frequencies": frequencies, + } + + result = re.finditer(_frequencies_pattern, d["rest"]) + if not result: + return mode + + for match in result: + d = match.groupdict() + frequency = float(d["frequency"]) + is_current = len(d["star"]) > 0 + is_preferred = len(d["plus"]) > 0 + f: Frequency = { + "frequency": frequency, + "is_current": is_current, + "is_preferred": is_preferred, + } + mode["frequencies"].append(f) + return mode + + +def parse(data: str, raw=False, quiet=False): + """ + Main text parsing function + + Parameters: + + data: (string) text data to parse + raw: (boolean) output preprocessed JSON if True + quiet: (boolean) suppress warning messages if True + + Returns: + + List of Dictionaries. Raw or processed structured data. + """ + if not quiet: + jc.utils.compatibility(__name__, info.compatible) + + warned = False + parent = "" + next_is_parent = False + new_section = False + + linedata = data.splitlines() + linedata.reverse() # For popping + + result: Response = {"screens": [], "unassociated_devices": []} + if jc.utils.has_data(data): + result: Response = {"screens": [], "unassociated_devices": []} + while linedata: + screen = _parse_screen(linedata) + if screen: + result["screens"].append(screen) + else: + device = _parse_device(linedata) + if device: + result["unassociated_devices"].append(device) + if not result["unassociated_devices"] and not result["screens"]: + return {} + return result diff --git a/tests/fixtures/generic/xrandr.out b/tests/fixtures/generic/xrandr.out new file mode 100644 index 00000000..93bbf7b5 --- /dev/null +++ b/tests/fixtures/generic/xrandr.out @@ -0,0 +1,38 @@ +Screen 0: minimum 8 x 8, current 1920 x 1080, maximum 32767 x 32767 +eDP1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 310mm x 170mm + 1920x1080 60.03*+ 59.93 + 1680x1050 59.88 + 1400x1050 59.98 + 1600x900 60.00 59.95 59.82 + 1280x1024 60.02 + 1400x900 59.96 59.88 + 1280x960 60.00 + 1368x768 60.00 59.88 59.85 + 1280x800 59.81 59.91 + 1280x720 59.86 60.00 59.74 + 1024x768 60.00 + 1024x576 60.00 59.90 59.82 + 960x540 60.00 59.63 59.82 + 800x600 60.32 56.25 + 864x486 60.00 59.92 59.57 + 640x480 59.94 + 720x405 59.51 60.00 58.99 + 640x360 59.84 59.32 60.00 +DP1 disconnected (normal left inverted right x axis y axis) +DP2 disconnected (normal left inverted right x axis y axis) +HDMI1 connected (normal left inverted right x axis y axis) + 1920x1080 60.00 + 50.00 59.94 + 1920x1080i 60.00 50.00 59.94 + 1680x1050 59.88 + 1280x1024 75.02 60.02 + 1440x900 59.90 + 1280x960 60.00 + 1280x720 60.00 50.00 59.94 + 1024x768 75.03 70.07 60.00 + 832x624 74.55 + 800x600 72.19 75.00 60.32 56.25 + 720x576 50.00 + 720x480 60.00 59.94 + 640x480 75.00 72.81 66.67 60.00 59.94 + 720x400 70.08 +VIRTUAL1 disconnected (normal left inverted right x axis y axis) diff --git a/tests/fixtures/generic/xrandr_2.out b/tests/fixtures/generic/xrandr_2.out new file mode 100644 index 00000000..3ed0efd5 --- /dev/null +++ b/tests/fixtures/generic/xrandr_2.out @@ -0,0 +1,43 @@ +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 + 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) +HDMI-1 disconnected (normal left inverted right x axis y axis) +DP-2 disconnected (normal left inverted right x axis y axis) diff --git a/tests/fixtures/generic/xrandr_device.out b/tests/fixtures/generic/xrandr_device.out new file mode 100644 index 00000000..32134538 --- /dev/null +++ b/tests/fixtures/generic/xrandr_device.out @@ -0,0 +1,15 @@ +HDMI1 connected (normal left inverted right x axis y axis) + 1920x1080 60.00 + 50.00 59.94 + 1920x1080i 60.00 50.00 59.94 + 1680x1050 59.88 + 1280x1024 75.02 60.02 + 1440x900 59.90 + 1280x960 60.00 + 1280x720 60.00 50.00 59.94 + 1024x768 75.03 70.07 60.00 + 832x624 74.55 + 800x600 72.19 75.00 60.32 56.25 + 720x576 50.00 + 720x480 60.00 59.94 + 640x480 75.00 72.81 66.67 60.00 59.94 + 720x400 70.08 diff --git a/tests/fixtures/generic/xrandr_simple.json b/tests/fixtures/generic/xrandr_simple.json new file mode 100644 index 00000000..a5aff206 --- /dev/null +++ b/tests/fixtures/generic/xrandr_simple.json @@ -0,0 +1,56 @@ +{ + "screens": [ + { + "screen_number": 0, + "minimum_width": 8, + "minimum_height": 8, + "current_width": 1920, + "current_height": 1080, + "maximum_width": 32767, + "maximum_height": 32767, + "associated_device": { + "associated_modes": [ + { + "resolution_width": 1920, + "resolution_height": 1080, + "is_high_resolution": false, + "frequencies": [ + { + "frequency": 60.03, + "is_current": true, + "is_preferred": true + }, + { + "frequency": 59.93, + "is_current": false, + "is_preferred": false + } + ] + }, + { + "resolution_width": 1680, + "resolution_height": 1050, + "is_high_resolution": false, + "frequencies": [ + { + "frequency": 59.88, + "is_current": false, + "is_preferred": false + } + ] + } + ], + "is_connected": true, + "is_primary": true, + "device_name": "eDP1", + "resolution_width": 1920, + "resolution_height": 1080, + "offset_width": 0, + "offset_height": 0, + "dimension_width": 310, + "dimension_height": 170 + } + } + ], + "unassociated_devices": [] +} \ No newline at end of file diff --git a/tests/fixtures/generic/xrandr_simple.out b/tests/fixtures/generic/xrandr_simple.out new file mode 100644 index 00000000..2ced152f --- /dev/null +++ b/tests/fixtures/generic/xrandr_simple.out @@ -0,0 +1,4 @@ +Screen 0: minimum 8 x 8, current 1920 x 1080, maximum 32767 x 32767 +eDP1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 310mm x 170mm + 1920x1080 60.03*+ 59.93 + 1680x1050 59.88 diff --git a/tests/test_xrandr.py b/tests/test_xrandr.py new file mode 100644 index 00000000..2b86368a --- /dev/null +++ b/tests/test_xrandr.py @@ -0,0 +1,172 @@ +import json +import re +import unittest +from typing import Optional + +from jc.parsers.xrandr import ( + _parse_screen, + _parse_device, + _parse_mode, + _device_pattern, + _screen_pattern, + _mode_pattern, + _frequencies_pattern, + parse, + Mode, + Device, + Screen, +) + + +class XrandrTests(unittest.TestCase): + def test_regexes(self): + devices = [ + "HDMI1 connected (normal left inverted right x axis y axis)", + "VIRTUAL1 disconnected (normal left inverted right x axis y axis)", + "eDP1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 310mm x 170mm", + "eDP-1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 309mm x 174mm" + ] + for device in devices: + self.assertIsNotNone(re.match(_device_pattern, device)) + + screens = [ + "Screen 0: minimum 8 x 8, current 1920 x 1080, maximum 32767 x 32767", + "Screen 0: minimum 320 x 200, current 1920 x 1080, maximum 16384 x 16384" + ] + for screen in screens: + self.assertIsNotNone(re.match(_screen_pattern, screen)) + + modes = [ + "1920x1080 60.03*+ 59.93", + "1680x1050 59.88", + "1400x1050 59.98", + "1600x900 60.00 59.95 59.82", + "1280x1024 60.02", + "1400x900 59.96 59.88", + ] + for mode in modes: + match = re.match(_mode_pattern, mode) + self.assertIsNotNone(match) + if match: + rest = match.groupdict()["rest"] + self.assertIsNotNone(re.match(_frequencies_pattern, rest)) + + 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]) + self.assertIsNotNone(actual) + + expected = { + "screen_number": 0, + "minimum_width": 8, + "minimum_height": 8, + "current_width": 1920, + "current_height": 1080, + "maximum_width": 32767, + "maximum_height": 32767, + } + if actual: + for k, v in expected.items(): + self.assertEqual(v, actual[k], f"screens regex failed on {k}") + + sample = "Screen 0: minimum 320 x 200, current 1920 x 1080, maximum 16384 x 16384" + actual = _parse_screen([sample]) + if actual: + self.assertEqual(320, actual["minimum_width"]) + else: + raise AssertionError("Screen should not be None") + + def test_device(self): + # regex101 sample link for tests/edits https://regex101.com/r/3cHMv3/1 + sample = "eDP1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 310mm x 170mm" + actual: Optional[Device] = _parse_device([sample]) + + expected = { + "device_name": "eDP1", + "is_connected": True, + "is_primary": True, + "resolution_width": 1920, + "resolution_height": 1080, + "offset_width": 0, + "offset_height": 0, + "dimension_width": 310, + "dimension_height": 170, + } + + self.assertIsNotNone(actual) + + if actual: + 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() + + device = _parse_device(extended_sample) + if device: + self.assertEqual( + 59.94, device["associated_modes"][12]["frequencies"][4]["frequency"] + ) + + def test_mode(self): + sample_1 = "1920x1080 60.03*+ 59.93" + expected = { + "frequencies": [ + {"frequency": 60.03, "is_current": True, "is_preferred": True}, + {"frequency": 59.93, "is_current": False, "is_preferred": False}, + ], + "resolution_width": 1920, + "resolution_height": 1080, + "is_high_resolution": False, + } + actual: Optional[Mode] = _parse_mode(sample_1) + + self.assertIsNotNone(actual) + + if actual: + 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) + self.assertIsNotNone(actual) + if actual: + self.assertEqual(True, actual["is_high_resolution"]) + self.assertEqual(50.0, actual["frequencies"][1]["frequency"]) + + def test_complete(self): + self.maxDiff = None + with open("tests/fixtures/generic/xrandr.out", "r") as f: + txt = f.read() + actual = parse(txt) + + self.assertEqual(1, len(actual["screens"])) + self.assertEqual(4, len(actual["unassociated_devices"])) + self.assertEqual( + 18, len(actual["screens"][0]["associated_device"]["associated_modes"]) + ) + + with open("tests/fixtures/generic/xrandr_2.out", "r") as f: + txt = f.read() + actual = parse(txt) + + self.assertEqual(1, len(actual["screens"])) + self.assertEqual(3, len(actual["unassociated_devices"])) + self.assertEqual( + 38, len(actual["screens"][0]["associated_device"]["associated_modes"]) + ) + + with open("tests/fixtures/generic/simple_xrandr.out", "r") as f: + txt = f.read() + actual = parse(txt) + + with open("tests/fixtures/generic/simple_xrandr.json", "w") as f: + json.dump(actual, f, indent=True) + + self.assertEqual(1, len(actual["screens"])) + self.assertEqual(0, len(actual["unassociated_devices"])) + self.assertEqual( + 2, len(actual["screens"][0]["associated_device"]["associated_modes"]) + )