diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 87b9046..81cd226 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,6 +11,8 @@ jobs: strategy: matrix: python: + - "3.8" + - "3.9" - "3.10" - "3.11" - "3.12" diff --git a/pyproject.toml b/pyproject.toml index 0da769a..cf40b88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ "Topic :: Security :: Cryptography", ] dependencies = [] -requires-python = ">=3.10" +requires-python = ">=3.8" [project.optional-dependencies] doc = ["pdoc"] diff --git a/src/rfc8785/_impl.py b/src/rfc8785/_impl.py index 2776d8f..f89feac 100644 --- a/src/rfc8785/_impl.py +++ b/src/rfc8785/_impl.py @@ -8,10 +8,19 @@ import math import re +import typing from io import BytesIO -from typing import IO -_Value = bool | int | str | float | None | list["_Value"] | tuple["_Value"] | dict[str, "_Value"] +_Value = typing.Union[ + bool, + int, + str, + float, + None, + typing.List["_Value"], + typing.Tuple["_Value"], + typing.Dict[str, "_Value"], +] _INT_MAX = 2**53 - 1 _INT_MIN = -(2**53) + 1 @@ -69,7 +78,7 @@ def __init__(self, f: float) -> None: super().__init__(f"{f} is not representable in JCS") -def _serialize_str(s: str, sink: IO[bytes]) -> None: +def _serialize_str(s: str, sink: typing.IO[bytes]) -> None: """ Serialize a string as a JSON string, per RFC 8785 3.2.2.2. """ @@ -87,7 +96,7 @@ def _replace(match: re.Match) -> str: sink.write(b'"') -def _serialize_float(f: float, sink: IO[bytes]) -> None: +def _serialize_float(f: float, sink: typing.IO[bytes]) -> None: """ Serialize a floating point number to a stable string format, as defined in ECMA 262 7.1.12.1 and amended by RFC 8785 3.2.2.3. @@ -178,63 +187,63 @@ def dumps(obj: _Value) -> bytes: return sink.getvalue() -def dump(obj: _Value, sink: IO[bytes]) -> None: +def dump(obj: _Value, sink: typing.IO[bytes]) -> None: """ Perform JCS serialization of `obj` into `sink`. """ - match obj: - case bool(): - if obj is True: - sink.write(b"true") - else: - sink.write(b"false") - case int(): - # Annoyance: int can be subclassed by types like IntEnum, - # which then break or change `int.__str__`. Rather than plugging - # these individually, we coerce back to `int`. - obj = int(obj) - - if obj < _INT_MIN or obj > _INT_MAX: - raise IntegerDomainError(obj) - sink.write(str(obj).encode("utf-8")) - case str(): - _serialize_str(obj, sink) - case float(): - _serialize_float(obj, sink) - case None: - sink.write(b"null") - case list() | tuple(): - if not obj: - # Optimization for empty lists. - sink.write(b"[]") - return - - sink.write(b"[") - for idx, elem in enumerate(obj): - if idx > 0: - sink.write(b",") - dump(elem, sink) - sink.write(b"]") - case dict(): - if not obj: - # Optimization for empty dicts. - sink.write(b"{}") - return - - # RFC 8785 3.2.3: Objects are sorted by key; keys are ordered - # by their UTF-16 encoding. The spec isn't clear about which endianness, - # but the examples imply that the big endian encoding is used. - obj_sorted = sorted(obj.items(), key=lambda kv: kv[0].encode("utf-16be")) - - sink.write(b"{") - for idx, (key, value) in enumerate(obj_sorted): - if idx > 0: - sink.write(b",") - - _serialize_str(key, sink) - sink.write(b":") - dump(value, sink) - - sink.write(b"}") - case _: - raise CanonicalizationError(f"unsupported type: {type(obj)}") + + if obj is None: + sink.write(b"null") + elif isinstance(obj, bool): + if obj is True: + sink.write(b"true") + else: + sink.write(b"false") + elif isinstance(obj, int): + # Annoyance: int can be subclassed by types like IntEnum, + # which then break or change `int.__str__`. Rather than plugging + # these individually, we coerce back to `int`. + obj = int(obj) + + if obj < _INT_MIN or obj > _INT_MAX: + raise IntegerDomainError(obj) + sink.write(str(obj).encode("utf-8")) + elif isinstance(obj, str): + _serialize_str(obj, sink) + elif isinstance(obj, float): + _serialize_float(obj, sink) + elif isinstance(obj, (list, tuple)): + if not obj: + # Optimization for empty lists. + sink.write(b"[]") + return + + sink.write(b"[") + for idx, elem in enumerate(obj): + if idx > 0: + sink.write(b",") + dump(elem, sink) + sink.write(b"]") + elif isinstance(obj, dict): + if not obj: + # Optimization for empty dicts. + sink.write(b"{}") + return + + # RFC 8785 3.2.3: Objects are sorted by key; keys are ordered + # by their UTF-16 encoding. The spec isn't clear about which endianness, + # but the examples imply that the big endian encoding is used. + obj_sorted = sorted(obj.items(), key=lambda kv: kv[0].encode("utf-16be")) + + sink.write(b"{") + for idx, (key, value) in enumerate(obj_sorted): + if idx > 0: + sink.write(b",") + + _serialize_str(key, sink) + sink.write(b":") + dump(value, sink) + + sink.write(b"}") + else: + raise CanonicalizationError(f"unsupported type: {type(obj)}") diff --git a/test/conftest.py b/test/conftest.py index c2d6192..fe29db4 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from collections.abc import Callable from pathlib import Path