Module pyracmon.testing.model

Expand source code
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from enum import Enum
from uuid import UUID, uuid1, uuid3
from typing import Optional, Union, TypeVar, Any, overload, TYPE_CHECKING
from typing_extensions import Self
from pyracmon.config import PyracmonConfiguration
from pyracmon.connection import Connection
from pyracmon.mixin import CRUDMixin
from pyracmon.model import Model
from pyracmon.graph.typing import issubgeneric
from pyracmon.dialect.shared import MultiInsertMixin, TruncateMixin
from .util import default_test_config, Matcher


if TYPE_CHECKING:
    class TestingModel(MultiInsertMixin, CRUDMixin):
        pass
else:
    class TestingModel():
        pass


M = TypeVar('M', bound=TestingModel)


class TestingState:
    indexes = {}

    @classmethod
    def reset(cls, model=None):
        cls.indexes.clear()

    @classmethod
    def inc(cls, model, count):
        v = cls.indexes.setdefault(model, 1)
        cls.indexes[model] = v + count
        return v

    @classmethod
    def set_index(cls, model, index):
        cls.indexes[model] = index


class TestingMixin(TestingModel):
    """
    Mixin class for model types providing methods designed for testing.
    """
    @classmethod
    def by(cls, index: int) -> Self:
        """
        Set current fixture index.

        Args:
            index: Fixture index.
        Returns:
            This type.
        """
        TestingState.set_index(cls, index)
        return cls # type: ignore

    @overload
    @classmethod
    def fixture(
        cls,
        db: Optional[Connection],
        variable: None = None,
        index: Optional[int] = None,
        cfg: Optional[PyracmonConfiguration] = None,
    ) -> list[Self]: ...
    @overload
    @classmethod
    def fixture(
        cls,
        db: Optional[Connection],
        variable: int,
        index: Optional[int] = None,
        cfg: Optional[PyracmonConfiguration] = None,
    ) -> list[Self]: ...
    @overload
    @classmethod
    def fixture(
        cls,
        db: Optional[Connection],
        variable: Self,
        index: Optional[int] = None,
        cfg: Optional[PyracmonConfiguration] = None,
    ) -> Self: ...
    @overload
    @classmethod
    def fixture(
        cls,
        db: Optional[Connection],
        variable: dict[str, Any],
        index: Optional[int] = None,
        cfg: Optional[PyracmonConfiguration] = None,
    ) -> Self: ...
    @overload
    @classmethod
    def fixture(
        cls,
        db: Optional[Connection],
        variable: list[Self],
        index: Optional[int] = None,
        cfg: Optional[PyracmonConfiguration] = None,
    ) -> list[Self]: ...
    @overload
    @classmethod
    def fixture(
        cls,
        db: Optional[Connection],
        variable: list[dict[str, Any]],
        index: Optional[int] = None,
        cfg: Optional[PyracmonConfiguration] = None,
    ) -> list[Self]: ...
    @classmethod
    def fixture(
        cls,
        db: Optional[Connection],
        variable: Optional[Union[int, dict[str, Any], Self, list[dict[str, Any]], list[Self]]] = None,
        index: Optional[int] = None,
        cfg: Optional[PyracmonConfiguration] = None,
    ) -> Union[Self, list[Self]]:
        """
        Inserts record(s) with auto-generated column values.

        Args:
            db: DB connection. If a value evaluated to be `False` in boolean context, generated model is not inserted and just returned.
            variable: When `int`, inserts records as many as the number. All of their column values are generated.
                When `dict`, model object or a list of them, inserts record(s) represented by them. Unspecified column values are generated.
            index: Use this to specify index used to generate column values explicitly. If set, indexing state is not updated.
            cfg: Configuration used to control the generation of fixuture values.
                This argument is prepared only for internal use and can be changed or removed in future version.
        Returns:
            Inserted model(s).
        """
        if variable is None or isinstance(variable, int):
            num = variable or 1
            index = TestingState.inc(cls, num) if index is None else index
            models = [_generate_model(cls, index+i, None, cfg) for i in range(num)]
            if db:
                cls.inserts(db, models)
            return models
        elif isinstance(variable, (cls, dict)):
            num = 1
            index = TestingState.inc(cls, num) if index is None else index
            model = _generate_model(cls, index, variable, cfg)
            if db:
                cls.insert(db, model)
            return model
        elif isinstance(variable, list):
            num = len(variable)
            index = TestingState.inc(cls, num) if index is None else index
            models = [_generate_model(cls, index+i, v, cfg) for i, v in enumerate(variable)]
            if db:
                cls.inserts(db, models)
            return models
        else:
            raise ValueError(f"Second argument of fixture() must be an int, dict, model or list of dict or model but {type(variable)} is passed.")

    def match(self, **expected: Union[Matcher, Any]) -> bool:
        """
        Tests columns values matches to expected values.

        .. warning::
            This method will be replaced in different implementation.

        Args:
            expected: Expected values.
        Returns
            Matches or not.
        """
        for k, v in expected.items():
            actual = getattr(self, k)

            if isinstance(v, Matcher):
                if not v.match(actual):
                    return False
            else:
                if v != actual:
                    return False
        return True


