Repository: fonttools/fonttools

Issue:
designspaceLib isinstance fails for custom descriptors
in Designspace editor we have custom descriptor objects that works nicely with NSObjects 

https://github.com/fonttools/fonttools/blob/main/Lib/fontTools/designspaceLib/__init__.py#L1448-L1452

patch:

hasattr(axisObject, "values") --> DiscreteAxisDescriptor
hasattr(axisObject, "minimum") and hasattr(axisObject, "maximum") --> AxisDescriptor




Discussion:
Agreed. Interface checking is better than class checking.
Agreed, I'll open a PR with the fixes.

Resolving patch:
diff --git a/Lib/fontTools/designspaceLib/__init__.py b/Lib/fontTools/designspaceLib/__init__.py
--- a/Lib/fontTools/designspaceLib/__init__.py
+++ b/Lib/fontTools/designspaceLib/__init__.py
@@ -8,7 +8,7 @@
 import posixpath
 from io import BytesIO, StringIO
 from textwrap import indent
-from typing import Any, Dict, List, MutableMapping, Optional, Tuple, Union
+from typing import Any, Dict, List, MutableMapping, Optional, Tuple, Union, cast
 
 from fontTools.misc import etree as ET
 from fontTools.misc import plistlib
@@ -950,6 +950,7 @@ class DiscreteAxisDescriptor(AbstractAxisDescriptor):
 
         a2 = DiscreteAxisDescriptor()
         a2.values = [0, 1]
+        a2.default = 0
         a2.name = "Italic"
         a2.tag = "ITAL"
         a2.labelNames['fr'] = "Italique"
