Repository: fonttools/fonttools

Issue:
[ufoLib] userNameToFileName very slow for large `existing` lists
Profiling https://github.com/madig/noto-amalgamated/blob/main/amalgamate-noto.py shows that ~2 mins of ~10 mins are spent in userNameToFileName, specifically 

https://github.com/fonttools/fonttools/blob/df2916af615a3d5676d457d547496c3f43da3479/Lib/fontTools/ufoLib/filenames.py#L101

since `existing` is derived from `self._existingFileNames.values()` in https://github.com/fonttools/fonttools/blob/df2916af615a3d5676d457d547496c3f43da3479/Lib/fontTools/ufoLib/glifLib.py#L461.

`writeGlyph` is called per-glyph, I don't see an easy way to fix this. Changing the call to  `set(self._existingFileNames.values())` only makes it slower because now you make a set every time. And I'm not sure the list is guaranteed to be ordered?


Discussion:
Good catch.

I think `GlyphSet._existingFileNames` needs to become a set. The only time its keys are used is in `GlyphSet.deleteGlyph`, and unless I'm missing something, I think we can write `self._existingFileNames.remove(self.contents[glyphName])` there, instead of the current `del self._existingFileNames[fileName]`.

(In other words, I think it's `userNameToFileName()`'s _caller_'s responsibility to pass a set-like thing for the `existing` argument.)

Resolving patch:
diff --git a/Lib/fontTools/ufoLib/filenames.py b/Lib/fontTools/ufoLib/filenames.py
--- a/Lib/fontTools/ufoLib/filenames.py
+++ b/Lib/fontTools/ufoLib/filenames.py
@@ -15,10 +15,9 @@ class NameTranslationError(Exception):
 	pass
 
 
-def userNameToFileName(userName: str, existing=[], prefix="", suffix=""):
+def userNameToFileName(userName: str, existing=(), prefix="", suffix=""):
 	"""
-	existing should be a case-insensitive list
-	of all existing file names.
+	`existing` should be a set-like object.
 
 	>>> userNameToFileName("a") == "a"
 	True
diff --git a/Lib/fontTools/ufoLib/glifLib.py b/Lib/fontTools/ufoLib/glifLib.py
--- a/Lib/fontTools/ufoLib/glifLib.py
+++ b/Lib/fontTools/ufoLib/glifLib.py
@@ -10,6 +10,8 @@
 glyph data. See the class doc string for details.
 """
 
+from __future__ import annotations
+
 import logging
 import enum
 from warnings import warn
@@ -205,7 +207,7 @@ def __init__(
 		self.glyphNameToFileName = glyphNameToFileNameFunc
 		self._validateRead = validateRead
 		self._validateWrite = validateWrite
-		self._existingFileNames = None
+		self._existingFileNames: set[str] | None = None
 		self._reverseContents = None
 
 		self.rebuildContents()
@@ -455,12 +457,12 @@ def writeGlyph(self, glyphName, glyphObject=None, drawPointsFunc=None, formatVer
 		fileName = self.contents.get(glyphName)
 		if fileName is None:
 			if self._existingFileNames is None:
-				self._existingFileNames = {}
-				for fileName in self.contents.values():
-					self._existingFileNames[fileName] = fileName.lower()
-			fileName = self.glyphNameToFileName(glyphName, self._existingFileNames.values())
+				self._existingFileNames = {
+					fileName.lower() for fileName in self.contents.values()
+				}
+			fileName = self.glyphNameToFileName(glyphName, self._existingFileNames)
 			self.contents[glyphName] = fileName
-			self._existingFileNames[fileName] = fileName.lower()
+			self._existingFileNames.add(fileName.lower())
 			if self._reverseContents is not None:
 				self._reverseContents[fileName.lower()] = glyphName
 		data = _writeGlyphToBytes(
@@ -485,9 +487,9 @@ def deleteGlyph(self, glyphName):
 		fileName = self.contents[glyphName]
 		self.fs.remove(fileName)
 		if self._existingFileNames is not None:
-			del self._existingFileNames[fileName]
+			self._existingFileNames.remove(fileName.lower())
 		if self._reverseContents is not None:
-			del self._reverseContents[self.contents[glyphName].lower()]
+			del self._reverseContents[fileName.lower()]
 		del self.contents[glyphName]
 
 	# dict-like support
@@ -573,9 +575,12 @@ def __exit__(self, exc_type, exc_value, exc_tb):
 def glyphNameToFileName(glyphName, existingFileNames):
 	"""
 	Wrapper around the userNameToFileName function in filenames.py
+
+	Note that existingFileNames should be a set for large glyphsets
+	or performance will suffer.
 	"""
 	if existingFileNames is None:
-		existingFileNames = []
+		existingFileNames = set()
 	return userNameToFileName(glyphName, existing=existingFileNames, suffix=".glif")
 
 # -----------------------


Test patch:
diff --git a/Tests/ufoLib/glifLib_test.py b/Tests/ufoLib/glifLib_test.py
--- a/Tests/ufoLib/glifLib_test.py
+++ b/Tests/ufoLib/glifLib_test.py
@@ -159,6 +159,8 @@ def test_conflicting_case_insensitive_file_names(self, tmp_path):
 		dst.writeGlyph("a", glyph)
 		dst.writeGlyph("A", glyph)
 		dst.writeGlyph("a_", glyph)
+		dst.deleteGlyph("a_")
+		dst.writeGlyph("a_", glyph)
 		dst.writeGlyph("A_", glyph)
 		dst.writeGlyph("i_j", glyph)