Repository: fonttools/fonttools

Issue:
Make xmlReader accept file object
See:
https://github.com/tchollingsworth/ttname/pull/1#issuecomment-154435172



Discussion:

Resolving patch:
diff --git a/Lib/fontTools/misc/xmlReader.py b/Lib/fontTools/misc/xmlReader.py
--- a/Lib/fontTools/misc/xmlReader.py
+++ b/Lib/fontTools/misc/xmlReader.py
@@ -13,9 +13,17 @@ class TTXParseError(Exception): pass
 
 class XMLReader(object):
 
-	def __init__(self, fileName, ttFont, progress=None, quiet=False):
+	def __init__(self, fileOrPath, ttFont, progress=None, quiet=False):
+		if fileOrPath == '-':
+			fileOrPath = sys.stdin
+		if not hasattr(fileOrPath, "read"):
+			self.file = open(fileOrPath, "rb")
+			self._closeStream = True
+		else:
+			# assume readable file object
+			self.file = fileOrPath
+			self._closeStream = False
 		self.ttFont = ttFont
-		self.fileName = fileName
 		self.progress = progress
 		self.quiet = quiet
 		self.root = None
@@ -24,11 +32,16 @@ def __init__(self, fileName, ttFont, progress=None, quiet=False):
 
 	def read(self):
 		if self.progress:
