Skip to content

Commit

Permalink
New DataclassFromType class for enums
Browse files Browse the repository at this point in the history
This is meant to replace LabeledEnum with regular Python Enums.
  • Loading branch information
jace committed Nov 22, 2023
1 parent a73cc19 commit 0e6485d
Show file tree
Hide file tree
Showing 8 changed files with 428 additions and 59 deletions.
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
* ``coaster.sqlalchemy.ModelBase`` now replaces Flask-SQLAlchemy's db.Model
with full support for type hinting
* New: ``coaster.assets.WebpackManifest`` provides Webpack assets in Jinja2
* New: ``coaster.utils.DataclassFromType`` allows a basic type to be annotated

0.6.1 - 2021-01-06
------------------
Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@ minversion = '6.0'
addopts = '--doctest-modules --ignore setup.py --cov-report=term-missing'
doctest_optionflags = ['ALLOW_UNICODE', 'ALLOW_BYTES']
env = ['FLASK_ENV=testing']
remote_data_strict = true

[tool.pylint.master]
max-parents = 10
Expand Down
350 changes: 303 additions & 47 deletions src/coaster/utils/classes.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/coaster/views/classview.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ def copy_for_subclass(self) -> te.Self:
return r

@overload
def __call__( # type: ignore[misc]
def __call__( # type: ignore[overload-overlap]
self, decorated: t.Type[ClassView]
) -> t.Type[ClassView]:
...
Expand Down
5 changes: 5 additions & 0 deletions tests/coaster_tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Reusable fixtures for Coaster tests."""
# pylint: disable=redefined-outer-name

import sys
import unittest
from os import environ
from typing import cast
Expand All @@ -13,6 +14,10 @@

from coaster.sqlalchemy import DeclarativeBase, ModelBase, Query

collect_ignore = []
if sys.version_info < (3, 10):
collect_ignore.append('utils_classes_dataclass_match_test.py')


class Model(ModelBase, DeclarativeBase):
"""Model base class for test models."""
Expand Down
9 changes: 3 additions & 6 deletions tests/coaster_tests/sqlalchemy_annotations_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,8 @@ class ReferralTarget(BaseMixin, Model):
__tablename__ = 'referral_target'


class IdOnly(BaseMixin, Model):
class IdOnly(BaseMixin[int], Model):
__tablename__ = 'id_only'
__uuid_primary_key__ = False

is_regular: Mapped[t.Optional[int]] = sa.orm.mapped_column(sa.Integer)
is_immutable: Mapped[t.Optional[int]] = immutable(sa.orm.mapped_column(sa.Integer))
Expand All @@ -41,9 +40,8 @@ class IdOnly(BaseMixin, Model):
referral_target: Mapped[t.Optional[ReferralTarget]] = relationship(ReferralTarget)


class IdUuid(UuidMixin, BaseMixin, Model):
class IdUuid(UuidMixin, BaseMixin[int], Model):
__tablename__ = 'id_uuid'
__uuid_primary_key__ = False

is_regular: Mapped[t.Optional[str]] = sa.orm.mapped_column(sa.Unicode(250))
is_immutable: Mapped[t.Optional[str]] = immutable(
Expand All @@ -60,9 +58,8 @@ class IdUuid(UuidMixin, BaseMixin, Model):
)


class UuidOnly(UuidMixin, BaseMixin, Model):
class UuidOnly(UuidMixin, BaseMixin[int], Model):
__tablename__ = 'uuid_only'
__uuid_primary_key__ = True

is_regular: Mapped[t.Optional[str]] = sa.orm.mapped_column(sa.Unicode(250))
is_immutable: Mapped[t.Optional[str]] = immutable(
Expand Down
113 changes: 113 additions & 0 deletions tests/coaster_tests/utils_classes_dataclass_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Tests for dataclass extensions of base types."""
# pylint: disable=redefined-outer-name,unused-variable

import typing as t
from dataclasses import dataclass
from enum import Enum

import pytest

from coaster.utils import DataclassFromType


@dataclass(frozen=True)
class StringMetadata(DataclassFromType, str):
description: str
extra: t.Optional[str] = None


class MetadataEnum(StringMetadata, Enum):
FIRST = "first", "First string"
SECOND = "second", "Second string", "Optional extra"


@pytest.fixture()
def a() -> StringMetadata:
return StringMetadata('a', "A string")


@pytest.fixture()
def b() -> StringMetadata:
return StringMetadata('b', "B string")


@pytest.fixture()
def b2() -> StringMetadata:
return StringMetadata('b', "Also B string", "Extra metadata")


def test_required_base_type() -> None:
with pytest.raises(
TypeError,
match="Subclasses must specify the data type as the second base class",
):

class MissingDataType(DataclassFromType):
pass

class GivenDataType(DataclassFromType, int):
pass

assert GivenDataType('0') == 0 # Same as int('0') == 0


def test_annotated_str(
a: StringMetadata, b: StringMetadata, b2: StringMetadata
) -> None:
"""Test AnnotatedStr-based dataclasses for string equivalency."""
assert a == 'a'
assert b == 'b'
assert b2 == 'b'
assert 'a' == a
assert 'b' == b
assert 'b' == b2
assert a != b
assert a != b2
assert b != a
assert b == b2
assert b2 == b
assert b2 != a
assert a < b
assert b > a

# All derivative objects will regress to the base data type
assert isinstance(a, StringMetadata)
assert isinstance(b, StringMetadata)
assert isinstance(a + b, str)
assert isinstance(b + a, str)
assert not isinstance(a + b, StringMetadata)
assert not isinstance(b + a, StringMetadata)


def test_dataclass_fields_set(
a: StringMetadata, b: StringMetadata, b2: StringMetadata
) -> None:
"""Confirm dataclass fields have been set."""
assert a.self_ == 'a'
assert a.description == "A string"
assert a.extra is None
assert b.self_ == 'b'
assert b.description == "B string"
assert b.extra is None
assert b2.self_ == 'b'
assert b2.description == "Also B string"
assert b2.extra == "Extra metadata"


def test_dict_keys(a: StringMetadata, b: StringMetadata, b2: StringMetadata):
"""Check if AnnotatedStr-based dataclasses can be used as dict keys."""
d: t.Dict[t.Any, t.Any] = {a: a.description, b: b.description}
assert d['a'] == a.description
assert set(d) == {a, b}
assert set(d) == {'a', 'b'}
for key in d:
assert isinstance(key, StringMetadata)


def test_metadata_enum() -> None:
assert isinstance(MetadataEnum.FIRST, str)
assert MetadataEnum.FIRST.self_ == "first"
assert MetadataEnum.FIRST == "first"
assert MetadataEnum.SECOND == "second" # type: ignore[unreachable]
assert MetadataEnum['FIRST'] is MetadataEnum.FIRST
assert MetadataEnum('first') is MetadataEnum.FIRST
6 changes: 2 additions & 4 deletions tests/coaster_tests/views_classview_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,9 @@ def doctype(self) -> str:
return 'scoped-doc'


class RenameableDocument(BaseIdNameMixin, Model):
# Use serial int pkeys so that we can get consistent `1-<name>` url_name in tests
class RenameableDocument(BaseIdNameMixin[int], Model):
__tablename__ = 'renameable_document'
__uuid_primary_key__ = (
False # So that we can get consistent `1-<name>` url_name in tests
)
__roles__ = {'all': {'read': {'name', 'title'}}}


Expand Down

0 comments on commit 0e6485d

Please sign in to comment.