Merge pull request #2061 from fonttools/plistlib-typing

Add typing info to plistlib
This commit is contained in:
Cosimo Lupo 2020-09-21 17:24:25 +01:00 committed by GitHub
commit b913fac4ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 228 additions and 137 deletions

View File

@ -18,6 +18,9 @@ branches:
matrix: matrix:
fast_finish: true fast_finish: true
include: include:
- python: 3.6
env:
- TOXENV=mypy
- python: 3.6 - python: 3.6
env: env:
- TOXENV=py36-cov,package_readme - TOXENV=py36-cov,package_readme

View File

@ -1,13 +1,25 @@
import collections.abc
import sys import sys
import re import re
from typing import (
Any,
Callable,
Dict,
List,
Mapping,
MutableMapping,
Optional,
Sequence,
Type,
Union,
IO,
)
import warnings import warnings
from io import BytesIO from io import BytesIO
from datetime import datetime from datetime import datetime
from base64 import b64encode, b64decode from base64 import b64encode, b64decode
from numbers import Integral from numbers import Integral
from types import SimpleNamespace from types import SimpleNamespace
from collections.abc import Mapping
from functools import singledispatch from functools import singledispatch
from fontTools.misc import etree from fontTools.misc import etree
@ -38,6 +50,7 @@ PLIST_DOCTYPE = (
b'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">' b'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
) )
# Date should conform to a subset of ISO 8601: # Date should conform to a subset of ISO 8601:
# YYYY '-' MM '-' DD 'T' HH ':' MM ':' SS 'Z' # YYYY '-' MM '-' DD 'T' HH ':' MM ':' SS 'Z'
_date_parser = re.compile( _date_parser = re.compile(
@ -48,23 +61,27 @@ _date_parser = re.compile(
r"(?::(?P<minute>\d\d)" r"(?::(?P<minute>\d\d)"
r"(?::(?P<second>\d\d))" r"(?::(?P<second>\d\d))"
r"?)?)?)?)?Z", r"?)?)?)?)?Z",
re.ASCII re.ASCII,
) )
def _date_from_string(s): def _date_from_string(s: str) -> datetime:
order = ("year", "month", "day", "hour", "minute", "second") order = ("year", "month", "day", "hour", "minute", "second")
gd = _date_parser.match(s).groupdict() m = _date_parser.match(s)
if m is None:
raise ValueError(f"Expected ISO 8601 date string, but got '{s:r}'.")
gd = m.groupdict()
lst = [] lst = []
for key in order: for key in order:
val = gd[key] val = gd[key]
if val is None: if val is None:
break break
lst.append(int(val)) lst.append(int(val))
return datetime(*lst) # NOTE: mypy doesn't know that lst is 6 elements long.
return datetime(*lst) # type:ignore
def _date_to_string(d): def _date_to_string(d: datetime) -> str:
return "%04d-%02d-%02dT%02d:%02d:%02dZ" % ( return "%04d-%02d-%02dT%02d:%02d:%02dZ" % (
d.year, d.year,
d.month, d.month,
@ -75,7 +92,45 @@ def _date_to_string(d):
) )
def _encode_base64(data, maxlinelength=76, indent_level=1): class Data:
"""Represents binary data when ``use_builtin_types=False.``
This class wraps binary data loaded from a plist file when the
``use_builtin_types`` argument to the loading function (:py:func:`fromtree`,
:py:func:`load`, :py:func:`loads`) is false.
The actual binary data is retrieved using the ``data`` attribute.
"""
def __init__(self, data: bytes) -> None:
if not isinstance(data, bytes):
raise TypeError("Expected bytes, found %s" % type(data).__name__)
self.data = data
@classmethod
def fromBase64(cls, data: Union[bytes, str]) -> "Data":
return cls(b64decode(data))
def asBase64(self, maxlinelength: int = 76, indent_level: int = 1) -> bytes:
return _encode_base64(
self.data, maxlinelength=maxlinelength, indent_level=indent_level
)
def __eq__(self, other: Any) -> bool:
if isinstance(other, self.__class__):
return self.data == other.data
elif isinstance(other, bytes):
return self.data == other
else:
return NotImplemented
def __repr__(self) -> str:
return "%s(%s)" % (self.__class__.__name__, repr(self.data))
def _encode_base64(
data: bytes, maxlinelength: Optional[int] = 76, indent_level: int = 1
) -> bytes:
data = b64encode(data) data = b64encode(data)
if data and maxlinelength: if data and maxlinelength:
# split into multiple lines right-justified to 'maxlinelength' chars # split into multiple lines right-justified to 'maxlinelength' chars
@ -90,44 +145,24 @@ def _encode_base64(data, maxlinelength=76, indent_level=1):
return data return data
class Data: # Mypy does not support recursive type aliases as of 0.782, Pylance does.
"""Represents binary data when ``use_builtin_types=False.`` # https://github.com/python/mypy/issues/731
# https://devblogs.microsoft.com/python/pylance-introduces-five-new-features-that-enable-type-magic-for-python-developers/#1-support-for-recursive-type-aliases
This class wraps binary data loaded from a plist file when the PlistEncodable = Union[
``use_builtin_types`` argument to the loading function (:py:func:`fromtree`, bool,
:py:func:`load`, :py:func:`loads`) is false. bytes,
Data,
The actual binary data is retrieved using the ``data`` attribute. datetime,
""" float,
int,
def __init__(self, data): Mapping[str, Any],
if not isinstance(data, bytes): Sequence[Any],
raise TypeError("Expected bytes, found %s" % type(data).__name__) str,
self.data = data ]
@classmethod
def fromBase64(cls, data):
return cls(b64decode(data))
def asBase64(self, maxlinelength=76, indent_level=1):
return _encode_base64(
self.data, maxlinelength=maxlinelength, indent_level=indent_level
)
def __eq__(self, other):
if isinstance(other, self.__class__):
return self.data == other.data
elif isinstance(other, bytes):
return self.data == other
else:
return NotImplemented
def __repr__(self):
return "%s(%s)" % (self.__class__.__name__, repr(self.data))
class PlistTarget: class PlistTarget:
""" Event handler using the ElementTree Target API that can be """Event handler using the ElementTree Target API that can be
passed to a XMLParser to produce property list objects from XML. passed to a XMLParser to produce property list objects from XML.
It is based on the CPython plistlib module's _PlistParser class, It is based on the CPython plistlib module's _PlistParser class,
but does not use the expat parser. but does not use the expat parser.
@ -148,10 +183,14 @@ class PlistTarget:
http://lxml.de/parsing.html#the-target-parser-interface http://lxml.de/parsing.html#the-target-parser-interface
""" """
def __init__(self, use_builtin_types=None, dict_type=dict): def __init__(
self.stack = [] self,
self.current_key = None use_builtin_types: Optional[bool] = None,
self.root = None dict_type: Type[MutableMapping[str, Any]] = dict,
) -> None:
self.stack: List[PlistEncodable] = []
self.current_key: Optional[str] = None
self.root: Optional[PlistEncodable] = None
if use_builtin_types is None: if use_builtin_types is None:
self._use_builtin_types = USE_BUILTIN_TYPES self._use_builtin_types = USE_BUILTIN_TYPES
else: else:
@ -164,40 +203,44 @@ class PlistTarget:
self._use_builtin_types = use_builtin_types self._use_builtin_types = use_builtin_types
self._dict_type = dict_type self._dict_type = dict_type
def start(self, tag, attrib): def start(self, tag: str, attrib: Mapping[str, str]) -> None:
self._data = [] self._data: List[str] = []
handler = _TARGET_START_HANDLERS.get(tag) handler = _TARGET_START_HANDLERS.get(tag)
if handler is not None: if handler is not None:
handler(self) handler(self)
def end(self, tag): def end(self, tag: str) -> None:
handler = _TARGET_END_HANDLERS.get(tag) handler = _TARGET_END_HANDLERS.get(tag)
if handler is not None: if handler is not None:
handler(self) handler(self)
def data(self, data): def data(self, data: str) -> None:
self._data.append(data) self._data.append(data)
def close(self): def close(self) -> PlistEncodable:
if self.root is None:
raise ValueError("No root set.")
return self.root return self.root
# helpers # helpers
def add_object(self, value): def add_object(self, value: PlistEncodable) -> None:
if self.current_key is not None: if self.current_key is not None:
if not isinstance(self.stack[-1], type({})): stack_top = self.stack[-1]
raise ValueError("unexpected element: %r" % self.stack[-1]) if not isinstance(stack_top, collections.abc.MutableMapping):
self.stack[-1][self.current_key] = value raise ValueError("unexpected element: %r" % stack_top)
stack_top[self.current_key] = value
self.current_key = None self.current_key = None
elif not self.stack: elif not self.stack:
# this is the root object # this is the root object
self.root = value self.root = value
else: else:
if not isinstance(self.stack[-1], type([])): stack_top = self.stack[-1]
raise ValueError("unexpected element: %r" % self.stack[-1]) if not isinstance(stack_top, list):
self.stack[-1].append(value) raise ValueError("unexpected element: %r" % stack_top)
stack_top.append(value)
def get_data(self): def get_data(self) -> str:
data = "".join(self._data) data = "".join(self._data)
self._data = [] self._data = []
return data return data
@ -206,68 +249,71 @@ class PlistTarget:
# event handlers # event handlers
def start_dict(self): def start_dict(self: PlistTarget) -> None:
d = self._dict_type() d = self._dict_type()
self.add_object(d) self.add_object(d)
self.stack.append(d) self.stack.append(d)
def end_dict(self): def end_dict(self: PlistTarget) -> None:
if self.current_key: if self.current_key:
raise ValueError("missing value for key '%s'" % self.current_key) raise ValueError("missing value for key '%s'" % self.current_key)
self.stack.pop() self.stack.pop()
def end_key(self): def end_key(self: PlistTarget) -> None:
if self.current_key or not isinstance(self.stack[-1], type({})): if self.current_key or not isinstance(self.stack[-1], collections.abc.Mapping):
raise ValueError("unexpected key") raise ValueError("unexpected key")
self.current_key = self.get_data() self.current_key = self.get_data()
def start_array(self): def start_array(self: PlistTarget) -> None:
a = [] a: List[PlistEncodable] = []
self.add_object(a) self.add_object(a)
self.stack.append(a) self.stack.append(a)
def end_array(self): def end_array(self: PlistTarget) -> None:
self.stack.pop() self.stack.pop()
def end_true(self): def end_true(self: PlistTarget) -> None:
self.add_object(True) self.add_object(True)
def end_false(self): def end_false(self: PlistTarget) -> None:
self.add_object(False) self.add_object(False)
def end_integer(self): def end_integer(self: PlistTarget) -> None:
self.add_object(int(self.get_data())) self.add_object(int(self.get_data()))
def end_real(self): def end_real(self: PlistTarget) -> None:
self.add_object(float(self.get_data())) self.add_object(float(self.get_data()))
def end_string(self): def end_string(self: PlistTarget) -> None:
self.add_object(self.get_data()) self.add_object(self.get_data())
def end_data(self): def end_data(self: PlistTarget) -> None:
if self._use_builtin_types: if self._use_builtin_types:
self.add_object(b64decode(self.get_data())) self.add_object(b64decode(self.get_data()))
else: else:
self.add_object(Data.fromBase64(self.get_data())) self.add_object(Data.fromBase64(self.get_data()))
def end_date(self): def end_date(self: PlistTarget) -> None:
self.add_object(_date_from_string(self.get_data())) self.add_object(_date_from_string(self.get_data()))
_TARGET_START_HANDLERS = {"dict": start_dict, "array": start_array} _TARGET_START_HANDLERS: Dict[str, Callable[[PlistTarget], None]] = {
"dict": start_dict,
"array": start_array,
}
_TARGET_END_HANDLERS = { _TARGET_END_HANDLERS: Dict[str, Callable[[PlistTarget], None]] = {
"dict": end_dict, "dict": end_dict,
"array": end_array, "array": end_array,
"key": end_key, "key": end_key,
@ -284,39 +330,37 @@ _TARGET_END_HANDLERS = {
# functions to build element tree from plist data # functions to build element tree from plist data
def _string_element(value, ctx): def _string_element(value: str, ctx: SimpleNamespace) -> etree.Element:
el = etree.Element("string") el = etree.Element("string")
el.text = value el.text = value
return el return el
def _bool_element(value, ctx): def _bool_element(value: bool, ctx: SimpleNamespace) -> etree.Element:
if value: if value:
return etree.Element("true") return etree.Element("true")
else:
return etree.Element("false") return etree.Element("false")
def _integer_element(value, ctx): def _integer_element(value: int, ctx: SimpleNamespace) -> etree.Element:
if -1 << 63 <= value < 1 << 64: if -1 << 63 <= value < 1 << 64:
el = etree.Element("integer") el = etree.Element("integer")
el.text = "%d" % value el.text = "%d" % value
return el return el
else:
raise OverflowError(value) raise OverflowError(value)
def _real_element(value, ctx): def _real_element(value: float, ctx: SimpleNamespace) -> etree.Element:
el = etree.Element("real") el = etree.Element("real")
el.text = repr(value) el.text = repr(value)
return el return el
def _dict_element(d, ctx): def _dict_element(d: Mapping[str, PlistEncodable], ctx: SimpleNamespace) -> etree.Element:
el = etree.Element("dict") el = etree.Element("dict")
items = d.items() items = d.items()
if ctx.sort_keys: if ctx.sort_keys:
items = sorted(items) items = sorted(items) # type: ignore
ctx.indent_level += 1 ctx.indent_level += 1
for key, value in items: for key, value in items:
if not isinstance(key, str): if not isinstance(key, str):
@ -330,7 +374,7 @@ def _dict_element(d, ctx):
return el return el
def _array_element(array, ctx): def _array_element(array: Sequence[PlistEncodable], ctx: SimpleNamespace) -> etree.Element:
el = etree.Element("array") el = etree.Element("array")
if len(array) == 0: if len(array) == 0:
return el return el
@ -341,15 +385,16 @@ def _array_element(array, ctx):
return el return el
def _date_element(date, ctx): def _date_element(date: datetime, ctx: SimpleNamespace) -> etree.Element:
el = etree.Element("date") el = etree.Element("date")
el.text = _date_to_string(date) el.text = _date_to_string(date)
return el return el
def _data_element(data, ctx): def _data_element(data: bytes, ctx: SimpleNamespace) -> etree.Element:
el = etree.Element("data") el = etree.Element("data")
el.text = _encode_base64( # NOTE: mypy is confused about whether el.text should be str or bytes.
el.text = _encode_base64( # type: ignore
data, data,
maxlinelength=(76 if ctx.pretty_print else None), maxlinelength=(76 if ctx.pretty_print else None),
indent_level=ctx.indent_level, indent_level=ctx.indent_level,
@ -357,7 +402,7 @@ def _data_element(data, ctx):
return el return el
def _string_or_data_element(raw_bytes, ctx): def _string_or_data_element(raw_bytes: bytes, ctx: SimpleNamespace) -> etree.Element:
if ctx.use_builtin_types: if ctx.use_builtin_types:
return _data_element(raw_bytes, ctx) return _data_element(raw_bytes, ctx)
else: else:
@ -365,21 +410,26 @@ def _string_or_data_element(raw_bytes, ctx):
string = raw_bytes.decode(encoding="ascii", errors="strict") string = raw_bytes.decode(encoding="ascii", errors="strict")
except UnicodeDecodeError: except UnicodeDecodeError:
raise ValueError( raise ValueError(
"invalid non-ASCII bytes; use unicode string instead: %r" "invalid non-ASCII bytes; use unicode string instead: %r" % raw_bytes
% raw_bytes
) )
return _string_element(string, ctx) return _string_element(string, ctx)
# The following is probably not entirely correct. The signature should take `Any`
# and return `NoReturn`. At the time of this writing, neither mypy nor Pyright
# can deal with singledispatch properly and will apply the signature of the base
# function to all others. Being slightly dishonest makes it type-check and return
# usable typing information for the optimistic case.
@singledispatch @singledispatch
def _make_element(value, ctx): def _make_element(value: PlistEncodable, ctx: SimpleNamespace) -> etree.Element:
raise TypeError("unsupported type: %s" % type(value)) raise TypeError("unsupported type: %s" % type(value))
_make_element.register(str)(_string_element) _make_element.register(str)(_string_element)
_make_element.register(bool)(_bool_element) _make_element.register(bool)(_bool_element)
_make_element.register(Integral)(_integer_element) _make_element.register(Integral)(_integer_element)
_make_element.register(float)(_real_element) _make_element.register(float)(_real_element)
_make_element.register(Mapping)(_dict_element) _make_element.register(collections.abc.Mapping)(_dict_element)
_make_element.register(list)(_array_element) _make_element.register(list)(_array_element)
_make_element.register(tuple)(_array_element) _make_element.register(tuple)(_array_element)
_make_element.register(datetime)(_date_element) _make_element.register(datetime)(_date_element)
@ -393,13 +443,13 @@ _make_element.register(Data)(lambda v, ctx: _data_element(v.data, ctx))
def totree( def totree(
value, value: PlistEncodable,
sort_keys=True, sort_keys: bool = True,
skipkeys=False, skipkeys: bool = False,
use_builtin_types=None, use_builtin_types: Optional[bool] = None,
pretty_print=True, pretty_print: bool = True,
indent_level=1, indent_level: int = 1,
): ) -> etree.Element:
"""Convert a value derived from a plist into an XML tree. """Convert a value derived from a plist into an XML tree.
Args: Args:
@ -439,7 +489,11 @@ def totree(
return _make_element(value, context) return _make_element(value, context)
def fromtree(tree, use_builtin_types=None, dict_type=dict): def fromtree(
tree: etree.Element,
use_builtin_types: Optional[bool] = None,
dict_type: Type[MutableMapping[str, Any]] = dict,
) -> Any:
"""Convert an XML tree to a plist structure. """Convert an XML tree to a plist structure.
Args: Args:
@ -451,9 +505,7 @@ def fromtree(tree, use_builtin_types=None, dict_type=dict):
Returns: An object (usually a dictionary). Returns: An object (usually a dictionary).
""" """
target = PlistTarget( target = PlistTarget(use_builtin_types=use_builtin_types, dict_type=dict_type)
use_builtin_types=use_builtin_types, dict_type=dict_type
)
for action, element in etree.iterwalk(tree, events=("start", "end")): for action, element in etree.iterwalk(tree, events=("start", "end")):
if action == "start": if action == "start":
target.start(element.tag, element.attrib) target.start(element.tag, element.attrib)
@ -469,7 +521,11 @@ def fromtree(tree, use_builtin_types=None, dict_type=dict):
# python3 plistlib API # python3 plistlib API
def load(fp, use_builtin_types=None, dict_type=dict): def load(
fp: IO[bytes],
use_builtin_types: Optional[bool] = None,
dict_type: Type[MutableMapping[str, Any]] = dict,
) -> Any:
"""Load a plist file into an object. """Load a plist file into an object.
Args: Args:
@ -485,13 +541,9 @@ def load(fp, use_builtin_types=None, dict_type=dict):
""" """
if not hasattr(fp, "read"): if not hasattr(fp, "read"):
raise AttributeError( raise AttributeError("'%s' object has no attribute 'read'" % type(fp).__name__)
"'%s' object has no attribute 'read'" % type(fp).__name__ target = PlistTarget(use_builtin_types=use_builtin_types, dict_type=dict_type)
) parser = etree.XMLParser(target=target) # type: ignore
target = PlistTarget(
use_builtin_types=use_builtin_types, dict_type=dict_type
)
parser = etree.XMLParser(target=target)
result = etree.parse(fp, parser=parser) result = etree.parse(fp, parser=parser)
# lxml returns the target object directly, while ElementTree wraps # lxml returns the target object directly, while ElementTree wraps
# it as the root of an ElementTree object # it as the root of an ElementTree object
@ -501,11 +553,15 @@ def load(fp, use_builtin_types=None, dict_type=dict):
return result return result
def loads(value, use_builtin_types=None, dict_type=dict): def loads(
value: bytes,
use_builtin_types: Optional[bool] = None,
dict_type: Type[MutableMapping[str, Any]] = dict,
) -> Any:
"""Load a plist file from a string into an object. """Load a plist file from a string into an object.
Args: Args:
value: A string containing a plist. value: A bytes string containing a plist.
use_builtin_types: If True, binary data is deserialized to use_builtin_types: If True, binary data is deserialized to
bytes strings. If False, it is wrapped in :py:class:`Data` bytes strings. If False, it is wrapped in :py:class:`Data`
objects. Defaults to True if not provided. Deprecated. objects. Defaults to True if not provided. Deprecated.
@ -521,13 +577,13 @@ def loads(value, use_builtin_types=None, dict_type=dict):
def dump( def dump(
value, value: PlistEncodable,
fp, fp: IO[bytes],
sort_keys=True, sort_keys: bool = True,
skipkeys=False, skipkeys: bool = False,
use_builtin_types=None, use_builtin_types: Optional[bool] = None,
pretty_print=True, pretty_print: bool = True,
): ) -> None:
"""Write a Python object to a plist file. """Write a Python object to a plist file.
Args: Args:
@ -553,9 +609,7 @@ def dump(
""" """
if not hasattr(fp, "write"): if not hasattr(fp, "write"):
raise AttributeError( raise AttributeError("'%s' object has no attribute 'write'" % type(fp).__name__)
"'%s' object has no attribute 'write'" % type(fp).__name__
)
root = etree.Element("plist", version="1.0") root = etree.Element("plist", version="1.0")
el = totree( el = totree(
value, value,
@ -574,18 +628,21 @@ def dump(
else: else:
header = XML_DECLARATION + PLIST_DOCTYPE header = XML_DECLARATION + PLIST_DOCTYPE
fp.write(header) fp.write(header)
tree.write( tree.write( # type: ignore
fp, encoding="utf-8", pretty_print=pretty_print, xml_declaration=False fp,
encoding="utf-8",
pretty_print=pretty_print,
xml_declaration=False,
) )
def dumps( def dumps(
value, value: PlistEncodable,
sort_keys=True, sort_keys: bool = True,
skipkeys=False, skipkeys: bool = False,
use_builtin_types=None, use_builtin_types: Optional[bool] = None,
pretty_print=True, pretty_print: bool = True,
): ) -> bytes:
"""Write a Python object to a string in plist format. """Write a Python object to a string in plist format.
Args: Args:

View File

View File

@ -13,6 +13,8 @@ include *requirements.txt
include tox.ini include tox.ini
include run-tests.sh include run-tests.sh
recursive-include Lib/fontTools py.typed
include .appveyor.yml include .appveyor.yml
include .codecov.yml include .codecov.yml
include .coveragerc include .coveragerc

View File

@ -2,3 +2,4 @@ pytest>=3.0
tox>=2.5 tox>=2.5
bump2version>=0.5.6 bump2version>=0.5.6
sphinx>=1.5.5 sphinx>=1.5.5
mypy>=0.782

21
mypy.ini Normal file
View File

@ -0,0 +1,21 @@
[mypy]
python_version = 3.6
files = Lib/fontTools/misc/plistlib
follow_imports = silent
ignore_missing_imports = True
warn_redundant_casts = True
warn_unused_configs = True
warn_unused_ignores = True
[mypy-fontTools.misc.plistlib]
check_untyped_defs = True
disallow_any_generics = True
disallow_incomplete_defs = True
disallow_subclassing_any = True
disallow_untyped_decorators = True
disallow_untyped_calls = False
disallow_untyped_defs = True
no_implicit_optional = True
no_implicit_reexport = True
strict_equality = True
warn_return_any = True

View File

@ -1,6 +1,6 @@
[tox] [tox]
minversion = 3.0 minversion = 3.0
envlist = py3{6,7,8}-cov, htmlcov envlist = mypy, py3{6,7,8}-cov, htmlcov
skip_missing_interpreters=true skip_missing_interpreters=true
[testenv] [testenv]
@ -33,6 +33,13 @@ commands =
coverage combine coverage combine
coverage html coverage html
[testenv:mypy]
deps =
-r dev-requirements.txt
skip_install = true
commands =
mypy
[testenv:codecov] [testenv:codecov]
passenv = * passenv = *
deps = deps =