1"""Helpers for writing unit tests.""" 2 3from collections.abc import Iterable 4from io import BytesIO 5import os 6import re 7import shutil 8import sys 9import tempfile 10from unittest import TestCase as _TestCase 11from fontTools.config import Config 12from fontTools.misc.textTools import tobytes 13from fontTools.misc.xmlWriter import XMLWriter 14 15 16def parseXML(xmlSnippet): 17 """Parses a snippet of XML. 18 19 Input can be either a single string (unicode or UTF-8 bytes), or a 20 a sequence of strings. 21 22 The result is in the same format that would be returned by 23 XMLReader, but the parser imposes no constraints on the root 24 element so it can be called on small snippets of TTX files. 25 """ 26 # To support snippets with multiple elements, we add a fake root. 27 reader = TestXMLReader_() 28 xml = b"<root>" 29 if isinstance(xmlSnippet, bytes): 30 xml += xmlSnippet 31 elif isinstance(xmlSnippet, str): 32 xml += tobytes(xmlSnippet, "utf-8") 33 elif isinstance(xmlSnippet, Iterable): 34 xml += b"".join(tobytes(s, "utf-8") for s in xmlSnippet) 35 else: 36 raise TypeError( 37 "expected string or sequence of strings; found %r" 38 % type(xmlSnippet).__name__ 39 ) 40 xml += b"</root>" 41 reader.parser.Parse(xml, 0) 42 return reader.root[2] 43 44 45def parseXmlInto(font, parseInto, xmlSnippet): 46 parsed_xml = [e for e in parseXML(xmlSnippet.strip()) if not isinstance(e, str)] 47 for name, attrs, content in parsed_xml: 48 parseInto.fromXML(name, attrs, content, font) 49 parseInto.populateDefaults() 50 return parseInto 51 52 53class FakeFont: 54 def __init__(self, glyphs): 55 self.glyphOrder_ = glyphs 56 self.reverseGlyphOrderDict_ = {g: i for i, g in enumerate(glyphs)} 57 self.lazy = False 58 self.tables = {} 59 self.cfg = Config() 60 61 def __getitem__(self, tag): 62 return self.tables[tag] 63 64 def __setitem__(self, tag, table): 65 self.tables[tag] = table 66 67 def get(self, tag, default=None): 68 return self.tables.get(tag, default) 69 70 def getGlyphID(self, name): 71 return self.reverseGlyphOrderDict_[name] 72 73 def getGlyphIDMany(self, lst): 74 return [self.getGlyphID(gid) for gid in lst] 75 76 def getGlyphName(self, glyphID): 77 if glyphID < len(self.glyphOrder_): 78 return self.glyphOrder_[glyphID] 79 else: 80 return "glyph%.5d" % glyphID 81 82 def getGlyphNameMany(self, lst): 83 return [self.getGlyphName(gid) for gid in lst] 84 85 def getGlyphOrder(self): 86 return self.glyphOrder_ 87 88 def getReverseGlyphMap(self): 89 return self.reverseGlyphOrderDict_ 90 91 def getGlyphNames(self): 92 return sorted(self.getGlyphOrder()) 93 94 95class TestXMLReader_(object): 96 def __init__(self): 97 from xml.parsers.expat import ParserCreate 98 99 self.parser = ParserCreate() 100 self.parser.StartElementHandler = self.startElement_ 101 self.parser.EndElementHandler = self.endElement_ 102 self.parser.CharacterDataHandler = self.addCharacterData_ 103 self.root = None 104 self.stack = [] 105 106 def startElement_(self, name, attrs): 107 element = (name, attrs, []) 108 if self.stack: 109 self.stack[-1][2].append(element) 110 else: 111 self.root = element 112 self.stack.append(element) 113 114 def endElement_(self, name): 115 self.stack.pop() 116 117 def addCharacterData_(self, data): 118 self.stack[-1][2].append(data) 119 120 121def makeXMLWriter(newlinestr="\n"): 122 # don't write OS-specific new lines 123 writer = XMLWriter(BytesIO(), newlinestr=newlinestr) 124 # erase XML declaration 125 writer.file.seek(0) 126 writer.file.truncate() 127 return writer 128 129 130def getXML(func, ttFont=None): 131 """Call the passed toXML function and return the written content as a 132 list of lines (unicode strings). 133 Result is stripped of XML declaration and OS-specific newline characters. 134 """ 135 writer = makeXMLWriter() 136 func(writer, ttFont) 137 xml = writer.file.getvalue().decode("utf-8") 138 # toXML methods must always end with a writer.newline() 139 assert xml.endswith("\n") 140 return xml.splitlines() 141 142 143def stripVariableItemsFromTTX( 144 string: str, 145 ttLibVersion: bool = True, 146 checkSumAdjustment: bool = True, 147 modified: bool = True, 148 created: bool = True, 149 sfntVersion: bool = False, # opt-in only 150) -> str: 151 """Strip stuff like ttLibVersion, checksums, timestamps, etc. from TTX dumps.""" 152 # ttlib changes with the fontTools version 153 if ttLibVersion: 154 string = re.sub(' ttLibVersion="[^"]+"', "", string) 155 # sometimes (e.g. some subsetter tests) we don't care whether it's OTF or TTF 156 if sfntVersion: 157 string = re.sub(' sfntVersion="[^"]+"', "", string) 158 # head table checksum and creation and mod date changes with each save. 159 if checkSumAdjustment: 160 string = re.sub('<checkSumAdjustment value="[^"]+"/>', "", string) 161 if modified: 162 string = re.sub('<modified value="[^"]+"/>', "", string) 163 if created: 164 string = re.sub('<created value="[^"]+"/>', "", string) 165 return string 166 167 168class MockFont(object): 169 """A font-like object that automatically adds any looked up glyphname 170 to its glyphOrder.""" 171 172 def __init__(self): 173 self._glyphOrder = [".notdef"] 174 175 class AllocatingDict(dict): 176 def __missing__(reverseDict, key): 177 self._glyphOrder.append(key) 178 gid = len(reverseDict) 179 reverseDict[key] = gid 180 return gid 181 182 self._reverseGlyphOrder = AllocatingDict({".notdef": 0}) 183 self.lazy = False 184 185 def getGlyphID(self, glyph): 186 gid = self._reverseGlyphOrder[glyph] 187 return gid 188 189 def getReverseGlyphMap(self): 190 return self._reverseGlyphOrder 191 192 def getGlyphName(self, gid): 193 return self._glyphOrder[gid] 194 195 def getGlyphOrder(self): 196 return self._glyphOrder 197 198 199class TestCase(_TestCase): 200 def __init__(self, methodName): 201 _TestCase.__init__(self, methodName) 202 # Python 3 renamed assertRaisesRegexp to assertRaisesRegex, 203 # and fires deprecation warnings if a program uses the old name. 204 if not hasattr(self, "assertRaisesRegex"): 205 self.assertRaisesRegex = self.assertRaisesRegexp 206 207 208class DataFilesHandler(TestCase): 209 def setUp(self): 210 self.tempdir = None 211 self.num_tempfiles = 0 212 213 def tearDown(self): 214 if self.tempdir: 215 shutil.rmtree(self.tempdir) 216 217 def getpath(self, testfile): 218 folder = os.path.dirname(sys.modules[self.__module__].__file__) 219 return os.path.join(folder, "data", testfile) 220 221 def temp_dir(self): 222 if not self.tempdir: 223 self.tempdir = tempfile.mkdtemp() 224 225 def temp_font(self, font_path, file_name): 226 self.temp_dir() 227 temppath = os.path.join(self.tempdir, file_name) 228 shutil.copy2(font_path, temppath) 229 return temppath 230