diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index fce5a70..d66b9ed 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -18,20 +18,28 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install . --no-deps python -m pip install -r requirements.txt python -m pip install -r requirements-dev.txt + - name: Run pre-commit uses: pre-commit/action@v3.0.0 with: extra_args: --all-files --verbose --show-diff-on-failure + - name: Test with pytest run: | pytest -vv + + - name: Run mypy + run: | + mypy src/ tests/ diff --git a/pyproject.toml b/pyproject.toml index 34e0ad6..91bf191 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,3 +61,19 @@ testpaths = [ "tests", ] asyncio_mode = "auto" + +[[tool.mypy.overrides]] +module = "fontTools.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "glyphsLib.glyphdata" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "urllib3" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "requests" +ignore_missing_imports = true diff --git a/requirements-dev.txt b/requirements-dev.txt index 84b7bfd..217c26b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,4 @@ +mypy==1.7.1 pre-commit==3.5.0 pytest==7.4.3 pytest-asyncio==0.23.2 diff --git a/setup.py b/setup.py deleted file mode 100644 index 8d0b2d2..0000000 --- a/setup.py +++ /dev/null @@ -1,5 +0,0 @@ -# This is just a stub; see pyproject.toml for the metadata. - -from setuptools import setup - -setup() diff --git a/src/fontra_rcjk/backend_fs.py b/src/fontra_rcjk/backend_fs.py index 817e989..ef59d94 100644 --- a/src/fontra_rcjk/backend_fs.py +++ b/src/fontra_rcjk/backend_fs.py @@ -4,12 +4,20 @@ import pathlib import shutil from functools import cached_property +from os import PathLike +from typing import Any import watchfiles from fontra.backends.designspace import cleanupWatchFilesChanges from fontra.backends.ufo_utils import extractGlyphNameAndUnicodes -from fontra.core.classes import unstructure +from fontra.core.classes import ( + GlobalAxis, + GlobalDiscreteAxis, + VariableGlyph, + unstructure, +) from fontra.core.instancer import mapLocationFromUserToSource +from fontra.core.protocols import WritableFontBackend from fontTools.ufoLib.filenames import userNameToFileName from .base import ( @@ -33,14 +41,14 @@ class RCJKBackend: @classmethod - def fromPath(cls, path): + def fromPath(cls, path: PathLike) -> WritableFontBackend: return cls(path) @classmethod - def createFromPath(cls, path): + def createFromPath(cls, path: PathLike) -> WritableFontBackend: return cls(path, create=True) - def __init__(self, path, *, create=False): + def __init__(self, path: PathLike, *, create: bool = False): self.path = pathlib.Path(path).resolve() if create: if self.path.is_dir(): @@ -49,9 +57,7 @@ def __init__(self, path, *, create=False): self.path.unlink() cgPath = self.path / "characterGlyph" cgPath.mkdir(exist_ok=True, parents=True) - self.characterGlyphGlyphSet = ( - RCJKGlyphSet(cgPath, self.registerWrittenPath), - ) + self.characterGlyphGlyphSet = RCJKGlyphSet(cgPath, self.registerWrittenPath) for name in glyphSetNames: setattr( @@ -69,7 +75,7 @@ def __init__(self, path, *, create=False): else: self.designspace = {} - self._glyphMap = {} + self._glyphMap: dict[str, list[int]] = {} for gs, hasEncoding in self._iterGlyphSets(): glyphMap = gs.getGlyphMap(not hasEncoding) for glyphName, unicodes in glyphMap.items(): @@ -78,7 +84,7 @@ def __init__(self, path, *, create=False): assert not unicodes self._glyphMap[glyphName] = unicodes - self._recentlyWrittenPaths = {} + self._recentlyWrittenPaths: dict[str, Any] = {} self._tempGlyphCache = TimedCache() def close(self): @@ -111,13 +117,16 @@ def _defaultLocation(self): } return mapLocationFromUserToSource(userLoc, axes) - async def getGlyphMap(self): + async def getGlyphMap(self) -> dict[str, list[int]]: return dict(self._glyphMap) - async def getGlobalAxes(self): + async def putGlyphMap(self, glyphMap: dict[str, list[int]]) -> None: + pass + + async def getGlobalAxes(self) -> list[GlobalAxis | GlobalDiscreteAxis]: return unpackAxes(self.designspace.get("axes", ())) - async def putGlobalAxes(self, axes): + async def putGlobalAxes(self, axes: list[GlobalAxis | GlobalDiscreteAxis]) -> None: self.designspace["axes"] = unstructure(axes) if hasattr(self, "_defaultLocation"): del self._defaultLocation @@ -129,10 +138,13 @@ def _writeDesignSpaceFile(self): json.dumps(self.designspace, indent=2), encoding="utf-8" ) - async def getUnitsPerEm(self): + async def getUnitsPerEm(self) -> int: return 1000 - async def getGlyph(self, glyphName): + async def putUnitsPerEm(self, value: int) -> None: + pass + + async def getGlyph(self, glyphName: str) -> VariableGlyph | None: layerGlyphs = self._getLayerGlyphs(glyphName) return buildVariableGlyphFromLayerGlyphs(layerGlyphs) @@ -168,7 +180,9 @@ def _getLayerGLIFData(self, glyphName): return gs.getGlyphLayerData(glyphName) return None - async def putGlyph(self, glyphName, glyph, unicodes): + async def putGlyph( + self, glyphName: str, glyph: VariableGlyph, unicodes: list[int] + ) -> None: if glyphName not in self._glyphMap: existingLayerGlyphs = {} else: @@ -194,14 +208,14 @@ async def deleteGlyph(self, glyphName): del self._glyphMap[glyphName] - async def getCustomData(self): + async def getCustomData(self) -> dict[str, Any]: customData = {} customDataPath = self.path / FONTLIB_FILENAME if customDataPath.is_file(): customData = json.loads(customDataPath.read_text(encoding="utf-8")) return customData | standardCustomDataItems - async def putCustomData(self, customData): + async def putCustomData(self, customData: dict[str, Any]) -> None: customDataPath = self.path / FONTLIB_FILENAME customData = { k: v diff --git a/src/fontra_rcjk/backend_mysql.py b/src/fontra_rcjk/backend_mysql.py index 59cb943..489c866 100644 --- a/src/fontra_rcjk/backend_mysql.py +++ b/src/fontra_rcjk/backend_mysql.py @@ -6,10 +6,18 @@ from dataclasses import dataclass from datetime import datetime, timedelta from random import random +from typing import Any, AsyncGenerator from fontra.backends.designspace import makeGlyphMapChange -from fontra.core.classes import VariableGlyph, structure, unstructure +from fontra.core.classes import ( + GlobalAxis, + GlobalDiscreteAxis, + VariableGlyph, + structure, + unstructure, +) from fontra.core.instancer import mapLocationFromUserToSource +from fontra.core.protocols import WritableFontBackend from .base import ( GLIFGlyph, @@ -47,8 +55,10 @@ class RCJKGlyphInfo: class RCJKMySQLBackend: @classmethod - def fromRCJKClient(cls, client, fontUID, cacheDir=None): - self = cls() + def fromRCJKClient(cls, client, fontUID, cacheDir=None) -> WritableFontBackend: + return cls(client, fontUID, cacheDir) + + def __init__(self, client, fontUID, cacheDir=None): self.client = client self.fontUID = fontUID if cacheDir is not None: @@ -65,22 +75,24 @@ def fromRCJKClient(cls, client, fontUID, cacheDir=None): self._glyphMap = None self._glyphMapTask = None self._defaultLocation = None - return self def close(self): self._tempFontItemsCache.cancel() - async def getGlyphMap(self): + async def getGlyphMap(self) -> dict[str, list[int]]: await self._ensureGlyphMap() return dict(self._glyphMap) - def _ensureGlyphMap(self): + async def putGlyphMap(self, value: dict[str, list[int]]) -> None: + pass + + def _ensureGlyphMap(self) -> asyncio.Task: # Prevent multiple concurrent queries by using a single task if self._glyphMapTask is None: self._glyphMapTask = asyncio.create_task(self._ensureGlyphMapTask()) return self._glyphMapTask - async def _ensureGlyphMapTask(self): + async def _ensureGlyphMapTask(self) -> None: if self._glyphMap is not None: return rcjkGlyphInfo = {} @@ -97,7 +109,7 @@ async def _ensureGlyphMapTask(self): self._glyphMap = glyphMap self._rcjkGlyphInfo = rcjkGlyphInfo - async def _getMiscFontItems(self): + async def _getMiscFontItems(self) -> None: if not hasattr(self, "_getMiscFontItemsTask"): async def taskFunc(): @@ -114,7 +126,7 @@ async def taskFunc(): self._getMiscFontItemsTask = asyncio.create_task(taskFunc()) await self._getMiscFontItemsTask - async def getGlobalAxes(self): + async def getGlobalAxes(self) -> list[GlobalAxis | GlobalDiscreteAxis]: axes = self._tempFontItemsCache.get("axes") if axes is None: await self._getMiscFontItems() @@ -125,34 +137,37 @@ async def getGlobalAxes(self): self._defaultLocation = mapLocationFromUserToSource(userLoc, axes) return axes - async def putGlobalAxes(self, axes): + async def putGlobalAxes(self, axes: list[GlobalAxis | GlobalDiscreteAxis]) -> None: await self._getMiscFontItems() designspace = self._tempFontItemsCache["designspace"] designspace["axes"] = unstructure(axes) _ = await self.client.font_update(self.fontUID, designspace=designspace) - async def getDefaultLocation(self): + async def getDefaultLocation(self) -> dict[str, float]: if self._defaultLocation is None: _ = await self.getGlobalAxes() assert self._defaultLocation is not None return self._defaultLocation - async def getUnitsPerEm(self): + async def getUnitsPerEm(self) -> int: return 1000 - async def getCustomData(self): + async def putUnitsPerEm(self, value: int) -> None: + pass + + async def getCustomData(self) -> dict[str, Any]: customData = self._tempFontItemsCache.get("customData") if customData is None: await self._getMiscFontItems() customData = self._tempFontItemsCache["customData"] return customData - async def putCustomData(self, customData): + async def putCustomData(self, customData: dict[str, Any]) -> None: await self._getMiscFontItems() self._tempFontItemsCache["customData"] = deepcopy(customData) _ = await self.client.font_update(self.fontUID, fontlib=customData) - def _readGlyphFromCacheDir(self, glyphName): + def _readGlyphFromCacheDir(self, glyphName: str) -> VariableGlyph | None: if self.cacheDir is None: return None glyphInfo = self._rcjkGlyphInfo[glyphName] @@ -168,7 +183,7 @@ def _readGlyphFromCacheDir(self, glyphName): logger.exception(f"error reading {glyphName!r} from local cache: {e!r}") return None - def _writeGlyphToCacheDir(self, glyphName, glyph): + def _writeGlyphToCacheDir(self, glyphName: str, glyph: VariableGlyph) -> None: if self.cacheDir is None: return glyphInfo = self._rcjkGlyphInfo[glyphName] @@ -185,7 +200,9 @@ def _writeGlyphToCacheDir(self, glyphName, glyph): except Exception as e: logger.exception(f"error writing {glyphName!r} to local cache: {e!r}") - def _deleteGlyphFromCacheDir(self, glyphName): + def _deleteGlyphFromCacheDir(self, glyphName: str) -> None: + if self.cacheDir is None: + return glyphInfo = self._rcjkGlyphInfo.get(glyphName) if glyphInfo is None: return @@ -193,7 +210,7 @@ def _deleteGlyphFromCacheDir(self, glyphName): for stalePath in self.cacheDir.glob(globPattern): stalePath.unlink() - async def getGlyph(self, glyphName): + async def getGlyph(self, glyphName: str) -> VariableGlyph | None: await self._ensureGlyphMap() if glyphName not in self._glyphMap: return None @@ -239,12 +256,14 @@ def _populateGlyphCache(self, glyphName, glyphData): assert glyphInfo.glyphID == subGlyphData["id"] self._populateGlyphCache(subGlyphName, subGlyphData) - async def putGlyph(self, glyphName, glyph, unicodes): + async def putGlyph( + self, glyphName: str, glyph: VariableGlyph, codePoints: list[int] + ) -> None: await self._ensureGlyphMap() logger.info(f"Start writing {glyphName}") self._writingChanges += 1 try: - return await self._putGlyph(glyphName, glyph, unicodes) + return await self._putGlyph(glyphName, glyph, codePoints) finally: self._writingChanges -= 1 logger.info(f"Done writing {glyphName}") @@ -323,7 +342,7 @@ async def _putGlyph(self, glyphName, glyph, unicodes): return errorMessage - async def _newGlyph(self, glyphName, unicodes): + async def _newGlyph(self, glyphName: str, unicodes: list[int]) -> None: # In _newGlyph() we create a new character glyph in the database. # _putGlyph will immediately overwrite it with the real glyph data, # with a lock acquired. Our dummy glyph has to have a glyph name, but @@ -344,7 +363,7 @@ async def _newGlyph(self, glyphName, unicodes): self._rcjkGlyphInfo[glyphName] = RCJKGlyphInfo("CG", glyphID, timeStamp) self._glyphTimeStamps[glyphName] = timeStamp - async def deleteGlyph(self, glyphName): + async def deleteGlyph(self, glyphName: str) -> None: await self._ensureGlyphMap() if glyphName not in self._rcjkGlyphInfo: raise KeyError(f"Glyph '{glyphName}' does not exist") @@ -372,7 +391,7 @@ async def _callGlyphMethod(self, glyphName, methodName, *args, **kwargs): method = getattr(self.client, apiMethodName) return await method(self.fontUID, glyphInfo.glyphID, *args, **kwargs) - async def watchExternalChanges(self): + async def watchExternalChanges(self) -> AsyncGenerator[tuple[Any, Any], None]: await self._ensureGlyphMap() errorDelay = 30 while True: @@ -387,7 +406,7 @@ async def watchExternalChanges(self): if externalChange or reloadPattern: yield externalChange, reloadPattern - async def _pollOnceForChanges(self): + async def _pollOnceForChanges(self) -> tuple[Any, Any]: try: await asyncio.wait_for( self._pollNowEvent.wait(), @@ -409,7 +428,7 @@ async def _pollOnceForChanges(self): ) responseData = response["data"] glyphNames = set() - glyphMapUpdates = {} + glyphMapUpdates: dict[str, list[int] | None] = {} latestTimeStamp = "" # less than any timestamp string for glyphInfo in responseData.get("deleted_glifs", []): @@ -471,11 +490,11 @@ async def _pollOnceForChanges(self): return externalChange, reloadPattern -def _unicodesFromGlyphInfo(glyphInfo): +def _unicodesFromGlyphInfo(glyphInfo: dict) -> list[int]: return glyphInfo.get("unicodes", []) -def getUpdatedTimeStamp(info): +def getUpdatedTimeStamp(info: dict) -> str: timeStamp = info["updated_at"] layers_updated_at = info.get("layers_updated_at") if layers_updated_at: @@ -483,7 +502,7 @@ def getUpdatedTimeStamp(info): return timeStamp -def buildLayerGlyphsFromResponseData(glyphData): +def buildLayerGlyphsFromResponseData(glyphData: dict) -> dict[str, GLIFGlyph]: layerGLIFData = [("foreground", glyphData["data"])] layerGLIFData.extend( (layer["group_name"], layer["data"]) for layer in glyphData.get("layers", ()) @@ -532,7 +551,7 @@ def __setitem__(self, key, value): del self[next(iter(self))] -def fudgeTimeStamp(isoString): +def fudgeTimeStamp(isoString: str) -> str: """Add one millisecond to the timestamp, so we can account for differences in the microsecond range. """ diff --git a/src/fontra_rcjk/base.py b/src/fontra_rcjk/base.py index 5226365..ea68f63 100644 --- a/src/fontra_rcjk/base.py +++ b/src/fontra_rcjk/base.py @@ -118,7 +118,7 @@ def cleanupAxis(axisDict): return LocalAxis(**axisDict) -def buildVariableGlyphFromLayerGlyphs(layerGlyphs): +def buildVariableGlyphFromLayerGlyphs(layerGlyphs) -> VariableGlyph: layers = { layerName: Layer(glyph=glyph.toStaticGlyph()) for layerName, glyph in layerGlyphs.items() diff --git a/src/fontra_rcjk/projectmanager.py b/src/fontra_rcjk/projectmanager.py index 6f6e2e8..daa15c4 100644 --- a/src/fontra_rcjk/projectmanager.py +++ b/src/fontra_rcjk/projectmanager.py @@ -1,11 +1,15 @@ +import argparse import logging import pathlib import secrets from importlib import resources +from types import SimpleNamespace +from typing import Callable from urllib.parse import parse_qs, quote from aiohttp import web from fontra.core.fonthandler import FontHandler +from fontra.core.protocols import ProjectManager from .backend_mysql import RCJKMySQLBackend from .client import HTTPError @@ -16,13 +20,13 @@ class RCJKProjectManagerFactory: @staticmethod - def addArguments(parser): + def addArguments(parser: argparse.ArgumentParser) -> None: parser.add_argument("rcjk_host") parser.add_argument("--read-only", action="store_true") parser.add_argument("--cache-dir") @staticmethod - def getProjectManager(arguments): + def getProjectManager(arguments: SimpleNamespace) -> ProjectManager: return RCJKProjectManager( host=arguments.rcjk_host, readOnly=arguments.read_only, @@ -40,11 +44,11 @@ def __init__(self, host, *, readOnly=False, cacheDir=None): self.cacheDir = cacheDir self.authorizedClients = {} - async def close(self): + async def close(self) -> None: for client in self.authorizedClients.values(): await client.close() - def setupWebRoutes(self, fontraServer): + def setupWebRoutes(self, fontraServer) -> None: routes = [ web.post("/login", self.loginHandler), web.post("/logout", self.logoutHandler), @@ -53,7 +57,7 @@ def setupWebRoutes(self, fontraServer): self.cookieMaxAge = fontraServer.cookieMaxAge self.startupTime = fontraServer.startupTime - async def loginHandler(self, request): + async def loginHandler(self, request: web.Request) -> web.Response: formContent = parse_qs(await request.text()) username = formContent["username"][0] password = formContent["password"][0] @@ -74,29 +78,33 @@ async def loginHandler(self, request): else: response.set_cookie("fontra-authorization-failed", "true", max_age=5) response.del_cookie("fontra-authorization-token") - raise response + return response - async def logoutHandler(self, request): + async def logoutHandler(self, request: web.Request) -> web.Response: token = request.cookies.get("fontra-authorization-token") if token is not None and token in self.authorizedClients: client = self.authorizedClients.pop(token) logger.info(f"logging out '{client.username}'") await client.close() - raise web.HTTPFound("/") + return web.HTTPFound("/") - async def authorize(self, request): + async def authorize(self, request: web.Request) -> str | None: token = request.cookies.get("fontra-authorization-token") if token not in self.authorizedClients: return None return token - async def projectPageHandler(self, request, filterContent=None): + async def projectPageHandler( + self, + request: web.Request, + filterContent: Callable[[bytes, str], bytes] | None = None, + ) -> web.Response: token = await self.authorize(request) htmlPath = resources.files("fontra_rcjk") / "landing.html" - html = htmlPath.read_text() + html = htmlPath.read_bytes() if filterContent is not None: html = filterContent(html, "text/html") - response = web.Response(text=html, content_type="text/html") + response = web.Response(body=html, content_type="text/html") if token: response.set_cookie( @@ -107,7 +115,7 @@ async def projectPageHandler(self, request, filterContent=None): return response - async def login(self, username, password): + async def login(self, username: str, password: str) -> str | None: url = f"https://{self.host}/" rcjkClient = RCJKClientAsync( host=url, @@ -127,15 +135,15 @@ async def login(self, username, password): ) return token - async def projectAvailable(self, path, token): + async def projectAvailable(self, path: str, token: str) -> bool: client = self.authorizedClients[token] return await client.projectAvailable(path) - async def getProjectList(self, token): + async def getProjectList(self, token: str) -> list[str]: client = self.authorizedClients[token] return await client.getProjectList() - async def getRemoteSubject(self, path, token): + async def getRemoteSubject(self, path: str, token: str) -> FontHandler | None: client = self.authorizedClients.get(token) if client is None: logger.info("reject unrecognized token") @@ -166,22 +174,22 @@ async def close(self): for fontHandler in self.fontHandlers.values(): await fontHandler.close() - async def projectAvailable(self, path): + async def projectAvailable(self, path: str) -> bool: await self._setupProjectList() return path in self.projectMapping - async def getProjectList(self): + async def getProjectList(self) -> list[str]: await self._setupProjectList(True) return sorted(self.projectMapping) - async def _setupProjectList(self, forceRebuild=False): + async def _setupProjectList(self, forceRebuild: bool = False) -> None: if not forceRebuild and self.projectMapping is not None: return projectMapping = await self.rcjkClient.get_project_font_uid_mapping() projectMapping = {f"{p}/{f}": uids for (p, f), uids in projectMapping.items()} self.projectMapping = projectMapping - async def getFontHandler(self, path): + async def getFontHandler(self, path: str) -> FontHandler: fontHandler = self.fontHandlers.get(path) if fontHandler is None: _, fontUID = self.projectMapping[path] diff --git a/src/fontra_rcjk/py.typed b/src/fontra_rcjk/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_font.py b/tests/test_font.py index a41468d..8b9b3f6 100644 --- a/tests/test_font.py +++ b/tests/test_font.py @@ -1,7 +1,6 @@ import contextlib import pathlib import shutil -from dataclasses import asdict from importlib.metadata import entry_points import pytest @@ -14,6 +13,7 @@ StaticGlyph, VariableGlyph, structure, + unstructure, ) from fontra_rcjk.base import makeSafeLayerName @@ -400,7 +400,7 @@ async def test_getGlyph(backendName, expectedGlyph): font = getTestFont(backendName) with contextlib.closing(font): glyph = await font.getGlyph(expectedGlyph.name) - assert asdict(glyph) == asdict(expectedGlyph) + assert unstructure(glyph) == unstructure(expectedGlyph) assert glyph == expectedGlyph @@ -871,60 +871,28 @@ async def test_delete_items(writableTestFont): expectedReadMixedComponentTestData = { "name": "b", - "axes": [], "sources": [ { "name": "", "layerName": "foreground", - "location": {}, - "inactive": False, "customData": {"fontra.development.status": 0}, } ], "layers": { "foreground": { "glyph": { - "path": {"coordinates": [], "pointTypes": [], "contourInfo": []}, "components": [ - { - "name": "a", - "transformation": { - "translateX": 0, - "translateY": 0, - "rotation": 0.0, - "scaleX": 1.0, - "scaleY": 1.0, - "skewX": 0.0, - "skewY": 0.0, - "tCenterX": 0, - "tCenterY": 0, - }, - "location": {}, - }, + {"name": "a"}, { "name": "DC_0033_00", - "transformation": { - "translateX": 30, - "translateY": 0, - "rotation": 0, - "scaleX": 1, - "scaleY": 1, - "skewX": 0, - "skewY": 0, - "tCenterX": 0, - "tCenterY": 0, - }, + "transformation": {"translateX": 30}, "location": {"X_X_bo": 0, "X_X_la": 0}, }, ], "xAdvance": 500, - "yAdvance": None, - "verticalOrigin": None, }, - "customData": {}, } }, - "customData": {}, } @@ -932,7 +900,7 @@ async def test_readMixClassicAndVariableComponents(): font = getTestFont("rcjk") with contextlib.closing(font): glyph = await font.getGlyph("b") - assert expectedReadMixedComponentTestData == asdict(glyph) + assert expectedReadMixedComponentTestData == unstructure(glyph) expectedWriteMixedComponentTestData = [