refactor(reports): improve Serializable base class

This commit is contained in:
Mike A.
2025-07-14 22:10:14 +02:00
parent a2c3b3136e
commit c37a51c2eb
3 changed files with 128 additions and 22 deletions

View File

@@ -10,17 +10,49 @@ from abc import ABC, abstractmethod
from datetime import datetime, timezone from datetime import datetime, timezone
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import BinaryIO from typing import BinaryIO, Literal, TypedDict, Union
from anisette import Anisette, AnisetteHeaders from anisette import Anisette, AnisetteHeaders
from typing_extensions import override from typing_extensions import override
from findmy.util.abc import Closable, Serializable from findmy.util.abc import Closable, Serializable
from findmy.util.files import read_data_json, save_and_return_json
from findmy.util.http import HttpSession from findmy.util.http import HttpSession
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RemoteAnisetteMapping(TypedDict):
"""JSON mapping representing state of a remote Anisette provider."""
type: Literal["aniRemote"]
url: str
class LocalAnisetteMapping(TypedDict):
"""JSON mapping representing state of a local Anisette provider."""
type: Literal["aniLocal"]
prov_data: str
AnisetteMapping = Union[RemoteAnisetteMapping, LocalAnisetteMapping]
def get_provider_from_mapping(
mapping: AnisetteMapping,
*,
libs_path: str | Path | None = None,
) -> RemoteAnisetteProvider | LocalAnisetteProvider:
"""Get the correct Anisette provider instance from saved JSON data."""
if mapping["type"] == "aniRemote":
return RemoteAnisetteProvider.from_json(mapping)
if mapping["type"] == "aniLocal":
return LocalAnisetteProvider.from_json(mapping, libs_path=libs_path)
msg = f"Unknown anisette type: {mapping['type']}"
raise ValueError(msg)
class BaseAnisetteProvider(Closable, Serializable, ABC): class BaseAnisetteProvider(Closable, Serializable, ABC):
""" """
Abstract base class for Anisette providers. Abstract base class for Anisette providers.
@@ -173,20 +205,25 @@ class RemoteAnisetteProvider(BaseAnisetteProvider):
self._anisette_data_expires_at: float = 0 self._anisette_data_expires_at: float = 0
@override @override
def serialize(self) -> dict: def to_json(self, dst: str | Path | None = None, /) -> RemoteAnisetteMapping:
"""See `BaseAnisetteProvider.serialize`.""" """See `BaseAnisetteProvider.serialize`."""
return { return save_and_return_json(
"type": "aniRemote", {
"url": self._server_url, "type": "aniRemote",
} "url": self._server_url,
},
dst,
)
@classmethod @classmethod
@override @override
def deserialize(cls, data: dict) -> RemoteAnisetteProvider: def from_json(cls, val: str | Path | RemoteAnisetteMapping) -> RemoteAnisetteProvider:
"""See `BaseAnisetteProvider.deserialize`.""" """See `BaseAnisetteProvider.deserialize`."""
assert data["type"] == "aniRemote" val = read_data_json(val)
server_url = data["url"] assert val["type"] == "aniRemote"
server_url = val["url"]
return cls(server_url) return cls(server_url)
@@ -276,24 +313,34 @@ class LocalAnisetteProvider(BaseAnisetteProvider):
) )
@override @override
def serialize(self) -> dict: def to_json(self, dst: str | Path | None = None, /) -> LocalAnisetteMapping:
"""See `BaseAnisetteProvider.serialize`.""" """See `BaseAnisetteProvider.serialize`."""
with BytesIO() as buf: with BytesIO() as buf:
self._ani.save_provisioning(buf) self._ani.save_provisioning(buf)
prov_data = base64.b64encode(buf.getvalue()).decode("utf-8") prov_data = base64.b64encode(buf.getvalue()).decode("utf-8")
return { return save_and_return_json(
"type": "aniLocal", {
"prov_data": prov_data, "type": "aniLocal",
} "prov_data": prov_data,
},
dst,
)
@classmethod @classmethod
@override @override
def deserialize(cls, data: dict, libs_path: str | Path | None = None) -> LocalAnisetteProvider: def from_json(
cls,
val: str | Path | LocalAnisetteMapping,
*,
libs_path: str | Path | None = None,
) -> LocalAnisetteProvider:
"""See `BaseAnisetteProvider.deserialize`.""" """See `BaseAnisetteProvider.deserialize`."""
assert data["type"] == "aniLocal" val = read_data_json(val)
state_blob = BytesIO(base64.b64decode(data["prov_data"])) assert val["type"] == "aniLocal"
state_blob = BytesIO(base64.b64decode(val["prov_data"]))
return cls(state_blob=state_blob, libs_path=libs_path) return cls(state_blob=state_blob, libs_path=libs_path)

View File

@@ -5,6 +5,10 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Generic, Self, TypeVar
if TYPE_CHECKING:
from pathlib import Path
logging.getLogger(__name__) logging.getLogger(__name__)
@@ -38,16 +42,37 @@ class Closable(ABC):
pass pass
class Serializable(ABC): T = TypeVar("T", bound=dict)
class Serializable(Generic[T], ABC):
"""ABC for serializable classes.""" """ABC for serializable classes."""
@abstractmethod @abstractmethod
def serialize(self) -> dict: def to_json(self, dst: str | Path | None = None, /) -> T:
"""Serialize the object to a JSON-serializable dictionary.""" """
Export the current state of the object as a JSON-serializable dictionary.
If an argument is provided, the output will also be written to that file.
The output of this method is guaranteed to be JSON-serializable, and passing
the return value of this function as an argument to `Serializable.from_json`
will always result in an exact copy of the internal state as it was when exported.
You are encouraged to save and load object states to and from disk whenever possible,
to prevent unnecessary API calls or otherwise unexpected behavior.
"""
raise NotImplementedError raise NotImplementedError
@classmethod @classmethod
@abstractmethod @abstractmethod
def deserialize(cls, data: dict) -> Serializable: def from_json(cls, val: str | Path | T, /) -> Self:
"""Deserialize the object from a JSON-serializable dictionary.""" """
Restore state from a previous `Closable.to_json` export.
If given a str or Path, it must point to a json file from `Serializable.to_json`.
Otherwise, it should be the Mapping itself.
See `Serializable.to_json` for more information.
"""
raise NotImplementedError raise NotImplementedError

34
findmy/util/files.py Normal file
View File

@@ -0,0 +1,34 @@
"""Utilities to simplify reading and writing data from and to files."""
from __future__ import annotations
import json
from collections.abc import Mapping
from pathlib import Path
from typing import TypeVar, cast
T = TypeVar("T", bound=Mapping)
def save_and_return_json(data: T, dst: str | Path | None) -> T:
"""Save and return a JSON-serializable data structure."""
if dst is None:
return data
if isinstance(dst, str):
dst = Path(dst)
dst.write_text(json.dumps(data, indent=4))
return data
def read_data_json(val: str | Path | T) -> T:
"""Read JSON data from a file if a path is passed, or return the argument itself."""
if isinstance(val, str):
val = Path(val)
if isinstance(val, Path):
val = cast("T", json.loads(val.read_text()))
return val