django-dbos 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.
- django_dbos/__init__.py +13 -0
- django_dbos/admin.py +257 -0
- django_dbos/admin_views.py +644 -0
- django_dbos/apps.py +28 -0
- django_dbos/backends.py +197 -0
- django_dbos/capacity.py +363 -0
- django_dbos/checks.py +260 -0
- django_dbos/client.py +91 -0
- django_dbos/context.py +357 -0
- django_dbos/dbos_setup.py +162 -0
- django_dbos/discovery.py +41 -0
- django_dbos/exceptions.py +83 -0
- django_dbos/heartbeat.py +217 -0
- django_dbos/logging.py +13 -0
- django_dbos/management/__init__.py +0 -0
- django_dbos/management/commands/__init__.py +0 -0
- django_dbos/management/commands/dbos.py +213 -0
- django_dbos/migrations/0001_initial.py +40 -0
- django_dbos/migrations/__init__.py +0 -0
- django_dbos/models.py +50 -0
- django_dbos/params.py +181 -0
- django_dbos/params_dataclass.py +55 -0
- django_dbos/pytest_plugin.py +288 -0
- django_dbos/results.py +199 -0
- django_dbos/retention.py +71 -0
- django_dbos/scheduling.py +219 -0
- django_dbos/sentry.py +163 -0
- django_dbos/serializers.py +134 -0
- django_dbos/settings.py +536 -0
- django_dbos/static/django_dbos/admin.css +134 -0
- django_dbos/system_db.py +161 -0
- django_dbos/system_engine.py +47 -0
- django_dbos/tasks.py +38 -0
- django_dbos/tasks_registry.py +246 -0
- django_dbos/templates/admin/django_dbos/base.html +10 -0
- django_dbos/templates/admin/django_dbos/delete_confirmation.html +15 -0
- django_dbos/templates/admin/django_dbos/error.html +10 -0
- django_dbos/templates/admin/django_dbos/schedule_list.html +50 -0
- django_dbos/templates/admin/django_dbos/workflow_detail.html +118 -0
- django_dbos/templates/admin/django_dbos/workflow_list.html +63 -0
- django_dbos/templatetags/__init__.py +0 -0
- django_dbos/templatetags/dbos_admin.py +41 -0
- django_dbos/test.py +35 -0
- django_dbos/workflow_names.py +40 -0
- django_dbos-0.1.0.dist-info/METADATA +247 -0
- django_dbos-0.1.0.dist-info/RECORD +49 -0
- django_dbos-0.1.0.dist-info/WHEEL +4 -0
- django_dbos-0.1.0.dist-info/entry_points.txt +2 -0
- django_dbos-0.1.0.dist-info/licenses/LICENSE +21 -0
django_dbos/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""DBOS-backed backend for Django's tasks framework."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from .context import as_completed, get_dbos_context
|
|
6
|
+
from .params import dbos_params
|
|
7
|
+
from .retention import purge_workflows
|
|
8
|
+
from .scheduling import cron
|
|
9
|
+
from .tasks import DBOSTask
|
|
10
|
+
|
|
11
|
+
__version__ = version('django-dbos')
|
|
12
|
+
|
|
13
|
+
__all__ = ['DBOSTask', '__version__', 'as_completed', 'cron', 'dbos_params', 'get_dbos_context', 'purge_workflows']
|
django_dbos/admin.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Registration of django-dbos pages in the Django admin.
|
|
2
|
+
|
|
3
|
+
Custom admin views: workflow list/detail, schedules, and operational actions,
|
|
4
|
+
all backed by a ``DBOSClient`` against the system database — plus a read-only
|
|
5
|
+
``ModelAdmin`` for worker heartbeats.
|
|
6
|
+
|
|
7
|
+
Registration is gated by ``OPTIONS["ADMIN_ENABLED"]`` (default ``True``)
|
|
8
|
+
and targets the admin sites listed in ``OPTIONS["ADMIN_SITE"]`` (dotted
|
|
9
|
+
paths, default: the default ``django.contrib.admin.site``).
|
|
10
|
+
|
|
11
|
+
The pages expose destructive operations and every workflow's data (inputs,
|
|
12
|
+
outputs, errors), so they additionally require the superuser flag — the
|
|
13
|
+
standard staff membership is not enough.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import weakref
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from django.contrib import admin as django_admin
|
|
22
|
+
from django.contrib.admin import AdminSite
|
|
23
|
+
from django.http import HttpRequest
|
|
24
|
+
from django.urls import URLPattern, URLResolver, path, reverse
|
|
25
|
+
from django.utils.module_loading import import_string
|
|
26
|
+
|
|
27
|
+
from . import admin_views
|
|
28
|
+
from .exceptions import ImproperlyConfiguredDbos
|
|
29
|
+
from .logging import logger
|
|
30
|
+
from .models import WorkerHeartbeat
|
|
31
|
+
from .settings import get_bool_option, get_dbos_setting
|
|
32
|
+
|
|
33
|
+
APP_LABEL = 'django_dbos'
|
|
34
|
+
ADMIN_URL_PREFIX = 'dbos/'
|
|
35
|
+
DEFAULT_ADMIN_SITES = ('django.contrib.admin.site',)
|
|
36
|
+
|
|
37
|
+
# Sites register_admin() already patched: registration is idempotent.
|
|
38
|
+
_registered_sites: weakref.WeakSet[AdminSite] = weakref.WeakSet()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_admin_enabled() -> bool:
|
|
42
|
+
"""Whether the django-dbos admin pages are enabled (``OPTIONS["ADMIN_ENABLED"]``).
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The option's value, ``True`` when it is unset or invalid: an invalid
|
|
46
|
+
value is reported by the ``django_dbos.E001`` check instead.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
return get_bool_option('ADMIN_ENABLED')
|
|
50
|
+
except ImproperlyConfiguredDbos:
|
|
51
|
+
# Read by ready(), before the system checks: an invalid value keeps the
|
|
52
|
+
# default and is reported by django_dbos.E001 rather than crashing every command.
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_admin_site_paths() -> tuple[str, ...]:
|
|
57
|
+
"""The dotted paths of the admin sites to register the pages on (``OPTIONS["ADMIN_SITE"]``).
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The configured paths, a single string being one path, or
|
|
61
|
+
``DEFAULT_ADMIN_SITES`` when the option is unset.
|
|
62
|
+
"""
|
|
63
|
+
sites = get_dbos_setting('ADMIN_SITE') or DEFAULT_ADMIN_SITES
|
|
64
|
+
if isinstance(sites, str):
|
|
65
|
+
sites = (sites,)
|
|
66
|
+
return tuple(sites)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_admin_sites() -> list[AdminSite]:
|
|
70
|
+
"""Resolve the configured AdminSite instances.
|
|
71
|
+
|
|
72
|
+
When the default site is requested but ``django.contrib.admin`` is not
|
|
73
|
+
installed, it is silently skipped (projects without an admin at all).
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The admin sites, in the configured order.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
ImportError: A configured path does not resolve.
|
|
80
|
+
LookupError: A configured site needs ``django.contrib.admin``, which
|
|
81
|
+
is not installed.
|
|
82
|
+
TypeError: A configured path does not point to an ``AdminSite``.
|
|
83
|
+
"""
|
|
84
|
+
configured_paths = get_admin_site_paths()
|
|
85
|
+
resolved: list[AdminSite] = []
|
|
86
|
+
for dotted_path in configured_paths:
|
|
87
|
+
try:
|
|
88
|
+
site = import_string(dotted_path)
|
|
89
|
+
# isinstance on the lazy object forces AdminConfig.setup(), which
|
|
90
|
+
# fails when django.contrib.admin is not in INSTALLED_APPS.
|
|
91
|
+
if not isinstance(site, AdminSite):
|
|
92
|
+
raise TypeError(f'{dotted_path!r} does not point to an AdminSite instance')
|
|
93
|
+
except (ImportError, LookupError):
|
|
94
|
+
if configured_paths == DEFAULT_ADMIN_SITES:
|
|
95
|
+
continue
|
|
96
|
+
raise
|
|
97
|
+
resolved.append(site)
|
|
98
|
+
return resolved
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class WorkerHeartbeatAdmin(django_admin.ModelAdmin):
|
|
102
|
+
"""The worker heartbeats, stale ones included (superusers only, like the DBOS pages).
|
|
103
|
+
|
|
104
|
+
Rows are written by the workers alone: they cannot be added or changed,
|
|
105
|
+
only deleted, to clear a dead worker's row before its automatic purge.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
list_display = ('worker_id', 'executor_id', 'hostname', 'pid', 'cpu_percent', 'memory_rss_mb', 'last_seen_at')
|
|
109
|
+
list_filter = ('executor_id',)
|
|
110
|
+
ordering = ('executor_id', 'hostname', 'pid')
|
|
111
|
+
|
|
112
|
+
def has_add_permission(self, request: HttpRequest) -> bool:
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
def has_change_permission(self, request: HttpRequest, obj: WorkerHeartbeat | None = None) -> bool:
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
def has_delete_permission(self, request: HttpRequest, obj: WorkerHeartbeat | None = None) -> bool:
|
|
119
|
+
return admin_views.is_active_superuser(request)
|
|
120
|
+
|
|
121
|
+
def has_view_permission(self, request: HttpRequest, obj: WorkerHeartbeat | None = None) -> bool:
|
|
122
|
+
return admin_views.is_active_superuser(request)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _dbos_page_entries(site: AdminSite) -> list[dict[str, Any]]:
|
|
126
|
+
"""App-list entries linking the workflow and schedule pages, shaped like the admin's model entries.
|
|
127
|
+
|
|
128
|
+
They sit next to the worker heartbeats in the "django_dbos" app, on the
|
|
129
|
+
admin index, the app page and the navigation sidebar. The app is present
|
|
130
|
+
for superusers since ``register_admin`` registers ``WorkerHeartbeat``.
|
|
131
|
+
"""
|
|
132
|
+
pages = (('Workflows', 'DbosWorkflow', 'workflow'), ('Schedules', 'DbosSchedule', 'schedule'))
|
|
133
|
+
return [
|
|
134
|
+
{
|
|
135
|
+
'model': None,
|
|
136
|
+
'name': name,
|
|
137
|
+
'object_name': object_name,
|
|
138
|
+
'perms': {'add': False, 'change': False, 'delete': False, 'view': True},
|
|
139
|
+
'admin_url': reverse(f'admin:django_dbos_{page}_changelist', current_app=site.name),
|
|
140
|
+
'add_url': None,
|
|
141
|
+
'view_only': True,
|
|
142
|
+
}
|
|
143
|
+
for name, object_name, page in pages
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def register_admin(site: AdminSite) -> None:
|
|
148
|
+
"""Add the django-dbos pages to an AdminSite instance (idempotent).
|
|
149
|
+
|
|
150
|
+
Registers ``WorkerHeartbeat`` unless the project already did, prepends
|
|
151
|
+
the pages' URLs to the site's own and lists the pages next to the
|
|
152
|
+
heartbeats in the site's app list, for superusers.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
site: The admin site to patch; a site already registered is left as is.
|
|
156
|
+
"""
|
|
157
|
+
if site in _registered_sites:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
if not site.is_registered(WorkerHeartbeat):
|
|
161
|
+
site.register(WorkerHeartbeat, WorkerHeartbeatAdmin)
|
|
162
|
+
|
|
163
|
+
original_get_urls = site.get_urls
|
|
164
|
+
|
|
165
|
+
def dbos_view(view: admin_views.View) -> admin_views.View:
|
|
166
|
+
return site.admin_view(admin_views.bind_site(view, site))
|
|
167
|
+
|
|
168
|
+
def get_urls() -> list[URLPattern | URLResolver]:
|
|
169
|
+
urls = original_get_urls()
|
|
170
|
+
dbos_urls: list[URLPattern | URLResolver] = [
|
|
171
|
+
path(
|
|
172
|
+
f'{ADMIN_URL_PREFIX}workflows/',
|
|
173
|
+
dbos_view(admin_views.workflow_list),
|
|
174
|
+
name='django_dbos_workflow_changelist',
|
|
175
|
+
),
|
|
176
|
+
path(
|
|
177
|
+
f'{ADMIN_URL_PREFIX}workflows/<str:workflow_id>/',
|
|
178
|
+
dbos_view(admin_views.workflow_detail),
|
|
179
|
+
name='django_dbos_workflow_detail',
|
|
180
|
+
),
|
|
181
|
+
path(
|
|
182
|
+
f'{ADMIN_URL_PREFIX}workflows/<str:workflow_id>/cancel/',
|
|
183
|
+
dbos_view(admin_views.workflow_cancel),
|
|
184
|
+
name='django_dbos_workflow_cancel',
|
|
185
|
+
),
|
|
186
|
+
path(
|
|
187
|
+
f'{ADMIN_URL_PREFIX}workflows/<str:workflow_id>/resume/',
|
|
188
|
+
dbos_view(admin_views.workflow_resume),
|
|
189
|
+
name='django_dbos_workflow_resume',
|
|
190
|
+
),
|
|
191
|
+
path(
|
|
192
|
+
f'{ADMIN_URL_PREFIX}workflows/<str:workflow_id>/fork/',
|
|
193
|
+
dbos_view(admin_views.workflow_fork),
|
|
194
|
+
name='django_dbos_workflow_fork',
|
|
195
|
+
),
|
|
196
|
+
path(
|
|
197
|
+
f'{ADMIN_URL_PREFIX}workflows/<str:workflow_id>/delete/',
|
|
198
|
+
dbos_view(admin_views.workflow_delete),
|
|
199
|
+
name='django_dbos_workflow_delete',
|
|
200
|
+
),
|
|
201
|
+
path(
|
|
202
|
+
f'{ADMIN_URL_PREFIX}schedules/',
|
|
203
|
+
dbos_view(admin_views.schedule_list),
|
|
204
|
+
name='django_dbos_schedule_changelist',
|
|
205
|
+
),
|
|
206
|
+
path(
|
|
207
|
+
f'{ADMIN_URL_PREFIX}schedules/<str:schedule_name>/pause/',
|
|
208
|
+
dbos_view(admin_views.schedule_pause),
|
|
209
|
+
name='django_dbos_schedule_pause',
|
|
210
|
+
),
|
|
211
|
+
path(
|
|
212
|
+
f'{ADMIN_URL_PREFIX}schedules/<str:schedule_name>/resume/',
|
|
213
|
+
dbos_view(admin_views.schedule_resume),
|
|
214
|
+
name='django_dbos_schedule_resume',
|
|
215
|
+
),
|
|
216
|
+
path(
|
|
217
|
+
f'{ADMIN_URL_PREFIX}schedules/<str:schedule_name>/delete/',
|
|
218
|
+
dbos_view(admin_views.schedule_delete),
|
|
219
|
+
name='django_dbos_schedule_delete',
|
|
220
|
+
),
|
|
221
|
+
]
|
|
222
|
+
# Prepend: the admin's own generic patterns (app_label/model_name)
|
|
223
|
+
# would otherwise swallow paths like "dbos/workflows/".
|
|
224
|
+
return dbos_urls + urls
|
|
225
|
+
|
|
226
|
+
original_get_app_list = site.get_app_list
|
|
227
|
+
|
|
228
|
+
def get_app_list(request: HttpRequest, app_label: str | None = None) -> list[dict[str, Any]]:
|
|
229
|
+
app_list = original_get_app_list(request, app_label)
|
|
230
|
+
if admin_views.is_active_superuser(request):
|
|
231
|
+
for app in app_list:
|
|
232
|
+
if app['app_label'] == APP_LABEL:
|
|
233
|
+
app['models'].extend(_dbos_page_entries(site))
|
|
234
|
+
app['models'].sort(key=lambda entry: entry['name'])
|
|
235
|
+
return app_list
|
|
236
|
+
|
|
237
|
+
site.get_urls = get_urls
|
|
238
|
+
site.get_app_list = get_app_list
|
|
239
|
+
_registered_sites.add(site)
|
|
240
|
+
logger.debug('registered django-dbos admin views', admin_site=site.name)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def register_admin_sites() -> None:
|
|
244
|
+
"""Register the django-dbos pages on every configured AdminSite (called on ready).
|
|
245
|
+
|
|
246
|
+
Does nothing when ``OPTIONS["ADMIN_ENABLED"]`` is false.
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
ImportError: A configured path does not resolve.
|
|
250
|
+
LookupError: A configured site needs ``django.contrib.admin``, which
|
|
251
|
+
is not installed.
|
|
252
|
+
TypeError: A configured path does not point to an ``AdminSite``.
|
|
253
|
+
"""
|
|
254
|
+
if not is_admin_enabled():
|
|
255
|
+
return
|
|
256
|
+
for site in get_admin_sites():
|
|
257
|
+
register_admin(site)
|