-			import stat
-			self.progress.set(0, os.stat(self.fileName)[stat.ST_SIZE] // 100 or 1)
-		file = open(self.fileName, 'rb')
-		self._parseFile(file)
-		file.close()
+			self.file.seek(0, 2)
+			fileSize = self.file.tell()
+			self.progress.set(0, fileSize // 100 or 1)
+			self.file.seek(0)
+		self._parseFile(self.file)
+		if self._closeStream:
+			self.close()
+
+	def close(self):
+		self.file.close()
 
 	def _parseFile(self, file):
 		from xml.parsers.expat import ParserCreate
@@ -63,7 +76,13 @@ def _startElementHandler(self, name, attrs):
 		elif stackSize == 1:
 			subFile = attrs.get("src")
 			if subFile is not None:
-				subFile = os.path.join(os.path.dirname(self.fileName), subFile)
+				if hasattr(self.file, 'name'):
+					# if file has a name, get its parent directory
+					dirname = os.path.dirname(self.file.name)
+				else:
+					# else fall back to using the current working directory
+					dirname = os.getcwd()
+				subFile = os.path.join(dirname, subFile)
 				subReader = XMLReader(subFile, self.ttFont, self.progress, self.quiet)
 				subReader.read()
 				self.contentStack.append([])
@@ -71,7 +90,7 @@ def _startElementHandler(self, name, attrs):
 			tag = ttLib.xmlToTag(name)
 			msg = "Parsing '%s' table..." % tag
 			if self.progress:
-				self.progress.setlabel(msg)
+				self.progress.setLabel(msg)
 			elif self.ttFont.verbose:
 				ttLib.debugmsg(msg)
 			else:
diff --git a/Lib/fontTools/ttLib/__init__.py b/Lib/fontTools/ttLib/__init__.py
--- a/Lib/fontTools/ttLib/__init__.py
+++ b/Lib/fontTools/ttLib/__init__.py
@@ -327,7 +327,7 @@ def _tableToXML(self, writer, tag, progress, quiet):
 		writer.newline()
 		writer.newline()
 
-	def importXML(self, file, progress=None, quiet=False):
+	def importXML(self, fileOrPath, progress=None, quiet=False):
 		"""Import a TTX file (an XML-based text format), so as to recreate
 		a font object.
 		"""
@@ -340,7 +340,7 @@ def importXML(self, file, progress=None, quiet=False):
 
 		from fontTools.misc import xmlReader
 
-		reader = xmlReader.XMLReader(file, self, progress, quiet)
+		reader = xmlReader.XMLReader(fileOrPath, self, progress, quiet)
 		reader.read()
 
 	def isLoaded(self, tag):


Test patch:
diff --git a/Lib/fontTools/misc/xmlReader_test.py b/Lib/fontTools/misc/xmlReader_test.py
--- a/Lib/fontTools/misc/xmlReader_test.py
+++ b/Lib/fontTools/misc/xmlReader_test.py
@@ -5,7 +5,7 @@
 import os
 import unittest
 from fontTools.ttLib import TTFont
-from .xmlReader import XMLReader
+from .xmlReader import XMLReader, ProgressPrinter, BUFSIZE
 import tempfile
 
 
@@ -15,9 +15,9 @@ def test_decode_utf8(self):
 
 		class DebugXMLReader(XMLReader):
 
-			def __init__(self, fileName, ttFont, progress=None, quiet=False):
+			def __init__(self, fileOrPath, ttFont, progress=None, quiet=False):
 				super(DebugXMLReader, self).__init__(
-					fileName, ttFont, progress, quiet)
+					fileOrPath, ttFont, progress, quiet)
 				self.contents = []
 
 			def _endElementHandler(self, name):
@@ -38,21 +38,19 @@ def _endElementHandler(self, name):
 </ttFont>
 ''' % expected
 
-		with tempfile.NamedTemporaryFile(delete=False) as tmp:
-			tmp.write(data.encode('utf-8'))
-		reader = DebugXMLReader(tmp.name, TTFont(), quiet=True)
-		reader.read()
-		os.remove(tmp.name)
-		content = strjoin(reader.contents[0]).strip() 
+		with BytesIO(data.encode('utf-8')) as tmp:
+			reader = DebugXMLReader(tmp, TTFont(), quiet=True)
+			reader.read()
+		content = strjoin(reader.contents[0]).strip()
 		self.assertEqual(expected, content)
 
 	def test_normalise_newlines(self):
 
 		class DebugXMLReader(XMLReader):
 
-			def __init__(self, fileName, ttFont, progress=None, quiet=False):
+			def __init__(self, fileOrPath, ttFont, progress=None, quiet=False):
 				super(DebugXMLReader, self).__init__(
-					fileName, ttFont, progress, quiet)
+					fileOrPath, ttFont, progress, quiet)
 				self.newlines = []
 
 			def _characterDataHandler(self, data):
@@ -68,13 +66,82 @@ def _characterDataHandler(self, data):
 			'    escaped CR and windows newline &#13;\r\n'  # &#13;\r\n -> \r\n
 			'  </test>\n'                                   #              \n
 			'</ttFont>')
+
+		with BytesIO(data.encode('utf-8')) as tmp:
+			reader = DebugXMLReader(tmp, TTFont(), quiet=True)
+			reader.read()
+		expected = ['\n'] * 3 + ['\r', '\n'] * 3 + ['\n']
+		self.assertEqual(expected, reader.newlines)
+
+	def test_progress(self):
+
+		class DummyProgressPrinter(ProgressPrinter):
+
+			def __init__(self, title, maxval=100):
+				self.label = title
+				self.maxval = maxval
+				self.pos = 0
+
+			def set(self, val, maxval=None):
+				if maxval is not None:
+					self.maxval = maxval
+				self.pos = val
+
+			def increment(self, val=1):
+				self.pos += val
+
+			def setLabel(self, text):
+				self.label = text
+
+		data = (
+			'<ttFont>\n'
+			'  <test>\n'
+			'    %s\n'
+			'  </test>\n'
+			'</ttFont>\n'
+			% ("z" * 2 * BUFSIZE)
+			).encode('utf-8')
+
+		dataSize = len(data)
+		progressBar = DummyProgressPrinter('test')
+		with BytesIO(data) as tmp:
+			reader = XMLReader(tmp, TTFont(), progress=progressBar)
+			self.assertEqual(progressBar.pos, 0)
+			reader.read()
+		self.assertEqual(progressBar.pos, dataSize // 100)
+		self.assertEqual(progressBar.maxval, dataSize // 100)
+		self.assertTrue('test' in progressBar.label)
+		with BytesIO(b"<ttFont></ttFont>") as tmp:
+			reader = XMLReader(tmp, TTFont(), progress=progressBar)
+			reader.read()
+		# when data size is less than 100 bytes, 'maxval' is 1
+		self.assertEqual(progressBar.maxval, 1)
+
+	def test_close_file_path(self):
 		with tempfile.NamedTemporaryFile(delete=False) as tmp:
-			tmp.write(data.encode('utf-8'))
-		reader = DebugXMLReader(tmp.name, TTFont(), quiet=True)
+			tmp.write(b'<ttFont></ttFont>')
+		reader = XMLReader(tmp.name, TTFont())
 		reader.read()
+		# when reading from path, the file is closed automatically at the end
+		self.assertTrue(reader.file.closed)
+		# this does nothing
+		reader.close()
+		self.assertTrue(reader.file.closed)
 		os.remove(tmp.name)
-		expected = ['\n'] * 3 + ['\r', '\n'] * 3 + ['\n']
-		self.assertEqual(expected, reader.newlines)
+
+	def test_close_file_obj(self):
+		with tempfile.NamedTemporaryFile(delete=False) as tmp:
+			tmp.write(b'<ttFont>"hello"</ttFont>')
+		with open(tmp.name, "rb") as f:
+			reader = XMLReader(f, TTFont())
+			reader.read()
+			# when reading from a file or file-like object, the latter is kept open
+			self.assertFalse(reader.file.closed)
+		# ... until the user explicitly closes it
+		reader.close()
+		self.assertTrue(reader.file.closed)
+		os.remove(tmp.name)
+
 
 
 if __name__ == '__main__':