unwind/unwind/models.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

365 lines
10 KiB
Python
Raw Normal View History

import json
from dataclasses import dataclass, field
from dataclasses import fields as _fields
from datetime import datetime, timezone
2021-08-18 20:08:09 +02:00
from functools import partial
2023-02-04 12:46:30 +01:00
from types import UnionType
from typing import (
Annotated,
Any,
ClassVar,
Container,
Literal,
Mapping,
Type,
TypeVar,
Union,
get_args,
get_origin,
)
from .types import ULID
2023-02-04 17:30:54 +01:00
JSON = int | float | str | None | list["JSON"] | dict[str, "JSON"]
JSONObject = dict[str, JSON]
2021-08-03 16:39:36 +02:00
T = TypeVar("T")
2023-02-02 23:46:02 +01:00
def annotations(tp: Type) -> tuple | None:
return tp.__metadata__ if hasattr(tp, "__metadata__") else None
def fields(class_or_instance):
"""Like dataclass' `fields` but with extra support for our models.
This function is a drop-in replacement for dataclass' `fields` and
SHOULD be used instead of it everywhere.
This function filters out fields marked as `Relation`. `Relation`
fields are meant to allow to store the data referenced by an ID field
directly on the instance.
"""
# XXX this might be a little slow (not sure), if so, memoize
for f in _fields(class_or_instance):
2021-07-22 20:30:23 +02:00
if f.name == "_is_lazy":
continue
if (attn := annotations(f.type)) and _RelationSentinel in attn:
continue # Relations are ignored
yield f
def is_optional(tp: Type) -> bool:
"""Return wether the given type is optional."""
2023-02-04 12:46:30 +01:00
if not isinstance(tp, UnionType) and get_origin(tp) is not Union:
return False
args = get_args(tp)
return len(args) == 2 and type(None) in args
2023-02-02 23:46:02 +01:00
def optional_type(tp: Type) -> Type | None:
"""Return the wrapped type from an optional type.
For example this will return `int` for `Optional[int]`.
Since they're equivalent this also works for other optioning notations, like
`Union[int, None]` and `int | None`.
"""
2023-02-04 12:46:30 +01:00
if not isinstance(tp, UnionType) and get_origin(tp) is not Union:
return None
args = get_args(tp)
if len(args) != 2 or type(None) not in args:
return None
return args[0] if args[1] is type(None) else args[1]
2021-06-21 18:54:03 +02:00
def optional_fields(o):
for f in fields(o):
if is_optional(f.type):
yield f
2021-08-18 20:08:09 +02:00
json_dump = partial(json.dumps, separators=(",", ":"))
def _id(x: T) -> T:
return x
def asplain(
2023-02-04 01:12:09 +01:00
o: object, *, filter_fields: Container[str] | None = None, serialize: bool = False
) -> dict[str, Any]:
"""Return the given model instance as `dict` with JSON compatible plain datatypes.
If `filter_fields` is given only matching field names will be included in
the resulting `dict`.
If `serialize` is `True`, collection types (lists, dicts, etc.) will be
serialized as strings; this can be useful to store them in a database. Be
sure to set `serialized=True` when using `fromplain` to successfully restore
the object.
"""
validate(o)
dump = json_dump if serialize else _id
d: JSONObject = {}
for f in fields(o):
if filter_fields is not None and f.name not in filter_fields:
continue
target = f.type
# XXX this doesn't properly support any kind of nested types
if (otype := optional_type(f.type)) is not None:
target = otype
if (otype := get_origin(target)) is not None:
target = otype
v = getattr(o, f.name)
if is_optional(f.type) and v is None:
d[f.name] = None
elif target is ULID:
assert isinstance(v, ULID)
d[f.name] = str(v)
elif target in {datetime}:
assert isinstance(v, datetime)
d[f.name] = v.isoformat()
elif target in {set}:
assert isinstance(v, set)
d[f.name] = dump(list(sorted(v)))
elif target in {list}:
assert isinstance(v, list)
d[f.name] = dump(list(v))
elif target in {bool, str, int, float}:
assert isinstance(
v, target
), f"Type mismatch: {f.name} ({target} != {type(v)})"
d[f.name] = v
else:
raise ValueError(f"Unsupported value type: {f.name}: {type(v)}")
return d
def fromplain(cls: Type[T], d: Mapping, *, serialized: bool = False) -> T:
"""Return an instance of the given model using the given data.
If `serialized` is `True`, collection types (lists, dicts, etc.) will be
deserialized from string. This is the opposite operation of `serialize` for
`asplain`.
"""
load = json.loads if serialized else _id
dd: JSONObject = {}
for f in fields(cls):
target = f.type
otype = optional_type(f.type)
is_opt = otype is not None
if is_opt:
target = otype
if (xtype := get_origin(target)) is not None:
target = xtype
v = d[f.name]
if is_opt and v is None:
dd[f.name] = v
elif isinstance(v, target):
dd[f.name] = v
elif target in {set, list}:
dd[f.name] = target(load(v))
elif target in {datetime}:
dd[f.name] = target.fromisoformat(v)
else:
dd[f.name] = target(v)
o = cls(**dd)
validate(o)
return o
def validate(o: object) -> None:
for f in fields(o):
vtype = type(getattr(o, f.name))
if vtype is not f.type:
if get_origin(f.type) is vtype or (
2023-02-04 12:46:30 +01:00
(isinstance(f.type, UnionType) or get_origin(f.type) is Union)
and vtype in get_args(f.type)
):
continue
raise ValueError(f"Invalid value type: {f.name}: {vtype}")
def utcnow():
2021-07-22 00:05:38 +02:00
return datetime.utcnow().replace(tzinfo=timezone.utc)
@dataclass
class Progress:
_table: ClassVar[str] = "progress"
id: ULID = field(default_factory=ULID)
type: str = None
state: str = None
started: datetime = field(default_factory=utcnow)
2023-02-02 23:46:02 +01:00
stopped: str | None = None
2021-07-28 23:07:04 +02:00
@property
def _state(self) -> dict:
return json.loads(self.state or "{}")
@_state.setter
def _state(self, state: dict):
2021-08-18 20:08:09 +02:00
self.state = json_dump(state)
2021-07-28 23:07:04 +02:00
@property
def percent(self) -> float:
return self._state["percent"]
@percent.setter
def percent(self, percent: float):
state = self._state
state["percent"] = percent
self._state = state
@property
def error(self) -> str:
return self._state.get("error", "")
@error.setter
def error(self, error: str):
state = self._state
state["error"] = error
self._state = state
@dataclass
class Movie:
_table: ClassVar[str] = "movies"
id: ULID = field(default_factory=ULID)
2021-06-21 18:54:03 +02:00
title: str = None # canonical title (usually English)
2023-02-02 23:46:02 +01:00
original_title: str | None = (
None # original title (usually transscribed to latin script)
)
release_year: int = None # canonical release date
2021-06-21 18:54:03 +02:00
media_type: str = None
imdb_id: str = None
2023-02-02 23:46:02 +01:00
imdb_score: int | None = None # range: [0,100]
imdb_votes: int | None = None
runtime: int | None = None # minutes
genres: set[str] = None
2021-07-22 20:30:23 +02:00
created: datetime = field(default_factory=utcnow)
updated: datetime = field(default_factory=utcnow)
2021-07-22 20:30:23 +02:00
_is_lazy: bool = field(default=False, init=False, repr=False, compare=False)
@classmethod
def lazy(cls, **kwds):
"""Return a new instance without running default factories.
This is meant purely for optimization purposes, to postpone possibly
expensive initialization operations.
"""
# XXX optimize using a metaclass & storing field refs on the class
kwds.setdefault("id", None)
kwds.setdefault("created", None)
kwds.setdefault("updated", None)
movie = cls(**kwds)
movie._is_lazy = True
return movie
def _lazy_init(self):
if not self._is_lazy:
return
for field in fields(Movie):
if getattr(self, field.name) is None and callable(field.default_factory):
2021-07-22 20:30:23 +02:00
setattr(self, field.name, field.default_factory())
self._is_lazy = False
_RelationSentinel = object()
"""Mark a model field as containing external data.
For each field marked as a Relation there should be another field on the
dataclass containing the ID of the linked data.
The contents of the Relation are ignored or discarded when using
`asplain`, `fromplain`, and `validate`.
"""
2023-02-02 23:46:02 +01:00
Relation = Annotated[T | None, _RelationSentinel]
@dataclass
class Rating:
_table: ClassVar[str] = "ratings"
id: ULID = field(default_factory=ULID)
movie_id: ULID = None
movie: Relation[Movie] = None
user_id: ULID = None
user: Relation["User"] = None
score: int = None # range: [0,100]
rating_date: datetime = None
2023-02-02 23:46:02 +01:00
favorite: bool | None = None
finished: bool | None = None
def __eq__(self, other):
"""Return wether two Ratings are equal.
This operation compares all fields as expected, except that it
ignores any field marked as Relation.
"""
if type(other) is not type(self):
return False
return all(
getattr(self, f.name) == getattr(other, f.name) for f in fields(self)
)
Access = Literal[
"r", # read
"i", # index
"w", # write
]
@dataclass
class User:
_table: ClassVar[str] = "users"
id: ULID = field(default_factory=ULID)
imdb_id: str = None
name: str = None # canonical user name
secret: str = None
groups: list[dict[str, str]] = field(default_factory=list)
2023-02-02 23:46:02 +01:00
def has_access(self, group_id: ULID | str, access: Access = "r"):
group_id = group_id if isinstance(group_id, str) else str(group_id)
return any(g["id"] == group_id and access == g["access"] for g in self.groups)
2023-02-02 23:46:02 +01:00
def set_access(self, group_id: ULID | str, access: Access):
group_id = group_id if isinstance(group_id, str) else str(group_id)
for g in self.groups:
if g["id"] == group_id:
g["access"] = access
break
else:
self.groups.append({"id": group_id, "access": access})
@dataclass
class Group:
_table: ClassVar[str] = "groups"
id: ULID = field(default_factory=ULID)
name: str = None
users: list[dict[str, str]] = field(default_factory=list)