Source code for smufolib.request

# pylint: disable=C0103, C0114, R0913
from __future__ import annotations
from pathlib import Path
import json
import urllib.request
import warnings

from smufolib import config
from smufolib.utils import error, normalizers
from smufolib.utils._annotations import JsonDict

CONFIG = config.load()


[docs] class Request: """Send HTTP or filesystem request. A fallback path (e.g., a filesystem path to the same file), may be specified in case of connection failure when the primary path is a URL. An optional warning may be raised in the event of a fallback. :param path: Primary URL or filepath. :param fallback: Fallback filepath to use if `path` raises :class:`urllib.error.URLError`. :param encoding: File text encoding. See :func:`open` for details. Defaults to :confval:`request.encoding`. :param warn: Warn if URLError is raised before fallback request. Defaults to :confval:`request.warn`. """
[docs] @classmethod def classes(cls, decode: bool = True) -> JsonDict | str | None: """Retrieve `classes` metadata from configured paths. This method attempts to load metadata from the path specified in :confval:`metadata.paths.classes`, falling back to :confval:`metadata.fallbacks.classes` if necessary. :param decode: If :obj:`True`, return parsed JSON data; otherwise, return raw response. Defaults to :obj:`True`. """ return cls._getMetadata("classes", decode=decode)
[docs] @classmethod def glyphnames(cls, decode: bool = True) -> JsonDict | str | None: """Retrieve `glyphnames` metadata from configured paths. This method attempts to load metadata from the path specified in :confval:`metadata.paths.glyphnames`, falling back to :confval:`metadata.fallbacks.glyphnames` if necessary. :param decode: If :obj:`True`, return parsed JSON data; otherwise, return raw response. Defaults to :obj:`True`. """ return cls._getMetadata("glyphnames", decode=decode)
[docs] @classmethod def ranges(cls, decode: bool = True) -> JsonDict | str | None: """Retrieve `ranges` metadata from configured paths. This method attempts to load metadata from the path specified in :confval:`metadata.paths.ranges`, falling back to :confval:`metadata.fallbacks.ranges` if necessary. :param decode: If :obj:`True`, return parsed JSON data; otherwise, return raw response. Defaults to :obj:`True`. """ return cls._getMetadata("ranges", decode=decode)
[docs] @classmethod def font(cls, decode: bool = True) -> JsonDict | str | None: """Retrieve `font` metadata from configured paths. This method attempts to load metadata from the path specified in the :confval:`metadata.paths.font`, falling back to :confval:`metadata.fallbacks.font` if necessary. :param decode: If :obj:`True`, return parsed JSON data; otherwise, return raw response. Defaults to :obj:`True`. """ return cls._getMetadata("font", decode=decode)
@classmethod def _getMetadata(cls, filename: str, decode: bool = True) -> JsonDict | str | None: path = CONFIG["metadata.paths"][filename] fallback = CONFIG["metadata.fallbacks"][filename] request = cls(path, fallback) return request.json() if decode else request.text def __init__( self, path: Path | str | None = None, fallback: Path | str | None = None, encoding: str = CONFIG["request"]["encoding"], warn: bool = CONFIG["request"]["warn"], ) -> None: self._path = path self._fallback = fallback self._encoding = encoding self._warn = warn def __repr__(self): return ( f"<{self.__class__.__name__} '{self.path}' " f"('{self.fallback}') at {id(self)}>" )
[docs] def json(self) -> JsonDict | None: """Parse request as JSON. :raises json.JSONDecodeError: If the response cannot be parsed as JSON. :raises TypeError: If the raw data is None. """ if self.text is None: return None return json.loads(self.text)
def _readPreferredSource(self) -> bytes | None: if self.path is None and self.fallback is None: return None try: return self._readFromURL() # TypeError guards against path being None except (urllib.error.URLError, TypeError): if self.fallback: return self._readFromFallback() raise # ValueError confirms that path is an assumed filepath (non-URL) except ValueError: return self._readFromPath() def _readFromURL(self) -> bytes: # Read data from URL. if self.path is None: raise TypeError( error.generateTypeError(self.path, (str, Path, Request), "Request.path") ) try: with urllib.request.urlopen(self.path) as raw: return raw.read() except urllib.error.URLError as exc: return self._handleURLError(exc) def _readFromFallback(self) -> bytes: # Read data from fallback file. if self.fallback is None: raise TypeError( error.generateTypeError( self.fallback, (str, Path, Request), "Request.fallback" ) ) with open(self.fallback, "rb") as raw: return raw.read() def _readFromPath(self) -> bytes: # Read data from path. if self.path is None: raise TypeError( error.generateTypeError(self.path, (str, Path, Request), "Request.path") ) with open(self.path, "rb") as raw: return raw.read() def _handleURLError(self, exc: urllib.error.URLError) -> bytes: # Handle URL error during online request. if not self.fallback: raise urllib.error.URLError( error.generateErrorMessage("urlError", url=self.path) ) from exc if self._warn: warnings.warn( error.generateErrorMessage("urlError", url=self.path), error.URLWarning ) with open(self.fallback, "rb") as fallback_file: return fallback_file.read() @property def path(self) -> str | None: """Primary URL or filepath as string. :raises TypeError: If the path cannot be normalized. """ return normalizers.normalizeRequestPath(self._path, "path") @path.setter def path(self, value: Path | str | None) -> None: self._path = normalizers.normalizeRequestPath(value, "path") @property def fallback(self) -> str | None: """Fallback URL or filepath as string. :raises TypeError: If the fallback path cannot be normalized. """ return normalizers.normalizeRequestPath(self._fallback, "fallback") @fallback.setter def fallback(self, value: str | None) -> None: self._fallback = normalizers.normalizeRequestPath(value, "fallback") @property def encoding(self) -> str: """File text encoding.""" return self._encoding @encoding.setter def encoding(self, value: str): self._encoding = value @property def content(self) -> bytes | None: """The raw response data as bytes. This property is read-only. """ return self._readPreferredSource() @property def text(self) -> str | None: """The raw response data as text. This property is read-only. """ raw = self._readPreferredSource() if raw is None: return None return raw.decode(self.encoding)
[docs] def writeJson( filepath: Path | str, source: JsonDict, encoding: str = CONFIG["request"]["encoding"], ) -> None: """Writes JSON data to filepath. :param filepath: Path to target file. :param source: JSON data source. :param encoding: File text encoding. See :func:`open` for details. Defaults to :confval:`request.encoding`. :raises TypeError: If `filepath` is not the expected type. :raises ValueError: If there is an error serializing the JSON data or if `filepath` does not have a ``.json`` exctension. :raises FileNotFoundError: If the specified `filepath` cannot be found. :raises OSError: If there are any issues opening or writing to the file. :raises UnsupportedOperation: If the operation is not supported. """ error.validateType(filepath, (Path, str), "filepath") if not str(filepath).endswith(".json"): raise ValueError( error.generateErrorMessage( "missingExtension", objectName="filepath", extension=".json" ) ) try: with open(filepath, "w", encoding=encoding) as outfile: json.dump(source, outfile, indent=4, sort_keys=False) except (TypeError, ValueError) as e: raise ValueError("serializationError") from e