diff --git a/README.md b/README.md index aee3625..8d6eeae 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Slack Status](https://empireslacking.herokuapp.com/badge.svg)](https://empireslacking.herokuapp.com) Graphtage is a commandline utility and [underlying library](https://trailofbits.github.io/graphtage/latest/library.html) -for semantically comparing and merging tree-like structures, such as JSON, XML, HTML, YAML, and CSS files. Its name is a +for semantically comparing and merging tree-like structures, such as JSON, XML, HTML, YAML, plist, and CSS files. Its name is a portmanteau of “graph” and “graftage”—the latter being the horticultural practice of joining two trees together such that they grow as one. diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html index 4d7675b..d23a622 100644 --- a/docs/_templates/layout.html +++ b/docs/_templates/layout.html @@ -17,6 +17,7 @@ {% endfor %} {% else %}
latest
+
0.2.5
0.2.4
0.2.3
0.2.2
diff --git a/graphtage/__init__.py b/graphtage/__init__.py index 2d14636..3bae418 100644 --- a/graphtage/__init__.py +++ b/graphtage/__init__.py @@ -7,7 +7,7 @@ from .version import __version__, VERSION_STRING from . import bounds, edits, expressions, fibonacci, formatter, levenshtein, matching, printer, \ search, sequences, tree, utils -from . import csv, json, xml, yaml +from . import csv, json, xml, yaml, plist import inspect diff --git a/graphtage/__main__.py b/graphtage/__main__.py index abb7068..f8964c7 100644 --- a/graphtage/__main__.py +++ b/graphtage/__main__.py @@ -241,6 +241,8 @@ def printer_type(*pos_args, **kwargs): mimetypes.suffix_map['.yaml'] = '.yml' if '.json5' not in mimetypes.types_map: mimetypes.add_type('application/json5', '.json5') + if '.plist' not in mimetypes.types_map: + mimetypes.add_type('application/x-plist', '.plist') if args.from_mime is not None: from_mime = args.from_mime diff --git a/graphtage/plist.py b/graphtage/plist.py new file mode 100644 index 0000000..f03cb69 --- /dev/null +++ b/graphtage/plist.py @@ -0,0 +1,171 @@ +"""A :class:`graphtage.Filetype` for parsing, diffing, and rendering Apple plist files.""" +import os +from xml.parsers.expat import ExpatError +from typing import Optional, Tuple, Union + +from plistlib import dumps, load + +from . import json +from .edits import Edit, EditCollection, Match +from .graphtage import BoolNode, BuildOptions, Filetype, FloatNode, KeyValuePairNode, IntegerNode, LeafNode, StringNode +from .printer import Printer +from .sequences import SequenceFormatter, SequenceNode +from .tree import ContainerNode, GraphtageFormatter, TreeNode + + +class PLISTNode(ContainerNode): + def __init__(self, root: TreeNode): + self.root: TreeNode = root + + def to_obj(self): + return self.root.to_obj() + + def edits(self, node: 'TreeNode') -> Edit: + if isinstance(node, PLISTNode): + return EditCollection( + from_node=self, + to_node=node, + edits=iter(( + Match(self, node, 0), + self.root.edits(node.root) + )), + collection=list, + add_to_collection=list.append, + explode_edits=False + ) + return self.root.edits(node) + + def calculate_total_size(self) -> int: + return self.root.calculate_total_size() + + def print(self, printer: Printer): + printer.write(PLIST_HEADER) + self.root.print(printer) + printer.write(PLIST_FOOTER) + + def __iter__(self): + yield self.root + + def __len__(self) -> int: + return 1 + + +def build_tree(path: str, options: Optional[BuildOptions] = None, *args, **kwargs) -> PLISTNode: + """Constructs a PLIST tree from an PLIST file.""" + with open(path, "rb") as stream: + data = load(stream) + return PLISTNode(json.build_tree(data, options=options, *args, **kwargs)) + + +class PLISTSequenceFormatter(SequenceFormatter): + is_partial = True + + def __init__(self): + super().__init__('', '', '') + + def print_SequenceNode(self, printer: Printer, node: SequenceNode): + self.parent.print(printer, node) + + def print_ListNode(self, printer: Printer, *args, **kwargs): + printer.write("") + super().print_SequenceNode(printer, *args, **kwargs) + printer.write("") + + def print_MultiSetNode(self, printer: Printer, *args, **kwargs): + printer.write("") + super().print_SequenceNode(printer, *args, **kwargs) + printer.write("") + + def print_KeyValuePairNode(self, printer: Printer, node: KeyValuePairNode): + printer.write("") + if isinstance(node.key, StringNode): + printer.write(node.key.object) + else: + self.print(printer, node.key) + printer.write("") + printer.newline() + self.print(printer, node.value) + + print_MappingNode = print_MultiSetNode + + +def _plist_header_footer() -> Tuple[str, str]: + string = "1234567890" + encoded = dumps(string).decode("utf-8") + expected = f"{string}" + body_offset = encoded.find(expected) + if body_offset <= 0: + raise ValueError("Unexpected plist encoding!") + return encoded[:body_offset], encoded[body_offset+len(expected):] + + +PLIST_HEADER: str +PLIST_FOOTER: str +PLIST_HEADER, PLIST_FOOTER = _plist_header_footer() + + +class PLISTFormatter(GraphtageFormatter): + sub_format_types = [PLISTSequenceFormatter] + + def print(self, printer: Printer, *args, **kwargs): + # PLIST uses an eight-space indent + printer.indent_str = " " * 8 + super().print(printer, *args, **kwargs) + + @staticmethod + def write_obj(printer: Printer, obj): + encoded = dumps(obj).decode("utf-8") + printer.write(encoded[len(PLIST_HEADER):-len(PLIST_FOOTER)]) + + def print_StringNode(self, printer: Printer, node: StringNode): + printer.write(f"{node.object}") + + def print_IntegerNode(self, printer: Printer, node: IntegerNode): + printer.write(f"{node.object}") + + def print_FloatNode(self, printer: Printer, node: FloatNode): + printer.write(f"{node.object}") + + def print_BoolNode(self, printer, node: BoolNode): + if node.object: + printer.write("") + else: + printer.write("") + + def print_LeafNode(self, printer: Printer, node: LeafNode): + self.write_obj(printer, node.object) + + def print_PLISTNode(self, printer: Printer, node: PLISTNode): + printer.write(PLIST_HEADER) + self.print(printer, node.root) + printer.write(PLIST_FOOTER) + + +class PLIST(Filetype): + """The Apple PLIST filetype.""" + def __init__(self): + """Initializes the PLIST file type. + + By default, PLIST associates itself with the "plist" and "application/x-plist" MIME types. + + """ + super().__init__( + 'plist', + 'application/x-plist' + ) + + def build_tree(self, path: str, options: Optional[BuildOptions] = None) -> TreeNode: + tree = build_tree(path=path, options=options) + for node in tree.dfs(): + if isinstance(node, StringNode): + node.quoted = False + return tree + + def build_tree_handling_errors(self, path: str, options: Optional[BuildOptions] = None) -> Union[str, TreeNode]: + try: + return self.build_tree(path=path, options=options) + except ExpatError as ee: + return f'Error parsing {os.path.basename(path)}: {ee})' + + def get_default_formatter(self) -> PLISTFormatter: + return PLISTFormatter.DEFAULT_INSTANCE diff --git a/graphtage/version.py b/graphtage/version.py index d299f36..8051533 100644 --- a/graphtage/version.py +++ b/graphtage/version.py @@ -48,7 +48,7 @@ def git_branch() -> Optional[str]: return None -DEV_BUILD = True +DEV_BUILD = False """Sets whether this build is a development build. This should only be set to :const:`False` to coincide with a release. It should *always* be :const:`False` before diff --git a/test/test_formatting.py b/test/test_formatting.py index 967324a..bc65098 100644 --- a/test/test_formatting.py +++ b/test/test_formatting.py @@ -1,5 +1,6 @@ import csv import json +import plistlib import random from functools import partial, wraps from io import StringIO @@ -44,8 +45,10 @@ def wrapper(self: 'TestFormatting'): formatter = filetype.get_default_formatter() for _ in trange(iterations): - orig_obj, str_representation = test_func(self) - with graphtage.utils.Tempfile(str_representation.encode('utf-8')) as t: + orig_obj, representation = test_func(self) + if isinstance(representation, str): + representation = representation.encode("utf-8") + with graphtage.utils.Tempfile(representation) as t: tree = filetype.build_tree(t) stream = StringIO() printer = graphtage.printer.Printer(out_stream=stream, ansi_color=False) @@ -58,7 +61,7 @@ def wrapper(self: 'TestFormatting'): self.fail(f"""{filetype_name.upper()} decode error {e}: Original object: {orig_obj!r} Expected format: -{str_representation!s} +{representation.decode("utf-8")} Actual format: {formatted_str!s}""") if test_equality: @@ -245,3 +248,8 @@ def test_yaml_formatting(self): s = StringIO() yaml.dump(orig_obj, s, Dumper=graphtage.yaml.Dumper) return orig_obj, s.getvalue() + + @filetype_test(test_equality=False) + def test_plist_formatting(self): + orig_obj = TestFormatting.make_random_obj(force_string_keys=True, exclude_bytes=frozenset('<>/\n&?|@{}[]')) + return orig_obj, plistlib.dumps(orig_obj)