diff --git a/.coveragerc b/.coveragerc index f159c20..3a1018c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,10 +3,6 @@ # See: http://coverage.readthedocs.io/en/coverage-4.5.1/branch.html branch = True -# Don't run codecov on tests -omit = - */test/* - # list of directories or packages to measure source = ufoLib diff --git a/Lib/ufoLib/__init__.py b/Lib/ufoLib/__init__.py index 390d9ea..f08eec2 100755 --- a/Lib/ufoLib/__init__.py +++ b/Lib/ufoLib/__init__.py @@ -1,14 +1,26 @@ from __future__ import absolute_import, unicode_literals +import sys import os -import shutil -from io import StringIO, BytesIO, open from copy import deepcopy +import zipfile +import enum +import fs +import fs.base +import fs.subfs +import fs.errors +import fs.copy +import fs.osfs +import fs.zipfs +import fs.tempfs +import fs.tools from fontTools.misc.py23 import basestring, unicode, tounicode -from ufoLib.glifLib import GlyphSet +from ufoLib import plistlib from ufoLib.validators import * from ufoLib.filenames import userNameToFileName from ufoLib.converters import convertUFO1OrUFO2KerningToUFO3Kerning -from ufoLib import plistlib +from ufoLib.errors import UFOLibError +from ufoLib.utils import datetimeAsTimestamp, fsdecode + """ A library for importing .ufo files and their descendants. Refer to http://unifiedfontobject.com for the UFO specification. @@ -51,20 +63,15 @@ "validateFontInfoVersion2ValueForAttribute", "validateFontInfoVersion3ValueForAttribute", "convertFontInfoValueForAttributeFromVersion1ToVersion2", - "convertFontInfoValueForAttributeFromVersion2ToVersion1", - # deprecated - "convertUFOFormatVersion1ToFormatVersion2", + "convertFontInfoValueForAttributeFromVersion2ToVersion1" ] -__version__ = "2.3.2" +__version__ = "3.0.0.dev0" -class UFOLibError(Exception): pass - - -# ---------- -# File Names -# ---------- +# --------- +# Constants +# --------- DEFAULT_GLYPHS_DIRNAME = "glyphs" DATA_DIRNAME = "data" @@ -83,34 +90,127 @@ class UFOLibError(Exception): pass supportedUFOFormatVersions = [1, 2, 3] +class UFOFileStructure(enum.Enum): + ZIP = "zip" + PACKAGE = "package" + + # -------------- # Shared Methods # -------------- + +def _getFileModificationTime(self, path): + """ + Returns the modification time for the file at the given path, as a + floating point number giving the number of seconds since the epoch. + The path must be relative to the UFO path. + Returns None if the file does not exist. + """ + try: + dt = self.fs.getinfo(fsdecode(path), namespaces=["details"]).modified + except (fs.errors.MissingInfoNamespace, fs.errors.ResourceNotFound): + return None + else: + return datetimeAsTimestamp(dt) + + +def _readBytesFromPath(self, path): + """ + Returns the bytes in the file at the given path. + The path must be relative to the UFO's filesystem root. + Returns None if the file does not exist. + """ + try: + return self.fs.getbytes(fsdecode(path)) + except fs.errors.ResourceNotFound: + return None + + def _getPlist(self, fileName, default=None): """ - Read a property list relative to the - path argument of UFOReader. If the file - is missing and default is None a - UFOLibError will be raised otherwise - default is returned. The errors that - could be raised during the reading of - a plist are unpredictable and/or too - large to list, so, a blind try: except: - is done. If an exception occurs, a - UFOLibError will be raised. + Read a property list relative to the UFO filesystem's root. + Raises UFOLibError if the file is missing and default is None, + otherwise default is returned. + + The errors that could be raised during the reading of a plist are + unpredictable and/or too large to list, so, a blind try: except: + is done. If an exception occurs, a UFOLibError will be raised. """ - path = os.path.join(self._path, fileName) - if not os.path.exists(path): - if default is not None: - return default - else: - raise UFOLibError("%s is missing in %s. This file is required" % (fileName, self._path)) try: - with open(path, "rb") as f: + with self.fs.open(fileName, "rb") as f: return plistlib.load(f) - except: - raise UFOLibError("The file %s could not be read." % fileName) + except fs.errors.ResourceNotFound: + if default is None: + raise UFOLibError( + "'%s' is missing on %s. This file is required" + % (fileName, self.fs) + ) + else: + return default + except Exception as e: + # TODO(anthrotype): try to narrow this down a little + raise UFOLibError( + "'%s' could not be read on %s: %s" % (fileName, self.fs, e) + ) + + +def _writePlist(self, fileName, obj): + """ + Write a property list to a file relative to the UFO filesystem's root. + + Do this sort of atomically, making it harder to corrupt existing files, + for example when plistlib encounters an error halfway during write. + This also checks to see if text matches the text that is already in the + file at path. If so, the file is not rewritten so that the modification + date is preserved. + + The errors that could be raised during the writing of a plist are + unpredictable and/or too large to list, so, a blind try: except: is done. + If an exception occurs, a UFOLibError will be raised. + """ + if self._havePreviousFile: + try: + data = plistlib.dumps(obj) + except Exception as e: + raise UFOLibError( + "'%s' could not be written on %s because " + "the data is not properly formatted: %s" + % (fileName, self.fs, e) + ) + if self.fs.exists(fileName) and data == self.fs.getbytes(fileName): + return + self.fs.setbytes(fileName, data) + else: + with self.fs.openbin(fileName, mode="w") as fp: + try: + plistlib.dump(obj, fp) + except Exception as e: + raise UFOLibError( + "'%s' could not be written on %s because " + "the data is not properly formatted: %s" + % (fileName, self.fs, e) + ) + + +def _sniffFileStructure(ufo_path): + """Return UFOFileStructure.ZIP if the UFO at path 'ufo_path' (basestring) + is a zip file, else return UFOFileStructure.PACKAGE if 'ufo_path' is a + directory. + Raise UFOLibError if it is a file with unknown structure, or if the path + does not exist. + """ + if zipfile.is_zipfile(ufo_path): + return UFOFileStructure.ZIP + elif os.path.isdir(ufo_path): + return UFOFileStructure.PACKAGE + elif os.path.isfile(ufo_path): + raise UFOLibError( + "The specified UFO does not have a known structure: '%s'" % ufo_path + ) + else: + raise UFOLibError("No such file or directory: '%s'" % ufo_path) + # ---------- # UFO Reader @@ -126,8 +226,67 @@ class UFOReader(object): """ def __init__(self, path, validate=True): - if not os.path.exists(path): - raise UFOLibError("The specified UFO doesn't exist.") + if hasattr(path, "__fspath__"): # support os.PathLike objects + path = path.__fspath__() + + if isinstance(path, basestring): + structure = self.sniffFileStructure(path) + try: + if structure is UFOFileStructure.ZIP: + parentFS = fs.zipfs.ZipFS(path, write=False, encoding="utf-8") + else: + parentFS = fs.osfs.OSFS(path) + except fs.errors.CreateFailed as e: + raise UFOLibError("unable to open '%s': %s" % (path, e)) + + if structure is UFOFileStructure.ZIP: + # .ufoz zip files must contain a single root directory, with arbitrary + # name, containing all the UFO files + rootDirs = [ + p.name for p in parentFS.scandir("/") + # exclude macOS metadata contained in zip file + if p.is_dir and p.name != "__MACOSX" + ] + if len(rootDirs) == 1: + # 'ClosingSubFS' ensures that the parent zip file is closed when + # its root subdirectory is closed + self.fs = parentFS.opendir( + rootDirs[0], factory=fs.subfs.ClosingSubFS + ) + else: + raise UFOLibError( + "Expected exactly 1 root directory, found %d" % len(rootDirs) + ) + else: + # normal UFO 'packages' are just a single folder + self.fs = parentFS + # when passed a path string, we make sure we close the newly opened fs + # upon calling UFOReader.close method or context manager's __exit__ + self._shouldClose = True + self._fileStructure = structure + elif isinstance(path, fs.base.FS): + filesystem = path + try: + filesystem.check() + except fs.errors.FilesystemClosed: + raise UFOLibError("the filesystem '%s' is closed" % path) + else: + self.fs = filesystem + try: + path = filesystem.getsyspath("/") + except fs.errors.NoSysPath: + # network or in-memory FS may not map to the local one + path = unicode(filesystem) + # when user passed an already initialized fs instance, it is her + # responsibility to close it, thus UFOReader.close/__exit__ are no-op + self._shouldClose = False + # default to a 'package' structure + self._fileStructure = UFOFileStructure.PACKAGE + else: + raise TypeError( + "Expected a path string or fs.base.FS object, found '%s'" + % type(path).__name__ + ) self._path = path self._validate = validate self.readMetaInfo(validate=validate) @@ -140,6 +299,17 @@ def _get_formatVersion(self): formatVersion = property(_get_formatVersion, doc="The format version of the UFO. This is determined by reading metainfo.plist during __init__.") + def _get_fileStructure(self): + return self._fileStructure + + fileStructure = property( + _get_fileStructure, + doc=( + "The current file structure of the UFO: " + "either UFOFileStructure.ZIP or UFOFileStructure.PACKAGE" + ) + ) + # up conversion def _upConvertKerning(self, validate): @@ -165,7 +335,7 @@ def _upConvertKerning(self, validate): invalidFormatMessage = "groups.plist is not properly formatted." if not isinstance(groups, dict): raise UFOLibError(invalidFormatMessage) - for groupName, glyphList in list(groups.items()): + for groupName, glyphList in groups.items(): if not isinstance(groupName, basestring): raise UFOLibError(invalidFormatMessage) elif not isinstance(glyphList, list): @@ -191,61 +361,29 @@ def _upConvertKerning(self, validate): # support methods - _checkForFile = staticmethod(os.path.exists) - _getPlist = _getPlist + getFileModificationTime = _getFileModificationTime + readBytesFromPath = _readBytesFromPath + sniffFileStructure = staticmethod(_sniffFileStructure) - def readBytesFromPath(self, path, encoding=None): + def getReadFileForPath(self, path, encoding=None): """ - Returns the bytes in the file at the given path. + Returns a file (or file-like) object for the file at the given path. The path must be relative to the UFO path. Returns None if the file does not exist. - An encoding may be passed if needed. - """ - fullPath = os.path.join(self._path, path) - if not self._checkForFile(fullPath): - return None - if os.path.isdir(fullPath): - raise UFOLibError("%s is a directory." % path) - if encoding: - f = open(fullPath, encoding=encoding) - else: - f = open(fullPath, "rb", encoding=encoding) - data = f.read() - f.close() - return data - - def getReadFileForPath(self, path, encoding=None): - """ - Returns a file (or file-like) object for the - file at the given path. The path must be relative - to the UFO path. Returns None if the file does not exist. - An encoding may be passed if needed. + By default the file is opened in binary mode (reads bytes). + If encoding is passed, the file is opened in text mode (reads unicode). Note: The caller is responsible for closing the open file. """ - fullPath = os.path.join(self._path, path) - if not self._checkForFile(fullPath): - return None - if os.path.isdir(fullPath): - raise UFOLibError("%s is a directory." % path) - if encoding: - f = open(fullPath, "rb", encoding=encoding) - else: - f = open(fullPath, "r") - return f - - def getFileModificationTime(self, path): - """ - Returns the modification time (as reported by os.path.getmtime) - for the file at the given path. The path must be relative to - the UFO path. Returns None if the file does not exist. - """ - fullPath = os.path.join(self._path, path) - if not self._checkForFile(fullPath): + path = fsdecode(path) + try: + if encoding is None: + return self.fs.openbin(path) + else: + return self.fs.open(path, mode="r", encoding=encoding) + except fs.errors.ResourceNotFound: return None - return os.path.getmtime(fullPath) - # metainfo.plist def readMetaInfo(self, validate=None): @@ -257,19 +395,21 @@ def readMetaInfo(self, validate=None): """ if validate is None: validate = self._validate - # should there be a blind try/except with a UFOLibError - # raised in except here (and elsewhere)? It would be nice to - # provide external callers with a single exception to catch. data = self._getPlist(METAINFO_FILENAME) if validate and not isinstance(data, dict): raise UFOLibError("metainfo.plist is not properly formatted.") formatVersion = data["formatVersion"] if validate: if not isinstance(formatVersion, int): - metaplist_path = os.path.join(self._path, METAINFO_FILENAME) - raise UFOLibError("formatVersion must be specified as an integer in " + metaplist_path) + raise UFOLibError( + "formatVersion must be specified as an integer in '%s' on %s" + % (METAINFO_FILENAME, self.fs) + ) if formatVersion not in supportedUFOFormatVersions: - raise UFOLibError("Unsupported UFO format (%d) in %s." % (formatVersion, self._path)) + raise UFOLibError( + "Unsupported UFO format (%d) in '%s' on %s" + % (formatVersion, METAINFO_FILENAME, self.fs) + ) self._formatVersion = formatVersion # groups.plist @@ -438,14 +578,14 @@ def readLib(self, validate=None): def readFeatures(self): """ - Read features.fea. Returns a string. + Read features.fea. Return a unicode string. + The returned string is empty if the file is missing. """ - path = os.path.join(self._path, FEATURES_FILENAME) - if not self._checkForFile(path): + try: + with self.fs.open(FEATURES_FILENAME, "r", encoding="utf-8") as f: + return f.read() + except fs.errors.ResourceNotFound: return "" - with open(path, "r", encoding="utf-8") as f: - text = f.read() - return text # glyph sets & layers @@ -458,10 +598,9 @@ def _readLayerContents(self, validate): """ if self._formatVersion < 3: return [(DEFAULT_LAYER_NAME, DEFAULT_GLYPHS_DIRNAME)] - # read the file on disk contents = self._getPlist(LAYERCONTENTS_FILENAME) if validate: - valid, error = layerContentsValidator(contents, self._path) + valid, error = layerContentsValidator(contents, self.fs) if not valid: raise UFOLibError(error) return contents @@ -508,6 +647,8 @@ def getGlyphSet(self, layerName=None, validateRead=None, validateWrite=None): ``validateWrte`` will validate the written data, by default it is set to the class's validate value, can be overridden. """ + from ufoLib.glifLib import GlyphSet + if validateRead is None: validateRead = self._validate if validateWrite is None: @@ -522,8 +663,18 @@ def getGlyphSet(self, layerName=None, validateRead=None, validateWrite=None): break if directory is None: raise UFOLibError("No glyphs directory is mapped to \"%s\"." % layerName) - glyphsPath = os.path.join(self._path, directory) - return GlyphSet(glyphsPath, ufoFormatVersion=self._formatVersion, validateRead=validateRead, validateWrite=validateWrite) + try: + glyphSubFS = self.fs.opendir(directory) + except fs.errors.ResourceNotFound: + raise UFOLibError( + "No '%s' directory for layer '%s'" % (directory, layerName) + ) + return GlyphSet( + glyphSubFS, + ufoFormatVersion=self._formatVersion, + validateRead=validateRead, + validateWrite=validateWrite, + ) def getCharacterMapping(self, layerName=None, validate=None): """ @@ -545,35 +696,22 @@ def getCharacterMapping(self, layerName=None, validate=None): # /data - def getDataDirectoryListing(self, maxDepth=100): + def getDataDirectoryListing(self): """ Returns a list of all files in the data directory. The returned paths will be relative to the UFO. This will not list directory names, only file names. Thus, empty directories will be skipped. - - The maxDepth argument sets the maximum number - of sub-directories that are allowed. """ - path = os.path.join(self._path, DATA_DIRNAME) - if not self._checkForFile(path): + try: + # fs Walker.files method returns "absolute" paths (in terms of the + # root of the 'data' SubFS), so we strip the leading '/' to make + # them relative + return [ + p.lstrip("/") for p in self.fs.opendir(DATA_DIRNAME).walk.files() + ] + except fs.errors.ResourceError: return [] - listing = self._getDirectoryListing(path, maxDepth=maxDepth) - listing = [os.path.relpath(path, "data") for path in listing] - return listing - - def _getDirectoryListing(self, path, depth=0, maxDepth=100): - if depth > maxDepth: - raise UFOLibError("Maximum recusion depth reached.") - result = [] - for fileName in os.listdir(path): - p = os.path.join(path, fileName) - if os.path.isdir(p): - result += self._getDirectoryListing(p, depth=depth+1, maxDepth=maxDepth) - else: - p = os.path.relpath(p, self._path) - result.append(p) - return result def getImageDirectoryListing(self, validate=None): """ @@ -584,28 +722,28 @@ def getImageDirectoryListing(self, validate=None): ``validate`` will validate the data, by default it is set to the class's validate value, can be overridden. """ - if validate is None: - validate = self._validate if self._formatVersion < 3: return [] - path = os.path.join(self._path, IMAGES_DIRNAME) - if not os.path.exists(path): + if validate is None: + validate = self._validate + if not self.fs.exists(IMAGES_DIRNAME): return [] - if not os.path.isdir(path): + elif not self.fs.isdir(IMAGES_DIRNAME): raise UFOLibError("The UFO contains an \"images\" file instead of a directory.") result = [] - for fileName in os.listdir(path): - p = os.path.join(path, fileName) - if os.path.isdir(p): + self._imagesFS = imagesFS = self.fs.opendir(IMAGES_DIRNAME) + for path in imagesFS.scandir("/"): + if path.is_dir: # silently skip this as version control # systems often have hidden directories continue if validate: - valid, error = pngValidator(path=p) + with imagesFS.openbin(path.name) as fp: + valid, error = pngValidator(fileObj=fp) if valid: - result.append(fileName) + result.append(path.name) else: - result.append(fileName) + result.append(path.name) return result def readImage(self, fileName, validate=None): @@ -619,7 +757,13 @@ def readImage(self, fileName, validate=None): validate = self._validate if self._formatVersion < 3: raise UFOLibError("Reading images is not allowed in UFO %d." % self._formatVersion) - data = self.readBytesFromPath(os.path.join(IMAGES_DIRNAME, fileName)) + try: + imagesFS = self._imagesFS + except AttributeError: + # in case readImage is called before getImageDirectoryListing + imagesFS = self.fs.opendir(IMAGES_DIRNAME) + fileName = fsdecode(fileName) + data = imagesFS.getbytes(fileName) if data is None: raise UFOLibError("No image file named %s." % fileName) if validate: @@ -628,11 +772,21 @@ def readImage(self, fileName, validate=None): raise UFOLibError(error) return data + def close(self): + if self._shouldClose: + self.fs.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + self.close() + + # ---------- # UFO Writer # ---------- - class UFOWriter(object): """ @@ -643,27 +797,130 @@ class UFOWriter(object): on a per method level if desired. """ - def __init__(self, path, formatVersion=3, fileCreator="org.robofab.ufoLib", validate=True): + def __init__( + self, + path, + formatVersion=3, + fileCreator="org.robofab.ufoLib", + structure=None, + validate=True, + ): if formatVersion not in supportedUFOFormatVersions: raise UFOLibError("Unsupported UFO format (%d)." % formatVersion) + + if hasattr(path, "__fspath__"): # support os.PathLike objects + path = path.__fspath__() + + if isinstance(path, basestring): + havePreviousFile = os.path.exists(path) + if havePreviousFile: + # ensure we use the same structure as the destination + existingStructure = self.sniffFileStructure(path) + if structure is not None: + try: + structure = UFOFileStructure(structure) + except ValueError: + raise UFOLibError( + "Invalid or unsupported structure: '%s'" % structure + ) + if structure is not existingStructure: + raise UFOLibError( + "A UFO with a different structure (%s) already exists " + "at the given path: '%s'" % (existingStructure, path) + ) + else: + structure = existingStructure + else: + # if not exists, default to 'package' structure + if structure is None: + structure = UFOFileStructure.PACKAGE + dirName = os.path.dirname(path) + if dirName and not os.path.isdir(dirName): + raise UFOLibError( + "Cannot write to '%s': directory does not exist" % path + ) + if structure is UFOFileStructure.ZIP: + if havePreviousFile: + # we can't write a zip in-place, so we have to copy its + # contents to a temporary location and work from there, then + # upon closing UFOWriter we create the final zip file + parentFS = fs.tempfs.TempFS() + with fs.zipfs.ZipFS(path, encoding="utf-8") as origFS: + fs.copy.copy_fs(origFS, parentFS) + # if output path is an existing zip, we require that it contains + # one, and only one, root directory (with arbitrary name), in turn + # containing all the existing UFO contents + rootDirs = [ + p.name for p in parentFS.scandir("/") + # exclude macOS metadata contained in zip file + if p.is_dir and p.name != "__MACOSX" + ] + if len(rootDirs) != 1: + raise UFOLibError( + "Expected exactly 1 root directory, found %d" % len(rootDirs) + ) + else: + # 'ClosingSubFS' ensures that the parent filesystem is closed + # when its root subdirectory is closed + self.fs = parentFS.opendir( + rootDirs[0], factory=fs.subfs.ClosingSubFS + ) + else: + # if the output zip file didn't exist, we create the root folder; + # we name it the same as input 'path', but with '.ufo' extension + rootDir = os.path.splitext(os.path.basename(path))[0] + ".ufo" + parentFS = fs.zipfs.ZipFS(path, write=True, encoding="utf-8") + parentFS.makedir(rootDir) + self.fs = parentFS.opendir(rootDir, factory=fs.subfs.ClosingSubFS) + else: + self.fs = fs.osfs.OSFS(path, create=True) + self._fileStructure = structure + self._havePreviousFile = havePreviousFile + self._shouldClose = True + elif isinstance(path, fs.base.FS): + filesystem = path + try: + filesystem.check() + except fs.errors.FilesystemClosed: + raise UFOLibError("the filesystem '%s' is closed" % path) + else: + self.fs = filesystem + try: + path = filesystem.getsyspath("/") + except fs.errors.NoSysPath: + # network or in-memory FS may not map to the local one + path = unicode(filesystem) + # if passed an FS object, always default to 'package' structure + self._fileStructure = UFOFileStructure.PACKAGE + # if FS contains a "metainfo.plist", we consider it non-empty + self._havePreviousFile = filesystem.exists(METAINFO_FILENAME) + # the user is responsible for closing the FS object + self._shouldClose = False + else: + raise TypeError( + "Expected a path string or fs object, found %s" + % type(path).__name__ + ) + # establish some basic stuff self._path = path self._formatVersion = formatVersion self._fileCreator = fileCreator self._downConversionKerningData = None self._validate = validate - # if the file already exists, get the format version. # this will be needed for up and down conversion. previousFormatVersion = None - if os.path.exists(path): + if self._havePreviousFile: metaInfo = self._getPlist(METAINFO_FILENAME) previousFormatVersion = metaInfo.get("formatVersion") try: previousFormatVersion = int(previousFormatVersion) - except: + except (ValueError, TypeError): + self.fs.close() raise UFOLibError("The existing metainfo.plist is not properly formatted.") if previousFormatVersion not in supportedUFOFormatVersions: + self.fs.close() raise UFOLibError("Unsupported UFO format (%d)." % formatVersion) # catch down conversion if previousFormatVersion is not None and previousFormatVersion > formatVersion: @@ -676,8 +933,7 @@ def __init__(self, path, formatVersion=3, fileCreator="org.robofab.ufoLib", vali else: # previous < 3 # imply the layer contents - p = os.path.join(path, DEFAULT_GLYPHS_DIRNAME) - if os.path.exists(p): + if self.fs.exists(DEFAULT_GLYPHS_DIRNAME): self.layerContents = {DEFAULT_LAYER_NAME : DEFAULT_GLYPHS_DIRNAME} # write the new metainfo self._writeMetaInfo() @@ -685,9 +941,16 @@ def __init__(self, path, formatVersion=3, fileCreator="org.robofab.ufoLib", vali # properties def _get_path(self): + import warnings + + warnings.warn( + "The 'path' attribute is deprecated; use the 'fs' attribute instead", + DeprecationWarning, + stacklevel=2, + ) return self._path - path = property(_get_path, doc="The path the UFO is being written to.") + path = property(_get_path, doc="The path the UFO is being written to (DEPRECATED).") def _get_formatVersion(self): return self._formatVersion @@ -699,149 +962,119 @@ def _get_fileCreator(self): fileCreator = property(_get_fileCreator, doc="The file creator of the UFO. This is set into metainfo.plist during __init__.") - # support methods + def _get_fileStructure(self): + return self._fileStructure + + fileStructure = property( + _get_fileStructure, + doc=( + "The file structure of the destination UFO: " + "either UFOFileStrucure.ZIP or UFOFileStructure.PACKAGE" + ) + ) + + # support methods for file system interaction _getPlist = _getPlist + _writePlist = _writePlist + readBytesFromPath = _readBytesFromPath + getFileModificationTime = _getFileModificationTime + sniffFileStructure = staticmethod(_sniffFileStructure) - def _writePlist(self, fileName, data): + def copyFromReader(self, reader, sourcePath, destPath): """ - Write a property list. The errors that - could be raised during the writing of - a plist are unpredictable and/or too - large to list, so, a blind try: except: - is done. If an exception occurs, a - UFOLibError will be raised. + Copy the sourcePath in the provided UFOReader to destPath + in this writer. The paths must be relative. This works with + both individual files and directories. """ - self._makeDirectory() - path = os.path.join(self._path, fileName) - try: - data = writePlistAtomically(data, path) - except: - raise UFOLibError("The data for the file %s could not be written because it is not properly formatted." % fileName) - - def _deleteFile(self, fileName): - path = os.path.join(self._path, fileName) - if os.path.exists(path): - os.remove(path) - - def _makeDirectory(self, subDirectory=None): - path = self._path - if subDirectory: - path = os.path.join(self._path, subDirectory) - if not os.path.exists(path): - os.makedirs(path) - return path - - def _buildDirectoryTree(self, path): - directory, fileName = os.path.split(path) - directoryTree = [] - while directory: - directory, d = os.path.split(directory) - directoryTree.append(d) - directoryTree.reverse() - built = "" - for d in directoryTree: - d = os.path.join(built, d) - p = os.path.join(self._path, d) - if not os.path.exists(p): - os.mkdir(p) - built = d - - def _removeFileForPath(self, path, raiseErrorIfMissing=False): - originalPath = path - path = os.path.join(self._path, path) - if not os.path.exists(path): - if raiseErrorIfMissing: - raise UFOLibError("The file %s does not exist." % path) - else: - if os.path.isdir(path): - shutil.rmtree(path) - else: - os.remove(path) - # remove any directories that are now empty - self._removeEmptyDirectoriesForPath(os.path.dirname(originalPath)) - - def _removeEmptyDirectoriesForPath(self, directory): - absoluteDirectory = os.path.join(self._path, directory) - if not os.path.exists(absoluteDirectory): - return - if not len(os.listdir(absoluteDirectory)): - shutil.rmtree(absoluteDirectory) + if not isinstance(reader, UFOReader): + raise UFOLibError("The reader must be an instance of UFOReader.") + sourcePath = fsdecode(sourcePath) + destPath = fsdecode(destPath) + if not reader.fs.exists(sourcePath): + raise UFOLibError("The reader does not have data located at \"%s\"." % sourcePath) + if self.fs.exists(destPath): + raise UFOLibError("A file named \"%s\" already exists." % destPath) + # create the destination directory if it doesn't exist + self.fs.makedirs(fs.path.dirname(destPath), recreate=True) + if reader.fs.isdir(sourcePath): + fs.copy.copy_dir(reader.fs, sourcePath, self.fs, destPath) else: - return - directory = os.path.dirname(directory) - if directory: - self._removeEmptyDirectoriesForPath(directory) + fs.copy.copy_file(reader.fs, sourcePath, self.fs, destPath) - # file system interaction - - def writeBytesToPath(self, path, data, encoding=None): + def writeBytesToPath(self, path, data): """ - Write bytes to path. If needed, the directory tree - for the given path will be built. The path must be - relative to the UFO. An encoding may be passed if needed. + Write bytes to a path relative to the UFO filesystem's root. + If writing to an existing UFO, check to see if data matches the data + that is already in the file at path; if so, the file is not rewritten + so that the modification date is preserved. + If needed, the directory tree for the given path will be built. """ - fullPath = os.path.join(self._path, path) - if os.path.exists(fullPath) and os.path.isdir(fullPath): - raise UFOLibError("A directory exists at %s." % path) - self._buildDirectoryTree(path) - if encoding: - data = StringIO(data).encode(encoding) - writeDataFileAtomically(data, fullPath) + path = fsdecode(path) + if self._havePreviousFile: + if self.fs.isfile(path) and data == self.fs.getbytes(path): + return + try: + self.fs.setbytes(path, data) + except fs.errors.FileExpected: + raise UFOLibError("A directory exists at '%s'" % path) + except fs.errors.ResourceNotFound: + self.fs.makedirs(fs.path.dirname(path), recreate=True) + self.fs.setbytes(path, data) - def getFileObjectForPath(self, path, encoding=None): + def getFileObjectForPath(self, path, mode="w", encoding=None): """ - Creates a write mode file object at path. If needed, - the directory tree for the given path will be built. - The path must be relative to the UFO. An encoding may - be passed if needed. + Returns a file (or file-like) object for the + file at the given path. The path must be relative + to the UFO path. Returns None if the file does + not exist and the mode is "r" or "rb. + An encoding may be passed if the file is opened in text mode. Note: The caller is responsible for closing the open file. """ - fullPath = os.path.join(self._path, path) - if os.path.exists(fullPath) and os.path.isdir(fullPath): - raise UFOLibError("A directory exists at %s." % path) - self._buildDirectoryTree(path) - return open(fullPath, "w", encoding=encoding) + path = fsdecode(path) + try: + return self.fs.open(path, mode=mode, encoding=encoding) + except fs.errors.ResourceNotFound as e: + m = mode[0] + if m == "r": + # XXX I think we should just let it raise. The docstring, + # however, says that this returns None if mode is 'r' + return None + elif m == "w" or m == "a" or m == "x": + self.fs.makedirs(fs.path.dirname(path), recreate=True) + return self.fs.open(path, mode=mode, encoding=encoding) + except fs.errors.ResourceError as e: + return UFOLibError( + "unable to open '%s' on %s: %s" % (path, self.fs, e) + ) - def removeFileForPath(self, path): + def removePath(self, path, force=False, removeEmptyParents=True): """ Remove the file (or directory) at path. The path - must be relative to the UFO. This is only allowed - for files in the data and image directories. - """ - # make sure that only data or images is being changed - d = path - parts = [] - while d: - d, p = os.path.split(d) - if p: - parts.append(p) - if parts[-1] not in ("images", "data"): - raise UFOLibError("Removing \"%s\" is not legal." % path) - # remove the file - self._removeFileForPath(path, raiseErrorIfMissing=True) - - def copyFromReader(self, reader, sourcePath, destPath): - """ - Copy the sourcePath in the provided UFOReader to destPath - in this writer. The paths must be relative. They may represent - directories or paths. This uses the most memory efficient - method possible for copying the data possible. + must be relative to the UFO. + Raises UFOLibError if the path doesn't exist. + If force=True, ignore non-existent paths. + If the directory where 'path' is located becomes empty, it will + be automatically removed, unless 'removeEmptyParents' is False. """ - if not isinstance(reader, UFOReader): - raise UFOLibError("The reader must be an instance of UFOReader.") - fullSourcePath = os.path.join(reader._path, sourcePath) - if not reader._checkForFile(fullSourcePath): - raise UFOLibError("No file named \"%s\" to copy from." % sourcePath) - fullDestPath = os.path.join(self._path, destPath) - if os.path.exists(fullDestPath): - raise UFOLibError("A file named \"%s\" already exists." % sourcePath) - self._buildDirectoryTree(destPath) - if os.path.isdir(fullSourcePath): - shutil.copytree(fullSourcePath, fullDestPath) - else: - shutil.copy(fullSourcePath, fullDestPath) + path = fsdecode(path) + try: + self.fs.remove(path) + except fs.errors.FileExpected: + self.fs.removetree(path) + except fs.errors.ResourceNotFound: + if not force: + raise UFOLibError( + "'%s' does not exist on %s" % (path, self.fs) + ) + if removeEmptyParents: + parent = fs.path.dirname(path) + if parent: + fs.tools.remove_empty(self.fs, parent) + + # alias kept for backward compatibility with old API + removeFileForPath = removePath # UFO mod time @@ -851,7 +1084,9 @@ def setModificationTime(self): This is never called automatically. It is up to the caller to call this when finished working on the UFO. """ - os.utime(self._path, None) + path = self._path + if path is not None: + os.utime(path, None) # metainfo.plist @@ -932,12 +1167,12 @@ def writeGroups(self, groups, validate=None): groups = remappedGroups # pack and write groupsNew = {} - for key, value in list(groups.items()): + for key, value in groups.items(): groupsNew[key] = list(value) if groupsNew: self._writePlist(GROUPS_FILENAME, groupsNew) - else: - self._deleteFile(GROUPS_FILENAME) + elif self._havePreviousFile: + self.removePath(GROUPS_FILENAME, force=True, removeEmptyParents=False) # fontinfo.plist @@ -1025,15 +1260,15 @@ def writeKerning(self, kerning, validate=None): kerning = remappedKerning # pack and write kerningDict = {} - for left, right in list(kerning.keys()): + for left, right in kerning.keys(): value = kerning[left, right] if left not in kerningDict: kerningDict[left] = {} kerningDict[left][right] = value if kerningDict: self._writePlist(KERNING_FILENAME, kerningDict) - else: - self._deleteFile(KERNING_FILENAME) + elif self._havePreviousFile: + self.removePath(KERNING_FILENAME, force=True, removeEmptyParents=False) # lib.plist @@ -1053,8 +1288,8 @@ def writeLib(self, libDict, validate=None): raise UFOLibError(message) if libDict: self._writePlist(LIB_FILENAME, libDict) - else: - self._deleteFile(LIB_FILENAME) + elif self._havePreviousFile: + self.removePath(LIB_FILENAME, force=True, removeEmptyParents=False) # features.fea @@ -1070,9 +1305,10 @@ def writeFeatures(self, features, validate=None): if validate: if not isinstance(features, basestring): raise UFOLibError("The features are not text.") - self._makeDirectory() - path = os.path.join(self._path, FEATURES_FILENAME) - writeFileAtomically(features, path) + if features: + self.writeBytesToPath(FEATURES_FILENAME, features.encode("utf8")) + elif self._havePreviousFile: + self.removePath(FEATURES_FILENAME, force=True, removeEmptyParents=False) # glyph sets & layers @@ -1087,7 +1323,7 @@ def _readLayerContents(self, validate): raw = self._getPlist(LAYERCONTENTS_FILENAME) contents = {} if validate: - valid, error = layerContentsValidator(raw, self._path) + valid, error = layerContentsValidator(raw, self.fs) if not valid: raise UFOLibError(error) for entry in raw: @@ -1170,16 +1406,36 @@ def getGlyphSet(self, layerName=None, defaultLayer=True, glyphNameToFileNameFunc return self._getGlyphSetFormatVersion2(validateRead, validateWrite, glyphNameToFileNameFunc=glyphNameToFileNameFunc) elif self.formatVersion == 3: return self._getGlyphSetFormatVersion3(validateRead, validateWrite, layerName=layerName, defaultLayer=defaultLayer, glyphNameToFileNameFunc=glyphNameToFileNameFunc) + else: + raise AssertionError(self.formatVersion) def _getGlyphSetFormatVersion1(self, validateRead, validateWrite, glyphNameToFileNameFunc=None): - glyphDir = self._makeDirectory(DEFAULT_GLYPHS_DIRNAME) - return GlyphSet(glyphDir, glyphNameToFileNameFunc, ufoFormatVersion=1, validateRead=validateRead, validateWrite=validateWrite) + from ufoLib.glifLib import GlyphSet + + glyphSubFS = self.fs.makedir(DEFAULT_GLYPHS_DIRNAME, recreate=True), + return GlyphSet( + glyphSubFS, + glyphNameToFileNameFunc=glyphNameToFileNameFunc, + ufoFormatVersion=1, + validateRead=validateRead, + validateWrite=validateWrite, + ) def _getGlyphSetFormatVersion2(self, validateRead, validateWrite, glyphNameToFileNameFunc=None): - glyphDir = self._makeDirectory(DEFAULT_GLYPHS_DIRNAME) - return GlyphSet(glyphDir, glyphNameToFileNameFunc, ufoFormatVersion=2, validateRead=validateRead, validateWrite=validateWrite) + from ufoLib.glifLib import GlyphSet + + glyphSubFS = self.fs.makedir(DEFAULT_GLYPHS_DIRNAME, recreate=True) + return GlyphSet( + glyphSubFS, + glyphNameToFileNameFunc=glyphNameToFileNameFunc, + ufoFormatVersion=2, + validateRead=validateRead, + validateWrite=validateWrite, + ) def _getGlyphSetFormatVersion3(self, validateRead, validateWrite, layerName=None, defaultLayer=True, glyphNameToFileNameFunc=None): + from ufoLib.glifLib import GlyphSet + # if the default flag is on, make sure that the default in the file # matches the default being written. also make sure that this layer # name is not already linked to a non-default layer. @@ -1208,13 +1464,17 @@ def _getGlyphSetFormatVersion3(self, validateRead, validateWrite, layerName=None raise UFOLibError("The specified layer name is not a Unicode string.") directory = userNameToFileName(layerName, existing=existing, prefix="glyphs.") # make the directory - path = os.path.join(self._path, directory) - if not os.path.exists(path): - self._makeDirectory(subDirectory=directory) + glyphSubFS = self.fs.makedir(directory, recreate=True) # store the mapping self.layerContents[layerName] = directory # load the glyph set - return GlyphSet(path, glyphNameToFileNameFunc=glyphNameToFileNameFunc, ufoFormatVersion=3, validateRead=validateRead, validateWrite=validateWrite) + return GlyphSet( + glyphSubFS, + glyphNameToFileNameFunc=glyphNameToFileNameFunc, + ufoFormatVersion=3, + validateRead=validateRead, + validateWrite=validateWrite, + ) def renameGlyphSet(self, layerName, newLayerName, defaultLayer=False): """ @@ -1257,9 +1517,7 @@ def renameGlyphSet(self, layerName, newLayerName, defaultLayer=False): del self.layerContents[layerName] self.layerContents[newLayerName] = newDirectory # do the file system copy - oldDirectory = os.path.join(self._path, oldDirectory) - newDirectory = os.path.join(self._path, newDirectory) - shutil.move(oldDirectory, newDirectory) + self.fs.movedir(oldDirectory, newDirectory, create=True) def deleteGlyphSet(self, layerName): """ @@ -1270,7 +1528,7 @@ def deleteGlyphSet(self, layerName): # just write the data from the default layer return foundDirectory = self._findDirectoryForLayerName(layerName) - self._removeFileForPath(foundDirectory) + self.removePath(foundDirectory, removeEmptyParents=False) del self.layerContents[layerName] # /images @@ -1284,24 +1542,21 @@ def writeImage(self, fileName, data, validate=None): validate = self._validate if self._formatVersion < 3: raise UFOLibError("Images are not allowed in UFO %d." % self._formatVersion) + fileName = fsdecode(fileName) if validate: valid, error = pngValidator(data=data) if not valid: raise UFOLibError(error) - path = os.path.join(IMAGES_DIRNAME, fileName) - self.writeBytesToPath(path, data) + self.writeBytesToPath("%s/%s" % (IMAGES_DIRNAME, fileName), data) - def removeImage(self, fileName, validate=None): + def removeImage(self, fileName, validate=None): # XXX remove unused 'validate'? """ Remove the file named fileName from the images directory. """ - if validate is None: - validate = self._validate if self._formatVersion < 3: raise UFOLibError("Images are not allowed in UFO %d." % self._formatVersion) - path = os.path.join(IMAGES_DIRNAME, fileName) - self.removeFileForPath(path) + self.removePath("%s/%s" % (IMAGES_DIRNAME, fsdecode(fileName))) def copyImageFromReader(self, reader, sourceFileName, destFileName, validate=None): """ @@ -1313,10 +1568,26 @@ def copyImageFromReader(self, reader, sourceFileName, destFileName, validate=Non validate = self._validate if self._formatVersion < 3: raise UFOLibError("Images are not allowed in UFO %d." % self._formatVersion) - sourcePath = os.path.join("images", sourceFileName) - destPath = os.path.join("images", destFileName) + sourcePath = "%s/%s" % (IMAGES_DIRNAME, fsdecode(sourceFileName)) + destPath = "%s/%s" % (IMAGES_DIRNAME, fsdecode(destFileName)) self.copyFromReader(reader, sourcePath, destPath) + def close(self): + if self._havePreviousFile and self._fileStructure is UFOFileStructure.ZIP: + # if we are updating an existing zip file, we can now compress the + # contents of the temporary filesystem in the destination path + rootDir = os.path.splitext(os.path.basename(self._path))[0] + ".ufo" + with fs.zipfs.ZipFS(self._path, write=True, encoding="utf-8") as destFS: + fs.copy.copy_fs(self.fs, destFS.makedir(rootDir)) + if self._shouldClose: + self.fs.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + self.close() + # ---------------- # Helper Functions @@ -1337,124 +1608,6 @@ def makeUFOPath(path): name = ".".join([".".join(name.split(".")[:-1]), "ufo"]) return os.path.join(dir, name) -def writePlistAtomically(obj, path): - """ - Write a plist for "obj" to "path". Do this sort of atomically, - making it harder to cause corrupt files, for example when writePlist - encounters an error halfway during write. This also checks to see - if text matches the text that is already in the file at path. - If so, the file is not rewritten so that the modification date - is preserved. - """ - data = plistlib.dumps(obj) - writeDataFileAtomically(data, path) - -def writeFileAtomically(text, path, encoding="utf-8"): - """ - Write text into a file at path. Do this sort of atomically - making it harder to cause corrupt files. This also checks to see - if text matches the text that is already in the file at path. - If so, the file is not rewritten so that the modification date - is preserved. An encoding may be passed if needed. - """ - if os.path.exists(path): - with open(path, "r", encoding=encoding) as f: - oldText = f.read() - if text == oldText: - return - # if the text is empty, remove the existing file - if not text: - os.remove(path) - if text: - with open(path, "w", encoding=encoding) as f: - f.write(text) - -def writeDataFileAtomically(data, path): - """ - Write data into a file at path. Do this sort of atomically - making it harder to cause corrupt files. This also checks to see - if data matches the data that is already in the file at path. - If so, the file is not rewritten so that the modification date - is preserved. - """ - assert isinstance(data, bytes) - if os.path.exists(path): - f = open(path, "rb") - oldData = f.read() - f.close() - if data == oldData: - return - # if the data is empty, remove the existing file - if not data: - os.remove(path) - if data: - f = open(path, "wb") - f.write(data) - f.close() - -# --------------------------- -# Format Conversion Functions -# --------------------------- - -def convertUFOFormatVersion1ToFormatVersion2(inPath, outPath=None, validateRead=False, validateWrite=True): - """ - Function for converting a version format 1 UFO - to version format 2. inPath should be a path - to a UFO. outPath is the path where the new UFO - should be written. If outPath is not given, the - inPath will be used and, therefore, the UFO will - be converted in place. Otherwise, if outPath is - specified, nothing must exist at that path. - - ``validateRead`` will validate the read data. - ``validateWrite`` will validate the written data. - """ - from warnings import warn - warn("convertUFOFormatVersion1ToFormatVersion2 is deprecated.", DeprecationWarning) - if outPath is None: - outPath = inPath - if inPath != outPath and os.path.exists(outPath): - raise UFOLibError("A file already exists at %s." % outPath) - # use a reader for loading most of the data - reader = UFOReader(inPath, validate=validateRead) - if reader.formatVersion == 2: - raise UFOLibError("The UFO at %s is already format version 2." % inPath) - groups = reader.readGroups() - kerning = reader.readKerning() - libData = reader.readLib() - # read the info data manually and convert - infoPath = os.path.join(inPath, FONTINFO_FILENAME) - if not os.path.exists(infoPath): - infoData = {} - else: - with open(infoPath, "rb") as f: - infoData = plistlib.load(f) - infoData = _convertFontInfoDataVersion1ToVersion2(infoData) - # if the paths are the same, only need to change the - # fontinfo and meta info files. - infoPath = os.path.join(outPath, FONTINFO_FILENAME) - if inPath == outPath: - metaInfoPath = os.path.join(inPath, METAINFO_FILENAME) - metaInfo = dict( - creator="org.robofab.ufoLib", - formatVersion=2 - ) - writePlistAtomically(metaInfo, metaInfoPath) - writePlistAtomically(infoData, infoPath) - # otherwise write everything. - else: - writer = UFOWriter(outPath, formatVersion=2, validate=validateWrite) - writer.writeGroups(groups) - writer.writeKerning(kerning) - writer.writeLib(libData) - # write the info manually - writePlistAtomically(infoData, infoPath) - # copy the glyph tree - inGlyphs = os.path.join(inPath, DEFAULT_GLYPHS_DIRNAME) - outGlyphs = os.path.join(outPath, DEFAULT_GLYPHS_DIRNAME) - if os.path.exists(inGlyphs): - shutil.copytree(inGlyphs, outGlyphs) - # ---------------------- # fontinfo.plist Support # ---------------------- diff --git a/Lib/ufoLib/errors.py b/Lib/ufoLib/errors.py new file mode 100644 index 0000000..fb048d1 --- /dev/null +++ b/Lib/ufoLib/errors.py @@ -0,0 +1,9 @@ +from __future__ import absolute_import, unicode_literals + + +class UFOLibError(Exception): + pass + + +class GlifLibError(UFOLibError): + pass diff --git a/Lib/ufoLib/glifLib.py b/Lib/ufoLib/glifLib.py index 77e58f3..82b8782 100755 --- a/Lib/ufoLib/glifLib.py +++ b/Lib/ufoLib/glifLib.py @@ -12,16 +12,27 @@ """ from __future__ import absolute_import, unicode_literals -import os -from io import BytesIO, open from warnings import warn from collections import OrderedDict +import fs +import fs.base +import fs.errors +import fs.osfs +import fs.path from fontTools.misc.py23 import basestring, unicode, tobytes, tounicode from ufoLib import plistlib +from ufoLib.errors import GlifLibError from ufoLib.pointPen import AbstractPointPen, PointToSegmentPen from ufoLib.filenames import userNameToFileName -from ufoLib.validators import isDictEnough, genericTypeValidator, colorValidator,\ - guidelinesValidator, anchorsValidator, identifierValidator, imageValidator, glyphLibValidator +from ufoLib.validators import ( + genericTypeValidator, + colorValidator, + guidelinesValidator, + anchorsValidator, + identifierValidator, + imageValidator, + glyphLibValidator, +) from ufoLib import etree @@ -33,14 +44,11 @@ ] -class GlifLibError(Exception): pass - -class MissingPlistError(GlifLibError): pass - # --------- # Constants # --------- +CONTENTS_FILENAME = "contents.plist" LAYERINFO_FILENAME = "layerinfo.plist" supportedUFOFormatVersions = [1, 2, 3] supportedGLIFFormatVersions = [1, 2] @@ -96,9 +104,17 @@ class GlyphSet(object): glyphClass = Glyph - def __init__(self, dirName, glyphNameToFileNameFunc=None, ufoFormatVersion=3, validateRead=True, validateWrite=True): + def __init__( + self, + path, + glyphNameToFileNameFunc=None, + ufoFormatVersion=3, + validateRead=True, + validateWrite=True, + ): """ - 'dirName' should be a path to an existing directory. + 'path' should be a path (string) to an existing local directory, or + an instance of fs.base.FS class. The optional 'glyphNameToFileNameFunc' argument must be a callback function that takes two arguments: a glyph name and a list of all @@ -109,20 +125,53 @@ def __init__(self, dirName, glyphNameToFileNameFunc=None, ufoFormatVersion=3, va ``validateRead`` will validate read operations. Its default is ``True``. ``validateWrite`` will validate write operations. Its default is ``True``. """ - self.dirName = dirName if ufoFormatVersion not in supportedUFOFormatVersions: raise GlifLibError("Unsupported UFO format version: %s" % ufoFormatVersion) + if isinstance(path, basestring): + try: + filesystem = fs.osfs.OSFS(path) + except fs.errors.CreateFailed: + raise GlifLibError("No glyphs directory '%s'" % path) + self._shouldClose = True + elif isinstance(path, fs.base.FS): + filesystem = path + try: + filesystem.check() + except fs.errors.FilesystemClosed: + raise GlifLibError("the filesystem '%s' is closed" % filesystem) + self._shouldClose = False + else: + raise TypeError( + "Expected a path string or fs object, found %s" + % type(path).__name__ + ) + try: + path = filesystem.getsyspath("/") + except fs.errors.NoSysPath: + # network or in-memory FS may not map to the local one + path = unicode(filesystem) + # 'dirName' is kept for backward compatibility only, but it's DEPRECATED + # as it's not guaranteed that it maps to an existing OSFS directory. + # Client could use the FS api via the `self.fs` attribute instead. + self.dirName = fs.path.parts(path)[-1] + self.fs = filesystem + # if glyphSet contains no 'contents.plist', we consider it empty + self._havePreviousFile = filesystem.exists(CONTENTS_FILENAME) self.ufoFormatVersion = ufoFormatVersion if glyphNameToFileNameFunc is None: glyphNameToFileNameFunc = glyphNameToFileName self.glyphNameToFileName = glyphNameToFileNameFunc self._validateRead = validateRead self._validateWrite = validateWrite - self.rebuildContents() self._existingFileNames = None self._reverseContents = None self._glifCache = {} + self.rebuildContents() + + # here we reuse the same methods from UFOReader/UFOWriter + from ufoLib import _getPlist, _writePlist, _getFileModificationTime + def rebuildContents(self, validateRead=None): """ Rebuild the contents dict by loading contents.plist. @@ -132,12 +181,7 @@ def rebuildContents(self, validateRead=None): """ if validateRead is None: validateRead = self._validateRead - contentsPath = os.path.join(self.dirName, "contents.plist") - try: - contents = self._readPlist(contentsPath) - except MissingPlistError: - # missing, consider the glyphset empty. - contents = {} + contents = self._getPlist(CONTENTS_FILENAME, {}) # validate the contents if validateRead: invalidFormat = False @@ -149,10 +193,13 @@ def rebuildContents(self, validateRead=None): invalidFormat = True if not isinstance(fileName, basestring): invalidFormat = True - elif not os.path.exists(os.path.join(self.dirName, fileName)): - raise GlifLibError("contents.plist references a file that does not exist: %s" % fileName) + elif not self.fs.exists(fileName): + raise GlifLibError( + "%s references a file that does not exist: %s" + % (CONTENTS_FILENAME, fileName) + ) if invalidFormat: - raise GlifLibError("contents.plist is not properly formatted") + raise GlifLibError("%s is not properly formatted" % CONTENTS_FILENAME) self.contents = contents self._existingFileNames = None self._reverseContents = None @@ -178,9 +225,7 @@ def writeContents(self): Write the contents.plist file out to disk. Call this method when you're done writing glyphs. """ - contentsPath = os.path.join(self.dirName, "contents.plist") - with open(contentsPath, "wb") as f: - plistlib.dump(self.contents, f) + self._writePlist(CONTENTS_FILENAME, self.contents) # layer info @@ -191,11 +236,7 @@ def readLayerInfo(self, info, validateRead=None): """ if validateRead is None: validateRead = self._validateRead - path = os.path.join(self.dirName, LAYERINFO_FILENAME) - try: - infoDict = self._readPlist(path) - except MissingPlistError: - return + infoDict = self._getPlist(LAYERINFO_FILENAME, {}) if validateRead: if not isinstance(infoDict, dict): raise GlifLibError("layerinfo.plist is not properly formatted.") @@ -227,15 +268,17 @@ def writeLayerInfo(self, info, validateWrite=None): if value is None or (attr == 'lib' and not value): continue infoData[attr] = value - # validate - if validateWrite: - infoData = validateLayerInfoVersion3Data(infoData) - # write file - path = os.path.join(self.dirName, LAYERINFO_FILENAME) - with open(path, "wb") as f: - plistlib.dump(infoData, f) - - # read caching + if infoData: + # validate + if validateWrite: + infoData = validateLayerInfoVersion3Data(infoData) + # write file + self._writePlist(LAYERINFO_FILENAME, infoData) + elif self._havePreviousFile and self.fs.exists(LAYERINFO_FILENAME): + # data empty, remove existing file + self.fs.remove(LAYERINFO_FILENAME) + + # # read caching def getGLIF(self, glyphName): """ @@ -253,25 +296,15 @@ def getGLIF(self, glyphName): efficiency, the cached GLIF will be purged by various other methods such as readGlyph. """ - needRead = False fileName = self.contents.get(glyphName) - path = None - if fileName is not None: - path = os.path.join(self.dirName, fileName) - if glyphName not in self._glifCache: - needRead = True - elif fileName is not None and os.path.getmtime(path) != self._glifCache[glyphName][1]: - needRead = True - if needRead: - fileName = self.contents[glyphName] - try: - with open(path, "rb") as f: - text = f.read() - except IOError as e: - if e.errno == 2: # aka. FileNotFoundError - raise KeyError(glyphName) - raise - self._glifCache[glyphName] = (text, os.path.getmtime(path)) + if glyphName not in self._glifCache or ( + fileName is not None + and self._getFileModificationTime(fileName) != self._glifCache[glyphName][1] + ): + if fileName is None or not self.fs.exists(fileName): + raise KeyError(glyphName) + data = self.fs.getbytes(fileName) + self._glifCache[glyphName] = (data, self._getFileModificationTime(fileName)) return self._glifCache[glyphName][0] def getGLIFModificationTime(self, glyphName): @@ -372,11 +405,13 @@ def writeGlyph(self, glyphName, glyphObject=None, drawPointsFunc=None, formatVer if formatVersion not in supportedGLIFFormatVersions: raise GlifLibError("Unsupported GLIF format version: %s" % formatVersion) if formatVersion == 2 and self.ufoFormatVersion < 3: - raise GlifLibError("Unsupported GLIF format version (%d) for UFO format version %d." % (formatVersion, self.ufoFormatVersion)) + raise GlifLibError( + "Unsupported GLIF format version (%d) for UFO format version %d." + % (formatVersion, self.ufoFormatVersion) + ) if validate is None: validate = self._validateWrite self._purgeCachedGLIF(glyphName) - data = _writeGlyphToBytes(glyphName, glyphObject, drawPointsFunc, formatVersion=formatVersion, validate=validate) fileName = self.contents.get(glyphName) if fileName is None: if self._existingFileNames is None: @@ -388,14 +423,20 @@ def writeGlyph(self, glyphName, glyphObject=None, drawPointsFunc=None, formatVer self._existingFileNames[fileName] = fileName.lower() if self._reverseContents is not None: self._reverseContents[fileName.lower()] = glyphName - path = os.path.join(self.dirName, fileName) - if os.path.exists(path): - with open(path, "rb") as f: - oldData = f.read() - if data == oldData: - return - with open(path, "wb") as f: - f.write(data) + data = _writeGlyphToBytes( + glyphName, + glyphObject, + drawPointsFunc, + formatVersion=formatVersion, + validate=validate, + ) + if ( + self._havePreviousFile + and self.fs.exists(fileName) + and data == self.fs.getbytes(fileName) + ): + return + self.fs.setbytes(fileName, data) def deleteGlyph(self, glyphName): """Permanently delete the glyph from the glyph set on disk. Will @@ -403,7 +444,7 @@ def deleteGlyph(self, glyphName): """ self._purgeCachedGLIF(glyphName) fileName = self.contents[glyphName] - os.remove(os.path.join(self.dirName, fileName)) + self.fs.remove(fileName) if self._existingFileNames is not None: del self._existingFileNames[fileName] if self._reverseContents is not None: @@ -475,17 +516,15 @@ def getImageReferences(self, glyphNames=None): images[glyphName] = _fetchImageFileName(text) return images - # internal methods + def close(self): + if self._shouldClose: + self.fs.close() - def _readPlist(self, path): - try: - with open(path, "rb") as f: - data = plistlib.load(f) - return data - except Exception as e: - if isinstance(e, IOError) and e.errno == 2: - raise MissingPlistError(path) - raise GlifLibError("The file %s could not be read." % path) + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + self.close() # ----------------------- diff --git a/Lib/ufoLib/utils.py b/Lib/ufoLib/utils.py index 7741e29..5044dd1 100644 --- a/Lib/ufoLib/utils.py +++ b/Lib/ufoLib/utils.py @@ -2,8 +2,40 @@ It's not considered part of the public ufoLib API. """ from __future__ import absolute_import, unicode_literals +import sys import warnings import functools +from datetime import datetime +from fontTools.misc.py23 import tounicode + + +if hasattr(datetime, "timestamp"): # python >= 3.3 + + def datetimeAsTimestamp(dt): + return dt.timestamp() + +else: + from datetime import tzinfo, timedelta + + ZERO = timedelta(0) + + class UTC(tzinfo): + + def utcoffset(self, dt): + return ZERO + + def tzname(self, dt): + return "UTC" + + def dst(self, dt): + return ZERO + + utc = UTC() + + EPOCH = datetime.fromtimestamp(0, tz=utc) + + def datetimeAsTimestamp(dt): + return (dt - EPOCH).total_seconds() def deprecated(msg=""): @@ -34,6 +66,10 @@ def wrapper(*args, **kwargs): return deprecated_decorator +def fsdecode(path, encoding=sys.getfilesystemencoding()): + return tounicode(path, encoding=encoding) + + if __name__ == "__main__": import doctest diff --git a/Lib/ufoLib/validators.py b/Lib/ufoLib/validators.py index ab31233..46c7502 100644 --- a/Lib/ufoLib/validators.py +++ b/Lib/ufoLib/validators.py @@ -1,9 +1,10 @@ """Various low level data validators.""" from __future__ import absolute_import, unicode_literals -import os import calendar from io import open +import fs.base +import fs.osfs try: from collections.abc import Mapping # python >= 3.3 @@ -775,11 +776,16 @@ def pngValidator(path=None, data=None, fileObj=None): # layercontents.plist # ------------------- -def layerContentsValidator(value, ufoPath): +def layerContentsValidator(value, ufoPathOrFileSystem): """ Check the validity of layercontents.plist. Version 3+. """ + if isinstance(ufoPathOrFileSystem, fs.base.FS): + fileSystem = ufoPathOrFileSystem + else: + fileSystem = fs.osfs.OSFS(ufoPathOrFileSystem) + bogusFileMessage = "layercontents.plist in not in the correct format." # file isn't in the right format if not isinstance(value, list): @@ -805,8 +811,7 @@ def layerContentsValidator(value, ufoPath): if len(layerName) == 0: return False, "Empty layer name in layercontents.plist." # directory doesn't exist - p = os.path.join(ufoPath, directoryName) - if not os.path.exists(p): + if not fileSystem.exists(directoryName): return False, "A glyphset does not exist at %s." % directoryName # default layer name if layerName == "public.default" and directoryName != "glyphs": diff --git a/MANIFEST.in b/MANIFEST.in index f00def8..03ee2cb 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,6 +3,7 @@ include README.md notes.txt LICENSE.txt include Documentation/Makefile recursive-include Documentation *.py *.rst -recursive-include Lib/ufoLib/test/testdata *.plist *.glif *.fea *.txt +recursive-include tests *.py +recursive-include tests/testdata *.plist *.glif *.fea *.txt *.ttx *ufoz include requirements.txt extra_requirements.txt .coveragerc tox.ini diff --git a/appveyor.yml b/appveyor.yml index 77c05f7..507429a 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -14,9 +14,7 @@ environment: - PYTHON: "C:\\Python37-x64" PYTHON_VERSION: "3.7.x" PYTHON_ARCH: "64" - # lxml doesn't have windows 3.7 wheels yet - # TOXENV: "py37-cov,py37-cov-lxml" - TOXENV: "py37-cov" + TOXENV: "py37-cov,py37-cov-lxml" skip_branch_with_pr: true diff --git a/extra_requirements.txt b/extra_requirements.txt index 8ccb74e..9c682a4 100644 --- a/extra_requirements.txt +++ b/extra_requirements.txt @@ -1,2 +1,2 @@ -lxml==4.2.4 +lxml==4.2.5 singledispatch==3.4.0.3; python_version < '3.4' diff --git a/requirements.txt b/requirements.txt index 5553f36..d02d86f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ fonttools==3.30.0 +fs==2.1.1 diff --git a/setup.cfg b/setup.cfg index f0c0ca8..8c6be1a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.3.2 +current_version = 3.0.0.dev0 commit = True tag = False tag_name = v{new_version} @@ -40,7 +40,12 @@ license_file = LICENSE.txt minversion = 3.0.2 testpaths = ufoLib + tests/ doctest_optionflags = ALLOW_UNICODE ALLOW_BYTES +filterwarnings = + ignore:readPlist:DeprecationWarning:test_plistlib + ignore:writePlist:DeprecationWarning:test_plistlib + ignore:some_function:DeprecationWarning:utils addopts = -r a --doctest-modules diff --git a/setup.py b/setup.py index 260a6c3..6db65e8 100755 --- a/setup.py +++ b/setup.py @@ -147,7 +147,7 @@ def run(self): setup_params = dict( name="ufoLib", - version="2.3.2", + version="3.0.0.dev0", description="A low-level UFO reader and writer.", author="Just van Rossum, Tal Leming, Erik van Blokland, others", author_email="info@robofab.com", @@ -165,7 +165,8 @@ def run(self): 'pytest>=3.0.2', ], install_requires=[ - "fonttools >= 3.1.2, < 4", + "fonttools >= 3.10.0, < 4", + "fs >= 2.1.1, < 3", ], extras_require={ "lxml": [ @@ -177,6 +178,10 @@ def run(self): "pytest-cov >= 2.5.1, <3", "pytest-randomly >= 1.2.3, <2", ], + # conditional dependency syntax compatible with setuptools >= 18 + # https://hynek.me/articles/conditional-python-dependencies/ + # install 'enum34' backport on python < 3.4 + ":python_version < '3.4'": ['enum34 ~= 1.1.6'], }, cmdclass={ "release": release, diff --git a/Lib/ufoLib/test/__init__.py b/tests/__init__.py similarity index 100% rename from Lib/ufoLib/test/__init__.py rename to tests/__init__.py diff --git a/Lib/ufoLib/test/testSupport.py b/tests/testSupport.py similarity index 96% rename from Lib/ufoLib/test/testSupport.py rename to tests/testSupport.py index 7c04ae5..4b4a541 100755 --- a/Lib/ufoLib/test/testSupport.py +++ b/tests/testSupport.py @@ -1,9 +1,7 @@ """Miscellaneous helpers for our test suite.""" from __future__ import absolute_import, unicode_literals -import sys import os -import unittest try: basestring @@ -21,34 +19,6 @@ def getDemoFontGlyphSetPath(): return os.path.join(getDemoFontPath(), "glyphs") -def _gatherTestCasesFromCallerByMagic(): - # UGLY magic: fetch TestClass subclasses from the globals of our - # caller's caller. - frame = sys._getframe(2) - return _gatherTestCasesFromDict(frame.f_globals) - - -def _gatherTestCasesFromDict(d): - testCases = [] - for ob in list(d.values()): - if isinstance(ob, type) and issubclass(ob, unittest.TestCase): - testCases.append(ob) - return testCases - - -def runTests(testCases=None, verbosity=1): - """Run a series of tests.""" - if testCases is None: - testCases = _gatherTestCasesFromCallerByMagic() - loader = unittest.TestLoader() - suites = [] - for testCase in testCases: - suites.append(loader.loadTestsFromTestCase(testCase)) - - testRunner = unittest.TextTestRunner(verbosity=verbosity) - testSuite = unittest.TestSuite(suites) - testRunner.run(testSuite) - # GLIF test tools class Glyph(object): diff --git a/Lib/ufoLib/test/test_GLIF1.py b/tests/test_GLIF1.py similarity index 99% rename from Lib/ufoLib/test/test_GLIF1.py rename to tests/test_GLIF1.py index ecd1783..b4371c9 100644 --- a/Lib/ufoLib/test/test_GLIF1.py +++ b/tests/test_GLIF1.py @@ -2,7 +2,7 @@ from __future__ import absolute_import, unicode_literals import unittest from ufoLib.glifLib import GlifLibError, readGlyphFromString, writeGlyphToString -from ufoLib.test.testSupport import Glyph, stripText +from .testSupport import Glyph, stripText from itertools import islice try: @@ -1335,8 +1335,3 @@ def testOpenContourLooseOffCurves_illegal(self): pointPen.endPath() """ self.assertRaises(GlifLibError, self.pyToGLIF, py) - - -if __name__ == "__main__": - from ufoLib.test.testSupport import runTests - runTests() diff --git a/Lib/ufoLib/test/test_GLIF2.py b/tests/test_GLIF2.py similarity index 99% rename from Lib/ufoLib/test/test_GLIF2.py rename to tests/test_GLIF2.py index 87ffb53..3db4931 100644 --- a/Lib/ufoLib/test/test_GLIF2.py +++ b/tests/test_GLIF2.py @@ -2,7 +2,7 @@ from __future__ import absolute_import, unicode_literals import unittest from ufoLib.glifLib import GlifLibError, readGlyphFromString, writeGlyphToString -from ufoLib.test.testSupport import Glyph, stripText +from .testSupport import Glyph, stripText from itertools import islice try: @@ -2370,8 +2370,3 @@ def testIdentifierConflict_anchor_anchor(self): """ self.assertRaises(GlifLibError, self.pyToGLIF, py) self.assertRaises(GlifLibError, self.glifToPy, glif) - - -if __name__ == "__main__": - from ufoLib.test.testSupport import runTests - runTests() diff --git a/Lib/ufoLib/test/test_UFO1.py b/tests/test_UFO1.py similarity index 96% rename from Lib/ufoLib/test/test_UFO1.py rename to tests/test_UFO1.py index cf1cbbc..2fb688c 100644 --- a/Lib/ufoLib/test/test_UFO1.py +++ b/tests/test_UFO1.py @@ -7,7 +7,7 @@ from io import open from ufoLib import UFOReader, UFOWriter, UFOLibError from ufoLib import plistlib -from ufoLib.test.testSupport import fontInfoVersion1, fontInfoVersion2 +from .testSupport import fontInfoVersion1, fontInfoVersion2 class TestInfoObject(object): pass @@ -150,8 +150,3 @@ def testWidthNameConversion(self): writer.writeInfo(infoObject) writtenData = self.readPlist() self.assertEqual(writtenData["widthName"], old) - - -if __name__ == "__main__": - from ufoLib.test.testSupport import runTests - runTests() diff --git a/Lib/ufoLib/test/test_UFO2.py b/tests/test_UFO2.py similarity index 99% rename from Lib/ufoLib/test/test_UFO2.py rename to tests/test_UFO2.py index 480390a..c44d657 100644 --- a/Lib/ufoLib/test/test_UFO2.py +++ b/tests/test_UFO2.py @@ -7,7 +7,7 @@ from io import open from ufoLib import UFOReader, UFOWriter, UFOLibError from ufoLib import plistlib -from ufoLib.test.testSupport import fontInfoVersion2 +from .testSupport import fontInfoVersion2 class TestInfoObject(object): pass @@ -1412,8 +1412,3 @@ def testPostscriptWrite(self): infoObject.macintoshFONDName = 123 writer = UFOWriter(self.dstDir, formatVersion=2) self.assertRaises(UFOLibError, writer.writeInfo, info=infoObject) - - -if __name__ == "__main__": - from ufoLib.test.testSupport import runTests - runTests() diff --git a/Lib/ufoLib/test/test_UFO3.py b/tests/test_UFO3.py similarity index 99% rename from Lib/ufoLib/test/test_UFO3.py rename to tests/test_UFO3.py index 2a79dd3..f59b136 100644 --- a/Lib/ufoLib/test/test_UFO3.py +++ b/tests/test_UFO3.py @@ -9,7 +9,7 @@ from ufoLib import UFOReader, UFOWriter, UFOLibError from ufoLib.glifLib import GlifLibError from ufoLib import plistlib -from ufoLib.test.testSupport import fontInfoVersion3 +from .testSupport import fontInfoVersion3 class TestInfoObject(object): pass @@ -2663,7 +2663,7 @@ def testWOFFWrite(self): infoObject = self.makeInfoObject() infoObject.woffMetadataUniqueID = None writer = UFOWriter(self.dstDir, formatVersion=3) - writer.writeInfo(TestInfoObject()) + writer.writeInfo(infoObject) self.tearDownUFO() ## not a dict infoObject = self.makeInfoObject() @@ -2693,7 +2693,7 @@ def testWOFFWrite(self): infoObject = self.makeInfoObject() infoObject.woffMetadataUniqueID = dict(id="") writer = UFOWriter(self.dstDir, formatVersion=3) - writer.writeInfo(TestInfoObject()) + writer.writeInfo(infoObject) self.tearDownUFO() # woffMetadataVendor ## no name @@ -2712,13 +2712,13 @@ def testWOFFWrite(self): infoObject = self.makeInfoObject() infoObject.woffMetadataVendor = dict(name="", url="foo") writer = UFOWriter(self.dstDir, formatVersion=3) - writer.writeInfo(TestInfoObject()) + writer.writeInfo(infoObject) self.tearDownUFO() ## no URL infoObject = self.makeInfoObject() infoObject.woffMetadataVendor = dict(name="foo") writer = UFOWriter(self.dstDir, formatVersion=3) - writer.writeInfo(TestInfoObject()) + writer.writeInfo(infoObject) self.tearDownUFO() ## url not a string infoObject = self.makeInfoObject() @@ -2730,18 +2730,18 @@ def testWOFFWrite(self): infoObject = self.makeInfoObject() infoObject.woffMetadataVendor = dict(name="foo", url="") writer = UFOWriter(self.dstDir, formatVersion=3) - writer.writeInfo(TestInfoObject()) + writer.writeInfo(infoObject) self.tearDownUFO() ## have dir infoObject = self.makeInfoObject() infoObject.woffMetadataVendor = dict(name="foo", url="bar", dir="ltr") writer = UFOWriter(self.dstDir, formatVersion=3) - writer.writeInfo(TestInfoObject()) + writer.writeInfo(infoObject) self.tearDownUFO() infoObject = self.makeInfoObject() infoObject.woffMetadataVendor = dict(name="foo", url="bar", dir="rtl") writer = UFOWriter(self.dstDir, formatVersion=3) - writer.writeInfo(TestInfoObject()) + writer.writeInfo(infoObject) self.tearDownUFO() ## dir not a string infoObject = self.makeInfoObject() @@ -2759,7 +2759,7 @@ def testWOFFWrite(self): infoObject = self.makeInfoObject() infoObject.woffMetadataVendor = {"name" : "foo", "url" : "bar", "class" : "hello"} writer = UFOWriter(self.dstDir, formatVersion=3) - writer.writeInfo(TestInfoObject()) + writer.writeInfo(infoObject) self.tearDownUFO() ## class not a string infoObject = self.makeInfoObject() @@ -2771,7 +2771,7 @@ def testWOFFWrite(self): infoObject = self.makeInfoObject() infoObject.woffMetadataVendor = {"name" : "foo", "url" : "bar", "class" : ""} writer = UFOWriter(self.dstDir, formatVersion=3) - writer.writeInfo(TestInfoObject()) + writer.writeInfo(infoObject) self.tearDownUFO() # woffMetadataCredits ## no credits attribute @@ -2856,7 +2856,8 @@ def testWOFFWrite(self): ## no url infoObject = self.makeInfoObject() infoObject.woffMetadataDescription = dict(text=[dict(text="foo")]) - writer.writeInfo(TestInfoObject()) + writer = UFOWriter(self.dstDir, formatVersion=3) + writer.writeInfo(infoObject) self.tearDownUFO() ## url not a string infoObject = self.makeInfoObject() @@ -2929,7 +2930,7 @@ def testWOFFWrite(self): infoObject = self.makeInfoObject() infoObject.woffMetadataLicense = dict(text=[dict(text="foo")]) writer = UFOWriter(self.dstDir, formatVersion=3) - writer.writeInfo(TestInfoObject()) + writer.writeInfo(infoObject) self.tearDownUFO() ## url not a string infoObject = self.makeInfoObject() @@ -2947,7 +2948,7 @@ def testWOFFWrite(self): infoObject = self.makeInfoObject() infoObject.woffMetadataLicense = dict(url="foo") writer = UFOWriter(self.dstDir, formatVersion=3) - writer.writeInfo(TestInfoObject()) + writer.writeInfo(infoObject) self.tearDownUFO() ## text not a list infoObject = self.makeInfoObject() @@ -4169,8 +4170,8 @@ def testUFOReaderDataDirectoryListing(self): reader = UFOReader(self.getFontPath()) found = reader.getDataDirectoryListing() expected = [ - 'org.unifiedfontobject.directory%(s)sbar%(s)slol.txt' % {'s': os.sep}, - 'org.unifiedfontobject.directory%(s)sfoo.txt' % {'s': os.sep}, + 'org.unifiedfontobject.directory/bar/lol.txt', + 'org.unifiedfontobject.directory/foo.txt', 'org.unifiedfontobject.file.txt' ] self.assertEqual(set(found), set(expected)) @@ -4282,7 +4283,6 @@ def testUFOWriterRemoveFile(self): self.assertEqual(os.path.exists(os.path.join(self.dstDir, path3)), False) self.assertEqual(os.path.exists(os.path.dirname(os.path.join(self.dstDir, path2))), False) self.assertEqual(os.path.exists(os.path.join(self.dstDir, "data/org.unifiedfontobject.removefile")), False) - self.assertRaises(UFOLibError, writer.removeFileForPath, path="metainfo.plist") self.assertRaises(UFOLibError, writer.removeFileForPath, path="data/org.unifiedfontobject.doesNotExist.txt") self.tearDownUFO() @@ -4398,7 +4398,7 @@ def testBogusLayerInfo(self): reader = UFOReader(self.ufoPath, validate=True) glyphSet = reader.getGlyphSet() info = TestLayerInfoObject() - self.assertRaises(GlifLibError, glyphSet.readLayerInfo, info) + self.assertRaises(UFOLibError, glyphSet.readLayerInfo, info) def testInvalidFormatLayerInfo(self): self.makeUFO() @@ -4684,7 +4684,3 @@ def testColor(self): info.color = "0, 0, 0, 2" glyphSet = self.makeGlyphSet() self.assertRaises(GlifLibError, glyphSet.writeLayerInfo, info) - -if __name__ == "__main__": - from ufoLib.test.testSupport import runTests - runTests() diff --git a/Lib/ufoLib/test/test_UFOConversion.py b/tests/test_UFOConversion.py similarity index 94% rename from Lib/ufoLib/test/test_UFOConversion.py rename to tests/test_UFOConversion.py index 4fc3335..8342373 100644 --- a/Lib/ufoLib/test/test_UFOConversion.py +++ b/tests/test_UFOConversion.py @@ -5,9 +5,9 @@ import unittest import tempfile from io import open -from ufoLib import convertUFOFormatVersion1ToFormatVersion2, UFOReader, UFOWriter +from ufoLib import UFOReader, UFOWriter from ufoLib import plistlib -from ufoLib.test.testSupport import expectedFontInfo1To2Conversion, expectedFontInfo2To1Conversion +from .testSupport import expectedFontInfo1To2Conversion, expectedFontInfo2To1Conversion # the format version 1 lib.plist contains some data @@ -117,13 +117,6 @@ def compareFileStructures(self, path1, path2, expectedInfoData, testFeatures): data2 = plistlib.load(f) self.assertEqual(data1, data2) - def test1To2(self): - path1 = self.getFontPath("TestFont1 (UFO1).ufo") - path2 = self.getFontPath("TestFont1 (UFO1) converted.ufo") - path3 = self.getFontPath("TestFont1 (UFO2).ufo") - convertUFOFormatVersion1ToFormatVersion2(path1, path2) - self.compareFileStructures(path2, path3, expectedFontInfo1To2Conversion, False) - # --------------------- # kerning up conversion @@ -352,9 +345,3 @@ def testWrite(self): writtenKerning = plistlib.load(f) self.assertEqual(writtenKerning, self.expectedWrittenKerning) self.tearDownUFO() - - - -if __name__ == "__main__": - from ufoLib.test.testSupport import runTests - runTests() diff --git a/tests/test_UFOZ.py b/tests/test_UFOZ.py new file mode 100644 index 0000000..d009e53 --- /dev/null +++ b/tests/test_UFOZ.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from fontTools.misc.py23 import tostr +from ufoLib import UFOReader, UFOWriter, UFOFileStructure +from ufoLib.errors import UFOLibError, GlifLibError +from ufoLib import plistlib +import sys +import os +import fs.osfs +import fs.tempfs +import fs.memoryfs +import fs.copy +import pytest +import warnings + + +TESTDATA = fs.osfs.OSFS( + os.path.join(os.path.dirname(__file__), "testdata") +) +TEST_UFO3 = "TestFont1 (UFO3).ufo" +TEST_UFOZ = "TestFont1 (UFO3).ufoz" + + +@pytest.fixture(params=[TEST_UFO3, TEST_UFOZ]) +def testufo(request): + name = request.param + with fs.tempfs.TempFS() as tmp: + if TESTDATA.isdir(name): + fs.copy.copy_dir(TESTDATA, name, tmp, name) + else: + fs.copy.copy_file(TESTDATA, name, tmp, name) + yield tmp.getsyspath(name) + + +@pytest.fixture +def testufoz(): + with fs.tempfs.TempFS() as tmp: + fs.copy.copy_file(TESTDATA, TEST_UFOZ, tmp, TEST_UFOZ) + yield tmp.getsyspath(TEST_UFOZ) + + +class TestUFOZ(object): + + def test_read(self, testufoz): + with UFOReader(testufoz) as reader: + assert reader.fileStructure == UFOFileStructure.ZIP + assert reader.formatVersion == 3 + + def test_write(self, testufoz): + with UFOWriter(testufoz, structure="zip") as writer: + writer.writeLib({"hello world": 123}) + with UFOReader(testufoz) as reader: + assert reader.readLib() == {"hello world": 123} + + +def test_pathlike(testufo): + + class PathLike(object): + + def __init__(self, s): + self._path = s + + def __fspath__(self): + return tostr(self._path, sys.getfilesystemencoding()) + + path = PathLike(testufo) + + with UFOReader(path) as reader: + assert reader._path == path.__fspath__() + + with UFOWriter(path) as writer: + assert writer._path == path.__fspath__() + + +def test_path_attribute_deprecated(testufo): + with UFOWriter(testufo) as writer: + with pytest.warns(DeprecationWarning, match="The 'path' attribute"): + writer.path + + +@pytest.fixture +def memufo(): + m = fs.memoryfs.MemoryFS() + fs.copy.copy_dir(TESTDATA, TEST_UFO3, m, "/") + return m + + +class TestMemoryFS(object): + + def test_init_reader(self, memufo): + with UFOReader(memufo) as reader: + assert reader.formatVersion == 3 + assert reader.fileStructure == UFOFileStructure.PACKAGE + + def test_init_writer(self): + m = fs.memoryfs.MemoryFS() + with UFOWriter(m) as writer: + assert m.exists("metainfo.plist") + assert writer._path == "" diff --git a/Lib/ufoLib/test/test_etree.py b/tests/test_etree.py similarity index 100% rename from Lib/ufoLib/test/test_etree.py rename to tests/test_etree.py diff --git a/Lib/ufoLib/test/test_filenames.py b/tests/test_filenames.py similarity index 100% rename from Lib/ufoLib/test/test_filenames.py rename to tests/test_filenames.py diff --git a/Lib/ufoLib/test/test_glifLib.py b/tests/test_glifLib.py similarity index 95% rename from Lib/ufoLib/test/test_glifLib.py rename to tests/test_glifLib.py index 43fac18..3120534 100644 --- a/Lib/ufoLib/test/test_glifLib.py +++ b/tests/test_glifLib.py @@ -4,7 +4,7 @@ import shutil import unittest from io import open -from ufoLib.test.testSupport import getDemoFontGlyphSetPath +from .testSupport import getDemoFontGlyphSetPath from ufoLib.glifLib import ( GlyphSet, glyphNameToFileName, readGlyphFromString, writeGlyphToString, _XML_DECLARATION, @@ -162,11 +162,3 @@ def testRoundTrip(self): def testXmlDeclaration(self): s = writeGlyphToString("a", _Glyph()) self.assertTrue(s.startswith(_XML_DECLARATION.decode("utf-8"))) - - -if __name__ == "__main__": - from ufoLib.test.testSupport import runTests - import sys - if len(sys.argv) > 1 and os.path.isdir(sys.argv[-1]): - GLYPHSETDIR = sys.argv.pop() - runTests() diff --git a/Lib/ufoLib/test/test_plistlib.py b/tests/test_plistlib.py similarity index 100% rename from Lib/ufoLib/test/test_plistlib.py rename to tests/test_plistlib.py diff --git a/Lib/ufoLib/test/testdata/DemoFont.ufo/fontinfo.plist b/tests/testdata/DemoFont.ufo/fontinfo.plist similarity index 100% rename from Lib/ufoLib/test/testdata/DemoFont.ufo/fontinfo.plist rename to tests/testdata/DemoFont.ufo/fontinfo.plist diff --git a/Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/A_.glif b/tests/testdata/DemoFont.ufo/glyphs/A_.glif similarity index 100% rename from Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/A_.glif rename to tests/testdata/DemoFont.ufo/glyphs/A_.glif diff --git a/Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/B_.glif b/tests/testdata/DemoFont.ufo/glyphs/B_.glif similarity index 100% rename from Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/B_.glif rename to tests/testdata/DemoFont.ufo/glyphs/B_.glif diff --git a/Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/F_.glif b/tests/testdata/DemoFont.ufo/glyphs/F_.glif similarity index 100% rename from Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/F_.glif rename to tests/testdata/DemoFont.ufo/glyphs/F_.glif diff --git a/Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/F__A__B_.glif b/tests/testdata/DemoFont.ufo/glyphs/F__A__B_.glif similarity index 100% rename from Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/F__A__B_.glif rename to tests/testdata/DemoFont.ufo/glyphs/F__A__B_.glif diff --git a/Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/G_.glif b/tests/testdata/DemoFont.ufo/glyphs/G_.glif similarity index 100% rename from Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/G_.glif rename to tests/testdata/DemoFont.ufo/glyphs/G_.glif diff --git a/Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/O_.glif b/tests/testdata/DemoFont.ufo/glyphs/O_.glif similarity index 100% rename from Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/O_.glif rename to tests/testdata/DemoFont.ufo/glyphs/O_.glif diff --git a/Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/R_.glif b/tests/testdata/DemoFont.ufo/glyphs/R_.glif similarity index 100% rename from Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/R_.glif rename to tests/testdata/DemoFont.ufo/glyphs/R_.glif diff --git a/Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/a.glif b/tests/testdata/DemoFont.ufo/glyphs/a.glif similarity index 100% rename from Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/a.glif rename to tests/testdata/DemoFont.ufo/glyphs/a.glif diff --git a/Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/contents.plist b/tests/testdata/DemoFont.ufo/glyphs/contents.plist similarity index 100% rename from Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/contents.plist rename to tests/testdata/DemoFont.ufo/glyphs/contents.plist diff --git a/Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/testglyph1.glif b/tests/testdata/DemoFont.ufo/glyphs/testglyph1.glif similarity index 100% rename from Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/testglyph1.glif rename to tests/testdata/DemoFont.ufo/glyphs/testglyph1.glif diff --git a/Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/testglyph1.reversed.glif b/tests/testdata/DemoFont.ufo/glyphs/testglyph1.reversed.glif similarity index 100% rename from Lib/ufoLib/test/testdata/DemoFont.ufo/glyphs/testglyph1.reversed.glif rename to tests/testdata/DemoFont.ufo/glyphs/testglyph1.reversed.glif diff --git a/Lib/ufoLib/test/testdata/DemoFont.ufo/lib.plist b/tests/testdata/DemoFont.ufo/lib.plist similarity index 100% rename from Lib/ufoLib/test/testdata/DemoFont.ufo/lib.plist rename to tests/testdata/DemoFont.ufo/lib.plist diff --git a/Lib/ufoLib/test/testdata/DemoFont.ufo/metainfo.plist b/tests/testdata/DemoFont.ufo/metainfo.plist similarity index 100% rename from Lib/ufoLib/test/testdata/DemoFont.ufo/metainfo.plist rename to tests/testdata/DemoFont.ufo/metainfo.plist diff --git a/Lib/ufoLib/test/testdata/TestFont1 (UFO1).ufo/fontinfo.plist b/tests/testdata/TestFont1 (UFO1).ufo/fontinfo.plist similarity index 100% rename from Lib/ufoLib/test/testdata/TestFont1 (UFO1).ufo/fontinfo.plist rename to tests/testdata/TestFont1 (UFO1).ufo/fontinfo.plist diff --git a/Lib/ufoLib/test/testdata/TestFont1 (UFO1).ufo/glyphs/A_.glif b/tests/testdata/TestFont1 (UFO1).ufo/glyphs/A_.glif similarity index 100% rename from Lib/ufoLib/test/testdata/TestFont1 (UFO1).ufo/glyphs/A_.glif rename to tests/testdata/TestFont1 (UFO1).ufo/glyphs/A_.glif diff --git a/Lib/ufoLib/test/testdata/TestFont1 (UFO1).ufo/glyphs/B_.glif b/tests/testdata/TestFont1 (UFO1).ufo/glyphs/B_.glif similarity index 100% rename from Lib/ufoLib/test/testdata/TestFont1 (UFO1).ufo/glyphs/B_.glif rename to tests/testdata/TestFont1 (UFO1).ufo/glyphs/B_.glif diff --git a/Lib/ufoLib/test/testdata/TestFont1 (UFO1).ufo/glyphs/contents.plist b/tests/testdata/TestFont1 (UFO1).ufo/glyphs/contents.plist similarity index 100% rename from Lib/ufoLib/test/testdata/TestFont1 (UFO1).ufo/glyphs/contents.plist rename to tests/testdata/TestFont1 (UFO1).ufo/glyphs/contents.plist diff --git a/Lib/ufoLib/test/testdata/TestFont1 (UFO1).ufo/groups.plist b/tests/testdata/TestFont1 (UFO1).ufo/groups.plist similarity index 100% rename from Lib/ufoLib/test/testdata/TestFont1 (UFO1).ufo/groups.plist rename to tests/testdata/TestFont1 (UFO1).ufo/groups.plist diff --git a/Lib/ufoLib/test/testdata/TestFont1 (UFO1).ufo/kerning.plist b/tests/testdata/TestFont1 (UFO1).ufo/kerning.plist similarity index 100% rename from Lib/ufoLib/test/testdata/TestFont1 (UFO1).ufo/kerning.plist rename to tests/testdata/TestFont1 (UFO1).ufo/kerning.plist diff --git a/Lib/ufoLib/test/testdata/TestFont1 (UFO1).ufo/lib.plist b/tests/testdata/TestFont1 (UFO1).ufo/lib.plist similarity index 100% rename from Lib/ufoLib/test/testdata/TestFont1 (UFO1).ufo/lib.plist rename to tests/testdata/TestFont1 (UFO1).ufo/lib.plist diff --git a/Lib/ufoLib/test/testdata/TestFont1 (UFO1).ufo/metainfo.plist b/tests/testdata/TestFont1 (UFO1).ufo/metainfo.plist similarity index 100% rename from Lib/ufoLib/test/testdata/TestFont1 (UFO1).ufo/metainfo.plist rename to tests/testdata/TestFont1 (UFO1).ufo/metainfo.plist diff --git a/Lib/ufoLib/test/testdata/TestFont1 (UFO2).ufo/features.fea b/tests/testdata/TestFont1 (UFO2).ufo/features.fea similarity index 100% rename from Lib/ufoLib/test/testdata/TestFont1 (UFO2).ufo/features.fea rename to tests/testdata/TestFont1 (UFO2).ufo/features.fea diff --git a/Lib/ufoLib/test/testdata/TestFont1 (UFO2).ufo/fontinfo.plist b/tests/testdata/TestFont1 (UFO2).ufo/fontinfo.plist similarity index 100% rename from Lib/ufoLib/test/testdata/TestFont1 (UFO2).ufo/fontinfo.plist rename to tests/testdata/TestFont1 (UFO2).ufo/fontinfo.plist diff --git a/Lib/ufoLib/test/testdata/TestFont1 (UFO2).ufo/glyphs/A_.glif b/tests/testdata/TestFont1 (UFO2).ufo/glyphs/A_.glif similarity index 100% rename from Lib/ufoLib/test/testdata/TestFont1 (UFO2).ufo/glyphs/A_.glif rename to tests/testdata/TestFont1 (UFO2).ufo/glyphs/A_.glif diff --git a/Lib/ufoLib/test/testdata/TestFont1 (UFO2).ufo/glyphs/B_.glif b/tests/testdata/TestFont1 (UFO2).ufo/glyphs/B_.glif similarity index 100% rename from Lib/ufoLib/test/testdata/TestFont1 (UFO2).ufo/glyphs/B_.glif rename to tests/testdata/TestFont1 (UFO2).ufo/glyphs/B_.glif diff --git a/Lib/ufoLib/test/testdata/TestFont1 (UFO2).ufo/glyphs/contents.plist b/tests/testdata/TestFont1 (UFO2).ufo/glyphs/contents.plist similarity index 100% rename from Lib/ufoLib/test/testdata/TestFont1 (UFO2).ufo/glyphs/contents.plist rename to tests/testdata/TestFont1 (UFO2).ufo/glyphs/contents.plist diff --git a/Lib/ufoLib/test/testdata/TestFont1 (UFO2).ufo/groups.plist b/tests/testdata/TestFont1 (UFO2).ufo/groups.plist similarity index 100% rename from Lib/ufoLib/test/testdata/TestFont1 (UFO2).ufo/groups.plist rename to tests/testdata/TestFont1 (UFO2).ufo/groups.plist diff --git a/Lib/ufoLib/test/testdata/TestFont1 (UFO2).ufo/kerning.plist b/tests/testdata/TestFont1 (UFO2).ufo/kerning.plist similarity index 100% rename from Lib/ufoLib/test/testdata/TestFont1 (UFO2).ufo/kerning.plist rename to tests/testdata/TestFont1 (UFO2).ufo/kerning.plist diff --git a/Lib/ufoLib/test/testdata/TestFont1 (UFO2).ufo/lib.plist b/tests/testdata/TestFont1 (UFO2).ufo/lib.plist similarity index 100% rename from Lib/ufoLib/test/testdata/TestFont1 (UFO2).ufo/lib.plist rename to tests/testdata/TestFont1 (UFO2).ufo/lib.plist diff --git a/Lib/ufoLib/test/testdata/TestFont1 (UFO2).ufo/metainfo.plist b/tests/testdata/TestFont1 (UFO2).ufo/metainfo.plist similarity index 100% rename from Lib/ufoLib/test/testdata/TestFont1 (UFO2).ufo/metainfo.plist rename to tests/testdata/TestFont1 (UFO2).ufo/metainfo.plist diff --git a/tests/testdata/TestFont1 (UFO3).ufo/data/com.github.fonttools.ttx/CUST.ttx b/tests/testdata/TestFont1 (UFO3).ufo/data/com.github.fonttools.ttx/CUST.ttx new file mode 100644 index 0000000..51847fd --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/data/com.github.fonttools.ttx/CUST.ttx @@ -0,0 +1,10 @@ + + + + + + 0001beef + + + + diff --git a/tests/testdata/TestFont1 (UFO3).ufo/fontinfo.plist b/tests/testdata/TestFont1 (UFO3).ufo/fontinfo.plist new file mode 100644 index 0000000..78dd88e --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/fontinfo.plist @@ -0,0 +1,338 @@ + + + + + ascender + 750 + capHeight + 750 + copyright + Copyright © Some Foundry. + descender + -250 + familyName + Some Font (Family Name) + guidelines + + + x + 250 + + + x + -20 + + + x + 30 + + + y + 500 + + + y + -200 + + + y + 700 + + + angle + 135 + x + 0 + y + 0 + + + angle + 45 + x + 0 + y + 700 + + + angle + 135 + x + 20 + y + 0 + + + italicAngle + -12.5 + macintoshFONDFamilyID + 15000 + macintoshFONDName + SomeFont Regular (FOND Name) + note + A note. + openTypeGaspRangeRecords + + + rangeGaspBehavior + + 1 + 3 + + rangeMaxPPEM + 7 + + + rangeGaspBehavior + + 0 + 1 + 2 + 3 + + rangeMaxPPEM + 65535 + + + openTypeHeadCreated + 2000/01/01 00:00:00 + openTypeHeadFlags + + 0 + 1 + + openTypeHeadLowestRecPPEM + 10 + openTypeHheaAscender + 750 + openTypeHheaCaretOffset + 0 + openTypeHheaCaretSlopeRise + 1 + openTypeHheaCaretSlopeRun + 0 + openTypeHheaDescender + -250 + openTypeHheaLineGap + 200 + openTypeNameCompatibleFullName + Some Font Regular (Compatible Full Name) + openTypeNameDescription + Some Font by Some Designer for Some Foundry. + openTypeNameDesigner + Some Designer + openTypeNameDesignerURL + http://somedesigner.com + openTypeNameLicense + License info for Some Foundry. + openTypeNameLicenseURL + http://somefoundry.com/license + openTypeNameManufacturer + Some Foundry + openTypeNameManufacturerURL + http://somefoundry.com + openTypeNamePreferredFamilyName + Some Font (Preferred Family Name) + openTypeNamePreferredSubfamilyName + Regular (Preferred Subfamily Name) + openTypeNameRecords + + + encodingID + 0 + languageID + 0 + nameID + 3 + platformID + 1 + string + Unique Font Identifier + + + encodingID + 1 + languageID + 1033 + nameID + 8 + platformID + 3 + string + Some Foundry (Manufacturer Name) + + + openTypeNameSampleText + Sample Text for Some Font. + openTypeNameUniqueID + OpenType name Table Unique ID + openTypeNameVersion + OpenType name Table Version + openTypeNameWWSFamilyName + Some Font (WWS Family Name) + openTypeNameWWSSubfamilyName + Regular (WWS Subfamily Name) + openTypeOS2CodePageRanges + + 0 + 1 + + openTypeOS2FamilyClass + + 1 + 1 + + openTypeOS2Panose + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + + openTypeOS2Selection + + 3 + + openTypeOS2StrikeoutPosition + 300 + openTypeOS2StrikeoutSize + 20 + openTypeOS2SubscriptXOffset + 0 + openTypeOS2SubscriptXSize + 200 + openTypeOS2SubscriptYOffset + -100 + openTypeOS2SubscriptYSize + 400 + openTypeOS2SuperscriptXOffset + 0 + openTypeOS2SuperscriptXSize + 200 + openTypeOS2SuperscriptYOffset + 200 + openTypeOS2SuperscriptYSize + 400 + openTypeOS2Type + + openTypeOS2TypoAscender + 750 + openTypeOS2TypoDescender + -250 + openTypeOS2TypoLineGap + 200 + openTypeOS2UnicodeRanges + + 0 + 1 + + openTypeOS2VendorID + SOME + openTypeOS2WeightClass + 500 + openTypeOS2WidthClass + 5 + openTypeOS2WinAscent + 750 + openTypeOS2WinDescent + 250 + openTypeVheaCaretOffset + 0 + openTypeVheaCaretSlopeRise + 0 + openTypeVheaCaretSlopeRun + 1 + openTypeVheaVertTypoAscender + 750 + openTypeVheaVertTypoDescender + -250 + openTypeVheaVertTypoLineGap + 200 + postscriptBlueFuzz + 1 + postscriptBlueScale + 0.039625 + postscriptBlueShift + 7 + postscriptBlueValues + + 500 + 510 + + postscriptDefaultCharacter + .notdef + postscriptDefaultWidthX + 400 + postscriptFamilyBlues + + 500 + 510 + + postscriptFamilyOtherBlues + + -250 + -260 + + postscriptFontName + SomeFont-Regular (Postscript Font Name) + postscriptForceBold + + postscriptFullName + Some Font-Regular (Postscript Full Name) + postscriptIsFixedPitch + + postscriptNominalWidthX + 400 + postscriptOtherBlues + + -250 + -260 + + postscriptSlantAngle + -12.5 + postscriptStemSnapH + + 100 + 120 + + postscriptStemSnapV + + 80 + 90 + + postscriptUnderlinePosition + -200 + postscriptUnderlineThickness + 20 + postscriptUniqueID + 4000000 + postscriptWeightName + Medium + postscriptWindowsCharacterSet + 1 + styleMapFamilyName + Some Font Regular (Style Map Family Name) + styleMapStyleName + regular + styleName + Regular (Style Name) + trademark + Trademark Some Foundry + unitsPerEm + 1000 + versionMajor + 1 + versionMinor + 0 + xHeight + 500 + year + 2008 + + diff --git a/tests/testdata/TestFont1 (UFO3).ufo/glyphs/_notdef.glif b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/_notdef.glif new file mode 100644 index 0000000..630ec6b --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/_notdef.glif @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/tests/testdata/TestFont1 (UFO3).ufo/glyphs/a.glif b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/a.glif new file mode 100644 index 0000000..a751d87 --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/a.glif @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tests/testdata/TestFont1 (UFO3).ufo/glyphs/b.glif b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/b.glif new file mode 100644 index 0000000..54066a2 --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/b.glif @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/tests/testdata/TestFont1 (UFO3).ufo/glyphs/c.glif b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/c.glif new file mode 100644 index 0000000..7abf451 --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/c.glif @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/testdata/TestFont1 (UFO3).ufo/glyphs/contents.plist b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/contents.plist new file mode 100644 index 0000000..f730b93 --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/contents.plist @@ -0,0 +1,34 @@ + + + + + .notdef + _notdef.glif + a + a.glif + b + b.glif + c + c.glif + d + d.glif + e + e.glif + f + f.glif + g + g.glif + h + h.glif + i + i.glif + j + j.glif + k + k.glif + l + l.glif + space + space.glif + + diff --git a/tests/testdata/TestFont1 (UFO3).ufo/glyphs/d.glif b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/d.glif new file mode 100644 index 0000000..09b619b --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/d.glif @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/testdata/TestFont1 (UFO3).ufo/glyphs/e.glif b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/e.glif new file mode 100644 index 0000000..52abd0a --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/e.glif @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/testdata/TestFont1 (UFO3).ufo/glyphs/f.glif b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/f.glif new file mode 100644 index 0000000..2c13b95 --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/f.glif @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/testdata/TestFont1 (UFO3).ufo/glyphs/g.glif b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/g.glif new file mode 100644 index 0000000..fdbe8ac --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/g.glif @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/testdata/TestFont1 (UFO3).ufo/glyphs/h.glif b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/h.glif new file mode 100644 index 0000000..561563f --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/h.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tests/testdata/TestFont1 (UFO3).ufo/glyphs/i.glif b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/i.glif new file mode 100644 index 0000000..84ef89b --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/i.glif @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/tests/testdata/TestFont1 (UFO3).ufo/glyphs/j.glif b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/j.glif new file mode 100644 index 0000000..550ee9b --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/j.glif @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/tests/testdata/TestFont1 (UFO3).ufo/glyphs/k.glif b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/k.glif new file mode 100644 index 0000000..c09ac3a --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/k.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tests/testdata/TestFont1 (UFO3).ufo/glyphs/l.glif b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/l.glif new file mode 100644 index 0000000..f71c34f --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/l.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tests/testdata/TestFont1 (UFO3).ufo/glyphs/space.glif b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/space.glif new file mode 100644 index 0000000..eaa0d16 --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/glyphs/space.glif @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/testdata/TestFont1 (UFO3).ufo/kerning.plist b/tests/testdata/TestFont1 (UFO3).ufo/kerning.plist new file mode 100644 index 0000000..1dd116e --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/kerning.plist @@ -0,0 +1,20 @@ + + + + + a + + a + 5 + b + -10 + space + 1 + + b + + a + -7 + + + diff --git a/tests/testdata/TestFont1 (UFO3).ufo/layercontents.plist b/tests/testdata/TestFont1 (UFO3).ufo/layercontents.plist new file mode 100644 index 0000000..963df40 --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/layercontents.plist @@ -0,0 +1,10 @@ + + + + + + public.default + glyphs + + + diff --git a/tests/testdata/TestFont1 (UFO3).ufo/lib.plist b/tests/testdata/TestFont1 (UFO3).ufo/lib.plist new file mode 100644 index 0000000..f257b3a --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/lib.plist @@ -0,0 +1,25 @@ + + + + + public.glyphOrder + + .notdef + glyph1 + glyph2 + space + a + b + c + d + e + f + g + h + i + j + k + l + + + diff --git a/tests/testdata/TestFont1 (UFO3).ufo/metainfo.plist b/tests/testdata/TestFont1 (UFO3).ufo/metainfo.plist new file mode 100644 index 0000000..8e836fb --- /dev/null +++ b/tests/testdata/TestFont1 (UFO3).ufo/metainfo.plist @@ -0,0 +1,10 @@ + + + + + creator + org.robofab.ufoLib + formatVersion + 3 + + diff --git a/tests/testdata/TestFont1 (UFO3).ufoz b/tests/testdata/TestFont1 (UFO3).ufoz new file mode 100644 index 0000000..d78d494 Binary files /dev/null and b/tests/testdata/TestFont1 (UFO3).ufoz differ diff --git a/Lib/ufoLib/test/testdata/UFO3-Read Data.ufo/data/org.unifiedfontobject.directory/bar/lol.txt b/tests/testdata/UFO3-Read Data.ufo/data/org.unifiedfontobject.directory/bar/lol.txt similarity index 100% rename from Lib/ufoLib/test/testdata/UFO3-Read Data.ufo/data/org.unifiedfontobject.directory/bar/lol.txt rename to tests/testdata/UFO3-Read Data.ufo/data/org.unifiedfontobject.directory/bar/lol.txt diff --git a/Lib/ufoLib/test/testdata/UFO3-Read Data.ufo/data/org.unifiedfontobject.directory/foo.txt b/tests/testdata/UFO3-Read Data.ufo/data/org.unifiedfontobject.directory/foo.txt similarity index 100% rename from Lib/ufoLib/test/testdata/UFO3-Read Data.ufo/data/org.unifiedfontobject.directory/foo.txt rename to tests/testdata/UFO3-Read Data.ufo/data/org.unifiedfontobject.directory/foo.txt diff --git a/Lib/ufoLib/test/testdata/UFO3-Read Data.ufo/data/org.unifiedfontobject.file.txt b/tests/testdata/UFO3-Read Data.ufo/data/org.unifiedfontobject.file.txt similarity index 100% rename from Lib/ufoLib/test/testdata/UFO3-Read Data.ufo/data/org.unifiedfontobject.file.txt rename to tests/testdata/UFO3-Read Data.ufo/data/org.unifiedfontobject.file.txt diff --git a/Lib/ufoLib/test/testdata/UFO3-Read Data.ufo/metainfo.plist b/tests/testdata/UFO3-Read Data.ufo/metainfo.plist similarity index 100% rename from Lib/ufoLib/test/testdata/UFO3-Read Data.ufo/metainfo.plist rename to tests/testdata/UFO3-Read Data.ufo/metainfo.plist diff --git a/Lib/ufoLib/test/testdata/test.plist b/tests/testdata/test.plist similarity index 100% rename from Lib/ufoLib/test/testdata/test.plist rename to tests/testdata/test.plist diff --git a/tox.ini b/tox.ini index c8df3a8..1c2a077 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{27,36,37}-cov,py{27,36,37}-cov-lxml,coverage +envlist = py{27,37}-cov,py{27,37}-cov-lxml,coverage minversion = 2.9.1 skip_missing_interpreters = true