def truncate(db: Connection, *models: type[TruncateMixin]):
    """
    Truncate tables in order.

    Args:
        db: DB connection.
        tables: Models of tables to truncate.
    """
    if len(models) == 0:
        raise ValueError(f"No tables are specified. Did you forget to pass DB connection at the first argument?")

    for m in models:
        m.truncate(db)


def _generate_model(model: type[M], index, model_or_dict, cfg) -> M:
    values = {}

    if isinstance(model_or_dict, TestingMixin):
        values = {c.name:v for c, v in model_or_dict}
    elif isinstance(model_or_dict, dict):
        values = dict(**model_or_dict)

    values.update(**{c.name:_generate_value(model.table, c, index, cfg) for c in model.columns if c.name not in values and not c.pk})

    return model(**values)


fixed_uuid=uuid1(0, 0)


def _generate_value(table, column, index, cfg):
    """
    Generates a value for the column on an index.

    Args:
        table: Table.
        column: Column.
        index: Index
    Returns:
        Generated value.
    """
    cfg = cfg or default_test_config()

    mapping = cfg.fixture_mapping
    if mapping:
        value = mapping(table, column, index)
        if value is not None:
            return value

    tz_aware = cfg.fixture_tz_aware

    if column.nullable and cfg.fixture_ignore_nullable:
        return None
    elif column.fk and cfg.fixture_ignore_fk:
        return None
    elif column.ptype is bool:
        return True
    elif column.ptype is float:
        return float(f"{index}.{index+1}")
    elif column.ptype is int:
        return index
    elif column.ptype is Decimal:
        return Decimal(index)
    elif column.ptype is str:
        return f"{column.name}-{index}"
    elif column.ptype is bytes:
        return f"{column.name}-{index}".encode()
    elif column.ptype is date:
        return date.today()
    elif column.ptype is datetime:
        return datetime.now().astimezone() if tz_aware else datetime.now()
    elif column.ptype is time:
        return datetime.now().astimezone().time() if tz_aware else datetime.now().time()
    elif column.ptype is timedelta:
        return timedelta(days=index+1)
    elif column.ptype is UUID:
        return str(uuid3(fixed_uuid, f"{table.name}-{column.name}-{index}"))
    elif issubgeneric(column.ptype, list):
        return []
    elif isinstance(column.ptype, type) and issubclass(column.ptype, Enum):
        return next(iter(column.ptype), None)
    else:
        return None

Functions

def truncate(db: Connection, *models: type[TruncateMixin])

Truncate tables in order.

Args

db
DB connection.
tables
Models of tables to truncate.
Expand source code
def truncate(db: Connection, *models: type[TruncateMixin]):
    """
    Truncate tables in order.

    Args:
        db: DB connection.
        tables: Models of tables to truncate.
    """
    if len(models) == 0:
        raise ValueError(f"No tables are specified. Did you forget to pass DB connection at the first argument?")

    for m in models:
        m.truncate(db)

Classes

class TestingMixin

Mixin class for model types providing methods designed for testing.

