Skip to content

Commit

Permalink
Python 3.8+ (#7)
Browse files Browse the repository at this point in the history
* workflows/tests: python 3.8+

Signed-off-by: William Woodruff <[email protected]>

* rfc8785: Python 3.8 accommodations

Signed-off-by: William Woodruff <[email protected]>

* conftest: deferred annotations

Signed-off-by: William Woodruff <[email protected]>

* _impl: Union

Signed-off-by: William Woodruff <[email protected]>

* _impl: more accommodations

Signed-off-by: William Woodruff <[email protected]>

* _impl: more accommodations

Signed-off-by: William Woodruff <[email protected]>

---------

Signed-off-by: William Woodruff <[email protected]>
  • Loading branch information
woodruffw authored Mar 19, 2024
1 parent a88332d commit e2e718f
Show file tree
Hide file tree
Showing 4 changed files with 75 additions and 62 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ jobs:
strategy:
matrix:
python:
- "3.8"
- "3.9"
- "3.10"
- "3.11"
- "3.12"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ classifiers = [
"Topic :: Security :: Cryptography",
]
dependencies = []
requires-python = ">=3.10"
requires-python = ">=3.8"

[project.optional-dependencies]
doc = ["pdoc"]
Expand Down
131 changes: 70 additions & 61 deletions src/rfc8785/_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
"""
Expand All @@ -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.
Expand Down Expand Up @@ -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)}")
2 changes: 2 additions & 0 deletions test/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from collections.abc import Callable
from pathlib import Path

Expand Down

0 comments on commit e2e718f

Please sign in to comment.