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)