From ad66ec5556b8e45670684cd05cf0e63055adb9e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=A4rdian?= Date: Tue, 1 Aug 2023 18:05:57 +0200 Subject: [PATCH 1/2] cli:status: Make rich pretty printing optional To avoid a hard dependency on python3-rich --- netplan_cli/cli/commands/status.py | 63 ++++++++----- tests/cli/test_status.py | 141 +++++++++++++++++++---------- 2 files changed, 133 insertions(+), 71 deletions(-) diff --git a/netplan_cli/cli/commands/status.py b/netplan_cli/cli/commands/status.py index 13e36e7c4..7b65f37e2 100644 --- a/netplan_cli/cli/commands/status.py +++ b/netplan_cli/cli/commands/status.py @@ -18,22 +18,30 @@ '''netplan status command line''' import json +import logging +import re import yaml -from rich.console import Console -from rich.highlighter import RegexHighlighter -from rich.theme import Theme from .. import utils from ..state import SystemConfigState, JSON -class NetplanHighlighter(RegexHighlighter): - base_style = 'netplan.' - highlights = [ - r'(^|[\s\/])(?P\d+)([\s:]?\s|$)', - r'(?P(\"|\').+(\"|\'))', - ] +RICH_OUTPUT = False +try: + from rich.console import Console + from rich.highlighter import RegexHighlighter + from rich.theme import Theme + + class NetplanHighlighter(RegexHighlighter): + base_style = 'netplan.' + highlights = [ + r'(^|[\s\/])(?P\d+)([\s:]?\s|$)', + r'(?P(\"|\').+(\"|\'))', + ] + RICH_OUTPUT = True +except ImportError: # pragma: nocover (we mock RICH_OUTPUT, ignore the logging) + logging.debug("python3-rich not found, falling back to plain output") class NetplanStatus(utils.NetplanCommand): @@ -57,20 +65,31 @@ def run(self): self.parse_args() self.run_command() + def plain_print(self, *args, **kwargs): + if len(args): + pattern = r'\[\/?\w+\]' + lst = list(args) + lst[0] = re.sub(pattern, '', lst[0]) # remove any tags, like '[...]' or '[/...]' + return print(*lst, **kwargs) + return print(*args, **kwargs) + def pretty_print(self, data: JSON, total: int, _console_width=None) -> None: - # TODO: Use a proper (subiquity?) color palette - theme = Theme({ - 'netplan.int': 'bold cyan', - 'netplan.str': 'yellow', - 'muted': 'grey62', - 'online': 'green bold', - 'offline': 'red bold', - 'unknown': 'yellow bold', - 'highlight': 'bold' - }) - console = Console(highlighter=NetplanHighlighter(), theme=theme, - width=_console_width, emoji=False) - pprint = console.print + if RICH_OUTPUT: + # TODO: Use a proper (subiquity?) color palette + theme = Theme({ + 'netplan.int': 'bold cyan', + 'netplan.str': 'yellow', + 'muted': 'grey62', + 'online': 'green bold', + 'offline': 'red bold', + 'unknown': 'yellow bold', + 'highlight': 'bold' + }) + console = Console(highlighter=NetplanHighlighter(), theme=theme, + width=_console_width, emoji=False) + pprint = console.print + else: + pprint = self.plain_print pad = '18' global_state = data.get('netplan-global-state', {}) diff --git a/tests/cli/test_status.py b/tests/cli/test_status.py index cfeaf384d..269f997b1 100644 --- a/tests/cli/test_status.py +++ b/tests/cli/test_status.py @@ -38,6 +38,57 @@ DNS_ADDRESSES = [(5, 2, DNS_IP4), (5, 10, DNS_IP6), (2, 2, DNS_IP4), (2, 10, DNS_IP6)] # (IFidx, IPfamily, IPbytes) DNS_SEARCH = [(5, 'search.domain', False), (2, 'search.domain', False)] FAKE_DEV = {'ifindex': 42, 'ifname': 'fakedev0', 'flags': [], 'operstate': 'DOWN'} +STATUS_OUTPUT = '''\ + Online state: online + DNS Addresses: 127.0.0.53 (stub) + DNS Search: search.domain + +● 2: enp0s31f6 ethernet UP (networkd: enp0s31f6) + MAC Address: 54:e1:ad:5f:24:b4 (Intel Corporation) + Addresses: 192.168.178.62/24 (dhcp) + 2001:9e8:a19f:1c00:56e1:adff:fe5f:24b4/64 + fe80::56e1:adff:fe5f:24b4/64 (link) + DNS Addresses: 192.168.178.1 + fd00::cece:1eff:fe3d:c737 + DNS Search: search.domain + Routes: default via 192.168.178.1 from 192.168.178.62 metric 100 (dhcp) + 192.168.178.0/24 from 192.168.178.62 metric 100 (link) + 2001:9e8:a19f:1c00::/64 metric 100 (ra) + 2001:9e8:a19f:1c00::/56 via fe80::cece:1eff:fe3d:c737 metric 100 (ra) + fe80::/64 metric 256 + Activation Mode: manual + +● 5: wlan0 wifi/"MYCON" UP (NetworkManager: NM-b6b7a21d-186e-45e1-b3a6-636da1735563) + MAC Address: 1c:4d:70:e4:e4:0e (Intel Corporation) + Addresses: 192.168.178.142/24 + 2001:9e8:a19f:1c00:7011:2d1:951:ad03/64 + 2001:9e8:a19f:1c00:f24f:f724:5dd1:d0ad/64 + fe80::fec1:6ced:5268:b46c/64 (link) + DNS Addresses: 192.168.178.1 + fd00::cece:1eff:fe3d:c737 + DNS Search: search.domain + Routes: default via 192.168.178.1 metric 600 (dhcp) + 192.168.178.0/24 from 192.168.178.142 metric 600 (link) + 2001:9e8:a19f:1c00::/64 metric 600 (ra) + 2001:9e8:a19f:1c00::/56 via fe80::cece:1eff:fe3d:c737 metric 600 (ra) + fe80::/64 metric 1024 + default via fe80::cece:1eff:fe3d:c737 metric 20600 (ra) + +● 41: wg0 tunnel/wireguard UNKNOWN/UP (networkd: wg0) + Addresses: 10.10.0.2/24 + Routes: 10.10.0.0/24 from 10.10.0.2 (link) + Activation Mode: manual + +● 48: tun0 tunnel/sit UNKNOWN/UP (networkd: tun0) + Addresses: 2001:dead:beef::2/64 + Routes: 2001:dead:beef::/64 metric 256 + Activation Mode: manual + +● 42: fakedev0 other DOWN (unmanaged) + Routes: 10.0.0.0/16 via 10.0.0.1 (local) + +1 inactive interfaces hidden. Use "--all" to show all. +''' class TestStatus(unittest.TestCase): @@ -53,9 +104,10 @@ def _call(self, args): def _get_itf(self, ifname): return next((itf for itf in yaml.safe_load(IPROUTE2) if itf['ifname'] == ifname), None) + @patch('netplan_cli.cli.commands.status.RICH_OUTPUT', False) @patch('netplan_cli.cli.state.Interface.query_nm_ssid') @patch('netplan_cli.cli.state.Interface.query_networkctl') - def test_pretty_print(self, networkctl_mock, nm_ssid_mock): + def test_plain_print(self, networkctl_mock, nm_ssid_mock): SSID = 'MYCON' nm_ssid_mock.return_value = SSID # networkctl mock output reduced to relevant lines @@ -92,57 +144,48 @@ def test_pretty_print(self, networkctl_mock, nm_ssid_mock): status.verbose = False status.pretty_print(data, len(interfaces)+1, _console_width=130) out = f.getvalue() - self.assertEqual(out, '''\ - Online state: online - DNS Addresses: 127.0.0.53 (stub) - DNS Search: search.domain - -● 2: enp0s31f6 ethernet UP (networkd: enp0s31f6) - MAC Address: 54:e1:ad:5f:24:b4 (Intel Corporation) - Addresses: 192.168.178.62/24 (dhcp) - 2001:9e8:a19f:1c00:56e1:adff:fe5f:24b4/64 - fe80::56e1:adff:fe5f:24b4/64 (link) - DNS Addresses: 192.168.178.1 - fd00::cece:1eff:fe3d:c737 - DNS Search: search.domain - Routes: default via 192.168.178.1 from 192.168.178.62 metric 100 (dhcp) - 192.168.178.0/24 from 192.168.178.62 metric 100 (link) - 2001:9e8:a19f:1c00::/64 metric 100 (ra) - 2001:9e8:a19f:1c00::/56 via fe80::cece:1eff:fe3d:c737 metric 100 (ra) - fe80::/64 metric 256 - Activation Mode: manual - -● 5: wlan0 wifi/"MYCON" UP (NetworkManager: NM-b6b7a21d-186e-45e1-b3a6-636da1735563) - MAC Address: 1c:4d:70:e4:e4:0e (Intel Corporation) - Addresses: 192.168.178.142/24 - 2001:9e8:a19f:1c00:7011:2d1:951:ad03/64 - 2001:9e8:a19f:1c00:f24f:f724:5dd1:d0ad/64 - fe80::fec1:6ced:5268:b46c/64 (link) - DNS Addresses: 192.168.178.1 - fd00::cece:1eff:fe3d:c737 - DNS Search: search.domain - Routes: default via 192.168.178.1 metric 600 (dhcp) - 192.168.178.0/24 from 192.168.178.142 metric 600 (link) - 2001:9e8:a19f:1c00::/64 metric 600 (ra) - 2001:9e8:a19f:1c00::/56 via fe80::cece:1eff:fe3d:c737 metric 600 (ra) - fe80::/64 metric 1024 - default via fe80::cece:1eff:fe3d:c737 metric 20600 (ra) + self.assertEqual(out, STATUS_OUTPUT) -● 41: wg0 tunnel/wireguard UNKNOWN/UP (networkd: wg0) - Addresses: 10.10.0.2/24 - Routes: 10.10.0.0/24 from 10.10.0.2 (link) - Activation Mode: manual - -● 48: tun0 tunnel/sit UNKNOWN/UP (networkd: tun0) - Addresses: 2001:dead:beef::2/64 - Routes: 2001:dead:beef::/64 metric 256 - Activation Mode: manual + @patch('netplan_cli.cli.state.Interface.query_nm_ssid') + @patch('netplan_cli.cli.state.Interface.query_networkctl') + def test_pretty_print(self, networkctl_mock, nm_ssid_mock): + SSID = 'MYCON' + nm_ssid_mock.return_value = SSID + # networkctl mock output reduced to relevant lines + networkctl_mock.return_value = \ + '''Activation Policy: manual + WiFi access point: {} (b4:fb:e4:75:c6:21)'''.format(SSID) -● 42: fakedev0 other DOWN (unmanaged) - Routes: 10.0.0.0/16 via 10.0.0.1 (local) + nd = SystemConfigState.process_networkd(NETWORKD) + nm = SystemConfigState.process_nm(NMCLI) + dns = (DNS_ADDRESSES, DNS_SEARCH) + routes = (SystemConfigState.process_generic(ROUTE4), SystemConfigState.process_generic(ROUTE6)) + fakeroute = {'type': 'local', 'dst': '10.0.0.0/16', 'gateway': '10.0.0.1', 'dev': FAKE_DEV['ifname'], 'table': 'main'} -1 inactive interfaces hidden. Use "--all" to show all. -''') + interfaces = [ + Interface(self._get_itf('enp0s31f6'), nd, nm, dns, routes), + Interface(self._get_itf('wlan0'), nd, nm, dns, routes), + Interface(self._get_itf('wg0'), nd, nm, dns, routes), + Interface(self._get_itf('tun0'), nd, nm, dns, routes), + Interface(FAKE_DEV, [], None, (None, None), ([fakeroute], None)), + ] + data = {'netplan-global-state': { + 'online': True, + 'nameservers': { + 'addresses': ['127.0.0.53'], + 'search': ['search.domain'], + 'mode': 'stub', + }}} + for itf in interfaces: + ifname, obj = itf.json() + data[ifname] = obj + f = io.StringIO() + with redirect_stdout(f): + status = NetplanStatus() + status.verbose = False + status.pretty_print(data, len(interfaces)+1, _console_width=130) + out = f.getvalue() + self.assertEqual(out, STATUS_OUTPUT) @patch('netplan_cli.cli.state.Interface.query_nm_ssid') @patch('netplan_cli.cli.state.Interface.query_networkctl') From 073a19004d44e0ddb189e9b17124959ca493d194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=A4rdian?= Date: Tue, 15 Aug 2023 11:20:19 +0200 Subject: [PATCH 2/2] status: improve tag matching pattern --- netplan_cli/cli/commands/status.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/netplan_cli/cli/commands/status.py b/netplan_cli/cli/commands/status.py index 7b65f37e2..908e31582 100644 --- a/netplan_cli/cli/commands/status.py +++ b/netplan_cli/cli/commands/status.py @@ -27,6 +27,7 @@ from ..state import SystemConfigState, JSON +MATCH_TAGS = re.compile(r'\[([a-z0-9]+)\].*\[\/\1\]') RICH_OUTPUT = False try: from rich.console import Console @@ -67,9 +68,11 @@ def run(self): def plain_print(self, *args, **kwargs): if len(args): - pattern = r'\[\/?\w+\]' lst = list(args) - lst[0] = re.sub(pattern, '', lst[0]) # remove any tags, like '[...]' or '[/...]' + for tag in MATCH_TAGS.findall(lst[0]): + # remove matching opening and closing tag + lst[0] = lst[0].replace('[{}]'.format(tag), '')\ + .replace('[/{}]'.format(tag), '') return print(*lst, **kwargs) return print(*args, **kwargs)