Skip to content

Commit

Permalink
Merge pull request #1006 from googlefonts/struct-unstruct
Browse files Browse the repository at this point in the history
Use central structure/unstructure funcs, so we can use a custom cattrs converter object
  • Loading branch information
justvanrossum authored Dec 2, 2023
2 parents e938076 + 41785de commit 5570e7c
Show file tree
Hide file tree
Showing 8 changed files with 56 additions and 47 deletions.
12 changes: 5 additions & 7 deletions src/fontra/backends/fontra.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@
from dataclasses import dataclass, field
from typing import Callable

import cattrs

from fontra.core.classes import Font, VariableGlyph
from fontra.core.classes import Font, VariableGlyph, structure, unstructure

from .filenames import stringToFileName

Expand Down Expand Up @@ -122,12 +120,12 @@ def _writeGlyphInfo(self):
writer.writerow([glyphName, codePoints])

def _readFontData(self):
self.fontData = cattrs.structure(
self.fontData = structure(
json.loads(self.fontDataPath.read_text(encoding="utf-8")), Font
)

def _writeFontData(self):
fontData = cattrs.unstructure(self.fontData)
fontData = unstructure(self.fontData)
fontData.pop("glyphs", None)
fontData.pop("glyphMap", None)
self.fontDataPath.write_text(serialize(fontData) + "\n", encoding="utf-8")
Expand All @@ -144,7 +142,7 @@ def getGlyphFilePath(self, glyphName):

def serializeGlyph(glyph, glyphName=None):
glyph = glyph.convertToPaths()
jsonGlyph = cattrs.unstructure(glyph)
jsonGlyph = unstructure(glyph)
if glyphName is not None:
jsonGlyph["name"] = glyphName
return serialize(jsonGlyph) + "\n"
Expand All @@ -154,7 +152,7 @@ def deserializeGlyph(jsonSource, glyphName=None):
jsonGlyph = json.loads(jsonSource)
if glyphName is not None:
jsonGlyph["name"] = glyphName
glyph = cattrs.structure(jsonGlyph, VariableGlyph)
glyph = structure(jsonGlyph, VariableGlyph)
return glyph.convertToPackedPaths()


Expand Down
42 changes: 26 additions & 16 deletions src/fontra/core/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,21 +185,21 @@ def makeSchema(*classes, schema=None):
return schema


# cattrs hooks
# cattrs hooks + structure/unstructure support


def _structurePath(d, tp):
if "pointTypes" not in d:
return cattrs.structure(d, Path)
return structure(d, Path)
else:
return cattrs.structure(d, PackedPath)
return structure(d, PackedPath)


def _structureGlobalAxis(d, tp):
if "values" not in d:
return cattrs.structure(d, GlobalAxis)
return structure(d, GlobalAxis)
else:
return cattrs.structure(d, GlobalDiscreteAxis)
return structure(d, GlobalDiscreteAxis)


def _structureNumber(d, tp):
Expand All @@ -219,24 +219,26 @@ def _structurePointType(v, tp):
return PointType(v)


cattrs.register_structure_hook(Union[PackedPath, Path], _structurePath)
cattrs.register_structure_hook(
_cattrsConverter = cattrs.Converter()

_cattrsConverter.register_structure_hook(Union[PackedPath, Path], _structurePath)
_cattrsConverter.register_structure_hook(
Union[GlobalAxis, GlobalDiscreteAxis], _structureGlobalAxis
)
cattrs.register_structure_hook(float, _structureNumber)
cattrs.register_structure_hook(Point, _structurePoint)
cattrs.register_unstructure_hook(Point, _unstructurePoint)
cattrs.register_structure_hook(bool, lambda x, y: x)
cattrs.register_structure_hook(PointType, _structurePointType)
_cattrsConverter.register_structure_hook(float, _structureNumber)
_cattrsConverter.register_structure_hook(Point, _structurePoint)
_cattrsConverter.register_unstructure_hook(Point, _unstructurePoint)
_cattrsConverter.register_structure_hook(bool, lambda x, y: x)
_cattrsConverter.register_structure_hook(PointType, _structurePointType)


def registerOmitDefaultHook(cls):
_hook = cattrs.gen.make_dict_unstructure_fn(
cls,
cattrs.global_converter,
_cattrsConverter,
_cattrs_omit_if_default=True,
)
cattrs.register_unstructure_hook(cls, _hook)
_cattrsConverter.register_unstructure_hook(cls, _hook)


# The order in which the hooks are applied is significant, for unclear reasons
Expand All @@ -250,21 +252,29 @@ def registerOmitDefaultHook(cls):
registerOmitDefaultHook(PackedPath)


def structure(obj, cls):
return _cattrsConverter.structure(obj, cls)


def unstructure(obj):
return _cattrsConverter.unstructure(obj)


atomicTypes = [str, int, float, bool, Any]


def makeCastFuncs(schema):
castFuncs = {}
for cls, fields in schema.items():
castFuncs[cls] = partial(cattrs.structure, cl=cls)
castFuncs[cls] = partial(structure, cls=cls)
for fieldName, fieldInfo in fields.items():
fieldType = fieldInfo["type"]
if fieldType in atomicTypes or fieldType in schema:
continue
itemType = get_args(fieldType)[-1]
if itemType in atomicTypes:
continue
castFuncs[fieldType] = partial(cattrs.structure, cl=fieldType)
castFuncs[fieldType] = partial(structure, cls=fieldType)
return castFuncs


Expand Down
9 changes: 6 additions & 3 deletions src/fontra/core/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from enum import IntEnum
from typing import TypedDict

import cattrs
from fontTools.misc.transform import DecomposedTransform

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -38,7 +37,9 @@ def asPath(self):
return self

def asPackedPath(self):
return PackedPath.fromUnpackedContours(cattrs.unstructure(self.contours))
from .classes import unstructure

return PackedPath.fromUnpackedContours(unstructure(self.contours))

def isEmpty(self):
return not self.contours
Expand Down Expand Up @@ -91,7 +92,9 @@ def fromUnpackedContours(cls, unpackedContours):
)

def asPath(self):
return Path(contours=cattrs.structure(self.unpackedContours(), list[Contour]))
from .classes import structure

return Path(contours=structure(self.unpackedContours(), list[Contour]))

def asPackedPath(self):
return self
Expand Down
5 changes: 3 additions & 2 deletions src/fontra/core/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
import logging
import traceback

import cattrs
from aiohttp import WSMsgType

from .classes import unstructure

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -91,7 +92,7 @@ async def _performCall(self, message, subject):
methodHandler = getattr(subject, methodName, None)
if getattr(methodHandler, "fontraRemoteMethod", False):
returnValue = await methodHandler(*arguments, connection=self)
returnValue = cattrs.unstructure(returnValue)
returnValue = unstructure(returnValue)
response = {"client-call-id": clientCallID, "return-value": returnValue}
else:
response = {
Expand Down
5 changes: 2 additions & 3 deletions src/fontra/core/serverutils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import cattrs

from . import clipboard, glyphnames
from .classes import unstructure

apiFunctions = {}

Expand All @@ -22,4 +21,4 @@ def getUnicodeFromGlyphName(glyphName):

@api
def parseClipboard(data):
return cattrs.unstructure(clipboard.parseClipboard(data))
return unstructure(clipboard.parseClipboard(data))
9 changes: 4 additions & 5 deletions test-py/test_classes.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import json
import pathlib

import cattrs

from fontra.backends.fontra import deserializeGlyph
from fontra.core.classes import (
Layer,
Source,
VariableGlyph,
classCastFuncs,
serializableClassSchema,
unstructure,
)

repoRoot = pathlib.Path(__file__).resolve().parent.parent
Expand All @@ -35,17 +34,17 @@ def test_cast():
/ "B^1.json"
)
originalGlyph = deserializeGlyph(glyphPath.read_text(encoding="utf-8"))
unstructuredGlyph = cattrs.unstructure(originalGlyph)
unstructuredGlyph = unstructure(originalGlyph)
# Ensure that the PointType enums get converted to ints
unstructuredGlyph = json.loads(json.dumps(unstructuredGlyph))
glyph = classCastFuncs[VariableGlyph](unstructuredGlyph)
assert glyph == originalGlyph
assert str(glyph) == str(originalGlyph)

sourcesList = cattrs.unstructure(glyph.sources)
sourcesList = unstructure(glyph.sources)
sources = classCastFuncs[list[Source]](sourcesList)
assert glyph.sources == sources

layersDict = cattrs.unstructure(glyph.layers)
layersDict = unstructure(glyph.layers)
layers = classCastFuncs[dict[str, Layer]](layersDict)
assert glyph.layers == layers
5 changes: 2 additions & 3 deletions test-py/test_font.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@
import pathlib
from dataclasses import asdict

import cattrs
import pytest

from fontra.backends import getFileSystemBackend
from fontra.core.classes import GlobalAxis, GlobalDiscreteAxis, VariableGlyph
from fontra.core.classes import GlobalAxis, GlobalDiscreteAxis, VariableGlyph, structure

dataDir = pathlib.Path(__file__).resolve().parent / "data"

Expand Down Expand Up @@ -963,7 +962,7 @@ async def test_getGlyphMap(testFontName, numGlyphs, testMapping):
@pytest.mark.asyncio
@pytest.mark.parametrize("testFontName, expectedGlyph", getGlyphTestData)
async def test_getGlyph(testFontName, expectedGlyph):
expectedGlyph = cattrs.structure(expectedGlyph, VariableGlyph)
expectedGlyph = structure(expectedGlyph, VariableGlyph)
font = getTestFont(testFontName)
with contextlib.closing(font):
glyph = await font.getGlyph(expectedGlyph.name)
Expand Down
16 changes: 8 additions & 8 deletions test-py/test_path.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import operator

import cattrs
import pytest

from fontra.core.classes import structure, unstructure
from fontra.core.path import (
Contour,
InterpolationError,
Expand Down Expand Up @@ -127,26 +127,26 @@

@pytest.mark.parametrize("path", pathTestData)
def test_packedPathPointPenRoundTrip(path):
path = cattrs.structure(path, PackedPath)
path = structure(path, PackedPath)
pen = PackedPathPointPen()
path.drawPoints(pen)
repackedPath = pen.getPath()
assert path == repackedPath
assert cattrs.unstructure(path) == cattrs.unstructure(repackedPath)
assert unstructure(path) == unstructure(repackedPath)


@pytest.mark.parametrize("path", pathTestData)
def test_unpackPathRoundTrip(path):
path = cattrs.structure(path, PackedPath)
path = structure(path, PackedPath)
unpackedPath = path.unpackedContours()
repackedPath = PackedPath.fromUnpackedContours(unpackedPath)
assert path == repackedPath
assert cattrs.unstructure(path) == cattrs.unstructure(repackedPath)
assert unstructure(path) == unstructure(repackedPath)


@pytest.mark.parametrize("path", pathTestData)
def test_pathConversion(path):
packedPath = cattrs.structure(path, PackedPath)
packedPath = structure(path, PackedPath)
path = packedPath.asPath()
packedPath2 = path.asPackedPath()
assert packedPath == packedPath2
Expand All @@ -167,7 +167,7 @@ def test_pathConversion(path):

def test_packedPathRepr():
path = pathTestData[1]
packedPath = cattrs.structure(path, PackedPath)
packedPath = structure(path, PackedPath)
assert expectedPackedPathRepr == str(packedPath)


Expand All @@ -183,7 +183,7 @@ def test_packedPathRepr():

def test_pathRepr():
path = pathTestData[1]
packedPath = cattrs.structure(path, PackedPath)
packedPath = structure(path, PackedPath)
path = packedPath.asPath()
assert expectedPathRepr == str(path)

Expand Down

0 comments on commit 5570e7c

Please sign in to comment.