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.
Files changed (49) hide show
  1. django_dbos/__init__.py +13 -0
  2. django_dbos/admin.py +257 -0
  3. django_dbos/admin_views.py +644 -0
  4. django_dbos/apps.py +28 -0
  5. django_dbos/backends.py +197 -0
  6. django_dbos/capacity.py +363 -0
  7. django_dbos/checks.py +260 -0
  8. django_dbos/client.py +91 -0
  9. django_dbos/context.py +357 -0
  10. django_dbos/dbos_setup.py +162 -0
  11. django_dbos/discovery.py +41 -0
  12. django_dbos/exceptions.py +83 -0
  13. django_dbos/heartbeat.py +217 -0
  14. django_dbos/logging.py +13 -0
  15. django_dbos/management/__init__.py +0 -0
  16. django_dbos/management/commands/__init__.py +0 -0
  17. django_dbos/management/commands/dbos.py +213 -0
  18. django_dbos/migrations/0001_initial.py +40 -0
  19. django_dbos/migrations/__init__.py +0 -0
  20. django_dbos/models.py +50 -0
  21. django_dbos/params.py +181 -0
  22. django_dbos/params_dataclass.py +55 -0
  23. django_dbos/pytest_plugin.py +288 -0
  24. django_dbos/results.py +199 -0
  25. django_dbos/retention.py +71 -0
  26. django_dbos/scheduling.py +219 -0
  27. django_dbos/sentry.py +163 -0
  28. django_dbos/serializers.py +134 -0
  29. django_dbos/settings.py +536 -0
  30. django_dbos/static/django_dbos/admin.css +134 -0
  31. django_dbos/system_db.py +161 -0
  32. django_dbos/system_engine.py +47 -0
  33. django_dbos/tasks.py +38 -0
  34. django_dbos/tasks_registry.py +246 -0
  35. django_dbos/templates/admin/django_dbos/base.html +10 -0
  36. django_dbos/templates/admin/django_dbos/delete_confirmation.html +15 -0
  37. django_dbos/templates/admin/django_dbos/error.html +10 -0
  38. django_dbos/templates/admin/django_dbos/schedule_list.html +50 -0
  39. django_dbos/templates/admin/django_dbos/workflow_detail.html +118 -0
  40. django_dbos/templates/admin/django_dbos/workflow_list.html +63 -0
  41. django_dbos/templatetags/__init__.py +0 -0
  42. django_dbos/templatetags/dbos_admin.py +41 -0
  43. django_dbos/test.py +35 -0
  44. django_dbos/workflow_names.py +40 -0
  45. django_dbos-0.1.0.dist-info/METADATA +247 -0
  46. django_dbos-0.1.0.dist-info/RECORD +49 -0
  47. django_dbos-0.1.0.dist-info/WHEEL +4 -0
  48. django_dbos-0.1.0.dist-info/entry_points.txt +2 -0
  49. django_dbos-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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)