@@ -1352,7 +1353,7 @@ def _getEffectiveFormatTuple(self):
         minVersion = self.documentObject.formatTuple
         if (
             any(
-                isinstance(axis, DiscreteAxisDescriptor) or
+                hasattr(axis, 'values') or
                 axis.axisOrdering is not None or
                 axis.axisLabels
                 for axis in self.documentObject.axes
@@ -1445,10 +1446,10 @@ def _addAxis(self, axisObject):
             for label in axisObject.axisLabels:
                 self._addAxisLabel(labelsElement, label)
             axisElement.append(labelsElement)
-        if isinstance(axisObject, AxisDescriptor):
+        if hasattr(axisObject, "minimum"):
             axisElement.attrib['minimum'] = self.intOrFloat(axisObject.minimum)
             axisElement.attrib['maximum'] = self.intOrFloat(axisObject.maximum)
-        elif isinstance(axisObject, DiscreteAxisDescriptor):
+        elif hasattr(axisObject, "values"):
             axisElement.attrib['values'] = " ".join(self.intOrFloat(v) for v in axisObject.values)
         axisElement.attrib['default'] = self.intOrFloat(axisObject.default)
         if axisObject.hidden:
@@ -1682,14 +1683,19 @@ def _addVariableFont(self, parentElement: ET.Element, vf: VariableFontDescriptor
             for subset in vf.axisSubsets:
                 subsetElement = ET.Element('axis-subset')
                 subsetElement.attrib['name'] = subset.name
-                if isinstance(subset, RangeAxisSubsetDescriptor):
+                # Mypy doesn't support narrowing union types via hasattr()
+                # https://mypy.readthedocs.io/en/stable/type_narrowing.html
+                # TODO(Python 3.10): use TypeGuard
+                if hasattr(subset, "userMinimum"):
+                    subset = cast(RangeAxisSubsetDescriptor, subset)
                     if subset.userMinimum != -math.inf:
                         subsetElement.attrib['userminimum'] = self.intOrFloat(subset.userMinimum)
                     if subset.userMaximum != math.inf:
                         subsetElement.attrib['usermaximum'] = self.intOrFloat(subset.userMaximum)
                     if subset.userDefault is not None:
                         subsetElement.attrib['userdefault'] = self.intOrFloat(subset.userDefault)
-                elif isinstance(subset, ValueAxisSubsetDescriptor):
+                elif hasattr(subset, "userValue"):
+                    subset = cast(ValueAxisSubsetDescriptor, subset)
                     subsetElement.attrib['uservalue'] = self.intOrFloat(subset.userValue)
                 subsetsElement.append(subsetElement)
             vfElement.append(subsetsElement)
@@ -2904,8 +2910,12 @@ def getVariableFonts(self) -> List[VariableFontDescriptor]:
         discreteAxes = []
         rangeAxisSubsets: List[Union[RangeAxisSubsetDescriptor, ValueAxisSubsetDescriptor]] = []
         for axis in self.axes:
-            if isinstance(axis, DiscreteAxisDescriptor):
-                discreteAxes.append(axis)
+            if hasattr(axis, "values"):
+                # Mypy doesn't support narrowing union types via hasattr()
+                # TODO(Python 3.10): use TypeGuard
+                # https://mypy.readthedocs.io/en/stable/type_narrowing.html
+                axis = cast(DiscreteAxisDescriptor, axis)
+                discreteAxes.append(axis)  # type: ignore
             else:
                 rangeAxisSubsets.append(RangeAxisSubsetDescriptor(name=axis.name))
         valueCombinations = itertools.product(*[axis.values for axis in discreteAxes])
diff --git a/Lib/fontTools/designspaceLib/split.py b/Lib/fontTools/designspaceLib/split.py
--- a/Lib/fontTools/designspaceLib/split.py
+++ b/Lib/fontTools/designspaceLib/split.py
@@ -7,7 +7,7 @@
 import itertools
 import logging
 import math
-from typing import Any, Callable, Dict, Iterator, List, Tuple
+from typing import Any, Callable, Dict, Iterator, List, Tuple, cast
 
 from fontTools.designspaceLib import (
     AxisDescriptor,
@@ -21,9 +21,9 @@
 )
 from fontTools.designspaceLib.statNames import StatNames, getStatNames
 from fontTools.designspaceLib.types import (
+    ConditionSet,
     Range,
     Region,
-    ConditionSet,
     getVFUserRegion,
     locationInRegion,
     regionInRegion,
@@ -87,11 +87,18 @@ def splitInterpolable(
     discreteAxes = []
     interpolableUserRegion: Region = {}
     for axis in doc.axes:
-        if isinstance(axis, DiscreteAxisDescriptor):
+        if hasattr(axis, "values"):
+            # Mypy doesn't support narrowing union types via hasattr()
+            # TODO(Python 3.10): use TypeGuard
+            # https://mypy.readthedocs.io/en/stable/type_narrowing.html
+            axis = cast(DiscreteAxisDescriptor, axis)
             discreteAxes.append(axis)
         else:
+            axis = cast(AxisDescriptor, axis)
             interpolableUserRegion[axis.name] = Range(
-                axis.minimum, axis.maximum, axis.default
+                axis.minimum,
+                axis.maximum,
+                axis.default,
             )
     valueCombinations = itertools.product(*[axis.values for axis in discreteAxes])
     for values in valueCombinations:
@@ -191,7 +198,11 @@ def maybeExpandDesignLocation(object):
 
     for axis in doc.axes:
         range = userRegion[axis.name]
-        if isinstance(range, Range) and isinstance(axis, AxisDescriptor):
+        if isinstance(range, Range) and hasattr(axis, "minimum"):
+            # Mypy doesn't support narrowing union types via hasattr()
+            # TODO(Python 3.10): use TypeGuard
+            # https://mypy.readthedocs.io/en/stable/type_narrowing.html
+            axis = cast(AxisDescriptor, axis)
             subDoc.addAxis(
                 AxisDescriptor(
                     # Same info
diff --git a/Lib/fontTools/designspaceLib/types.py b/Lib/fontTools/designspaceLib/types.py
--- a/Lib/fontTools/designspaceLib/types.py
+++ b/Lib/fontTools/designspaceLib/types.py
@@ -1,14 +1,15 @@
 from __future__ import annotations
 
 from dataclasses import dataclass
-from typing import Dict, List, Optional, Union
+from typing import Dict, List, Optional, Union, cast
 
 from fontTools.designspaceLib import (
+    AxisDescriptor,
     DesignSpaceDocument,
     DesignSpaceDocumentError,
-    DiscreteAxisDescriptor,
     RangeAxisSubsetDescriptor,
     SimpleLocationDict,
+    ValueAxisSubsetDescriptor,
     VariableFontDescriptor,
 )
 
@@ -117,18 +118,24 @@ def getVFUserRegion(doc: DesignSpaceDocument, vf: VariableFontDescriptor) -> Reg
             raise DesignSpaceDocumentError(
                 f"Cannot find axis named '{axisSubset.name}' for variable font '{vf.name}'."
             )
-        if isinstance(axisSubset, RangeAxisSubsetDescriptor):
-            if isinstance(axis, DiscreteAxisDescriptor):
+        if hasattr(axisSubset, "userMinimum"):
+            # Mypy doesn't support narrowing union types via hasattr()
+            # TODO(Python 3.10): use TypeGuard
+            # https://mypy.readthedocs.io/en/stable/type_narrowing.html
+            axisSubset = cast(RangeAxisSubsetDescriptor, axisSubset)
+            if not hasattr(axis, "minimum"):
                 raise DesignSpaceDocumentError(
                     f"Cannot select a range over '{axis.name}' for variable font '{vf.name}' "
                     "because it's a discrete axis, use only 'userValue' instead."
                 )
+            axis = cast(AxisDescriptor, axis)
             vfUserRegion[axis.name] = Range(
                 max(axisSubset.userMinimum, axis.minimum),
                 min(axisSubset.userMaximum, axis.maximum),
                 axisSubset.userDefault or axis.default,
             )
         else:
+            axisSubset = cast(ValueAxisSubsetDescriptor, axisSubset)
             vfUserRegion[axis.name] = axisSubset.userValue
     # Any axis not mentioned explicitly has a single location = default value
     for axis in doc.axes:


Test patch: