diff --git a/src/objdictgen/__main__.py b/src/objdictgen/__main__.py index 8dcfcfe..f11682f 100644 --- a/src/objdictgen/__main__.py +++ b/src/objdictgen/__main__.py @@ -30,7 +30,7 @@ import objdictgen from objdictgen import jsonod -from objdictgen.printing import GetPrintEntry +from objdictgen.printing import format_node from objdictgen.typing import TDiffEntries, TDiffNodes, TPath T = TypeVar('T') @@ -122,59 +122,6 @@ def _printlines(entries: TDiffEntries): _printlines(diffs[index]) -def list_od( - od: "Node", - name: str, - opts: argparse.Namespace) -> Generator[str, None, None]: - """Generator for producing the output for odg list""" - - # Get the indexes to print and determine the order - keys = od.GetAllIndices(sort=opts.sort) - if opts.index: - indexp = [jsonod.str_to_int(i) for i in opts.index] - keys = [k for k in keys if k in indexp] - missing = ", ".join((str(k) for k in indexp if k not in keys)) - if missing: - raise ValueError(f"Unknown index {missing}") - - profiles = [] - if od.DS302: - loaded, equal = jsonod.compare_profile("DS-302", od.DS302) - if equal: - extra = "DS-302 (equal)" - elif loaded: - extra = "DS-302 (not equal)" - else: - extra = "DS-302 (not loaded)" - profiles.append(extra) - - pname = od.ProfileName - if pname and pname != 'None': - loaded, equal = jsonod.compare_profile(pname, od.Profile, od.SpecificMenu) - if equal: - extra = f"{pname} (equal)" - elif loaded: - extra = f"{pname} (not equal)" - else: - extra = f"{pname} (not loaded)" - profiles.append(extra) - - if not opts.compact: - yield f"{Fore.CYAN}File:{Style.RESET_ALL} {name}" - yield f"{Fore.CYAN}Name:{Style.RESET_ALL} {od.Name} [{od.Type.upper()}] {od.Description}" - tp = ", ".join(profiles) or None - yield f"{Fore.CYAN}Profiles:{Style.RESET_ALL} {tp}" - if od.ID: - yield f"{Fore.CYAN}ID:{Style.RESET_ALL} {od.ID}" - yield "" - - # Print the parameters - yield from GetPrintEntry( - od, keys=keys, short=opts.short, compact=opts.compact, unused=opts.unused, - verbose=opts.all, raw=opts.raw - ) - - @debug_wrapper() def main(debugopts: DebugOpts, args: Sequence[str]|None = None): """ Main command dispatcher """ @@ -265,6 +212,7 @@ def main(debugopts: DebugOpts, args: Sequence[str]|None = None): subp.add_argument('--raw', action="store_true", help="Show raw parameter values") subp.add_argument('--short', action="store_true", help="Do not list sub-index") subp.add_argument('--unused', action="store_true", help="Include unused profile parameters") + subp.add_argument('--internal', action="store_true", help="Show internal data") subp.add_argument('-D', '--debug', **opt_debug) # type: ignore[arg-type] subp.add_argument('--no-color', action='store_true', help="Disable colored output") @@ -400,7 +348,7 @@ def main(debugopts: DebugOpts, args: Sequence[str]|None = None): print(Fore.LIGHTBLUE_EX + name + '\n' + "=" * len(name) + Style.RESET_ALL) od = open_od(name) - for line in list_od(od, name, opts): + for line in format_node(od, name, index=opts.index, opts=opts): print(line) diff --git a/src/objdictgen/nodelist.py b/src/objdictgen/nodelist.py index a58f3f2..7b207bb 100644 --- a/src/objdictgen/nodelist.py +++ b/src/objdictgen/nodelist.py @@ -20,14 +20,14 @@ import errno import os -from pathlib import Path import shutil from dataclasses import dataclass +from pathlib import Path from objdictgen import eds_utils from objdictgen.node import Node from objdictgen.nodemanager import NodeManager -from objdictgen.printing import GetPrintEntry +from objdictgen.printing import format_node from objdictgen.typing import TODObj, TODSubObj, TPath # ------------------------------------------------------------------------------ @@ -270,11 +270,11 @@ def main(projectdir): print("MasterNode :") node = manager.CurrentNode if node: - for line in GetPrintEntry(node, raw=True): + for line in format_node(node, "MasterNode", raw=True): print(line) print() for nodeid, nodeinfo in nodelist.SlaveNodes.items(): print(f"SlaveNode name={nodeinfo.Name} id=0x{nodeid:02X} :") - for line in GetPrintEntry(nodeinfo.Node): + for line in format_node(nodeinfo.Node, nodeinfo.Name): print(line) print() diff --git a/src/objdictgen/nodemanager.py b/src/objdictgen/nodemanager.py index 5b81132..727c582 100644 --- a/src/objdictgen/nodemanager.py +++ b/src/objdictgen/nodemanager.py @@ -24,8 +24,6 @@ from pathlib import Path from typing import Container, Generic, TypeVar, cast -import colorama - from objdictgen import maps from objdictgen.maps import OD, ODMapping from objdictgen.node import Node @@ -35,9 +33,6 @@ log = logging.getLogger('objdictgen') -Fore = colorama.Fore -Style = colorama.Style - UNDO_BUFFER_LENGTH = 20 type_model = re.compile(r'([\_A-Z]*)([0-9]*)') diff --git a/src/objdictgen/printing.py b/src/objdictgen/printing.py index d622d68..d24e9b6 100644 --- a/src/objdictgen/printing.py +++ b/src/objdictgen/printing.py @@ -1,20 +1,146 @@ """ Functions for printing the object dictionary. """ from __future__ import annotations +from dataclasses import dataclass +from pprint import pformat from typing import Generator from colorama import Fore, Style -from objdictgen import maps +from objdictgen import jsonod, maps from objdictgen.maps import OD from objdictgen.node import Node from objdictgen.typing import TIndexEntry +from objdictgen.utils import TERM_COLS, str_to_int + + +@dataclass +class FormatNodeOpts: + """ Options for formatting the node """ + compact: bool = False + short: bool = False + unused: bool = False + all: bool = False + raw: bool = False + internal: bool = False + + @classmethod + def from_args(cls, opts: FormatNodeOpts|None, kwargs) -> FormatNodeOpts: + """ Create a FormatNodeOpts object from the arguments or kwargs. """ + obj = opts or cls() + for key, value in kwargs.items(): + setattr(obj, key, value) + return obj + + +def format_node( + node: Node, + name: str, *, + index: list[str]|None = None, + minus: Node|None = None, + opts: FormatNodeOpts|None = None, + **kwargs: FormatNodeOpts, +) -> Generator[str, None, None]: + """Generator for producing the print formatting of a node.""" + + # Get the options for the function + opts = FormatNodeOpts.from_args(opts, kwargs) + + # Get the indexes to print and determine the order + keys = node.GetAllIndices(sort=True) + if index: + indexp = [str_to_int(i) for i in index] + keys = [k for k in keys if k in indexp] + missing = ", ".join((str(k) for k in indexp if k not in keys)) + if missing: + raise ValueError(f"Unknown index {missing}") + + profiles = [] + if node.DS302: + loaded, equal = jsonod.compare_profile("DS-302", node.DS302) + if equal: + extra = "DS-302 (equal)" + elif loaded: + extra = "DS-302 (not equal)" + else: + extra = "DS-302 (not loaded)" + profiles.append(extra) + + pname = node.ProfileName + if pname and pname != 'None': + loaded, equal = jsonod.compare_profile(pname, node.Profile, node.SpecificMenu) + if equal: + extra = f"{pname} (equal)" + elif loaded: + extra = f"{pname} (not equal)" + else: + extra = f"{pname} (not loaded)" + profiles.append(extra) + + if not kwargs.get("compact"): + yield f"{Fore.CYAN}File:{Style.RESET_ALL} {name}" + yield f"{Fore.CYAN}Name:{Style.RESET_ALL} {node.Name} [{node.Type.upper()}] {node.Description}" + tp = ", ".join(profiles) or None + yield f"{Fore.CYAN}Profiles:{Style.RESET_ALL} {tp}" + if node.ID: + yield f"{Fore.CYAN}ID:{Style.RESET_ALL} {node.ID}" + yield "" + + index_range = None + header = '' + + for k in keys: + + # Get the index range title + ir = maps.INDEX_RANGES.get_index_range(k) + if index_range != ir: + index_range = ir + if not opts.compact: + header = Fore.YELLOW + ir.description + Style.RESET_ALL + + obj = node.GetIndexEntry(k) + + if minus and k in minus: + minusobj = minus.GetIndexEntry(k) + + if obj == minusobj: + linegen = format_od_object(node, k, short=True) + lines = [remove_color(line) for line in linegen] + lines[0] = Fore.LIGHTBLACK_EX + lines[0] + f" {Fore.LIGHTRED_EX}{Style.RESET_ALL}" + yield from lines + continue + + # Yield the text for the index + lines = list(format_od_object( + node, k, short=opts.short, compact=opts.compact, unused=opts.unused, + verbose=opts.all, raw=opts.raw + )) + + if opts.internal and lines[-1] == "": + lines.pop() + + for line in lines: + # Print the header if it exists + if header: + yield header + header = '' + + # Output the line + yield line + if opts.internal: + obj = node.GetIndexEntry(k) + lines = pformat(obj, width=TERM_COLS).splitlines() + yield from lines + if not opts.compact: + yield "" -def GetPrintEntryHeader( - node: Node, index: int, unused=False, compact=False, raw=False, + +def format_od_header( + node: Node, index: int, *, unused=False, compact=False, raw=False, entry: TIndexEntry|None = None ) -> tuple[str, dict[str, str]]: + """Get the print output for a dictionary entry header""" # Get the information about the index if it wasn't passed along if not entry: @@ -61,6 +187,7 @@ def GetPrintEntryHeader( t_string = maps.ODStructTypes.to_string(obj['struct']) or '???' # ** PRINT PARAMETER ** + # Returned as a tuple to allow for futher usage return "{pre}{key} {name} {struct}{flags}", { 'key': f"{Fore.LIGHTGREEN_EX}0x{index:04x} ({index}){Style.RESET_ALL}", 'name': f"{Fore.LIGHTWHITE_EX}{t_name}{Style.RESET_ALL}", @@ -70,133 +197,119 @@ def GetPrintEntryHeader( } -def GetPrintEntry( - node: Node, keys: list[int]|None = None, short=False, compact=False, +def format_od_object( + node: Node, index: int, *, short=False, compact=False, unused=False, verbose=False, raw=False, ) -> Generator[str, None, None]: - """ - Generator for printing the dictionary values - """ - - # Get the indexes to print and determine the order - keys = keys or node.GetAllIndices(sort=True) - - index_range = None - for k in keys: - - # Get the index entry information - param = node.GetIndexEntry(k, withbase=True) - obj = param["object"] - - # Get the header for the entry - line, fmt = GetPrintEntryHeader( - node, k, unused=unused, compact=compact, entry=param, raw=raw - ) - if not line: - continue - - # Print the parameter range header - ir = maps.INDEX_RANGES.get_index_range(k) - if index_range != ir: - index_range = ir - if not compact: - yield Fore.YELLOW + ir.description + Style.RESET_ALL - - # Yield the parameter header - yield line.format(**fmt) - - # Omit printing sub index data if: - if short: - continue - - # Fetch the dictionary values and the parameters, if present - if k in node.Dictionary: - values = node.GetEntry(k, aslist=True, compute=not raw) - else: - values = ['__N/A__'] * len(obj["values"]) - if k in node.ParamsDictionary: - params = node.GetParamsEntry(k, aslist=True) + """Return the print formatting for an object dictionary entry.""" + + # Get the index entry information + param = node.GetIndexEntry(index, withbase=True) + obj = param["object"] + + # Get the header for the entry and output it unless it is empty + line, fmt = format_od_header( + node, index, unused=unused, compact=compact, entry=param, raw=raw + ) + if not line: + return + yield line.format(**fmt) + + # Get the index range title + index_range = maps.INDEX_RANGES.get_index_range(index) + + # Omit printing sub index data if short is requested + if short: + return + + # Fetch the dictionary values and the parameters, if present + if index in node.Dictionary: + values = node.GetEntry(index, aslist=True, compute=not raw) + else: + values = ['__N/A__'] * len(obj["values"]) + if index in node.ParamsDictionary: + params = node.GetParamsEntry(index, aslist=True) + else: + params = [maps.DEFAULT_PARAMS] * len(obj["values"]) + # For mypy to ensure that values and entries are lists + assert isinstance(values, list) and isinstance(params, list) + + infos = [] + for i, (value, param) in enumerate(zip(values, params)): + + # Prepare data for printing + info = node.GetSubentryInfos(index, i) + typename = node.GetTypeName(info['type']) + + # Type specific formatting of the value + if value == "__N/A__": + t_value = f'{Fore.LIGHTBLACK_EX}N/A{Style.RESET_ALL}' + elif isinstance(value, str): + length = len(value) + if typename == 'DOMAIN': + value = value.encode('unicode_escape').decode() + t_value = '"' + value + f'" ({length})' + elif i and index_range and index_range.name in ('rpdom', 'tpdom'): + # FIXME: In PDO mappings, the value is ints + assert isinstance(value, int) + mapindex, submapindex, _ = node.GetMapIndex(value) + try: + pdo = node.GetSubentryInfos(mapindex, submapindex) + t_v = f"{value:x}" + t_value = f"0x{t_v[0:4]}_{t_v[4:6]}_{t_v[6:]} {Fore.LIGHTCYAN_EX}{pdo['name']}{Style.RESET_ALL}" + except ValueError: + suffix = ' ???' if value else '' + t_value = f"0x{value:x}{suffix}" + elif i and value and (index in (4120, ) or 'COB ID' in info["name"]): + t_value = f"0x{value:x}" else: - params = [maps.DEFAULT_PARAMS] * len(obj["values"]) - # For mypy to ensure that values and entries are lists - assert isinstance(values, list) and isinstance(params, list) - - infos = [] - for i, (value, param) in enumerate(zip(values, params)): - - # Prepare data for printing - info = node.GetSubentryInfos(k, i) - typename = node.GetTypeName(info['type']) - - # Type specific formatting of the value - if value == "__N/A__": - t_value = f'{Fore.LIGHTBLACK_EX}N/A{Style.RESET_ALL}' - elif isinstance(value, str): - length = len(value) - if typename == 'DOMAIN': - value = value.encode('unicode_escape').decode() - t_value = '"' + value + f'" ({length})' - elif i and index_range and index_range.name in ('rpdom', 'tpdom'): - # FIXME: In PDO mappings, the value is ints - assert isinstance(value, int) - index, subindex, _ = node.GetMapIndex(value) - try: - pdo = node.GetSubentryInfos(index, subindex) - t_v = f"{value:x}" - t_value = f"0x{t_v[0:4]}_{t_v[4:6]}_{t_v[6:]} {Fore.LIGHTCYAN_EX}{pdo['name']}{Style.RESET_ALL}" - except ValueError: - suffix = ' ???' if value else '' - t_value = f"0x{value:x}{suffix}" - elif i and value and (k in (4120, ) or 'COB ID' in info["name"]): - t_value = f"0x{value:x}" - else: - t_value = str(value) - - # Add comment if present - t_comment = param['comment'] or '' - if t_comment: - t_comment = f"{Fore.LIGHTBLACK_EX}/* {t_comment} */{Style.RESET_ALL}" - - # Omit printing the first element unless specifically requested - if (not verbose and i == 0 - and obj['struct'] & OD.MultipleSubindexes - and not t_comment - ): - continue - - # Print formatting - infos.append({ - 'i': f"{Fore.GREEN}{i:02d}{Style.RESET_ALL}", - 'access': info['access'], - 'pdo': 'P' if info['pdo'] else ' ', - 'name': info['name'], - 'type': f"{Fore.LIGHTBLUE_EX}{typename}{Style.RESET_ALL}", - 'value': t_value, - 'comment': t_comment, - 'pre': fmt['pre'], - }) - - # Must skip the next step if list is empty, as the first element is - # used for the header - if not infos: + t_value = str(value) + + # Add comment if present + t_comment = param['comment'] or '' + if t_comment: + t_comment = f"{Fore.LIGHTBLACK_EX}/* {t_comment} */{Style.RESET_ALL}" + + # Omit printing the first element unless specifically requested + if (not verbose and i == 0 + and obj['struct'] & OD.MultipleSubindexes + and not t_comment + ): continue - # Calculate the max width for each of the columns - w = { - col: max(len(str(row[col])) for row in infos) or '' - for col in infos[0] - } + # Print formatting + infos.append({ + 'i': f"{Fore.GREEN}{i:02d}{Style.RESET_ALL}", + 'access': info['access'], + 'pdo': 'P' if info['pdo'] else ' ', + 'name': info['name'], + 'type': f"{Fore.LIGHTBLUE_EX}{typename}{Style.RESET_ALL}", + 'value': t_value, + 'comment': t_comment, + 'pre': fmt['pre'], + }) + + # Must skip the next step if list is empty, as the first element is + # used for the header + if not infos: + return + + # Calculate the max width for each of the columns + w = { + col: max(len(str(row[col])) for row in infos) or '' + for col in infos[0] + } - # Generate a format string based on the calculcated column widths - # Legitimate use of % as this is making a string containing format specifiers - fmt = "{pre} {i:%ss} {access:%ss} {pdo:%ss} {name:%ss} {type:%ss} {value:%ss} {comment}" % ( - w["i"], w["access"], w["pdo"], w["name"], w["type"], w["value"] - ) + # Generate a format string based on the calculcated column widths + # Legitimate use of % as this is making a string containing format specifiers + fmt = "{pre} {i:%ss} {access:%ss} {pdo:%ss} {name:%ss} {type:%ss} {value:%ss} {comment}" % ( + w["i"], w["access"], w["pdo"], w["name"], w["type"], w["value"] + ) - # Print each line using the generated format string - for infoentry in infos: - yield fmt.format(**infoentry) + # Print each line using the generated format string + for infoentry in infos: + yield fmt.format(**infoentry) - if not compact and infos: - yield "" + if not compact and infos: + yield "" diff --git a/src/objdictgen/utils.py b/src/objdictgen/utils.py index fa33087..00b7691 100644 --- a/src/objdictgen/utils.py +++ b/src/objdictgen/utils.py @@ -1,9 +1,18 @@ """ Utility functions for objdictgen """ +import os from typing import Mapping, Sequence, TypeVar, cast +from colorama import Fore, Style + T = TypeVar('T') M = TypeVar('M', bound=Mapping) +try: + TERMINAL = os.get_terminal_size() + TERM_COLS = TERMINAL.columns +except OSError: + TERM_COLS = 80 + def exc_amend(exc: Exception, text: str) -> Exception: """ Helper to prefix text to an exception """ diff --git a/tests/test_main.py b/tests/test_main.py index edd6381..05eee44 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -16,41 +16,6 @@ def test_main_open_od(odpath, file): assert "not found in mapping dictionary" in str(exc.value) -@pytest.mark.parametrize("file", [ - 'master', 'slave', - 'profile-ds302', 'profile-ds302-modified', - 'profile-ds401', 'profile-ds401-modified', -]) -def test_main_list_od(odpath, file): - - od = __main__.open_od(odpath / (file + '.json')) - od.ID = 1 - - import argparse - ns = argparse.Namespace( - sort=True, index=[], compact=False, short=False, unused=True, - all=True, raw=False - ) - - lines = list(__main__.list_od(od, file, ns)) - - ns = argparse.Namespace( - sort=True, index=[0x1000], compact=False, short=False, unused=True, - all=True, raw=False - ) - - lines = list(__main__.list_od(od, file, ns)) - - ns = argparse.Namespace( - sort=True, index=[0x5000], compact=False, short=False, unused=True, - all=True, raw=False - ) - - with pytest.raises(ValueError) as exc: - lines = list(__main__.list_od(od, file, ns)) - assert "Unknown index 20480" in str(exc.value) - - def test_main_odg_help(): main(( diff --git a/tests/test_printing.py b/tests/test_printing.py index 1328b09..549c893 100644 --- a/tests/test_printing.py +++ b/tests/test_printing.py @@ -1,17 +1,71 @@ """Test functions for printing.py""" +import types import pytest -from objdictgen.printing import GetPrintEntry +from objdictgen import __main__ +from objdictgen.printing import FormatNodeOpts, format_node, format_od_header, format_od_object from objdictgen.node import Node -@pytest.mark.parametrize("file", ['master', 'slave']) -def test_printing_GetPrintEntry(odpath, file): +@pytest.mark.parametrize("file", [ + 'master', 'slave', + 'profile-ds302', 'profile-ds302-modified', + 'profile-ds401', 'profile-ds401-modified', +]) +def test_printing_format_node(odpath, file): od = Node.LoadFile(odpath / (file + '.json')) + od.ID = 1 - out = list(GetPrintEntry(od)) - assert isinstance(out, list) - for line in out: + opts = FormatNodeOpts( + compact=False, short=False, unused=True, all=True, raw=False + ) + + lines = format_node(od, file, opts=opts) + assert isinstance(lines, types.GeneratorType) + for line in lines: + assert isinstance(line, str) + + opts = FormatNodeOpts( + compact=False, short=False, unused=True, all=True, raw=False + ) + + lines = format_node(od, file, index=[0x1000], opts=opts) + assert isinstance(lines, types.GeneratorType) + for line in lines: assert isinstance(line, str) + opts = FormatNodeOpts( + compact=False, short=False, unused=True, all=True, raw=False + ) + + with pytest.raises(ValueError) as exc: + lines = list(format_node(od, file, index=[0x5000], opts=opts)) + assert "Unknown index 20480" in str(exc.value) + + +@pytest.mark.parametrize("file", [ + 'master', 'slave', +]) +def test_printing_format_od_header(odpath, file): + + od = Node.LoadFile(odpath / (file + '.json')) + + fmt, info = format_od_header(od, 0x1000) + assert isinstance(fmt, str) + assert isinstance(info, dict) + out = fmt.format(**info) + assert isinstance(out, str) + + +@pytest.mark.parametrize("file", [ + 'master', 'slave', +]) +def test_printing_format_od_object(odpath, file): + + od = Node.LoadFile(odpath / (file + '.json')) + + lines = format_od_object(od, 0x1000) + assert isinstance(lines, types.GeneratorType) + for line in lines: + assert isinstance(line, str)