/
misc.py
429 lines (346 loc) · 13.6 KB
/
misc.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
from __future__ import annotations
import collections.abc
import uuid
from abc import abstractmethod
from collections.abc import Mapping, MutableMapping
from copy import deepcopy
from functools import lru_cache
from typing import (
TYPE_CHECKING,
Any,
Dict,
Iterator,
NamedTuple,
Optional,
Sequence,
Set,
Tuple,
Type,
TypeVar,
cast,
)
import attr
from attr import define, field
from fontTools.misc.arrayTools import unionRect
from fontTools.misc.transform import Transform
from fontTools.pens.boundsPen import BoundsPen, ControlBoundsPen
from fontTools.ufoLib import UFOReader, UFOWriter
from ufoLib2.constants import OBJECT_LIBS_KEY
from ufoLib2.typing import Drawable, GlyphSet, HasIdentifier
if TYPE_CHECKING:
from cattr import GenConverter
class BoundingBox(NamedTuple):
"""Represents a bounding box as a tuple of (xMin, yMin, xMax, yMax)."""
xMin: float
yMin: float
xMax: float
yMax: float
def getBounds(drawable: Drawable, layer: GlyphSet | None) -> BoundingBox | None:
pen = BoundsPen(layer)
# raise 'KeyError' when a referenced component is missing from glyph set
pen.skipMissingComponents = False
drawable.draw(pen)
return None if pen.bounds is None else BoundingBox(*pen.bounds)
def getControlBounds(drawable: Drawable, layer: GlyphSet | None) -> BoundingBox | None:
pen = ControlBoundsPen(layer)
# raise 'KeyError' when a referenced component is missing from glyph set
pen.skipMissingComponents = False
drawable.draw(pen)
return None if pen.bounds is None else BoundingBox(*pen.bounds)
def unionBounds(
bounds1: BoundingBox | None, bounds2: BoundingBox | None
) -> BoundingBox | None:
if bounds1 is None:
return bounds2
if bounds2 is None:
return bounds1
return BoundingBox(*unionRect(bounds1, bounds2))
def _deepcopy_unlazify_attrs(self: Any, memo: Any) -> Any:
if self._lazy:
self.unlazify()
return self.__class__(
**{
(a.name if a.name[0] != "_" else a.name[1:]): deepcopy(
getattr(self, a.name), memo
)
for a in attr.fields(self.__class__)
if a.init
},
)
def _getstate_unlazify_attrs(self: Any) -> Dict[str, Any]:
if self._lazy:
self.unlazify()
return {
a.name: getattr(self, a.name) if a.init else a.default
for a in attr.fields(self.__class__)
}
_obj_setattr = object.__setattr__
# Since we override __getstate__, we must also override __setstate__.
# Below is adapted from `attrs._make._ClassBuilder._make_getstate_setstate` method:
# https://github.com/python-attrs/attrs/blob/36ed0204/src/attr/_make.py#L931-L937
def _setstate_attrs(self: Any, state: Dict[str, Any]) -> None:
_bound_setattr = _obj_setattr.__get__(self, attr.Attribute) # type: ignore
for a in attr.fields(self.__class__):
if a.name in state:
_bound_setattr(a.name, state[a.name])
def _object_lib(parent_lib: dict[str, Any], obj: HasIdentifier) -> dict[str, Any]:
if obj.identifier is None:
# Use UUID4 because it allows us to set a new identifier without
# checking if it's already used anywhere else and be right most
# of the time.
obj.identifier = str(uuid.uuid4())
object_libs: dict[str, Any]
if "public.objectLibs" not in parent_lib:
object_libs = parent_lib["public.objectLibs"] = {}
else:
object_libs = parent_lib["public.objectLibs"]
assert isinstance(object_libs, collections.abc.MutableMapping)
if obj.identifier in object_libs:
object_lib: dict[str, Any] = object_libs[obj.identifier]
return object_lib
lib: dict[str, Any] = {}
object_libs[obj.identifier] = lib
return lib
def _prune_object_libs(parent_lib: dict[str, Any], identifiers: set[str]) -> None:
"""Prune non-existing objects and empty libs from a lib's
public.objectLibs.
Empty object libs are pruned, but object identifiers stay.
"""
if OBJECT_LIBS_KEY not in parent_lib:
return
object_libs = parent_lib[OBJECT_LIBS_KEY]
parent_lib[OBJECT_LIBS_KEY] = {
k: v for k, v in object_libs.items() if k in identifiers and v
}
class DataPlaceholder(bytes):
"""Represents a sentinel value to signal a "lazy" DataSet item hasn't been loaded yet."""
_DATA_NOT_LOADED = DataPlaceholder(b"__UFOLIB2_DATA_NOT_LOADED__")
# Create a generic variable for mypy that can be 'DataStore' or any subclass.
Tds = TypeVar("Tds", bound="DataStore")
# For Python 3.7 compatibility.
if TYPE_CHECKING:
DataStoreMapping = MutableMapping[str, bytes]
else:
DataStoreMapping = MutableMapping
@define
class DataStore(DataStoreMapping):
"""Represents the base class for ImageSet and DataSet.
Both behave like a dictionary that loads its "values" lazily by default and only
differ in which reader and writer methods they call.
"""
_data: Dict[str, bytes] = field(factory=dict)
_lazy: Optional[bool] = field(default=False, kw_only=True, eq=False, init=False)
_reader: Optional[UFOReader] = field(default=None, init=False, repr=False, eq=False)
_scheduledForDeletion: Set[str] = field(
factory=set, init=False, repr=False, eq=False
)
def __eq__(self, other: object) -> bool:
# same as attrs-defined __eq__ method, only that it un-lazifies DataStores
# if needed.
# NOTE: Avoid isinstance check that mypy recognizes because we don't want to
# test possible Font subclasses for equality.
if other.__class__ is not self.__class__:
return NotImplemented
other = cast(DataStore, other)
for data_store in (self, other):
if data_store._lazy:
data_store.unlazify()
return self._data == other._data
def __ne__(self, other: object) -> bool:
result = self.__eq__(other)
if result is NotImplemented:
return NotImplemented
return not result
@classmethod
def read(cls: type[Tds], reader: UFOReader, lazy: bool = True) -> Tds:
"""Instantiate the data store from a :class:`fontTools.ufoLib.UFOReader`."""
self = cls()
for fileName in cls.list_contents(reader):
if lazy:
self._data[fileName] = _DATA_NOT_LOADED
else:
self._data[fileName] = cls.read_data(reader, fileName)
self._lazy = lazy
if lazy:
self._reader = reader
return self
@staticmethod
@abstractmethod
def list_contents(reader: UFOReader) -> list[str]:
"""Returns a list of POSIX filename strings in the data store."""
...
@staticmethod
@abstractmethod
def read_data(reader: UFOReader, filename: str) -> bytes:
"""Returns the data at filename within the store."""
...
@staticmethod
@abstractmethod
def write_data(writer: UFOWriter, filename: str, data: bytes) -> None:
"""Writes the data to filename within the store."""
...
@staticmethod
@abstractmethod
def remove_data(writer: UFOWriter, filename: str) -> None:
"""Remove the data at filename within the store."""
...
def unlazify(self) -> None:
"""Load all data into memory."""
if self._lazy and self._reader is not None:
for _ in self.items():
pass
self._lazy = False
__deepcopy__ = _deepcopy_unlazify_attrs
__getstate__ = _getstate_unlazify_attrs
__setstate__ = _setstate_attrs
# MutableMapping methods
def __len__(self) -> int:
return len(self._data)
def __iter__(self) -> Iterator[str]:
return iter(self._data)
def __getitem__(self, fileName: str) -> bytes:
data_object = self._data[fileName]
if data_object is _DATA_NOT_LOADED:
data_object = self._data[fileName] = self.read_data(self._reader, fileName)
return data_object
def __setitem__(self, fileName: str, data: bytes) -> None:
# should we forbid overwrite?
self._data[fileName] = data
if fileName in self._scheduledForDeletion:
self._scheduledForDeletion.remove(fileName)
def __delitem__(self, fileName: str) -> None:
del self._data[fileName]
self._scheduledForDeletion.add(fileName)
def __repr__(self) -> str:
n = len(self._data)
return "<{}.{} ({}) at {}>".format(
self.__class__.__module__,
self.__class__.__name__,
"empty" if n == 0 else "{} file{}".format(n, "s" if n > 1 else ""),
hex(id(self)),
)
def write(self, writer: UFOWriter, saveAs: bool | None = None) -> None:
"""Write the data store to a :class:`fontTools.ufoLib.UFOWriter`."""
if saveAs is None:
saveAs = self._reader is not writer
# if in-place, remove deleted data
if not saveAs:
for fileName in self._scheduledForDeletion:
self.remove_data(writer, fileName)
# Write data. Iterating over _data.items() prevents automatic loading.
for fileName, data in self._data.items():
# Two paths:
# 1) We are saving in-place. Only write to disk what is loaded, it
# might be modified.
# 2) We save elsewhere. Load all data files to write them back out.
# XXX: Move write_data into `if saveAs` branch to simplify code?
if data is _DATA_NOT_LOADED:
if saveAs:
data = self.read_data(self._reader, fileName)
self._data[fileName] = data
else:
continue
self.write_data(writer, fileName, data)
self._scheduledForDeletion = set()
if saveAs:
# all data was read by now, ref to reader no longer needed
self._reader = None
@property
def fileNames(self) -> list[str]:
"""Returns a list of filenames in the data store."""
return list(self._data.keys())
def _unstructure(self, converter: GenConverter) -> dict[str, str]:
# avoid encoding if converter supports bytes natively
test = converter.unstructure(b"\0")
if isinstance(test, bytes):
# mypy complains that 'Argument 1 to "dict" has incompatible type
# "DataStore"; expected "SupportsKeysAndGetItem[str, Dict[str, str]]"'.
# We _are_ a subclass of Mapping so we do support keys and getitem...
return dict(self) # type: ignore
elif not isinstance(test, str):
raise NotImplementedError(type(test))
data: dict[str, str] = {k: converter.unstructure(v) for k, v in self.items()}
# since we unpacked all data by now, we're no longer lazy
if self._lazy:
self._lazy = False
return data
@staticmethod
def _structure(
data: Mapping[str, Any],
cls: Type[DataStore],
converter: GenConverter,
) -> DataStore:
self = cls()
for k, v in data.items():
if isinstance(v, str):
self[k] = converter.structure(v, bytes)
elif isinstance(v, bytes):
self[k] = v
else:
raise TypeError(
f"Expected (base64) str or bytes, found: {type(v).__name__!r}"
)
return self
# For Python 3.7 compatibility.
if TYPE_CHECKING:
AttrDictMixinMapping = Mapping[str, Any]
else:
AttrDictMixinMapping = Mapping
_T = TypeVar("_T", bound="AttrDictMixin")
class AttrDictMixin(AttrDictMixinMapping):
"""Read attribute values using mapping interface.
For use with Anchors, Guidelines and WoffMetadata classes, where client code
expects them to behave as dict.
"""
# XXX: Use generics?
@classmethod
@lru_cache(maxsize=None)
def _key_to_attr_map(cls, reverse: bool = False) -> dict[str, str]:
result = {}
for a in attr.fields(cls):
attr_name = a.name
key = attr_name
if "rename_attr" in a.metadata:
key = a.metadata["rename_attr"]
if reverse:
result[attr_name] = key
else:
result[key] = attr_name
return result
def __getitem__(self, key: str) -> Any:
attr_name = self._key_to_attr_map()[key]
try:
value = getattr(self, attr_name)
except AttributeError as e:
raise KeyError(key) from e
if value is None:
raise KeyError(key)
return value
def __iter__(self) -> Iterator[str]:
key_map = self._key_to_attr_map(reverse=True)
for attr_name in attr.fields_dict(self.__class__):
if getattr(self, attr_name) is not None:
yield key_map[attr_name]
def __len__(self) -> int:
return sum(1 for _ in self)
@classmethod
def coerce_from_dict(cls: Type[_T], value: _T | Mapping[str, Any]) -> _T:
if isinstance(value, cls):
return value
elif isinstance(value, Mapping):
attr_map = cls._key_to_attr_map()
return cls(**{attr_map[k]: v for k, v in value.items()})
raise TypeError(
f"Expected {cls.__name__} or mapping, found: {type(value).__name__}"
)
@classmethod
def coerce_from_optional_dict(
cls: Type[_T], value: _T | Mapping[str, Any] | None
) -> _T | None:
if value is None:
return None
return cls.coerce_from_dict(value)
def _convert_transform(t: Transform | Sequence[float]) -> Transform:
"""Return a passed-in Transform as is, otherwise convert a sequence of
numbers to a Transform if need be."""
return t if isinstance(t, Transform) else Transform(*t)