Skip to content

Commit

Permalink
New DataclassFromType class for dataclass enums (#429)
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 29, 2023
1 parent 08bf241 commit 8aeca9c
Show file tree
Hide file tree
Showing 12 changed files with 603 additions and 67 deletions.
4 changes: 4 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,7 @@ exclude_lines =

# Ignore stub code
\.\.\.

# Ignore type checking declarations
if TYPE_CHECKING:
if t.TYPE_CHECKING:
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
2 changes: 1 addition & 1 deletion src/coaster/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from flask.json.provider import DefaultJSONProvider
from flask.sessions import SecureCookieSessionInterface

try: # Flask >= 3.0
try: # Flask >= 3.0 # pragma: no cover
from flask.sansio.app import App as FlaskApp
except ModuleNotFoundError: # Flask < 3.0
from flask import Flask as FlaskApp
Expand Down
2 changes: 1 addition & 1 deletion src/coaster/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from flask import g, request, session
from flask.config import Config

try: # Flask >= 3.0
try: # Flask >= 3.0 # pragma: no cover
from flask.sansio.app import App as FlaskApp
except ModuleNotFoundError:
from flask import Flask as FlaskApp
Expand Down
2 changes: 1 addition & 1 deletion src/coaster/sqlalchemy/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class MyModel(BaseMixin[int], Model): # Integer serial primary key; alt: UUID

from flask import current_app, url_for

try: # Flask >= 3.0
try: # Flask >= 3.0 # pragma: no cover
from flask.sansio.app import App as FlaskApp
except ModuleNotFoundError: # Flask < 3.0
from flask import Flask as FlaskApp
Expand Down
426 changes: 378 additions & 48 deletions src/coaster/utils/classes.py

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/coaster/views/classview.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from flask import abort, g, has_app_context, make_response, redirect, request
from flask.typing import ResponseReturnValue

try: # Flask >= 3.0
try: # Flask >= 3.0 # pragma: no cover
from flask.sansio.app import App as FlaskApp
from flask.sansio.blueprints import Blueprint, BlueprintSetupState
except ModuleNotFoundError: # Flask < 3.0
Expand Down 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
11 changes: 8 additions & 3 deletions tests/coaster_tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""Reusable fixtures for Coaster tests."""
# pylint: disable=redefined-outer-name

import sys
import typing as t
import unittest
from os import environ
from typing import cast

import pytest
import sqlalchemy as sa
Expand All @@ -13,6 +14,10 @@

from coaster.sqlalchemy import DeclarativeBase, ModelBase, Query

collect_ignore: t.List[str] = []
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 Expand Up @@ -57,10 +62,10 @@ class AppTestCase(unittest.TestCase): # skipcq: PTC-W0046

def setUp(self) -> None:
"""Prepare test context."""
self.ctx = cast(RequestContext, self.app.test_request_context())
self.ctx = t.cast(RequestContext, self.app.test_request_context())
self.ctx.push()
db.create_all()
self.session = cast(sa.orm.Session, db.session)
self.session = t.cast(sa.orm.Session, db.session)
# SQLAlchemy doesn't fire mapper_configured events until the first time a
# mapping is used
db.configure_mappers()
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
202 changes: 202 additions & 0 deletions tests/coaster_tests/utils_classes_dataclass_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"""Tests for dataclass extensions of base types."""
# pylint: disable=redefined-outer-name,unused-variable

import pickle # nosec B403
import typing as t
from dataclasses import FrozenInstanceError, dataclass
from enum import Enum

import pytest

from coaster.utils import DataclassFromType


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


@dataclass(frozen=True, eq=False)
class IntMetadata(DataclassFromType, int):
title: str


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


# Required until ReprEnum in Python 3.11:
del MetadataEnum.__str__
del MetadataEnum.__format__


@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_no_init() -> None:
"""DataclassFromType cannot be instantiated."""
with pytest.raises(TypeError, match="cannot be directly instantiated"):
DataclassFromType(0)


def test_first_base() -> None:
"""DataclassFromType must be the first base in a subclass."""
with pytest.raises(TypeError, match="must be the first base"):

class WrongSubclass(str, DataclassFromType):
pass


def test_required_data_type() -> None:
"""Subclasses must have a second base class for the data type."""
with pytest.raises(TypeError, match="second base class"):

class MissingDataType(DataclassFromType):
pass

class GivenDataType(DataclassFromType, int):
pass

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


def test_immutable_data_type() -> None:
"""The data type must be immutable."""

class Immutable(DataclassFromType, tuple): # skipcq: PTC-W0065
pass

with pytest.raises(TypeError, match="data type must be immutable"):

class Mutable(DataclassFromType, list):
pass


def test_annotated_str(
a: StringMetadata, b: StringMetadata, b2: StringMetadata
) -> None:
"""DataclassFromType string dataclasses have 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:
"""Dataclass fields are set correctly."""
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"
# Confirm self cannot be set
with pytest.raises(FrozenInstanceError):
a.self = 'b' # type: ignore[misc]


def test_dict_keys(a: StringMetadata, b: StringMetadata, b2: StringMetadata) -> None:
"""DataclassFromType-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_dict_overlap(a: StringMetadata) -> None:
"""Dict key overlap retains the key and type but replaces the value."""
d1 = {'a': "Primary", a: "Overlap"}
d2 = {a: "Primary", 'a': "Overlap"}
assert len(d2) == 1
assert len(d1) == 1
assert d1['a'] == "Overlap"
assert d2['a'] == "Overlap"
assert isinstance(list(d1.keys())[0], str)
assert isinstance(list(d2.keys())[0], str)
assert not isinstance(list(d1.keys())[0], StringMetadata) # Retained str
assert isinstance(list(d2.keys())[0], StringMetadata) # Retained StringMetadata


def test_pickle(a: StringMetadata) -> None:
"""Pickle dump and load will reconstruct the full dataclass."""
p = pickle.dumps(a)
a2 = pickle.loads(p) # nosec B301
assert isinstance(a2, StringMetadata)
assert a2 == a
assert a2.self == 'a'
assert a2.description == "A string"


def test_repr() -> None:
"""Dataclass-provided repr and original repr both work correctly."""

@dataclass(frozen=True, repr=True)
class WithRepr(DataclassFromType, str):
second: str

@dataclass(frozen=True, repr=False)
class WithoutRepr(DataclassFromType, str):
second: str

a = WithRepr('a', "A")
b = WithoutRepr('b', "B")
# Dataclass-provided repr, but fixed to report `self`
assert repr(a) == "test_repr.<locals>.WithRepr('a', second='A')"
# Original repr from `str` data type
assert repr(b) == "'b'"


def test_metadata_enum() -> None:
"""Enum members behave like strings."""
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
assert str(MetadataEnum.FIRST) == 'first'
assert format(MetadataEnum.FIRST) == 'first'
assert str(MetadataEnum.SECOND) == 'second'
assert format(MetadataEnum.SECOND) == 'second'
assert hash(MetadataEnum.FIRST) == hash('first')
assert hash(MetadataEnum.SECOND) == hash('second')
assert hash(MetadataEnum.FIRST) != MetadataEnum.SECOND
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 8aeca9c

Please sign in to comment.