django-lambda-tasks 0.1.0__py3-none-any.whl

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,359 @@
1
+ """
2
+ Decorator and wrapper for lambda tasks.
3
+
4
+ Provides LambdaTaskWrapper, which wraps a callable to expose:
5
+ - direct __call__(**kwargs) — synchronous invocation
6
+ - on_commit(**kwargs) — enqueue via django.db.transaction.on_commit
7
+
8
+ Also provides the lambda_task decorator factory.
9
+ """
10
+
11
+ import functools
12
+ import inspect
13
+ import types
14
+ import uuid
15
+ from collections.abc import Callable
16
+ from typing import Any, overload
17
+
18
+ import pydantic
19
+
20
+ from lambda_tasks.models import SQSLambdaTask, SQSLambdaTaskMessage
21
+ from lambda_tasks.settings import MAX_TIMEOUT, LambdaTasksSettings
22
+
23
+
24
+ def _build_kwargs_model(func: Callable[..., Any]) -> type[pydantic.BaseModel]:
25
+ """Build a Pydantic model matching the keyword-only parameters of *func*.
26
+
27
+ The model uses strict mode to prevent silent coercion (e.g. "1" → int),
28
+ with the exception that bool is accepted for int-annotated fields since
29
+ bool is a legitimate int subclass in Python.
30
+ """
31
+ sig = inspect.signature(func)
32
+ hints = func.__annotations__.copy()
33
+ hints.pop("return", None)
34
+
35
+ fields: dict[str, Any] = {}
36
+
37
+ for name, param in sig.parameters.items():
38
+ annotation = hints.get(name, Any)
39
+
40
+ if param.default is inspect.Parameter.empty:
41
+ fields[name] = (annotation, ...)
42
+ else:
43
+ fields[name] = (annotation, param.default)
44
+
45
+ return pydantic.create_model(
46
+ f"_{func.__name__}_kwargs", # type: ignore[union-attr]
47
+ __config__=pydantic.ConfigDict(strict=True, extra="forbid"),
48
+ **fields,
49
+ )
50
+
51
+
52
+ class LambdaTaskWrapper:
53
+ """Wraps a function to be executed as a background task.
54
+
55
+ Preserves __name__, __doc__, and __wrapped__ via functools.wraps.
56
+ """
57
+
58
+ # Declared explicitly so type checkers can see them (set by functools.update_wrapper)
59
+ __wrapped__: Callable[..., Any]
60
+ __name__: str
61
+ __doc__: str | None
62
+
63
+ def __init__(
64
+ self,
65
+ func: Callable[..., Any],
66
+ *,
67
+ delay: int = 0,
68
+ soft_timeout: int | None = None,
69
+ hard_timeout: int | None = None,
70
+ queue: str = "default",
71
+ ignore_errors: tuple[type[BaseException], ...] = (),
72
+ retry_on: tuple[type[BaseException], ...] = (),
73
+ ) -> None:
74
+ self._validate_func(func=func)
75
+ self._validate_timeouts(soft_timeout=soft_timeout, hard_timeout=hard_timeout)
76
+ self._validate_ignore_errors(ignore_errors=ignore_errors)
77
+ self._validate_retry_on(retry_on=retry_on)
78
+ self._validate_no_overlap(retry_on=retry_on, ignore_errors=ignore_errors)
79
+
80
+ functools.update_wrapper(self, func)
81
+
82
+ self._func: types.FunctionType = func # type: ignore[assignment]
83
+ self._delay = delay
84
+ self._soft_timeout = soft_timeout
85
+ self._hard_timeout = hard_timeout
86
+ self._queue = queue
87
+ self._ignore_errors = ignore_errors
88
+ self._retry_on = retry_on
89
+ self._kwargs_model: type[pydantic.BaseModel] = _build_kwargs_model(func)
90
+
91
+ @property
92
+ def resolved_timeouts(self) -> tuple[int, int]:
93
+ """Return (soft_timeout, hard_timeout) resolved against settings defaults.
94
+
95
+ Merges decorator-supplied values with settings defaults, then validates
96
+ the final pair. Result is cached after first access.
97
+ """
98
+ try:
99
+ return self._resolved_timeouts_cache
100
+ except AttributeError:
101
+ pass
102
+
103
+ conf = LambdaTasksSettings()
104
+ soft = (
105
+ self._soft_timeout
106
+ if self._soft_timeout is not None
107
+ else conf.DEFAULT_SOFT_TIMEOUT
108
+ )
109
+ hard = (
110
+ self._hard_timeout
111
+ if self._hard_timeout is not None
112
+ else conf.DEFAULT_HARD_TIMEOUT
113
+ )
114
+
115
+ # Validate settings-sourced values against the cap (decorator values are checked at decoration time)
116
+ for name, value, source in (
117
+ ("soft_timeout", soft, self._soft_timeout),
118
+ ("hard_timeout", hard, self._hard_timeout),
119
+ ):
120
+ if source is None and value > MAX_TIMEOUT:
121
+ raise ValueError(
122
+ f"{name} ({value}) from settings exceeds the maximum allowed value of {MAX_TIMEOUT} seconds."
123
+ )
124
+
125
+ # Cross-validate the resolved pair (decorator values may mix with settings defaults)
126
+ if soft >= hard:
127
+ raise ValueError(
128
+ f"Resolved soft_timeout ({soft}) must be strictly less than "
129
+ f"hard_timeout ({hard}) for task '{self.__name__}'."
130
+ )
131
+
132
+ self._resolved_timeouts_cache: tuple[int, int] = (soft, hard)
133
+ return self._resolved_timeouts_cache
134
+
135
+ def __call__(self, **kwargs: Any) -> Any:
136
+ """Directly invoke the wrapped function."""
137
+ self._kwargs_model.model_validate(kwargs)
138
+ return self._func(**kwargs)
139
+
140
+ def _build_task(self, *, kwargs: dict[str, Any]) -> SQSLambdaTask:
141
+ """Pop overrides, validate kwargs, and build a SQSLambdaSQSLambdaTaskMessage.
142
+
143
+ Mutates *kwargs* in-place (pops ``_delay`` and ``_n_retries``).
144
+ Returns ``SQSLambdaTask``.
145
+
146
+ Raises:
147
+ pydantic.ValidationError: if the remaining kwargs fail type validation.
148
+ """
149
+ delay = kwargs.pop("_delay", self._delay)
150
+ n_retries = kwargs.pop("_n_retries", 0)
151
+
152
+ self._kwargs_model.model_validate(kwargs)
153
+
154
+ message = SQSLambdaTaskMessage(
155
+ task_name=f"{self._func.__module__}.{self._func.__qualname__}",
156
+ invocation_id=str(uuid.uuid4()),
157
+ kwargs=dict(kwargs),
158
+ n_retries=n_retries,
159
+ )
160
+
161
+ return SQSLambdaTask(
162
+ message=message,
163
+ delay=delay,
164
+ queue=self._queue,
165
+ )
166
+
167
+ def serialize(self, **kwargs: Any) -> dict:
168
+ """Serialize this task invocation to a JSON-compatible dict for deferred enqueuing.
169
+
170
+ Accepts task kwargs plus reserved override kwargs:
171
+ _delay: int
172
+
173
+ Returns a dict matching SQSLambdaTaskMessage schema with a stable invocation_id.
174
+
175
+ Raises:
176
+ pydantic.ValidationError: if kwargs fail the task's declared type annotations.
177
+ """
178
+ task = self._build_task(kwargs=kwargs)
179
+ return task.model_dump()
180
+
181
+ def execute_on_commit(self, **kwargs: Any) -> None:
182
+ """Enqueue the task to run after the current transaction commits.
183
+
184
+ Accepts task kwargs plus reserved override kwargs:
185
+ _delay: int
186
+ """
187
+ task = self._build_task(kwargs=kwargs)
188
+ task.execute_on_commit()
189
+
190
+ @staticmethod
191
+ def _validate_func(*, func: Callable[..., Any]) -> None:
192
+ """Raise TypeError if *func* has positional, **kwargs, underscore-prefixed, or unannotated parameters."""
193
+ sig = inspect.signature(func)
194
+ hints = func.__annotations__.copy()
195
+ hints.pop("return", None)
196
+ name: str = getattr(func, "__name__", repr(func))
197
+
198
+ for param in sig.parameters.values():
199
+ if param.kind in (
200
+ inspect.Parameter.POSITIONAL_ONLY,
201
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
202
+ ):
203
+ raise TypeError(
204
+ f"lambda_task functions must use keyword-only arguments. "
205
+ f"'{name}' has positional parameter '{param.name}'."
206
+ )
207
+ if param.kind is inspect.Parameter.VAR_KEYWORD:
208
+ raise TypeError(
209
+ f"lambda_task functions must not use **kwargs. "
210
+ f"'{name}' has a **{param.name} parameter — declare all parameters explicitly."
211
+ )
212
+ if param.name.startswith("_"):
213
+ raise TypeError(
214
+ f"lambda_task function parameters must not start with '_' "
215
+ f"(reserved for on_commit overrides). "
216
+ f"'{name}' has reserved parameter '{param.name}'."
217
+ )
218
+ if param.kind is inspect.Parameter.KEYWORD_ONLY and param.name not in hints:
219
+ raise TypeError(
220
+ f"lambda_task function parameters must be type-annotated. "
221
+ f"'{name}' has unannotated parameter '{param.name}'."
222
+ )
223
+
224
+ @property
225
+ def ignore_errors(self) -> tuple[type[BaseException], ...]:
226
+ """Exception types that are treated as non-fatal during task execution."""
227
+ return self._ignore_errors
228
+
229
+ @property
230
+ def retry_on(self) -> tuple[type[BaseException], ...]:
231
+ """Exception types that trigger an automatic retry."""
232
+ return self._retry_on
233
+
234
+ @staticmethod
235
+ def _validate_ignore_errors(
236
+ *, ignore_errors: tuple[type[BaseException], ...]
237
+ ) -> None:
238
+ """Raise TypeError if any element is not a subclass of BaseException."""
239
+ for item in ignore_errors:
240
+ if not (isinstance(item, type) and issubclass(item, BaseException)):
241
+ raise TypeError(
242
+ f"ignore_errors must contain only exception types (subclasses of "
243
+ f"BaseException); got {item!r}."
244
+ )
245
+
246
+ @staticmethod
247
+ def _validate_retry_on(*, retry_on: tuple[type[BaseException], ...]) -> None:
248
+ """Raise TypeError if any element is not a subclass of BaseException."""
249
+ for item in retry_on:
250
+ if not (isinstance(item, type) and issubclass(item, BaseException)):
251
+ raise TypeError(
252
+ f"retry_on must contain only exception types (subclasses of "
253
+ f"BaseException); got {item!r}."
254
+ )
255
+
256
+ @staticmethod
257
+ def _validate_no_overlap(
258
+ *,
259
+ retry_on: tuple[type[BaseException], ...],
260
+ ignore_errors: tuple[type[BaseException], ...],
261
+ ) -> None:
262
+ """Raise TypeError if any type in retry_on and ignore_errors overlap via subclass."""
263
+ for retry_type in retry_on:
264
+ for ignore_type in ignore_errors:
265
+ if issubclass(retry_type, ignore_type) or issubclass(
266
+ ignore_type, retry_type
267
+ ):
268
+ raise TypeError(
269
+ f"retry_on and ignore_errors must not overlap: "
270
+ f"{retry_type.__name__!r} and {ignore_type.__name__!r} conflict "
271
+ f"(one is a subclass of the other)."
272
+ )
273
+
274
+ @staticmethod
275
+ def _validate_timeouts(
276
+ *, soft_timeout: int | None, hard_timeout: int | None
277
+ ) -> None:
278
+ """Raise ValueError if any timeout exceeds 900s or soft_timeout >= hard_timeout."""
279
+ for name, value in (
280
+ ("soft_timeout", soft_timeout),
281
+ ("hard_timeout", hard_timeout),
282
+ ):
283
+ if value is not None and value > MAX_TIMEOUT:
284
+ raise ValueError(
285
+ f"{name} ({value}) exceeds the maximum allowed value of {MAX_TIMEOUT} seconds."
286
+ )
287
+ if soft_timeout is not None and hard_timeout is not None:
288
+ if soft_timeout >= hard_timeout:
289
+ raise ValueError(
290
+ f"soft_timeout ({soft_timeout}) must be strictly less than "
291
+ f"hard_timeout ({hard_timeout})."
292
+ )
293
+
294
+
295
+ @overload
296
+ def lambda_task(
297
+ func: Callable[..., Any],
298
+ *,
299
+ delay: int = ...,
300
+ soft_timeout: int | None = ...,
301
+ hard_timeout: int | None = ...,
302
+ queue: str = ...,
303
+ ignore_errors: tuple[type[BaseException], ...] = ...,
304
+ retry_on: tuple[type[BaseException], ...] = ...,
305
+ ) -> LambdaTaskWrapper: ...
306
+
307
+
308
+ @overload
309
+ def lambda_task(
310
+ func: None = None,
311
+ *,
312
+ delay: int = ...,
313
+ soft_timeout: int | None = ...,
314
+ hard_timeout: int | None = ...,
315
+ queue: str = ...,
316
+ ignore_errors: tuple[type[BaseException], ...] = ...,
317
+ retry_on: tuple[type[BaseException], ...] = ...,
318
+ ) -> Callable[[Callable[..., Any]], LambdaTaskWrapper]: ...
319
+
320
+
321
+ def lambda_task(
322
+ func: Callable[..., Any] | None = None,
323
+ *,
324
+ delay: int = 0,
325
+ soft_timeout: int | None = None,
326
+ hard_timeout: int | None = None,
327
+ queue: str = "default",
328
+ ignore_errors: tuple[type[BaseException], ...] = (),
329
+ retry_on: tuple[type[BaseException], ...] = (),
330
+ ) -> LambdaTaskWrapper | Callable[[Callable[..., Any]], LambdaTaskWrapper]:
331
+ """Decorator factory that registers a function as a background task.
332
+
333
+ Can be used with or without parentheses::
334
+
335
+ @lambda_task
336
+ def my_task(*, x: int): ...
337
+
338
+ @lambda_task(delay=5, soft_timeout=60, hard_timeout=120)
339
+ def my_task(*, x: int): ...
340
+ """
341
+
342
+ def _decorate(f: Callable[..., Any]) -> LambdaTaskWrapper:
343
+ wrapper = LambdaTaskWrapper(
344
+ f,
345
+ delay=delay,
346
+ soft_timeout=soft_timeout,
347
+ hard_timeout=hard_timeout,
348
+ queue=queue,
349
+ ignore_errors=ignore_errors,
350
+ retry_on=retry_on,
351
+ )
352
+ return wrapper
353
+
354
+ if func is not None:
355
+ # Called as @lambda_task (no parentheses)
356
+ return _decorate(func)
357
+
358
+ # Called as @lambda_task(...) — return the decorator
359
+ return _decorate
@@ -0,0 +1,50 @@
1
+ """
2
+ AWS Lambda handler for lambda_tasks.
3
+
4
+ Processes a batch of SQS records using partial-batch failure reporting.
5
+ Each record is processed independently — a failure in one record does not
6
+ prevent processing of other records.
7
+ """
8
+
9
+ import logging
10
+ import os
11
+
12
+ import django
13
+ from django.apps import apps as django_apps
14
+
15
+ from lambda_tasks.models import SQSLambdaTaskMessage
16
+ from lambda_tasks.secret_loader import resolve_secrets_into_env
17
+
18
+ # Cold-start Django setup — runs once per Lambda container.
19
+ # Secrets are resolved first so Django settings can reference the populated
20
+ # env vars. resolve_secrets_into_env() is idempotent and caches fetched
21
+ # secrets in-process, so subsequent invocations pay no extra cost.
22
+ if os.environ.get("DJANGO_SETTINGS_MODULE") and not django_apps.ready:
23
+ resolve_secrets_into_env()
24
+ django.setup()
25
+
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ def handler(*, event: dict, context: object) -> dict:
31
+ """AWS Lambda entry point. Processes a batch of SQS records.
32
+
33
+ Returns a partial-batch failure report so AWS only re-drives failed records.
34
+ """
35
+ batch_item_failures: list[dict] = []
36
+
37
+ for record in event["Records"]:
38
+ try:
39
+ SQSLambdaTaskMessage.model_validate_json(
40
+ record["body"]
41
+ ).execute_immediately()
42
+ except Exception:
43
+ logger.error(
44
+ "Failed to process SQS record %s",
45
+ record.get("messageId"),
46
+ exc_info=True,
47
+ )
48
+ batch_item_failures.append({"itemIdentifier": record["messageId"]})
49
+
50
+ return {"batchItemFailures": batch_item_failures}
@@ -0,0 +1,34 @@
1
+ """
2
+ Provides task_logger — a LoggerAdapter that automatically includes the
3
+ current invocation_id on every log record while a task is executing.
4
+
5
+ Usage in task functions::
6
+
7
+ from lambda_tasks.logging import task_logger
8
+
9
+ @lambda_task(...)
10
+ def my_task(*, user_id: int) -> None:
11
+ task_logger.info("processing user %s", user_id)
12
+ # → "processing user 42" tagged with the active invocation_id
13
+ """
14
+
15
+ import logging
16
+ from collections.abc import MutableMapping
17
+ from typing import Any
18
+
19
+
20
+ class _TaskLogger(logging.LoggerAdapter):
21
+ """LoggerAdapter that prepends [invocation_id] to every message."""
22
+
23
+ def __init__(self) -> None:
24
+ super().__init__(logging.getLogger("lambda_tasks.task"), extra={})
25
+ self.invocation_id: str | None = None
26
+
27
+ def process(
28
+ self, msg: Any, kwargs: MutableMapping[str, Any]
29
+ ) -> tuple[Any, MutableMapping[str, Any]]:
30
+ prefix = f"[{self.invocation_id}] " if self.invocation_id else ""
31
+ return f"{prefix}{msg}", kwargs
32
+
33
+
34
+ task_logger = _TaskLogger()
@@ -0,0 +1,73 @@
1
+ # Generated by Django 6.0.3 on 2026-03-26 11:44
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ initial = True
9
+
10
+ dependencies = []
11
+
12
+ operations = [
13
+ migrations.CreateModel(
14
+ name="TaskRecord",
15
+ fields=[
16
+ (
17
+ "id",
18
+ models.BigAutoField(
19
+ auto_created=True,
20
+ primary_key=True,
21
+ serialize=False,
22
+ verbose_name="ID",
23
+ ),
24
+ ),
25
+ ("task_name", models.CharField(editable=False, max_length=255)),
26
+ ("invocation_id", models.UUIDField(editable=False, unique=True)),
27
+ ("kwargs", models.JSONField(editable=False)),
28
+ ("n_retries", models.PositiveSmallIntegerField(editable=False)),
29
+ (
30
+ "status",
31
+ models.CharField(
32
+ choices=[
33
+ ("RUNNING", "Running"),
34
+ ("SUCCESS", "Success"),
35
+ ("FAILED", "Failed"),
36
+ ("RETRYING", "Retrying"),
37
+ ],
38
+ editable=False,
39
+ max_length=10,
40
+ ),
41
+ ),
42
+ ("start_time", models.DateTimeField(editable=False, null=True)),
43
+ ("end_time", models.DateTimeField(editable=False, null=True)),
44
+ ("result", models.JSONField(editable=False, null=True)),
45
+ ("traceback", models.TextField(editable=False, null=True)),
46
+ ],
47
+ options={
48
+ "ordering": ["-start_time"],
49
+ "indexes": [
50
+ models.Index(
51
+ fields=["task_name"], name="lambda_task_task_na_f00cb7_idx"
52
+ ),
53
+ models.Index(
54
+ fields=["invocation_id"], name="lambda_task_invocat_3d5a22_idx"
55
+ ),
56
+ models.Index(
57
+ fields=["status"], name="lambda_task_status_6901ee_idx"
58
+ ),
59
+ models.Index(
60
+ fields=["-start_time"], name="lambda_task_start_t_9caffc_idx"
61
+ ),
62
+ ],
63
+ "constraints": [
64
+ models.CheckConstraint(
65
+ condition=models.Q(
66
+ ("status__in", ["RUNNING", "SUCCESS", "FAILED", "RETRYING"])
67
+ ),
68
+ name="taskrecord_status_valid",
69
+ )
70
+ ],
71
+ },
72
+ ),
73
+ ]
File without changes