typing: corrections for dbcore/types.py

tricky...
- the only way I found to express the concept of the "associated type"
  (in Rust lingo) model_type was by making Type generic over its value
  and null types.
- in addition, the class hierarchy of Integer and Float types had to be
  modified, since previously some of them would have conflicting null
  types relative to their super class (this required a change to the
  edit plugin; hopefully no more breakage is caused by these changes)
- don't import the query module, but only the relevant Query's to avoid
  confusing the module query and the class variable query
This commit is contained in:
wisp3rwind 2023-02-23 23:16:22 +01:00
parent 854fec2634
commit a84b3542f9
2 changed files with 103 additions and 41 deletions

View file

@ -15,28 +15,47 @@
"""Representation of type information for DBCore model fields.
"""
from typing import Union, Any, Callable
from . import query
from abc import ABC
import typing
from typing import Any, cast, Generic, List, TypeVar, Union
from .query import BooleanQuery, FieldQuery, NumericQuery, SubstringQuery
from beets.util import str2bool
# Abstract base.
class Type:
class ModelType(Protocol):
"""Protocol that specifies the required constructor for model types, i.e.
a function that takes any argument and attempts to parse it to the given
type.
"""
def __init__(self, value: Any = None):
...
# Generic type variables, used for the value type T and null type N (if
# nullable, else T and N are set to the same type for the concrete subclasses
# of Type).
N = TypeVar("N")
T = TypeVar("T", bound=ModelType)
class Type(ABC, Generic[T, N]):
"""An object encapsulating the type of a model field. Includes
information about how to store, query, format, and parse a given
field.
"""
sql = 'TEXT'
sql: str = 'TEXT'
"""The SQLite column type for the value.
"""
query = query.SubstringQuery
query: typing.Type[FieldQuery] = SubstringQuery
"""The `Query` subclass to be used when querying the field.
"""
model_type: Callable[[Any], str] = str
model_type: typing.Type[T]
"""The Python type that is used to represent the value in the model.
The model is guaranteed to return a value of this type if the field
@ -45,12 +64,15 @@ class Type:
"""
@property
def null(self) -> model_type:
def null(self) -> N:
"""The value to be exposed when the underlying value is None.
"""
return self.model_type()
# Note that this default implementation only makes sense for T = N.
# It would be better to implement `null()` only in subclasses, or
# have a field null_type similar to `model_type` and use that here.
return cast(N, self.model_type())
def format(self, value: model_type) -> str:
def format(self, value: Union[N, T]) -> str:
"""Given a value of this type, produce a Unicode string
representing the value. This is used in template evaluation.
"""
@ -58,13 +80,13 @@ class Type:
value = self.null
# `self.null` might be `None`
if value is None:
value = ''
if isinstance(value, bytes):
value = value.decode('utf-8', 'ignore')
return ''
elif isinstance(value, bytes):
return value.decode('utf-8', 'ignore')
else:
return str(value)
def parse(self, string: str) -> model_type:
def parse(self, string: str) -> Union[T, N]:
"""Parse a (possibly human-written) string and return the
indicated value of this type.
"""
@ -73,7 +95,7 @@ class Type:
except ValueError:
return self.null
def normalize(self, value: Union[None, int, float, bytes]) -> model_type:
def normalize(self, value: Any) -> Union[T, N]:
"""Given a value that will be assigned into a field of this
type, normalize the value to have the appropriate type. This
base implementation only reinterprets `None`.
@ -84,12 +106,12 @@ class Type:
else:
# TODO This should eventually be replaced by
# `self.model_type(value)`
return value
return cast(T, value)
def from_sql(
self,
sql_value: Union[None, int, float, str, bytes],
) -> model_type:
) -> Union[T, N]:
"""Receives the value stored in the SQL backend and return the
value to be stored in the model.
@ -119,18 +141,22 @@ class Type:
# Reusable types.
class Default(Type):
null = None
class Default(Type[str, None]):
model_type = str
@property
def null(self):
return None
class Integer(Type):
class BaseInteger(Type[int, N]):
"""A basic integer type.
"""
sql = 'INTEGER'
query = query.NumericQuery
query = NumericQuery
model_type = int
def normalize(self, value: str) -> Union[int, str]:
def normalize(self, value: Any) -> Union[int, N]:
try:
return self.model_type(round(float(value)))
except ValueError:
@ -139,21 +165,39 @@ class Integer(Type):
return self.null
class PaddedInt(Integer):
class Integer(BaseInteger[int]):
@property
def null(self) -> int:
return 0
class NullInteger(BaseInteger[None]):
@property
def null(self) -> None:
return None
class BasePaddedInt(BaseInteger[N]):
"""An integer field that is formatted with a given number of digits,
padded with zeroes.
"""
def __init__(self, digits: int):
self.digits = digits
def format(self, value: int) -> str:
def format(self, value: Union[int, N]) -> str:
return '{0:0{1}d}'.format(value or 0, self.digits)
class NullPaddedInt(PaddedInt):
"""Same as `PaddedInt`, but does not normalize `None` to `0.0`.
class PaddedInt(BasePaddedInt[int]):
pass
class NullPaddedInt(BasePaddedInt[None]):
"""Same as `PaddedInt`, but does not normalize `None` to `0`.
"""
null = None
@property
def null(self) -> None:
return None
class ScaledInt(Integer):
@ -168,52 +212,70 @@ class ScaledInt(Integer):
return '{}{}'.format((value or 0) // self.unit, self.suffix)
class Id(Integer):
class Id(NullInteger):
"""An integer used as the row id or a foreign key in a SQLite table.
This type is nullable: None values are not translated to zero.
"""
null = None
@property
def null(self) -> None:
return None
def __init__(self, primary: bool = True):
if primary:
self.sql = 'INTEGER PRIMARY KEY'
class Float(Type):
class BaseFloat(Type[float, N]):
"""A basic floating-point type. The `digits` parameter specifies how
many decimal places to use in the human-readable representation.
"""
sql = 'REAL'
query = query.NumericQuery
query = NumericQuery
model_type = float
def __init__(self, digits: int = 1):
self.digits = digits
def format(self, value: float) -> str:
def format(self, value: Union[float, N]) -> str:
return '{0:.{1}f}'.format(value or 0, self.digits)
class NullFloat(Float):
class Float(BaseFloat[float]):
"""Floating-point type that normalizes `None` to `0.0`.
"""
@property
def null(self) -> float:
return 0.0
class NullFloat(BaseFloat[None]):
"""Same as `Float`, but does not normalize `None` to `0.0`.
"""
null = None
@property
def null(self) -> None:
return None
class String(Type):
class BaseString(Type[T, N]):
"""A Unicode string type.
"""
sql = 'TEXT'
query = query.SubstringQuery
query = SubstringQuery
def normalize(self, value: str) -> str:
def normalize(self, value: Any) -> Union[T, N]:
if value is None:
return self.null
else:
return self.model_type(value)
class DelimitedString(String):
class String(BaseString[str, Any]):
"""A Unicode string type.
"""
model_type = str
class DelimitedString(BaseString[List[str], List[str]]):
"""A list of Unicode strings, represented in-database by a single string
containing delimiter-separated values.
"""
@ -238,7 +300,7 @@ class Boolean(Type):
"""A boolean type.
"""
sql = 'INTEGER'
query = query.BooleanQuery
query = BooleanQuery
model_type = bool
def format(self, value: bool) -> str:

View file

@ -31,7 +31,7 @@ import shlex
# These "safe" types can avoid the format/parse cycle that most fields go
# through: they are safe to edit with native YAML types.
SAFE_TYPES = (types.Float, types.Integer, types.Boolean)
SAFE_TYPES = (types.BaseFloat, types.BaseInteger, types.Boolean)
class ParseError(Exception):