Expand source code
class TestingMixin(TestingModel):
    """
    Mixin class for model types providing methods designed for testing.
    """
    @classmethod
    def by(cls, index: int) -> Self:
        """
        Set current fixture index.

        Args:
            index: Fixture index.
        Returns:
            This type.
        """
        TestingState.set_index(cls, index)
        return cls # type: ignore

    @overload
    @classmethod
    def fixture(
        cls,
        db: Optional[Connection],
        variable: None = None,
        index: Optional[int] = None,
        cfg: Optional[PyracmonConfiguration] = None,
    ) -> list[Self]: ...
    @overload
    @classmethod
    def fixture(
        cls,
        db: Optional[Connection],
        variable: int,
        index: Optional[int] = None,
        cfg: Optional[PyracmonConfiguration] = None,
    ) -> list[Self]: ...
    @overload
    @classmethod
    def fixture(
        cls,
        db: Optional[Connection],
        variable: Self,
        index: Optional[int] = None,
        cfg: Optional[PyracmonConfiguration] = None,
    ) -> Self: ...
    @overload
    @classmethod
    def fixture(
        cls,
        db: Optional[Connection],
        variable: dict[str, Any],
        index: Optional[int] = None,
        cfg: Optional[PyracmonConfiguration] = None,
    ) -> Self: ...
    @overload
    @classmethod
    def fixture(
        cls,
        db: Optional[Connection],
        variable: list[Self],
        index: Optional[int] = None,
        cfg: Optional[PyracmonConfiguration] = None,
    ) -> list[Self]: ...
    @overload
    @classmethod
    def fixture(
        cls,
        db: Optional[Connection],
        variable: list[dict[str, Any]],
        index: Optional[int] = None,
        cfg: Optional[PyracmonConfiguration] = None,
    ) -> list[Self]: ...
    @classmethod
    def fixture(
        cls,
        db: Optional[Connection],
        variable: Optional[Union[int, dict[str, Any], Self, list[dict[str, Any]], list[Self]]] = None,
        index: Optional[int] = None,
        cfg: Optional[PyracmonConfiguration] = None,
    ) -> Union[Self, list[Self]]:
        """
        Inserts record(s) with auto-generated column values.

        Args:
            db: DB connection. If a value evaluated to be `False` in boolean context, generated model is not inserted and just returned.
            variable: When `int`, inserts records as many as the number. All of their column values are generated.
                When `dict`, model object or a list of them, inserts record(s) represented by them. Unspecified column values are generated.
            index: Use this to specify index used to generate column values explicitly. If set, indexing state is not updated.
            cfg: Configuration used to control the generation of fixuture values.
                This argument is prepared only for internal use and can be changed or removed in future version.
        Returns:
            Inserted model(s).
        """
        if variable is None or isinstance(variable, int):
            num = variable or 1
            index = TestingState.inc(cls, num) if index is None else index
            models = [_generate_model(cls, index+i, None, cfg) for i in range(num)]
            if db:
                cls.inserts(db, models)
            return models
        elif isinstance(variable, (cls, dict)):
            num = 1
            index = TestingState.inc(cls, num) if index is None else index
            model = _generate_model(cls, index, variable, cfg)
            if db:
                cls.insert(db, model)
            return model
        elif isinstance(variable, list):
            num = len(variable)
            index = TestingState.inc(cls, num) if index is None else index
            models = [_generate_model(cls, index+i, v, cfg) for i, v in enumerate(variable)]
            if db:
                cls.inserts(db, models)
            return models
        else:
            raise ValueError(f"Second argument of fixture() must be an int, dict, model or list of dict or model but {type(variable)} is passed.")

    def match(self, **expected: Union[Matcher, Any]) -> bool:
        """
        Tests columns values matches to expected values.

        .. warning::
            This method will be replaced in different implementation.

        Args:
            expected: Expected values.
        Returns
            Matches or not.
        """
        for k, v in expected.items():
            actual = getattr(self, k)

            if isinstance(v, Matcher):
                if not v.match(actual):
                    return False
            else:
                if v != actual:
                    return False
        return True

Ancestors

Static methods

def by(index: int) ‑> typing_extensions.Self

Set current fixture index.

Args

index
Fixture index.

Returns

This type.

Expand source code
@classmethod
def by(cls, index: int) -> Self:
    """
    Set current fixture index.

    Args:
        index: Fixture index.
    Returns:
        This type.
    """
    TestingState.set_index(cls, index)
    return cls # type: ignore
