Skip to content

Commit

Permalink
cli:status: Make rich pretty printing optional (#388)
Browse files Browse the repository at this point in the history
* cli:status: Make rich pretty printing optional

To avoid a hard dependency on python3-rich

* status: improve tag matching pattern
  • Loading branch information
slyon authored Aug 15, 2023
1 parent 715fefb commit 2a3f12a
Show file tree
Hide file tree
Showing 2 changed files with 136 additions and 71 deletions.
66 changes: 44 additions & 22 deletions netplan_cli/cli/commands/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,31 @@
'''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<int>\d+)([\s:]?\s|$)',
r'(?P<str>(\"|\').+(\"|\'))',
]
MATCH_TAGS = re.compile(r'\[([a-z0-9]+)\].*\[\/\1\]')
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<int>\d+)([\s:]?\s|$)',
r'(?P<str>(\"|\').+(\"|\'))',
]
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):
Expand All @@ -57,20 +66,33 @@ def run(self):
self.parse_args()
self.run_command()

def plain_print(self, *args, **kwargs):
if len(args):
lst = list(args)
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)

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', {})
Expand Down
141 changes: 92 additions & 49 deletions tests/cli/test_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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')
Expand Down

0 comments on commit 2a3f12a

Please sign in to comment.