Skip to content

Commit

Permalink
Remove Pydantic from dependency (#91)
Browse files Browse the repository at this point in the history
  • Loading branch information
lasuillard authored Oct 23, 2024
1 parent a3b03f8 commit e7d9e13
Show file tree
Hide file tree
Showing 9 changed files with 316 additions and 358 deletions.
5 changes: 3 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@
"cSpell.words": [
"lasuillard",
"mrkdwn",
"pydantic",
"Pylance",
"pytestmark",
"subteam",
"usergroup",
"usergroups"
"usergroups",
"dataclasses",
"dataclass"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
Expand Down
7 changes: 4 additions & 3 deletions django_slack_tools/slack_messages/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import dataclasses
import traceback
from abc import ABC, abstractmethod
from logging import getLogger
Expand Down Expand Up @@ -50,9 +51,9 @@ def prepare_message(
Prepared message.
"""
_header: dict = policy.header_defaults if policy else {}
_header.update(header.model_dump(exclude_unset=True))
_header.update(dataclasses.asdict(header))

_body = body.model_dump()
_body = dataclasses.asdict(body)

return SlackMessage(policy=policy, channel=channel, header=_header, body=_body)

Expand Down Expand Up @@ -95,7 +96,7 @@ def prepare_messages_from_policy(

# Render template and parse as body
rendered = template.render(context=render_context)
body = MessageBody.model_validate(rendered)
body = MessageBody.from_any(rendered)

# Create message instance
message = self.prepare_message(policy=policy, channel=recipient.channel, header=header, body=body)
Expand Down
6 changes: 3 additions & 3 deletions django_slack_tools/slack_messages/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ def slack_message( # noqa: PLR0913
Returns:
Sent message instance or `None`.
"""
body = MessageBody(text=body) if isinstance(body, str) else MessageBody.model_validate(body)
header = MessageHeader.model_validate(header or {})
body = MessageBody.from_any(body)
header = MessageHeader.from_any(header)
message = backend.prepare_message(channel=channel, header=header, body=body)

return backend.send_message(message, raise_exception=raise_exception, get_permalink=get_permalink)
Expand Down Expand Up @@ -95,7 +95,7 @@ def slack_message_via_policy( # noqa: PLR0913
else:
policy = SlackMessagingPolicy.objects.get(code=policy)

header = MessageHeader.model_validate(header or {})
header = MessageHeader.from_any(header)
context = context or {}

messages = backend.prepare_messages_from_policy(policy, header=header, context=context)
Expand Down
32 changes: 8 additions & 24 deletions django_slack_tools/utils/slack/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,28 @@

from __future__ import annotations

from typing import Any

import pydantic
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _

from .message import MessageBody, MessageHeader


def header_validator(value: Any) -> None:
def header_validator(d: dict) -> None:
"""Validate given value is valid message header."""
try:
MessageHeader.model_validate(value)
except pydantic.ValidationError as exc:
MessageHeader.from_any(d)
except Exception as exc:
err = _convert_errors(exc)
raise err from exc


def body_validator(value: Any) -> None:
def body_validator(d: dict) -> None:
"""Validate given value is valid message body."""
try:
MessageBody.model_validate(value)
except pydantic.ValidationError as exc:
MessageBody.from_any(d)
except Exception as exc:
err = _convert_errors(exc)
raise err from exc


def _convert_errors(exc: pydantic.ValidationError) -> ValidationError:
"""Convert Pydantic validation error to Django error."""
errors = [
ValidationError(
_("Input validation failed [msg=%(msg)r, input=%(input)r]"),
code=", ".join(map(str, err["loc"])),
params={
"msg": err["msg"],
"input": err["input"],
},
)
for err in exc.errors()
]
return ValidationError(errors)
def _convert_errors(exc: Exception) -> ValidationError:
return ValidationError(str(exc))
99 changes: 74 additions & 25 deletions django_slack_tools/utils/slack/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,92 @@

from __future__ import annotations

from typing import List, Optional
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

from pydantic import BaseModel, model_validator
if TYPE_CHECKING:
from typing import Any, List, Optional

NoneType = type(None)

class MessageHeader(BaseModel):
"""Type definition for message header."""

# NOTE: `channel` is omitted to handle it in recipients
# Because extra fields not forbidden for reasons, channel can be passed but not recommended
@dataclass
class MessageHeader:
"""Message header data definition."""

mrkdwn: Optional[str] = None # noqa: UP007
parse: Optional[str] = None # noqa: UP007
reply_broadcast: Optional[bool] = None # noqa: UP007
thread_ts: Optional[str] = None # noqa: UP007
unfurl_links: Optional[bool] = None # noqa: UP007
unfurl_media: Optional[bool] = None # noqa: UP007
mrkdwn: Optional[str] = field(default=None) # noqa: UP007
parse: Optional[str] = field(default=None) # noqa: UP007
reply_broadcast: Optional[bool] = field(default=None) # noqa: UP007
thread_ts: Optional[str] = field(default=None) # noqa: UP007
unfurl_links: Optional[bool] = field(default=None) # noqa: UP007
unfurl_media: Optional[bool] = field(default=None) # noqa: UP007

def __post_init__(self) -> None:
_assert_type(self.mrkdwn, (str, NoneType))
_assert_type(self.parse, (str, NoneType))
_assert_type(self.reply_broadcast, (bool, NoneType))
_assert_type(self.thread_ts, (str, NoneType))
_assert_type(self.unfurl_links, (bool, NoneType))
_assert_type(self.unfurl_media, (bool, NoneType))

class MessageBody(BaseModel):
"""Type definition for message body."""
@classmethod
def from_any(
cls,
obj: MessageHeader | dict[str, Any] | None = None,
) -> MessageHeader:
"""Create instance from compatible types."""
if obj is None:
return cls()

attachments: Optional[List[dict]] = None # noqa: UP006, UP007
if isinstance(obj, dict):
return cls(**obj)

msg = f"Unsupported type {type(obj)}"
raise TypeError(msg)


@dataclass
class MessageBody:
"""Data definition for message body."""

attachments: Optional[List[dict]] = field(default=None) # noqa: UP006, UP007

# See more about blocks at https://api.slack.com/reference/block-kit/blocks
blocks: Optional[List[dict]] = None # noqa: UP006, UP007
blocks: Optional[List[dict]] = field(default=None) # noqa: UP006, UP007

text: Optional[str] = field(default=None) # noqa: UP007
icon_emoji: Optional[str] = field(default=None) # noqa: UP007
icon_url: Optional[str] = field(default=None) # noqa: UP007
metadata: Optional[dict] = field(default=None) # noqa: UP007
username: Optional[str] = field(default=None) # noqa: UP007

text: Optional[str] = None # noqa: UP007
icon_emoji: Optional[str] = None # noqa: UP007
icon_url: Optional[str] = None # noqa: UP007
metadata: Optional[dict] = None # noqa: UP007
username: Optional[str] = None # noqa: UP007
def __post_init__(self) -> None:
_assert_type(self.attachments, (list, NoneType))
_assert_type(self.blocks, (list, NoneType))
_assert_type(self.text, (str, NoneType))
_assert_type(self.icon_emoji, (str, NoneType))
_assert_type(self.icon_url, (str, NoneType))
_assert_type(self.metadata, (dict, NoneType))
_assert_type(self.username, (str, NoneType))

@model_validator(mode="after")
def _check_one_of_exists(self) -> MessageBody:
if not self.attachments and not self.blocks and not self.text:
if not any((self.attachments, self.blocks, self.text)):
msg = "At least one of `attachments`, `blocks` and `text` must set"
raise ValueError(msg)

return self
@classmethod
def from_any(cls, obj: str | MessageBody | dict[str, Any]) -> MessageBody:
"""Create instance from compatible types."""
if isinstance(obj, dict):
return cls(**obj)

if isinstance(obj, str):
return cls(text=obj)

msg = f"Unsupported type {type(obj)}"
raise TypeError(msg)


def _assert_type(obj: Any, cls: type | tuple[type, ...]) -> None:
if not isinstance(obj, cls):
msg = f"Invalid value type, expected {cls}, got {type(obj)}"
raise TypeError(msg)
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ requires-python = ">=3.8, <4.0"
dependencies = [
"slack-bolt>=1,<2",
"django>=4.2,<5.2",
"pydantic>=2,<3",
"xmltodict>=0.14.1,<1",
]

Expand Down Expand Up @@ -65,7 +64,7 @@ convention = "google"
[tool.mypy]
python_version = "3.8"
exclude = ['^\.venv/*']
plugins = ["mypy_django_plugin.main", "pydantic.mypy"]
plugins = ["mypy_django_plugin.main"]
namespace_packages = true
check_untyped_defs = true
disallow_untyped_defs = true
Expand Down
9 changes: 8 additions & 1 deletion tests/slack_messages/test_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,14 @@ def test_slack_message(mock_slack_client: Mock) -> None:
assert SlackMessage.objects.filter(id=msg.id).exists()
assert msg.policy is None
assert msg.channel == "whatever-channel"
assert msg.header == {}
assert msg.header == {
"mrkdwn": None,
"parse": None,
"reply_broadcast": None,
"thread_ts": None,
"unfurl_links": None,
"unfurl_media": None,
}
assert msg.body["text"] == "Hello, World!"
assert msg.ok
assert msg.permalink == ""
Expand Down
28 changes: 27 additions & 1 deletion tests/utils/slack/test_message.py
Original file line number Diff line number Diff line change
@@ -1 +1,27 @@
# TODO(lasuillard): Test data models
import pytest

from django_slack_tools.utils.slack.message import MessageBody, MessageHeader


class TestMessageHeader:
def test_instance_creation(self) -> None:
assert MessageHeader()

def test_from_any(self) -> None:
assert MessageHeader.from_any(None) == MessageHeader()
assert MessageHeader.from_any(
{"mrkdwn": "some-markdown"},
) == MessageHeader(mrkdwn="some-markdown")
with pytest.raises(TypeError, match="Unsupported type <class 'int'>"):
MessageHeader.from_any(-1) # type: ignore[arg-type]


class TestMessageBody:
def test_instance_creation(self) -> None:
assert MessageBody(text="some-text")

def test_from_any(self) -> None:
assert MessageBody.from_any({"text": "some-text"}) == MessageBody(text="some-text")
assert MessageBody.from_any("some-text") == MessageBody(text="some-text")
with pytest.raises(TypeError, match="Unsupported type <class 'int'>"):
MessageBody.from_any(-1) # type: ignore[arg-type]
Loading

0 comments on commit e7d9e13

Please sign in to comment.