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. """Representation of type information for DBCore model fields.
""" """
from typing import Union, Any, Callable from abc import ABC
from . import query import typing
from typing import Any, cast, Generic, List, TypeVar, Union
from .query import BooleanQuery, FieldQuery, NumericQuery, SubstringQuery
from beets.util import str2bool from beets.util import str2bool
# Abstract base. # 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 """An object encapsulating the type of a model field. Includes
information about how to store, query, format, and parse a given information about how to store, query, format, and parse a given
field. field.
""" """
sql = 'TEXT' sql: str = 'TEXT'
"""The SQLite column type for the value. """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. """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 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 The model is guaranteed to return a value of this type if the field
@ -45,12 +64,15 @@ class Type:
""" """
@property @property
def null(self) -> model_type: def null(self) -> N:
"""The value to be exposed when the underlying value is None. """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 """Given a value of this type, produce a Unicode string
representing the value. This is used in template evaluation. representing the value. This is used in template evaluation.
""" """
@ -58,13 +80,13 @@ class Type:
value = self.null value = self.null
# `self.null` might be `None` # `self.null` might be `None`
if value is None: if value is None:
value = '' return ''
if isinstance(value, bytes): elif isinstance(value, bytes):
value = value.decode('utf-8', 'ignore') return value.decode('utf-8', 'ignore')
else:
return str(value)
return str(value) def parse(self, string: str) -> Union[T, N]:
def parse(self, string: str) -> model_type:
"""Parse a (possibly human-written) string and return the """Parse a (possibly human-written) string and return the
indicated value of this type. indicated value of this type.
""" """
@ -73,7 +95,7 @@ class Type:
except ValueError: except ValueError:
return self.null 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 """Given a value that will be assigned into a field of this
type, normalize the value to have the appropriate type. This type, normalize the value to have the appropriate type. This
base implementation only reinterprets `None`. base implementation only reinterprets `None`.
@ -84,12 +106,12 @@ class Type:
else: else:
# TODO This should eventually be replaced by # TODO This should eventually be replaced by
# `self.model_type(value)` # `self.model_type(value)`
return value return cast(T, value)
def from_sql( def from_sql(
self, self,
sql_value: Union[None, int, float, str, bytes], sql_value: Union[None, int, float, str, bytes],
) -> model_type: ) -> Union[T, N]:
"""Receives the value stored in the SQL backend and return the """Receives the value stored in the SQL backend and return the
value to be stored in the model. value to be stored in the model.
@ -119,18 +141,22 @@ class Type:
# Reusable types. # Reusable types.
class Default(Type): class Default(Type[str, None]):
null = None model_type = str
@property
def null(self):
return None
class Integer(Type): class BaseInteger(Type[int, N]):
"""A basic integer type. """A basic integer type.
""" """
sql = 'INTEGER' sql = 'INTEGER'
query = query.NumericQuery query = NumericQuery
model_type = int model_type = int
def normalize(self, value: str) -> Union[int, str]: def normalize(self, value: Any) -> Union[int, N]:
try: try:
return self.model_type(round(float(value))) return self.model_type(round(float(value)))
except ValueError: except ValueError:
@ -139,21 +165,39 @@ class Integer(Type):
return self.null 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, """An integer field that is formatted with a given number of digits,
padded with zeroes. padded with zeroes.
""" """
def __init__(self, digits: int): def __init__(self, digits: int):
self.digits = digits 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) return '{0:0{1}d}'.format(value or 0, self.digits)
class NullPaddedInt(PaddedInt): class PaddedInt(BasePaddedInt[int]):
"""Same as `PaddedInt`, but does not normalize `None` to `0.0`. 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): class ScaledInt(Integer):
@ -168,52 +212,70 @@ class ScaledInt(Integer):
return '{}{}'.format((value or 0) // self.unit, self.suffix) 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. """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. 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): def __init__(self, primary: bool = True):
if primary: if primary:
self.sql = 'INTEGER PRIMARY KEY' self.sql = 'INTEGER PRIMARY KEY'
class Float(Type): class BaseFloat(Type[float, N]):
"""A basic floating-point type. The `digits` parameter specifies how """A basic floating-point type. The `digits` parameter specifies how
many decimal places to use in the human-readable representation. many decimal places to use in the human-readable representation.
""" """
sql = 'REAL' sql = 'REAL'
query = query.NumericQuery query = NumericQuery
model_type = float model_type = float
def __init__(self, digits: int = 1): def __init__(self, digits: int = 1):
self.digits = digits 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) 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`. """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. """A Unicode string type.
""" """
sql = 'TEXT' 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: if value is None:
return self.null return self.null
else: else:
return self.model_type(value) 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 """A list of Unicode strings, represented in-database by a single string
containing delimiter-separated values. containing delimiter-separated values.
""" """
@ -238,7 +300,7 @@ class Boolean(Type):
"""A boolean type. """A boolean type.
""" """
sql = 'INTEGER' sql = 'INTEGER'
query = query.BooleanQuery query = BooleanQuery
model_type = bool model_type = bool
def format(self, value: bool) -> str: 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 # These "safe" types can avoid the format/parse cycle that most fields go
# through: they are safe to edit with native YAML types. # 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): class ParseError(Exception):