django-spec-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.
Files changed (44) hide show
  1. django_spec_tasks/__init__.py +60 -0
  2. django_spec_tasks/__init__.pyi +33 -0
  3. django_spec_tasks/admin.py +65 -0
  4. django_spec_tasks/apps.py +35 -0
  5. django_spec_tasks/backends/__init__.py +1 -0
  6. django_spec_tasks/backends/celery_backend.py +41 -0
  7. django_spec_tasks/backends/django_tasks_backend.py +45 -0
  8. django_spec_tasks/binding/__init__.py +1 -0
  9. django_spec_tasks/binding/task_binding.py +93 -0
  10. django_spec_tasks/caller/__init__.py +1 -0
  11. django_spec_tasks/caller/enqueue.py +135 -0
  12. django_spec_tasks/checks.py +39 -0
  13. django_spec_tasks/management/__init__.py +1 -0
  14. django_spec_tasks/management/commands/__init__.py +1 -0
  15. django_spec_tasks/management/commands/prune_spec_runs.py +26 -0
  16. django_spec_tasks/management/commands/sweep_spec_runs.py +30 -0
  17. django_spec_tasks/migrations/0001_initial.py +122 -0
  18. django_spec_tasks/migrations/__init__.py +0 -0
  19. django_spec_tasks/models/__init__.py +3 -0
  20. django_spec_tasks/models/spec_run.py +64 -0
  21. django_spec_tasks/models/storable_json.py +33 -0
  22. django_spec_tasks/py.typed +0 -0
  23. django_spec_tasks/read/__init__.py +1 -0
  24. django_spec_tasks/read/read_run.py +54 -0
  25. django_spec_tasks/read/state_of.py +16 -0
  26. django_spec_tasks/read/utils.py +23 -0
  27. django_spec_tasks/sweep/__init__.py +1 -0
  28. django_spec_tasks/sweep/prune_runs.py +31 -0
  29. django_spec_tasks/sweep/sweep_runs.py +189 -0
  30. django_spec_tasks/types/__init__.py +1 -0
  31. django_spec_tasks/types/run_state.py +28 -0
  32. django_spec_tasks/types/run_status.py +28 -0
  33. django_spec_tasks/types/sweep_report.py +25 -0
  34. django_spec_tasks/types/task_backend.py +17 -0
  35. django_spec_tasks/types/terminal_states.py +6 -0
  36. django_spec_tasks/version.py +7 -0
  37. django_spec_tasks/worker/__init__.py +1 -0
  38. django_spec_tasks/worker/row_progress.py +67 -0
  39. django_spec_tasks/worker/run_spec.py +298 -0
  40. django_spec_tasks/worker/run_state_for.py +42 -0
  41. django_spec_tasks-0.1.0.dist-info/METADATA +73 -0
  42. django_spec_tasks-0.1.0.dist-info/RECORD +44 -0
  43. django_spec_tasks-0.1.0.dist-info/WHEEL +4 -0
  44. django_spec_tasks-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,60 @@
