vintasend-celery 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017 Vinta Serviços e Soluções Tecnológicas Ltda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.3
2
+ Name: vintasend-celery
3
+ Version: 0.1.0
4
+ Summary: Celery adapter for VintaSend
5
+ License: MIT
6
+ Author: Hugo bessa
7
+ Author-email: hugo@vinta.com.br
8
+ Requires-Python: >=3.10,<3.13
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Dist: celery (>=5.4.0,<6.0.0)
15
+ Requires-Dist: vintasend
16
+ Description-Content-Type: text/markdown
17
+
18
+ # VintaSend Celery
19
+
20
+ Celery adapter base implementation for VintaSend.
21
+
22
+ This package doesn't implement an actual adapter, but it helps you to make an existing adapter to run from a Celery worker.
23
+
24
+
25
+ ## Installation
26
+
27
+ To install VintaSend Celery you need to run the following command:
28
+
29
+ ```shell
30
+ pip install vinstasend vintasend-celery
31
+ ```
32
+
33
+
34
+ ## Quick start
35
+
36
+ To use VintaSend Celery you need to manually register your tasks
@@ -0,0 +1,19 @@
1
+ # VintaSend Celery
2
+
3
+ Celery adapter base implementation for VintaSend.
4
+
5
+ This package doesn't implement an actual adapter, but it helps you to make an existing adapter to run from a Celery worker.
6
+
7
+
8
+ ## Installation
9
+
10
+ To install VintaSend Celery you need to run the following command:
11
+
12
+ ```shell
13
+ pip install vinstasend vintasend-celery
14
+ ```
15
+
16
+
17
+ ## Quick start
18
+
19
+ To use VintaSend Celery you need to manually register your tasks
@@ -0,0 +1,161 @@
1
+ [tool.poetry]
2
+ name = "vintasend-celery"
3
+ version = "0.1.0"
4
+ description = "Celery adapter for VintaSend"
5
+ authors = ["Hugo bessa <hugo@vinta.com.br>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+
9
+ [tool.poetry.dependencies]
10
+ python = "<3.13,>=3.10"
11
+ vintasend = "*"
12
+ celery = "^5.4.0"
13
+
14
+
15
+ [tool.poetry.group.dev.dependencies]
16
+ coverage = "^7.6.4"
17
+ tox = "^4.23.2"
18
+ pytest = "^8.3.3"
19
+ pytest-xdist = {version = "^3.6.1", extras=["psutil"]}
20
+ coveralls = "^4.0.1"
21
+ pytest-cov = "^6.0.0"
22
+ pytest-celery = "^1.1.3"
23
+
24
+ [build-system]
25
+ requires = ["poetry-core"]
26
+ build-backend = "poetry.core.masonry.api"
27
+
28
+
29
+ [tool.ruff]
30
+ select = [
31
+ # pycodestyle
32
+ "E",
33
+ # Pyflakes
34
+ "F",
35
+ # pep8-naming
36
+ "N",
37
+ # pyupgrade
38
+ "UP",
39
+ # flake8-bugbear
40
+ "B",
41
+ # flake8-bandit
42
+ "S",
43
+ # flake8-blind-except
44
+ "BLE",
45
+ # flake8-builtins
46
+ "A",
47
+ # flake8-django
48
+ "DJ",
49
+ # isort
50
+ "I",
51
+ # flake8-logging-format
52
+ "G",
53
+ # flake8-no-pep420
54
+ "INP",
55
+ # Ruff-specific rules
56
+ "RUF",
57
+ ]
58
+ exclude = [
59
+ ".bzr",
60
+ ".direnv",
61
+ ".eggs",
62
+ ".git",
63
+ ".git-rewrite",
64
+ ".hg",
65
+ ".mypy_cache",
66
+ ".nox",
67
+ ".pants.d",
68
+ ".pytype",
69
+ ".ruff_cache",
70
+ ".svn",
71
+ ".tox",
72
+ ".venv",
73
+ "__pypackages__",
74
+ "_build",
75
+ "buck-out",
76
+ "build",
77
+ "dist",
78
+ "node_modules",
79
+ "venv",
80
+ "virtualenvs",
81
+ "*/migrations/*",
82
+ ]
83
+ ignore = [
84
+ # Disable eradicate (commented code removal)
85
+ "ERA001",
86
+ # Disable Conflicting lint rules,
87
+ # see https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
88
+ "W191",
89
+ "E501",
90
+ "E111",
91
+ "E117",
92
+ "D206",
93
+ "D300",
94
+ "Q000",
95
+ "Q001",
96
+ "Q002",
97
+ "Q003",
98
+ "COM812",
99
+ "COM819",
100
+ "ISC001",
101
+ "ISC002",
102
+ # Allow `except Exception`:
103
+ "BLE001",
104
+ # Disable unused `noqa` directive
105
+ "RUF100",
106
+ # Disable pyupgrade UP rules that conflict with django-ninja
107
+ "UP006",
108
+ "UP035",
109
+ "UP037",
110
+ "UP040",
111
+ ]
112
+ line-length = 100
113
+ indent-width = 4
114
+ target-version = "py312"
115
+ # Allow unused variables when underscore-prefixed:
116
+ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
117
+
118
+ [tool.ruff.pycodestyle]
119
+ ignore-overlong-task-comments = true
120
+
121
+ [tool.ruff.lint.isort]
122
+ section-order = [
123
+ "future",
124
+ "standard-library",
125
+ "django",
126
+ "third-party",
127
+ "first-party",
128
+ "local-folder",
129
+ ]
130
+ lines-after-imports = 2
131
+
132
+ [tool.ruff.lint.isort.sections]
133
+ # Group all Django imports into a separate section.
134
+ "django" = ["django"]
135
+
136
+ [tool.ruff.per-file-ignores]
137
+ # Ignore "E402", "F403", "F405" (import violations) in __init__.py files.
138
+ # Ignore "S" (flake8-bandit) and "N802" (function name should be lowercase) in tests and docs.
139
+ # Ignore "RUF" (Ruff-specific rules) and "I" (isort) in migrations.
140
+ "__init__.py" = ["E402", "F403", "F405"]
141
+ "**/{tests,docs}/*" = ["E402", "F403", "F405", "S", "N802"]
142
+ "**/*test*.py" = ["E402", "F403", "F405", "S", "N802"]
143
+ "**/{settings}/*" = ["E402", "F403", "F405"]
144
+ "**/migrations/*" = ["RUF", "I"]
145
+
146
+ [tool.coverage.run]
147
+ branch = true
148
+ source = ["backend"]
149
+ omit = [
150
+ "**/venv/*",
151
+ "**/env/*",
152
+ "**/virtualenvs/*",
153
+ "**/node_modules/*",
154
+ "**/migrations/*",
155
+ "**/settings/*",
156
+ "**/tests/*",
157
+ ]
158
+
159
+ [tool.pytest.ini_options]
160
+ python_files = ["test_*.py"]
161
+ addopts = "--dist=loadscope"
File without changes
@@ -0,0 +1,92 @@
1
+ import datetime
2
+ import uuid
3
+ from typing import Generic, TypeVar, cast
4
+
5
+ from celery import Task # type: ignore
6
+ from vintasend.services.dataclasses import Notification, NotificationContextDict
7
+ from vintasend.services.notification_adapters.async_base import (
8
+ AsyncBaseNotificationAdapter,
9
+ NotificationDict,
10
+ )
11
+ from vintasend.services.notification_backends.base import BaseNotificationBackend
12
+ from vintasend.services.notification_template_renderers.base import BaseNotificationTemplateRenderer
13
+
14
+
15
+ B = TypeVar("B", bound=BaseNotificationBackend)
16
+ T = TypeVar("T", bound=BaseNotificationTemplateRenderer)
17
+
18
+
19
+ class CeleryNotificationAdapter(Generic[B, T], AsyncBaseNotificationAdapter[B, T]):
20
+ send_notification_task: Task
21
+
22
+ def delayed_send(self, notification_dict: NotificationDict, context_dict: dict) -> None:
23
+ notification = self.notification_from_dict(notification_dict)
24
+ context = NotificationContextDict(**context_dict)
25
+ super().send(notification, context) # type: ignore
26
+
27
+ def notification_to_dict(self, notification: "Notification") -> NotificationDict:
28
+ non_serializable_fields = ["send_after"]
29
+ serialized_notification = {}
30
+ for field in notification.__dataclass_fields__.keys():
31
+ if field in non_serializable_fields:
32
+ continue
33
+ serialized_notification[field] = getattr(notification, field)
34
+
35
+ serialized_notification["send_after"] = (
36
+ notification.send_after.isoformat() if notification.send_after else None
37
+ )
38
+
39
+ return cast(NotificationDict, serialized_notification)
40
+
41
+ def _convert_to_uuid(self, value: str) -> uuid.UUID | str:
42
+ try:
43
+ return uuid.UUID(value)
44
+ except ValueError:
45
+ return value
46
+
47
+ def notification_from_dict(self, notification_dict: NotificationDict) -> "Notification":
48
+ send_after = (
49
+ datetime.datetime.fromisoformat(notification_dict["send_after"])
50
+ if notification_dict["send_after"]
51
+ else None
52
+ )
53
+ return Notification(
54
+ id=(
55
+ self._convert_to_uuid(notification_dict["id"])
56
+ if isinstance(notification_dict["id"], str)
57
+ else notification_dict["id"]
58
+ ),
59
+ user_id=(
60
+ self._convert_to_uuid(notification_dict["user_id"])
61
+ if isinstance(notification_dict["user_id"], str)
62
+ else notification_dict["user_id"]
63
+ ),
64
+ context_kwargs={
65
+ key: self._convert_to_uuid(value) if isinstance(value, str) else value
66
+ for key, value in notification_dict["context_kwargs"].items()
67
+ },
68
+ notification_type=notification_dict["notification_type"],
69
+ title=notification_dict["title"],
70
+ body_template=notification_dict["body_template"],
71
+ context_name=notification_dict["context_name"],
72
+ subject_template=notification_dict["subject_template"],
73
+ preheader_template=notification_dict["preheader_template"],
74
+ status=notification_dict["status"],
75
+ context_used=notification_dict["context_used"],
76
+ send_after=send_after,
77
+ )
78
+
79
+ def send(self, notification: "Notification", context: "NotificationContextDict") -> None:
80
+ self.send_notification_task.delay(
81
+ notification=self.notification_to_dict(notification),
82
+ context=context,
83
+ backend=self.backend.backend_import_str,
84
+ adapters=[
85
+ (
86
+ self.adapter_import_str,
87
+ self.template_renderer.template_renderer_import_str,
88
+ )
89
+ ],
90
+ backend_kwargs=self.backend.backend_kwargs,
91
+ config=self.serialize_config(),
92
+ )
@@ -0,0 +1,11 @@
1
+ import logging
2
+
3
+ from celery import Celery # type: ignore
4
+ from vintasend.tasks.background_tasks import send_notification
5
+
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def send_notification_task_factory(celery_app: Celery):
11
+ return celery_app.task(send_notification)
@@ -0,0 +1,6 @@
1
+ from celery import Celery # type: ignore
2
+ from vintasend.tasks.periodic_tasks import periodic_send_pending_notifications
3
+
4
+
5
+ def periodic_send_pending_notifications_task_factory(celery_app: Celery):
6
+ return celery_app.task(periodic_send_pending_notifications)
@@ -0,0 +1,136 @@
1
+ import uuid
2
+ from unittest.mock import patch
3
+
4
+ import pytest
5
+ from vintasend.constants import NotificationStatus, NotificationTypes
6
+ from vintasend.services.dataclasses import Notification
7
+ from vintasend.services.notification_backends.stubs.fake_backend import (
8
+ Config,
9
+ FakeFileBackend,
10
+ FakeFileBackendWithNonSerializableKWArgs,
11
+ )
12
+ from vintasend.services.notification_service import NotificationService, register_context
13
+ from vintasend.services.notification_template_renderers.stubs.fake_templated_email_renderer import (
14
+ FakeTemplateRenderer,
15
+ FakeTemplateRendererWithException,
16
+ )
17
+
18
+ from example_app.celery import (
19
+ AsyncCeleryFakeEmailAdapter,
20
+ AsyncCeleryFakeEmailAdapterWithBackendWithNonSerializableKWArgs,
21
+ )
22
+
23
+
24
+ @pytest.fixture()
25
+ def celery_app():
26
+ from example_app.celery import celery_app
27
+ celery_app.conf.update(task_always_eager=True)
28
+ return celery_app
29
+
30
+
31
+ @pytest.fixture(scope="function")
32
+ def notification_backend():
33
+ backend = FakeFileBackend(database_file_name="celery-adapter-tests-notifications.json")
34
+ yield backend
35
+ backend.clear()
36
+
37
+
38
+ @pytest.fixture()
39
+ def renderer():
40
+ return FakeTemplateRenderer()
41
+
42
+
43
+ @pytest.fixture()
44
+ def notification_service(notification_backend, renderer):
45
+ async_adapter = AsyncCeleryFakeEmailAdapter(
46
+ template_renderer=renderer, backend=notification_backend
47
+ )
48
+
49
+ notification_service = NotificationService[AsyncCeleryFakeEmailAdapter, FakeFileBackend](
50
+ [async_adapter],
51
+ notification_backend,
52
+ )
53
+
54
+ return notification_service
55
+
56
+
57
+ def create_notification():
58
+ register_context("test_context")(create_notification_context)
59
+ return Notification(
60
+ id=uuid.uuid4(),
61
+ user_id=1,
62
+ notification_type=NotificationTypes.EMAIL.value,
63
+ title="Test Notification",
64
+ body_template="vintasend_django/emails/test/test_templated_email_body.html",
65
+ context_name="test_context",
66
+ context_kwargs={"test": "test"},
67
+ send_after=None,
68
+ subject_template="vintasend_django/emails/test/test_templated_email_subject.txt",
69
+ preheader_template="vintasend_django/emails/test/test_templated_email_preheader.html",
70
+ status=NotificationStatus.PENDING_SEND.value,
71
+ )
72
+
73
+
74
+ def create_notification_context(test):
75
+ if test != "test":
76
+ raise ValueError("Invalid test value")
77
+ return {"foo": "bar"}
78
+
79
+
80
+ def test_send_notification(notification_backend, notification_service, celery_app, celery_worker):
81
+ notification = create_notification()
82
+ notification_backend.notifications.append(notification)
83
+ notification_backend._store_notifications()
84
+
85
+ notification_service.send(notification)
86
+ assert len(notification_backend.notifications) == 1
87
+
88
+
89
+ def test_send_notification_with_render_error(notification_backend, celery_app, celery_worker):
90
+ notification = create_notification()
91
+
92
+ renderer = FakeTemplateRendererWithException()
93
+ async_adapter = AsyncCeleryFakeEmailAdapter(
94
+ template_renderer=renderer, backend=notification_backend
95
+ )
96
+
97
+ notification_service = NotificationService(
98
+ [async_adapter],
99
+ notification_backend,
100
+ )
101
+
102
+ notification_backend.notifications.append(notification)
103
+ notification_backend._store_notifications()
104
+
105
+ with patch("vintasend.tasks.background_tasks.logger.exception") as mock_log_exception:
106
+ notification_service.send(notification)
107
+
108
+ mock_log_exception.assert_called_once()
109
+
110
+ assert len(async_adapter.sent_emails) == 0
111
+
112
+
113
+ def test_backend_with_non_serializable_kwargs(renderer, celery_app, celery_worker):
114
+ notification = create_notification()
115
+ config = Config()
116
+ backend = FakeFileBackendWithNonSerializableKWArgs(
117
+ database_file_name="celery-adapter-tests-notifications.json",
118
+ config=config,
119
+ )
120
+ async_adapter = AsyncCeleryFakeEmailAdapterWithBackendWithNonSerializableKWArgs(
121
+ template_renderer=renderer, backend=backend, config=config
122
+ )
123
+
124
+ notification_service = NotificationService(
125
+ [async_adapter],
126
+ backend,
127
+ )
128
+
129
+ backend.notifications.append(notification)
130
+ backend._store_notifications()
131
+
132
+ notification_service.send(notification)
133
+
134
+ assert len(backend.notifications) == 1
135
+
136
+ backend.clear()