def fixture(db: Optional[Connection], variable: Union[int, dict[str, Any], typing_extensions.Self, list[dict[str, Any]], list[typing_extensions.Self], ForwardRef(None)] = None, index: Optional[int] = None, cfg: Optional[PyracmonConfiguration] = None) ‑> Union[typing_extensions.Self, list[typing_extensions.Self]]

Inserts record(s) with auto-generated column values.

Args

db
DB connection. If a value evaluated to be False in boolean context, generated model is not inserted and just returned.
variable
When int, inserts records as many as the number. All of their column values are generated. When dict, model object or a list of them, inserts record(s) represented by them. Unspecified column values are generated.
index
Use this to specify index used to generate column values explicitly. If set, indexing state is not updated.
cfg
Configuration used to control the generation of fixuture values. This argument is prepared only for internal use and can be changed or removed in future version.

Returns

Inserted model(s).

Expand source code
@classmethod
def fixture(
    cls,
    db: Optional[Connection],
    variable: Optional[Union[int, dict[str, Any], Self, list[dict[str, Any]], list[Self]]] = None,
    index: Optional[int] = None,
    cfg: Optional[PyracmonConfiguration] = None,
) -> Union[Self, list[Self]]:
    """
    Inserts record(s) with auto-generated column values.

    Args:
        db: DB connection. If a value evaluated to be `False` in boolean context, generated model is not inserted and just returned.
        variable: When `int`, inserts records as many as the number. All of their column values are generated.
            When `dict`, model object or a list of them, inserts record(s) represented by them. Unspecified column values are generated.
        index: Use this to specify index used to generate column values explicitly. If set, indexing state is not updated.
        cfg: Configuration used to control the generation of fixuture values.
            This argument is prepared only for internal use and can be changed or removed in future version.
    Returns:
        Inserted model(s).
    """
    if variable is None or isinstance(variable, int):
        num = variable or 1
        index = TestingState.inc(cls, num) if index is None else index
        models = [_generate_model(cls, index+i, None, cfg) for i in range(num)]
        if db:
            cls.inserts(db, models)
        return models
    elif isinstance(variable, (cls, dict)):
        num = 1
        index = TestingState.inc(cls, num) if index is None else index
        model = _generate_model(cls, index, variable, cfg)
        if db:
            cls.insert(db, model)
        return model
    elif isinstance(variable, list):
        num = len(variable)
        index = TestingState.inc(cls, num) if index is None else index
        models = [_generate_model(cls, index+i, v, cfg) for i, v in enumerate(variable)]
        if db:
            cls.inserts(db, models)
        return models
    else:
        raise ValueError(f"Second argument of fixture() must be an int, dict, model or list of dict or model but {type(variable)} is passed.")

Methods

def match(self, **expected: Union[Matcher, Any]) ‑> bool

Tests columns values matches to expected values.

Warning

This method will be replaced in different implementation.

Args

expected
Expected values.

Returns Matches or not.

Expand source code
def match(self, **expected: Union[Matcher, Any]) -> bool:
    """
    Tests columns values matches to expected values.

    .. warning::
        This method will be replaced in different implementation.

    Args:
        expected: Expected values.
    Returns
        Matches or not.
    """
    for k, v in expected.items():
        actual = getattr(self, k)

        if isinstance(v, Matcher):
            if not v.match(actual):
                return False
        else:
            if v != actual:
                return False
    return True
class TestingModel
Expand source code
class TestingModel(MultiInsertMixin, CRUDMixin):
    pass

Subclasses

class TestingState
Expand source code
class TestingState:
    indexes = {}

    @classmethod
    def reset(cls, model=None):
        cls.indexes.clear()

    @classmethod
    def inc(cls, model, count):
        v = cls.indexes.setdefault(model, 1)
        cls.indexes[model] = v + count
        return v

    @classmethod
    def set_index(cls, model, index):
        cls.indexes[model] = index

Class variables

var indexes

Static methods

def inc(model, count)
Expand source code
@classmethod
def inc(cls, model, count):
    v = cls.indexes.setdefault(model, 1)
    cls.indexes[model] = v + count
    return v
def reset(model=None)
Expand source code
@classmethod
def reset(cls, model=None):
    cls.indexes.clear()
def set_index(model, index)
Expand source code
@classmethod
def set_index(cls, model, index):
    cls.indexes[model] = index