1
0
mirror of https://github.com/kellyjonbrazil/jc.git synced 2025-06-17 00:07:37 +02:00

Merge pull request #204 from lyterk/xrandr

New parser: xrandr
This commit is contained in:
Kelly Brazil
2022-02-13 17:45:53 -08:00
committed by GitHub
8 changed files with 649 additions and 0 deletions

View File

@ -97,6 +97,7 @@ parsers = [
'wc',
'who',
'xml',
'xrandr',
'yaml',
'zipinfo'
]

320
jc/parsers/xrandr.py Normal file
View File

@ -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<screen_number>\d+): "
+ "minimum (?P<minimum_width>\d+) x (?P<minimum_height>\d+), "
+ "current (?P<current_width>\d+) x (?P<current_height>\d+), "
+ "maximum (?P<maximum_width>\d+) x (?P<maximum_height>\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<device_name>.+) "
+ "(?P<is_connected>(connected|disconnected)) ?"
+ "(?P<is_primary> primary)? ?"
+ "((?P<resolution_width>\d+)x(?P<resolution_height>\d+)"
+ "\+(?P<offset_width>\d+)\+(?P<offset_height>\d+))? "
+ "\(normal left inverted right x axis y axis\)"
+ "( ((?P<dimension_width>\d+)mm x (?P<dimension_height>\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<resolution_width>\d+)x(?P<resolution_height>\d+)(?P<is_high_resolution>i)?\s+(?P<rest>.*)"
_frequencies_pattern = r"(((?P<frequency>\d+\.\d+)(?P<star>\*| |)(?P<plus>\+?)?)+)"
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

38
tests/fixtures/generic/xrandr.out vendored Normal file
View File

@ -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)

43
tests/fixtures/generic/xrandr_2.out vendored Normal file
View File

@ -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)

View File

@ -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

View File

@ -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": []
}

View File

@ -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

172
tests/test_xrandr.py Normal file
View File

@ -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"])
)