1
+ """Run a django-service-specs spec as a background task."""
2
+
3
+ import importlib
4
+
5
+ from django_spec_tasks.binding.task_binding import TaskBinding
6
+ from django_spec_tasks.types.run_state import RunState
7
+ from django_spec_tasks.types.run_status import RunStatus
8
+ from django_spec_tasks.types.sweep_report import SweepReport
9
+ from django_spec_tasks.types.task_backend import TaskBackend
10
+ from django_spec_tasks.types.terminal_states import TERMINAL_STATES
11
+ from django_spec_tasks.version import __version__
12
+ from django_spec_tasks.worker.run_state_for import run_state_for
13
+
14
+ # Served on first attribute access rather than imported here. Each of these reaches
15
+ # the model layer when its module is imported, and Django imports this package while
16
+ # it is still loading apps, before a model may be imported, so a plain import of one
17
+ # here makes the package unimportable with AppRegistryNotReady. A consumer sees the
18
+ # same names, and their static types come from __init__.pyi.
19
+ _LAZY_EXPORTS: dict[str, str] = {
20
+ "SpecRun": "django_spec_tasks.models.spec_run",
21
+ "enqueue": "django_spec_tasks.caller.enqueue",
22
+ "prune_runs": "django_spec_tasks.sweep.prune_runs",
23
+ "read_run": "django_spec_tasks.read.read_run",
24
+ "run_spec": "django_spec_tasks.worker.run_spec",
25
+ "state_of": "django_spec_tasks.read.state_of",
26
+ "sweep_runs": "django_spec_tasks.sweep.sweep_runs",
27
+ }
28
+
29
+ __all__ = [
30
+ "TERMINAL_STATES",
31
+ "RunState",
32
+ "RunStatus",
33
+ "SpecRun",
34
+ "SweepReport",
35
+ "TaskBackend",
36
+ "TaskBinding",
37
+ "__version__",
38
+ "enqueue",
39
+ "prune_runs",
40
+ "read_run",
41
+ "run_spec",
42
+ "run_state_for",
43
+ "state_of",
44
+ "sweep_runs",
45
+ ]
46
+
47
+
48
+ def __getattr__(name: str) -> object:
49
+ """Resolve a lazily exported name from its leaf module, and cache the result."""
50
+ module_path = _LAZY_EXPORTS.get(name)
51
+ if module_path is None:
52
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
53
+ value = getattr(importlib.import_module(module_path), name)
54
+ globals()[name] = value
55
+ return value
56
+
57
+
58
+ def __dir__() -> list[str]:
59
+ """List every public name, lazy ones included, so tab completion sees them."""
60
+ return sorted(set(globals()) | set(__all__))
@@ -0,0 +1,33 @@
1
+ from django_spec_tasks.binding.task_binding import TaskBinding as TaskBinding
2
+ from django_spec_tasks.caller.enqueue import enqueue as enqueue
3
+ from django_spec_tasks.models.spec_run import SpecRun as SpecRun
4
+ from django_spec_tasks.read.read_run import read_run as read_run
5
+ from django_spec_tasks.read.state_of import state_of as state_of
6
+ from django_spec_tasks.sweep.prune_runs import prune_runs as prune_runs
7
+ from django_spec_tasks.sweep.sweep_runs import sweep_runs as sweep_runs
8
+ from django_spec_tasks.types.run_state import RunState as RunState
9
+ from django_spec_tasks.types.run_status import RunStatus as RunStatus
10
+ from django_spec_tasks.types.sweep_report import SweepReport as SweepReport
11
+ from django_spec_tasks.types.task_backend import TaskBackend as TaskBackend
12
+ from django_spec_tasks.types.terminal_states import TERMINAL_STATES as TERMINAL_STATES
13
+ from django_spec_tasks.version import __version__ as __version__
14
+ from django_spec_tasks.worker.run_spec import run_spec as run_spec
15
+ from django_spec_tasks.worker.run_state_for import run_state_for as run_state_for
16
+
17
+ __all__ = [
18
+ "TERMINAL_STATES",
19
+ "RunState",
20
+ "RunStatus",
21
+ "SpecRun",
22
+ "SweepReport",
23
+ "TaskBackend",
24
+ "TaskBinding",
25
+ "__version__",
26
+ "enqueue",
27
+ "prune_runs",
28
+ "read_run",
29
+ "run_spec",
30
+ "run_state_for",
31
+ "state_of",
32
+ "sweep_runs",
33
+ ]
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from django.contrib import admin
6
+ from django.db.models.constants import LOOKUP_SEP
7
+ from django.http import HttpRequest
8
+
9
+ from django_spec_tasks.models.spec_run import SpecRun
10
+
11
+ _PAYLOADS = frozenset({"params", "carried", "output"})
12
+
13
+
14
+ @admin.register(SpecRun)
15
+ class SpecRunAdmin(admin.ModelAdmin):
16
+ """Runs, to look at and never to change.
17
+
18
+ Nothing here adds, changes or deletes a row, and there are no actions: an admin action
19
+ skips the change permission, so an action that deleted would delete for a user who
20
+ could not. ``params``, ``carried`` and ``output`` hold what the call was given and what
21
+ it returned, so they are hidden, and refused as URL lookups, since a count of results
22
+ would answer a guess; a project that wants them in its admin sets ``show_payloads`` on a
23
+ subclass. The ``traceback`` shows, because triage needs it.
24
+ """
25
+
26
+ show_payloads = False
27
+
28
+ list_display = (
29
+ "id",
30
+ "state",
31
+ "spec_name",
32
+ "binding",
33
+ "actor_pk",
34
+ "attempts",
35
+ "created_at",
36
+ "claimed_at",
37
+ "finished_at",
38
+ )
39
+ list_filter = ("state", "binding")
40
+ search_fields = ("spec_name", "=actor_pk")
41
+ ordering = ("-created_at",)
42
+ show_full_result_count = False
43
+ actions = None
44
+
45
+ def get_fields(self, request: HttpRequest, obj: Any = None) -> list[Any]:
46
+ names = [field.name for field in SpecRun._meta.concrete_fields]
47
+ return [name for name in names if self.show_payloads or name not in _PAYLOADS]
48
+
49
+ def get_readonly_fields(self, request: HttpRequest, obj: Any = None) -> list[str]:
50
+ return self.get_fields(request, obj)
51
+
52
+ def lookup_allowed(self, lookup: str, *args: Any, **kwargs: Any) -> bool:
53
+ # The rest of the signature differs across the Django versions this package supports.
54
+ if not self.show_payloads and lookup.split(LOOKUP_SEP)[0] in _PAYLOADS:
55
+ return False
56
+ return super().lookup_allowed(lookup, *args, **kwargs)
57
+
58
+ def has_add_permission(self, request: HttpRequest) -> bool:
59
+ return False
60
+
61
+ def has_change_permission(self, request: HttpRequest, obj: Any = None) -> bool:
62
+ return False
63
+
64
+ def has_delete_permission(self, request: HttpRequest, obj: Any = None) -> bool:
65
+ return False
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib import import_module
4
+ from importlib.util import find_spec
5
+
6
+ from django.apps import AppConfig
7
+ from django.core.checks import register
8
+ from django.utils.module_loading import autodiscover_modules
9
+
10
+ from django_spec_tasks.checks import check_spec_run_on_default_alias
11
+
12
+
13
+ class DjangoSpecTasksConfig(AppConfig):
14
+ """Registers the check, the Celery task and every project's bindings."""
15
+
16
+ name = "django_spec_tasks"
17
+ verbose_name = "Spec tasks"
18
+
19
+ def ready(self) -> None:
20
+ register(check_spec_run_on_default_alias)
21
+ _register_celery_task()
22
+ autodiscover_modules("spec_tasks")
23
+
24
+
25
+ def _register_celery_task() -> None:
26
+ """Import the Celery backend whenever ``celery`` is installed.
27
+
28
+ A Celery worker runs a message by looking its name up in its own registry, so the
29
+ task has to be registered by importing the module, in that worker, at startup. The
30
+ module imports ``celery`` at its top, so the import is gated on the optional
31
+ dependency being there, and asking first rather than catching ``ImportError`` keeps
32
+ a broken backend module from reading as an absent queue.
33
+ """
34
+ if find_spec("celery") is not None:
35
+ import_module("django_spec_tasks.backends.celery_backend")
@@ -0,0 +1 @@
1
+ """Where a run's code executes: one ``TaskBackend`` per queue."""
@@ -0,0 +1,41 @@
1
+ """The Celery backend. ``apps.py`` imports this module whenever ``celery`` is installed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from celery import shared_task
6
+
7
+ from django_spec_tasks.worker.run_spec import run_spec
8
+
9
+
10
+ @shared_task(name="django_spec_tasks.run_spec", ignore_result=True)
11
+ def _run_spec(run_id: str) -> None:
12
+ """The task a worker runs: hand the id to the package's worker.
13
+
14
+ Defined here at the top of the module, never inside ``enqueue``: a Celery worker finds a
15
+ message's task by looking its name up in its own registry, so the task exists there only
16
+ if importing this module registered it, and ``apps.py`` does that at startup. The name
17
+ is spelled out because messages already on a broker carry it, and a name derived from
18
+ this module's path would orphan them the day the module moved.
19
+
20
+ Returns ``None`` and ignores its result: the outcome is on the run's row.
21
+ """
22
+ run_spec(run_id)
23
+
24
+
25
+ class CeleryBackend:
26
+ """Hands a run to a Celery worker.
27
+
28
+ ``queue`` names the queue to send to; ``None`` leaves routing to the project's
29
+ ``task_routes``, keyed by the stable task name. The package sets no retry, no late
30
+ acknowledgement, no result backend and no schedule, each because the row already
31
+ answers it: a second copy of a message finds the claim taken, a lost one is asked
32
+ for again by the sweep, and the result is on the row.
33
+ """
34
+
35
+ def __init__(self, queue: str | None = None) -> None:
36
+ self.queue = queue
37
+
38
+ def enqueue(self, run_id: str) -> None:
39
+ """Send the run, with its id as Celery's ``task_id`` so a log line names the run."""
40
+ options = {} if self.queue is None else {"queue": self.queue}
41
+ _run_spec.apply_async(args=[run_id], task_id=run_id, **options)
@@ -0,0 +1,45 @@
1
+ """The Django Tasks backend: ``django.tasks`` from 6.0, the ``django-tasks`` backport below."""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ from django.tasks import task
7
+ except ImportError:
8
+ from django_tasks import task
9
+
10
+ from django_spec_tasks.worker.run_spec import run_spec
11
+
12
+
13
+ @task
14
+ def _run_spec(run_id: str) -> None:
15
+ """The task body.
16
+
17
+ Decorated at the top of the module because Django Tasks finds a task again by its dotted
18
+ path, which has to resolve to the ``Task`` itself: a worker reading a stored result, or
19
+ a ``db_worker`` picking up a row, imports ``module._run_spec`` and refuses a plain
20
+ function there. Nested in a method it would not be importable at all. Returns ``None``,
21
+ since a returned frozen dataclass fails the task after the work has committed, and the
22
+ outcome is on the run's row.
23
+ """
24
+ run_spec(run_id)
25
+
26
+
27
+ class DjangoTasksBackend:
28
+ """Hands a run to Django's Tasks framework.
29
+
30
+ Name the backend in the binding; it is never chosen by what imports. ``django.tasks``
31
+ imports on any Django 6, and its default backend runs a task inline on the caller's
32
+ thread, so choosing by availability would pick that in a project running Celery.
33
+
34
+ ``alias`` is the ``TASKS`` entry to send to, ``queue`` the queue on it; ``None`` leaves
35
+ the framework's default. The module's task is built against the ``default`` entry, as
36
+ any ``@task`` is, so ``TASKS`` needs one.
37
+ """
38
+
39
+ def __init__(self, alias: str = "default", queue: str | None = None) -> None:
40
+ self.alias = alias
41
+ self.queue = queue
42
+
43
+ def enqueue(self, run_id: str) -> None:
44
+ """Send the run to the aliased backend."""
45
+ _run_spec.using(backend=self.alias, queue_name=self.queue).enqueue(run_id)
@@ -0,0 +1 @@
1
+ """What a queue needs that a spec does not declare."""
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from contextlib import AbstractContextManager
5
+ from dataclasses import dataclass
6
+ from datetime import timedelta
7
+ from typing import Any, ClassVar
8
+
9
+ from django_service_specs import DEFAULT_POOL_SEEDS, PoolSeeds, SpecRegistry, resolve_principal
10
+
11
+ from django_spec_tasks.types.task_backend import TaskBackend
12
+
13
+
14
+ @dataclass(frozen=True, kw_only=True, eq=False)
15
+ class TaskBinding:
16
+ """A registry served as background tasks, with everything a queue needs beside it.
17
+
18
+ Collaborators are objects passed here, never dotted paths in settings. A seam
19
+ that takes a run is handed the ``SpecRun`` row, typed ``Any`` because nothing here
20
+ imports a model: that is what keeps this class importable before the app registry
21
+ is ready.
22
+
23
+ Constructing one registers it under its name, since a worker has only the name a
24
+ row recorded. Autodiscovery of each installed app's ``spec_tasks`` module is what
25
+ makes every binding known in a worker that imported Django.
26
+
27
+ Attributes:
28
+ name: Recorded on every row, so it must not change while runs are pending.
29
+ registry: Where enqueue and the worker find a spec by name.
30
+ backend: Where a run's code executes.
31
+ resolve_actor: Turns a row's ``actor_pk`` into the principal. Raises
32
+ ``PrincipalUnavailable`` for anyone who may no longer act.
33
+ pool_seeds: The same set at enqueue and in the worker, so the prelude's seed
34
+ check agrees between them.
35
+ lease: How long a claimed run may take. Sized to the binding's longest run: a
36
+ worker past it is refused at its terminal write.
37
+ grace: How long a ``PENDING`` row waits, from the last time a backend was asked for
38
+ it, before the sweep asks again. Sized to how long the queue may back up: each
39
+ ask past it is one more message.
40
+ max_attempts: How many claims an idempotent run gets before it is abandoned.
41
+ max_output_bytes: Past this, a run ends ``OUTPUT_TOO_LARGE``. Output is never
42
+ truncated.
43
+ can_read: ``(principal, run) -> bool``, consulted beside the run's owner.
44
+ progress_using: A database alias progress is written through, which is what
45
+ makes it visible during an ``atomic=True`` run. A second connection to the same
46
+ database: on any other it finds no row, and writes nothing.
47
+ carry: ``(actor) -> JSON mapping``, recorded on the row at enqueue and handed
48
+ to the call context in both processes.
49
+ around_run: ``(run, principal) -> context manager`` around ``dispatch`` and
50
+ ``present``.
51
+ on_finish: ``(run) -> None``, inside the transaction of the terminal write.
52
+ """
53
+
54
+ name: str
55
+ registry: SpecRegistry
56
+ backend: TaskBackend
57
+ resolve_actor: Callable[[str], Any] = resolve_principal
58
+ pool_seeds: PoolSeeds = DEFAULT_POOL_SEEDS
59
+ lease: timedelta = timedelta(hours=1)
60
+ grace: timedelta = timedelta(minutes=10)
61
+ max_attempts: int = 3
62
+ max_output_bytes: int = 1_048_576
63
+ can_read: Callable[[Any, Any], bool] | None = None
64
+ progress_using: str | None = None
65
+ carry: Callable[[Any], Mapping[str, Any]] | None = None
66
+ around_run: Callable[[Any, Any], AbstractContextManager[Any]] | None = None
67
+ on_finish: Callable[[Any], None] | None = None
68
+
69
+ # On the class rather than in a module of its own: a module holding bindings
70
+ # would have to import this class, and this class has to reach it to register.
71
+ _registered: ClassVar[dict[str, TaskBinding]] = {}
72
+
73
+ def __post_init__(self) -> None:
74
+ if self.name in TaskBinding._registered:
75
+ raise ValueError(
76
+ f"A TaskBinding named {self.name!r} is already registered. A row records "
77
+ "a binding by name, so two of one name could not be told apart."
78
+ )
79
+ TaskBinding._registered[self.name] = self
80
+
81
+ @classmethod
82
+ def named(cls, name: str) -> TaskBinding | None:
83
+ """The binding registered under ``name``, or ``None``.
84
+
85
+ A row naming no binding is a rolling deploy rather than a bug, so an unknown
86
+ name is an answer here and not an exception.
87
+ """
88
+ return TaskBinding._registered.get(name)
89
+
90
+ @classmethod
91
+ def clear_registry(cls) -> None:
92
+ """Forget every binding: the one way a test suite empties the registry."""
93
+ TaskBinding._registered.clear()
@@ -0,0 +1 @@
1
+ """The caller's half: refuse what cannot run, write the row, ask a backend on commit."""
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Iterator, Mapping
5
+ from typing import Any
6
+
7
+ from django.db import router, transaction
8
+ from django_service_specs import (
9
+ Parameters,
10
+ build_call_context,
11
+ check_call,
12
+ kernel_spec,
13
+ )
14
+
15
+ from django_spec_tasks.binding.task_binding import TaskBinding
16
+ from django_spec_tasks.models.spec_run import SpecRun
17
+ from django_spec_tasks.models.storable_json import storable_json
18
+ from django_spec_tasks.types.run_state import RunState
19
+
20
+
21
+ def enqueue(
22
+ binding: TaskBinding,
23
+ spec_name: str,
24
+ params: Mapping[str, Any],
25
+ *,
26
+ actor: Any,
27
+ ) -> str:
28
+ """Record a run of ``spec_name`` and ask the binding's backend to execute it on commit.
29
+
30
+ Everything that can be refused while the caller is still on the stack is refused here,
31
+ and a refusal writes no row and sends nothing. What is left is decided by the worker,
32
+ which checks again through the kernel's ``dispatch``: a grant cannot cross a process,
33
+ so none is carried.
34
+
35
+ ``actor`` is required. ``None`` is the declared system run, which a spec admits or
36
+ refuses by its own permissions; leaving it out is a ``TypeError``, so an unauthenticated
37
+ call cannot become a system run by omission.
38
+
39
+ The Validator does not run here. A stock one queries, needs the row it acts on, and its
40
+ answer would not hold by the time a worker ran.
41
+
42
+ Returns:
43
+ The run's id, as a string. A backend that carries a task id is given this one.
44
+
45
+ Raises:
46
+ LookupError: The binding's registry holds no spec of that name.
47
+ TypeError: The spec takes a ``file`` parameter; ``actor`` is a principal with no
48
+ primary key (an anonymous user); or ``params`` or the carried values would
49
+ change on their way through JSON.
50
+ PrincipalUnavailable: ``actor`` is deactivated.
51
+ InvalidArguments: ``params`` do not fit the spec.
52
+ NotPermitted: ``actor`` fails the spec's class-level permissions.
53
+ ImproperlyConfigured: A parameter collides with one of the binding's pool seeds.
54
+ """
55
+ registered = binding.registry.get(spec_name)
56
+ if registered is None:
57
+ raise LookupError(f"Binding {binding.name!r} has no spec named {spec_name!r}.")
58
+ spec = registered.spec
59
+
60
+ for path in _file_parameters(kernel_spec(spec).parameters()):
61
+ raise TypeError(
62
+ f"Spec {spec_name!r} takes a file parameter, {path!r}, which cannot cross to a "
63
+ "worker. Save the upload to storage and declare a parameter holding its key."
64
+ )
65
+
66
+ if actor is not None and getattr(actor, "pk", None) is None:
67
+ raise TypeError(
68
+ f"actor {actor!r} has no primary key, so the worker could not load it. Pass "
69
+ "actor=None for a system run; an unauthenticated call is not one."
70
+ )
71
+
72
+ carried = dict(binding.carry(actor)) if binding.carry is not None else {}
73
+ _require_json_round_trip(carried, "carried value")
74
+
75
+ # The grant and the seeds the checks resolved are discarded: neither crosses a process,
76
+ # and the worker resolves its own.
77
+ check_call(
78
+ spec,
79
+ principal=actor,
80
+ arguments=params,
81
+ pool_seeds=binding.pool_seeds,
82
+ call_context=build_call_context("task", base={"carried": carried}),
83
+ )
84
+
85
+ _require_json_round_trip(params, "parameter")
86
+
87
+ alias = router.db_for_write(SpecRun)
88
+ run = SpecRun.objects.using(alias).create(
89
+ binding=binding.name,
90
+ spec_name=spec_name,
91
+ params=dict(params),
92
+ actor_pk=None if actor is None else str(actor.pk),
93
+ carried=carried,
94
+ state=RunState.PENDING,
95
+ )
96
+ run_id = str(run.pk)
97
+ # robust: a broker down at commit would otherwise raise out of this callback and cancel
98
+ # every later one the project registered in the same transaction, while the row already
99
+ # makes the lost message recoverable.
100
+ transaction.on_commit(lambda: binding.backend.enqueue(run_id), using=alias, robust=True)
101
+ return run_id
102
+
103
+
104
+ def _file_parameters(parameters: Parameters, prefix: str = "") -> Iterator[str]:
105
+ """The dotted path of every ``file`` parameter, however deeply it is nested."""
106
+ for parameter in parameters:
107
+ path = f"{prefix}{parameter.name}"
108
+ if parameter.type == "file" or parameter.items == "file":
109
+ yield path
110
+ for nested in (parameter.items, parameter.fields):
111
+ if isinstance(nested, Parameters):
112
+ yield from _file_parameters(nested, f"{path}.")
113
+
114
+
115
+ def _require_json_round_trip(values: Mapping[str, Any], label: str) -> None:
116
+ """Refuse a value that JSON would change or cannot carry, naming its key.
117
+
118
+ A queue stores JSON, and what it hands a worker is what survived: a tuple comes back a
119
+ list, bytes a string, an integer key a string. Django Tasks accepts some of those
120
+ without complaint, so a backend's own gate is not this check. ``inf``, a NUL and half a
121
+ surrogate pair survive the round trip and fail the write, where ``TypeError`` is promised.
122
+ """
123
+ for key, value in values.items():
124
+ if not isinstance(key, str):
125
+ raise TypeError(f"The {label} key {key!r} is not a string, and JSON keys are.")
126
+ try:
127
+ storable_json(key)
128
+ restored = json.loads(storable_json(value))
129
+ except (TypeError, ValueError) as error:
130
+ raise TypeError(f"The {label} {key!r} is not JSON: {error}") from error
131
+ if restored != value:
132
+ raise TypeError(
133
+ f"The {label} {key!r} changes on its way through JSON ({value!r} comes back "
134
+ f"as {restored!r}). Pass the form a JSON body would carry."
135
+ )
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Any
5
+
6
+ from django.apps import AppConfig, apps
7
+ from django.core.checks import CheckMessage, Error
8
+ from django.db import DEFAULT_DB_ALIAS, router
9
+
10
+
11
+ def check_spec_run_on_default_alias(
12
+ app_configs: Sequence[AppConfig] | None, **kwargs: Any
13
+ ) -> list[CheckMessage]:
14
+ """Refuse a database router sending ``SpecRun`` away from the default alias.
15
+
16
+ The kernel's ``atomic`` opens on the default alias, and a run's guarantee is that
17
+ the service's writes and its terminal state commit together, which needs the row in
18
+ that transaction. Reads count as well: a worker or a caller reading a run from a
19
+ replica sees a state the row has already left.
20
+
21
+ Registered with no tag, because a ``Tags.database`` check runs only when ``check``
22
+ is told to look at a database. It reads the model from the app registry, since
23
+ ``apps.py`` imports this module before a model may be imported.
24
+ """
25
+ spec_run = apps.get_model("django_spec_tasks", "SpecRun")
26
+ routed = {"writes": router.db_for_write(spec_run), "reads": router.db_for_read(spec_run)}
27
+ return [
28
+ Error(
29
+ f"A database router sends SpecRun {operation} to {alias!r}.",
30
+ hint=(
31
+ "A run's terminal state commits in the transaction the service ran in, "
32
+ f"which the kernel opens on {DEFAULT_DB_ALIAS!r}. Route the "
33
+ "django_spec_tasks app there."
34
+ ),
35
+ id="django_spec_tasks.E001",
36
+ )
37
+ for operation, alias in routed.items()
38
+ if alias != DEFAULT_DB_ALIAS
39
+ ]
@@ -0,0 +1 @@
1
+ """Management commands: the sweep and retention, for a project's scheduler to call."""
@@ -0,0 +1 @@
1
+ """The sweep and retention commands."""
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import timedelta
4
+ from typing import Any
5
+
6
+ from django.core.management.base import BaseCommand, CommandError, CommandParser
7
+
8
+ from django_spec_tasks.sweep.prune_runs import prune_runs
9
+
10
+
11
+ class Command(BaseCommand):
12
+ help = (
13
+ "Delete runs that finished more than --older-than days ago. A run that is still "
14
+ "pending or running is never deleted."
15
+ )
16
+
17
+ def add_arguments(self, parser: CommandParser) -> None:
18
+ # No default: how long to keep a run is the project's decision, not the package's.
19
+ parser.add_argument("--older-than", type=int, required=True, metavar="DAYS")
20
+
21
+ def handle(self, *args: Any, **options: Any) -> None:
22
+ days: int = options["older_than"]
23
+ if days < 0:
24
+ raise CommandError("--older-than must not be negative.")
25
+ deleted = prune_runs(older_than=timedelta(days=days))
26
+ self.stdout.write(f"Deleted {deleted} {'run' if deleted == 1 else 'runs'}.")
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from django.core.management.base import BaseCommand, CommandParser
6
+
7
+ from django_spec_tasks.sweep.sweep_runs import sweep_runs
8
+
9
+
10
+ class Command(BaseCommand):
11
+ help = (
12
+ "Ask the backend again for runs it seems to have lost, and end the runs whose "
13
+ "worker did not come back. Run it on the project's own schedule."
14
+ )
15
+
16
+ def add_arguments(self, parser: CommandParser) -> None:
17
+ parser.add_argument(
18
+ "--limit",
19
+ type=int,
20
+ default=1000,
21
+ help="The most rows one sweep looks at for one binding.",
22
+ )
23
+
24
+ def handle(self, *args: Any, **options: Any) -> None:
25
+ report = sweep_runs(limit=options["limit"])
26
+ self.stdout.write(
27
+ f"Swept spec runs: {report.requeued} requeued, {report.abandoned} abandoned, "
28
+ f"{report.unavailable} spec unavailable, {report.reasked} asked again, "
29
+ f"{report.errors} errors."
30
+ )