From 60cd669d2cee2a17d4e6ab6fce9101069d311e23 Mon Sep 17 00:00:00 2001 From: Nataliia Solomko Date: Fri, 11 Oct 2024 13:24:43 +0300 Subject: T6695: Machine-readable operational mode support for traceroute --- src/op_mode/mtr.py | 2 +- src/op_mode/mtr_execute.py | 217 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 src/op_mode/mtr_execute.py (limited to 'src/op_mode') diff --git a/src/op_mode/mtr.py b/src/op_mode/mtr.py index baf9672a1..eea80c4da 100644 --- a/src/op_mode/mtr.py +++ b/src/op_mode/mtr.py @@ -23,7 +23,7 @@ from vyos.utils.network import vrf_list from vyos.utils.process import call options = { - 'report': { + 'report-mode': { 'mtr': '{command} --report', 'type': 'noarg', 'help': 'This option puts mtr into report mode. When in this mode, mtr will run for the number of cycles specified by the -c option, and then print statistics and exit.' diff --git a/src/op_mode/mtr_execute.py b/src/op_mode/mtr_execute.py new file mode 100644 index 000000000..2585a7ee4 --- /dev/null +++ b/src/op_mode/mtr_execute.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import ipaddress +import socket +import sys +import typing + +from json import loads + +from vyos.utils.network import interface_list +from vyos.utils.network import vrf_list +from vyos.utils.process import cmd +from vyos.utils.process import call + +import vyos.opmode + +ArgProtocol = typing.Literal['tcp', 'udp', 'sctp'] +noargs_list = [ + 'report_mode', + 'json', + 'report_wide', + 'split', + 'raw', + 'no_dns', + 'aslookup', +] + + +def vrf_list_default(): + return vrf_list() + ['default'] + + +options = { + 'report_mode': { + 'mtr': '{command} --report', + }, + 'protocol': { + 'mtr': '{command} --{value}', + }, + 'json': { + 'mtr': '{command} --json', + }, + 'report_wide': { + 'mtr': '{command} --report-wide', + }, + 'raw': { + 'mtr': '{command} --raw', + }, + 'split': { + 'mtr': '{command} --split', + }, + 'no_dns': { + 'mtr': '{command} --no-dns', + }, + 'show_ips': { + 'mtr': '{command} --show-ips {value}', + }, + 'ipinfo': { + 'mtr': '{command} --ipinfo {value}', + }, + 'aslookup': { + 'mtr': '{command} --aslookup', + }, + 'interval': { + 'mtr': '{command} --interval {value}', + }, + 'report_cycles': { + 'mtr': '{command} --report-cycles {value}', + }, + 'psize': { + 'mtr': '{command} --psize {value}', + }, + 'bitpattern': { + 'mtr': '{command} --bitpattern {value}', + }, + 'gracetime': { + 'mtr': '{command} --gracetime {value}', + }, + 'tos': { + 'mtr': '{command} --tos {value}', + }, + 'mpls': { + 'mtr': '{command} --mpls {value}', + }, + 'interface': { + 'mtr': '{command} --interface {value}', + 'helpfunction': interface_list, + }, + 'address': { + 'mtr': '{command} --address {value}', + }, + 'first_ttl': { + 'mtr': '{command} --first-ttl {value}', + }, + 'max_ttl': { + 'mtr': '{command} --max-ttl {value}', + }, + 'max_unknown': { + 'mtr': '{command} --max-unknown {value}', + }, + 'port': { + 'mtr': '{command} --port {value}', + }, + 'localport': { + 'mtr': '{command} --localport {value}', + }, + 'timeout': { + 'mtr': '{command} --timeout {value}', + }, + 'mark': { + 'mtr': '{command} --mark {value}', + }, + 'vrf': { + 'mtr': 'sudo ip vrf exec {value} {command}', + 'helpfunction': vrf_list_default, + 'dflt': 'default', + }, +} + +mtr_command = { + 4: '/bin/mtr -4', + 6: '/bin/mtr -6', +} + + +def mtr( + host: str, + for_api: typing.Optional[bool], + report_mode: typing.Optional[bool], + protocol: typing.Optional[ArgProtocol], + report_wide: typing.Optional[bool], + raw: typing.Optional[bool], + json: typing.Optional[bool], + split: typing.Optional[bool], + no_dns: typing.Optional[bool], + show_ips: typing.Optional[str], + ipinfo: typing.Optional[str], + aslookup: typing.Optional[bool], + interval: typing.Optional[str], + report_cycles: typing.Optional[str], + psize: typing.Optional[str], + bitpattern: typing.Optional[str], + gracetime: typing.Optional[str], + tos: typing.Optional[str], + mpl: typing.Optional[bool], + interface: typing.Optional[str], + address: typing.Optional[str], + first_ttl: typing.Optional[str], + max_ttl: typing.Optional[str], + max_unknown: typing.Optional[str], + port: typing.Optional[str], + localport: typing.Optional[str], + timeout: typing.Optional[str], + mark: typing.Optional[str], + vrf: typing.Optional[str], +): + args = locals() + for name, option in options.items(): + if 'dflt' in option and not args[name]: + args[name] = option['dflt'] + + try: + ip = socket.gethostbyname(host) + except UnicodeError: + raise vyos.opmode.InternalError(f'Unknown host: {host}') + except socket.gaierror: + ip = host + + try: + version = ipaddress.ip_address(ip).version + except ValueError: + raise vyos.opmode.InternalError(f'Unknown host: {host}') + + command = mtr_command[version] + + for key, val in args.items(): + if key in options and val: + if 'helpfunction' in options[key]: + allowed_values = options[key]['helpfunction']() + if val not in allowed_values: + raise vyos.opmode.InternalError( + f'Invalid argument for option {key} - {val}' + ) + value = '' if key in noargs_list else val + command = options[key]['mtr'].format(command=command, value=val) + + if json: + output = cmd(f'{command} {host}') + if for_api: + output = loads(output) + print(output) + else: + call(f'{command} --curses --displaymode 0 {host}') + + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, vyos.opmode.Error) as e: + print(e) + sys.exit(1) -- cgit v1.2.3 From 5c76607a9faef1fb5dc07459a38d37261ce988c1 Mon Sep 17 00:00:00 2001 From: Nataliia Solomko Date: Fri, 11 Oct 2024 17:03:35 +0300 Subject: T6695: normalize formatting --- python/vyos/opmode.py | 163 +++++++++++++++++++++++++++++++------------------- src/op_mode/mtr.py | 74 +++++++++++------------ 2 files changed, 139 insertions(+), 98 deletions(-) (limited to 'src/op_mode') diff --git a/python/vyos/opmode.py b/python/vyos/opmode.py index 83e25fb50..58c1e2c9d 100644 --- a/python/vyos/opmode.py +++ b/python/vyos/opmode.py @@ -20,71 +20,90 @@ from humps import decamelize class Error(Exception): - """ Any error that makes requested operation impossible to complete - for reasons unrelated to the user input or script logic. + """Any error that makes requested operation impossible to complete + for reasons unrelated to the user input or script logic. - This is the base class, scripts should not use it directly - and should raise more specific errors instead, - whenever possible. + This is the base class, scripts should not use it directly + and should raise more specific errors instead, + whenever possible. """ + pass + class UnconfiguredSubsystem(Error): - """ Requested operation is valid, but cannot be completed - because corresponding subsystem is not configured - and thus is not running. + """Requested operation is valid, but cannot be completed + because corresponding subsystem is not configured + and thus is not running. """ + pass + class UnconfiguredObject(UnconfiguredSubsystem): - """ Requested operation is valid but cannot be completed - because its parameter refers to an object that does not exist - in the system configuration. + """Requested operation is valid but cannot be completed + because its parameter refers to an object that does not exist + in the system configuration. """ + pass + class DataUnavailable(Error): - """ Requested operation is valid, but cannot be completed - because data for it is not available. - This error MAY be treated as temporary because such issues - are often caused by transient events such as service restarts. + """Requested operation is valid, but cannot be completed + because data for it is not available. + This error MAY be treated as temporary because such issues + are often caused by transient events such as service restarts. """ + pass + class PermissionDenied(Error): - """ Requested operation is valid, but the caller has no permission - to perform it. + """Requested operation is valid, but the caller has no permission + to perform it. """ + pass + class InsufficientResources(Error): - """ Requested operation and its arguments are valid but the system - does not have enough resources (such as drive space or memory) - to complete it. + """Requested operation and its arguments are valid but the system + does not have enough resources (such as drive space or memory) + to complete it. """ + pass + class UnsupportedOperation(Error): - """ Requested operation is technically valid but is not implemented yet. """ + """Requested operation is technically valid but is not implemented yet.""" + pass + class IncorrectValue(Error): - """ Requested operation is valid, but an argument provided has an - incorrect value, preventing successful completion. + """Requested operation is valid, but an argument provided has an + incorrect value, preventing successful completion. """ + pass + class CommitInProgress(Error): - """ Requested operation is valid, but not possible at the time due + """Requested operation is valid, but not possible at the time due to a commit being in progress. """ + pass + class InternalError(Error): - """ Any situation when VyOS detects that it could not perform - an operation correctly due to logic errors in its own code - or errors in underlying software. + """Any situation when VyOS detects that it could not perform + an operation correctly due to logic errors in its own code + or errors in underlying software. """ + pass @@ -97,12 +116,14 @@ def _is_op_mode_function_name(name): else: return False + def _capture_output(name): - if re.match(r"^(show|generate)", name): + if re.match(r'^(show|generate)', name): return True else: return False + def _get_op_mode_functions(module): from inspect import getmembers, isfunction @@ -113,32 +134,35 @@ def _get_op_mode_functions(module): funcs = list(filter(lambda ft: _is_op_mode_function_name(ft[0]), funcs)) funcs_dict = {} - for (name, thunk) in funcs: + for name, thunk in funcs: funcs_dict[name] = thunk return funcs_dict + def _is_optional_type(t): # Optional[t] is internally an alias for Union[t, NoneType] # and there's no easy way to get union members it seems - if (type(t) == typing._UnionGenericAlias): - if (len(t.__args__) == 2): - if t.__args__[1] == type(None): + if type(t) is typing._UnionGenericAlias: + if len(t.__args__) == 2: + if t.__args__[1] is type(None): return True return False + def _get_arg_type(t): - """ Returns the type itself if it's a primitive type, - or the "real" type of typing.Optional + """Returns the type itself if it's a primitive type, + or the "real" type of typing.Optional - Doesn't work with anything else at the moment! + Doesn't work with anything else at the moment! """ if _is_optional_type(t): return t.__args__[0] else: return t + def _is_literal_type(t): if _is_optional_type(t): t = _get_arg_type(t) @@ -148,9 +172,9 @@ def _is_literal_type(t): return False + def _get_literal_values(t): - """ Returns the tuple of allowed values for a Literal type - """ + """Returns the tuple of allowed values for a Literal type""" if not _is_literal_type(t): return tuple() if _is_optional_type(t): @@ -158,6 +182,7 @@ def _get_literal_values(t): return typing.get_args(t) + def _normalize_field_name(name): # Convert the name to string if it is not # (in some cases they may be numbers) @@ -182,6 +207,7 @@ def _normalize_field_name(name): return name + def _normalize_dict_field_names(old_dict): new_dict = {} @@ -191,10 +217,11 @@ def _normalize_dict_field_names(old_dict): # Sanity check if len(old_dict) != len(new_dict): - raise InternalError("Dictionary fields do not allow unique normalization") + raise InternalError('Dictionary fields do not allow unique normalization') else: return new_dict + def _normalize_field_names(value): if isinstance(value, dict): return _normalize_dict_field_names(value) @@ -203,16 +230,19 @@ def _normalize_field_names(value): else: return value + def run(module): from argparse import ArgumentParser functions = _get_op_mode_functions(module) parser = ArgumentParser() - subparsers = parser.add_subparsers(dest="subcommand") + subparsers = parser.add_subparsers(dest='subcommand') for function_name in functions: - subparser = subparsers.add_parser(function_name, help=functions[function_name].__doc__) + subparser = subparsers.add_parser( + function_name, help=functions[function_name].__doc__ + ) type_hints = typing.get_type_hints(functions[function_name]) if 'return' in type_hints: @@ -225,62 +255,73 @@ def run(module): # Without this, we'd get options like "--foo_bar" opt = re.sub(r'_', '-', opt) - if _get_arg_type(th) == bool: - subparser.add_argument(f"--{opt}", action='store_true') + if _get_arg_type(th) is bool: + subparser.add_argument(f'--{opt}', action='store_true') else: if _is_optional_type(th): if _is_literal_type(th): - subparser.add_argument(f"--{opt}", - choices=list(_get_literal_values(th)), - default=None) + subparser.add_argument( + f'--{opt}', + choices=list(_get_literal_values(th)), + default=None, + ) else: - subparser.add_argument(f"--{opt}", - type=_get_arg_type(th), default=None) + subparser.add_argument( + f'--{opt}', + type=_get_arg_type(th), + default=None, + ) else: if _is_literal_type(th): - subparser.add_argument(f"--{opt}", - choices=list(_get_literal_values(th)), - required=True) + subparser.add_argument( + f'--{opt}', + choices=list(_get_literal_values(th)), + required=True, + ) else: - subparser.add_argument(f"--{opt}", - type=_get_arg_type(th), required=True) + subparser.add_argument( + f'--{opt}', type=_get_arg_type(th), required=True + ) # Get options as a dict rather than a namespace, # so that we can modify it and pack for passing to functions args = vars(parser.parse_args()) - if not args["subcommand"]: - print("Subcommand required!") + if not args['subcommand']: + print('Subcommand required!') parser.print_usage() sys.exit(1) - function_name = args["subcommand"] + function_name = args['subcommand'] func = functions[function_name] # Remove the subcommand from the arguments, # it would cause an extra argument error when we pass the dict to a function - del args["subcommand"] + del args['subcommand'] # Show and generate commands must always get the "raw" argument, # but other commands (clear/reset/restart/add/delete) should not, # because they produce no output and it makes no sense for them. - if ("raw" not in args) and _capture_output(function_name): - args["raw"] = False + if ('raw' not in args) and _capture_output(function_name): + args['raw'] = False if _capture_output(function_name): # Show and generate commands are slightly special: # they may return human-formatted output # or a raw dict that we need to serialize in JSON for printing res = func(**args) - if not args["raw"]: + if not args['raw']: return res else: if not isinstance(res, dict) and not isinstance(res, list): - raise InternalError(f"Bare literal is not an acceptable raw output, must be a list or an object.\ - The output was:{res}") + raise InternalError( + f'Bare literal is not an acceptable raw output, must be a list or an object.\ + The output was:{res}' + ) res = decamelize(res) res = _normalize_field_names(res) from json import dumps + return dumps(res, indent=4) else: # Other functions should not return anything, diff --git a/src/op_mode/mtr.py b/src/op_mode/mtr.py index eea80c4da..522cbe008 100644 --- a/src/op_mode/mtr.py +++ b/src/op_mode/mtr.py @@ -26,152 +26,152 @@ options = { 'report-mode': { 'mtr': '{command} --report', 'type': 'noarg', - 'help': 'This option puts mtr into report mode. When in this mode, mtr will run for the number of cycles specified by the -c option, and then print statistics and exit.' + 'help': 'This option puts mtr into report mode. When in this mode, mtr will run for the number of cycles specified by the -c option, and then print statistics and exit.', }, 'report-wide': { 'mtr': '{command} --report-wide', 'type': 'noarg', - 'help': 'This option puts mtr into wide report mode. When in this mode, mtr will not cut hostnames in the report.' + 'help': 'This option puts mtr into wide report mode. When in this mode, mtr will not cut hostnames in the report.', }, 'raw': { 'mtr': '{command} --raw', 'type': 'noarg', - 'help': 'Use the raw output format. This format is better suited for archival of the measurement results.' + 'help': 'Use the raw output format. This format is better suited for archival of the measurement results.', }, 'json': { 'mtr': '{command} --json', 'type': 'noarg', - 'help': 'Use this option to tell mtr to use the JSON output format.' + 'help': 'Use this option to tell mtr to use the JSON output format.', }, 'split': { 'mtr': '{command} --split', 'type': 'noarg', - 'help': 'Use this option to set mtr to spit out a format that is suitable for a split-user interface.' + 'help': 'Use this option to set mtr to spit out a format that is suitable for a split-user interface.', }, 'no-dns': { 'mtr': '{command} --no-dns', 'type': 'noarg', - 'help': 'Use this option to force mtr to display numeric IP numbers and not try to resolve the host names.' + 'help': 'Use this option to force mtr to display numeric IP numbers and not try to resolve the host names.', }, 'show-ips': { 'mtr': '{command} --show-ips {value}', 'type': '', - 'help': 'Use this option to tell mtr to display both the host names and numeric IP numbers.' + 'help': 'Use this option to tell mtr to display both the host names and numeric IP numbers.', }, 'ipinfo': { 'mtr': '{command} --ipinfo {value}', 'type': '', - 'help': 'Displays information about each IP hop.' + 'help': 'Displays information about each IP hop.', }, 'aslookup': { 'mtr': '{command} --aslookup', 'type': 'noarg', - 'help': 'Displays the Autonomous System (AS) number alongside each hop. Equivalent to --ipinfo 0.' + 'help': 'Displays the Autonomous System (AS) number alongside each hop. Equivalent to --ipinfo 0.', }, 'interval': { 'mtr': '{command} --interval {value}', 'type': '', - 'help': 'Use this option to specify the positive number of seconds between ICMP ECHO requests. The default value for this parameter is one second. The root user may choose values between zero and one.' + 'help': 'Use this option to specify the positive number of seconds between ICMP ECHO requests. The default value for this parameter is one second. The root user may choose values between zero and one.', }, 'report-cycles': { 'mtr': '{command} --report-cycles {value}', 'type': '', - 'help': 'Use this option to set the number of pings sent to determine both the machines on the network and the reliability of those machines. Each cycle lasts one second.' + 'help': 'Use this option to set the number of pings sent to determine both the machines on the network and the reliability of those machines. Each cycle lasts one second.', }, 'psize': { 'mtr': '{command} --psize {value}', 'type': '', - 'help': 'This option sets the packet size used for probing. It is in bytes, inclusive IP and ICMP headers. If set to a negative number, every iteration will use a different, random packet size up to that number.' + 'help': 'This option sets the packet size used for probing. It is in bytes, inclusive IP and ICMP headers. If set to a negative number, every iteration will use a different, random packet size up to that number.', }, 'bitpattern': { 'mtr': '{command} --bitpattern {value}', 'type': '', - 'help': 'Specifies bit pattern to use in payload. Should be within range 0 - 255. If NUM is greater than 255, a random pattern is used.' + 'help': 'Specifies bit pattern to use in payload. Should be within range 0 - 255. If NUM is greater than 255, a random pattern is used.', }, 'gracetime': { 'mtr': '{command} --gracetime {value}', 'type': '', - 'help': 'Use this option to specify the positive number of seconds to wait for responses after the final request. The default value is five seconds.' + 'help': 'Use this option to specify the positive number of seconds to wait for responses after the final request. The default value is five seconds.', }, 'tos': { 'mtr': '{command} --tos {value}', 'type': '', - 'help': 'Specifies value for type of service field in IP header. Should be within range 0 - 255.' + 'help': 'Specifies value for type of service field in IP header. Should be within range 0 - 255.', }, 'mpls': { 'mtr': '{command} --mpls {value}', 'type': 'noarg', - 'help': 'Use this option to tell mtr to display information from ICMP extensions for MPLS (RFC 4950) that are encoded in the response packets.' + 'help': 'Use this option to tell mtr to display information from ICMP extensions for MPLS (RFC 4950) that are encoded in the response packets.', }, 'interface': { 'mtr': '{command} --interface {value}', 'type': '', 'helpfunction': interface_list, - 'help': 'Use the network interface with a specific name for sending network probes. This can be useful when you have multiple network interfaces with routes to your destination, for example both wired Ethernet and WiFi, and wish to test a particular interface.' + 'help': 'Use the network interface with a specific name for sending network probes. This can be useful when you have multiple network interfaces with routes to your destination, for example both wired Ethernet and WiFi, and wish to test a particular interface.', }, 'address': { 'mtr': '{command} --address {value}', 'type': ' ', - 'help': 'Use this option to bind the outgoing socket to ADDRESS, so that all packets will be sent with ADDRESS as source address.' + 'help': 'Use this option to bind the outgoing socket to ADDRESS, so that all packets will be sent with ADDRESS as source address.', }, 'first-ttl': { 'mtr': '{command} --first-ttl {value}', 'type': '', - 'help': 'Specifies with what TTL to start. Defaults to 1.' + 'help': 'Specifies with what TTL to start. Defaults to 1.', }, 'max-ttl': { 'mtr': '{command} --max-ttl {value}', 'type': '', - 'help': 'Specifies the maximum number of hops or max time-to-live value mtr will probe. Default is 30.' + 'help': 'Specifies the maximum number of hops or max time-to-live value mtr will probe. Default is 30.', }, 'max-unknown': { 'mtr': '{command} --max-unknown {value}', 'type': '', - 'help': 'Specifies the maximum unknown host. Default is 5.' + 'help': 'Specifies the maximum unknown host. Default is 5.', }, 'udp': { 'mtr': '{command} --udp', 'type': 'noarg', - 'help': 'Use UDP datagrams instead of ICMP ECHO.' + 'help': 'Use UDP datagrams instead of ICMP ECHO.', }, 'tcp': { 'mtr': '{command} --tcp', 'type': 'noarg', - 'help': ' Use TCP SYN packets instead of ICMP ECHO. PACKETSIZE is ignored, since SYN packets can not contain data.' + 'help': ' Use TCP SYN packets instead of ICMP ECHO. PACKETSIZE is ignored, since SYN packets can not contain data.', }, 'sctp': { 'mtr': '{command} --sctp', 'type': 'noarg', - 'help': 'Use Stream Control Transmission Protocol packets instead of ICMP ECHO.' + 'help': 'Use Stream Control Transmission Protocol packets instead of ICMP ECHO.', }, 'port': { 'mtr': '{command} --port {value}', 'type': '', - 'help': 'The target port number for TCP/SCTP/UDP traces.' + 'help': 'The target port number for TCP/SCTP/UDP traces.', }, 'localport': { 'mtr': '{command} --localport {value}', 'type': '', - 'help': 'The source port number for UDP traces.' + 'help': 'The source port number for UDP traces.', }, 'timeout': { 'mtr': '{command} --timeout {value}', 'type': '', - 'help': ' The number of seconds to keep probe sockets open before giving up on the connection.' + 'help': ' The number of seconds to keep probe sockets open before giving up on the connection.', }, 'mark': { 'mtr': '{command} --mark {value}', 'type': '', - 'help': ' Set the mark for each packet sent through this socket similar to the netfilter MARK target but socket-based. MARK is 32 unsigned integer.' + 'help': ' Set the mark for each packet sent through this socket similar to the netfilter MARK target but socket-based. MARK is 32 unsigned integer.', }, 'vrf': { 'mtr': 'sudo ip vrf exec {value} {command}', 'type': '', 'help': 'Use specified VRF table', 'helpfunction': vrf_list, - 'dflt': 'default' - } - } + 'dflt': 'default', + }, +} mtr = { 4: '/bin/mtr -4', @@ -204,8 +204,8 @@ def completion_failure(option: str) -> None: def expension_failure(option, completions): reason = 'Ambiguous' if completions else 'Invalid' sys.stderr.write( - '\n\n {} command: {} [{}]\n\n'.format(reason, ' '.join(sys.argv), - option)) + '\n\n {} command: {} [{}]\n\n'.format(reason, ' '.join(sys.argv), option) + ) if completions: sys.stderr.write(' Possible completions:\n ') sys.stderr.write('\n '.join(completions)) @@ -229,13 +229,13 @@ def convert(command, args): if longname == 'json': to_json = True if options[longname]['type'] == 'noarg': - command = options[longname]['mtr'].format( - command=command, value='') + command = options[longname]['mtr'].format(command=command, value='') elif not args: sys.exit(f'mtr: missing argument for {longname} option') else: command = options[longname]['mtr'].format( - command=command, value=args.first()) + command=command, value=args.first() + ) return command, to_json @@ -244,7 +244,7 @@ if __name__ == '__main__': host = args.first() if not host: - sys.exit("mtr: Missing host") + sys.exit('mtr: Missing host') if host == '--get-options' or host == '--get-options-nested': if host == '--get-options-nested': -- cgit v1.2.3