django-simpletask5 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 (38) hide show
  1. django_simpletask5/__init__.py +7 -0
  2. django_simpletask5/admin.py +205 -0
  3. django_simpletask5/apps.py +8 -0
  4. django_simpletask5/core/__init__.py +0 -0
  5. django_simpletask5/core/cronjob_registry.py +96 -0
  6. django_simpletask5/core/defaults.py +24 -0
  7. django_simpletask5/core/executor_scanner.py +82 -0
  8. django_simpletask5/core/lock.py +24 -0
  9. django_simpletask5/core/message_queue.py +102 -0
  10. django_simpletask5/core/publisher.py +93 -0
  11. django_simpletask5/core/signals.py +120 -0
  12. django_simpletask5/core/worker_registry.py +134 -0
  13. django_simpletask5/dashboards.py +274 -0
  14. django_simpletask5/executors/__init__.py +0 -0
  15. django_simpletask5/executors/archive.py +24 -0
  16. django_simpletask5/executors/base.py +10 -0
  17. django_simpletask5/executors/bash_script.py +61 -0
  18. django_simpletask5/executors/loader.py +17 -0
  19. django_simpletask5/executors/ping_pong.py +12 -0
  20. django_simpletask5/executors/python_script.py +42 -0
  21. django_simpletask5/executors/retry_timeout.py +32 -0
  22. django_simpletask5/executors/simple_request.py +47 -0
  23. django_simpletask5/executors/status_check.py +40 -0
  24. django_simpletask5/management/__init__.py +0 -0
  25. django_simpletask5/management/commands/__init__.py +0 -0
  26. django_simpletask5/management/commands/django_simpletask_crontab.py +169 -0
  27. django_simpletask5/management/commands/django_simpletask_executor.py +286 -0
  28. django_simpletask5/management/commands/django_simpletask_sync_cronjobs.py +14 -0
  29. django_simpletask5/migrations/0001_initial.py +283 -0
  30. django_simpletask5/migrations/__init__.py +0 -0
  31. django_simpletask5/models.py +274 -0
  32. django_simpletask5/services/__init__.py +0 -0
  33. django_simpletask5/services/archive.py +198 -0
  34. django_simpletask5-0.1.0.dist-info/METADATA +251 -0
  35. django_simpletask5-0.1.0.dist-info/RECORD +38 -0
  36. django_simpletask5-0.1.0.dist-info/WHEEL +5 -0
  37. django_simpletask5-0.1.0.dist-info/licenses/LICENSE +21 -0
  38. django_simpletask5-0.1.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,120 @@
1
+ import threading
2
+
3
+ from django.db.models.signals import post_save, pre_save
4
+ from django.dispatch import receiver
5
+
6
+ from django_simpletask5.models import Task
7
+ from django_simpletask5.core.publisher import create_and_publish, _model_to_dict
8
+
9
+
10
+ class _DeleteIntercepted(Exception):
11
+ def __init__(self, instance):
12
+ self.instance = instance
13
+ super().__init__('Delete intercepted, TaskExecution created instead')
14
+
15
+
16
+ _pre_save_data = threading.local()
17
+
18
+
19
+ @receiver(pre_save)
20
+ def handle_task_pre_save(sender, instance, **kwargs):
21
+ if not _is_task_subclass(sender):
22
+ return
23
+ if instance.pk and not getattr(instance, '_skip_pre_save_snapshot', False):
24
+ try:
25
+ old = sender.objects.get(pk=instance.pk)
26
+ _pre_save_data.data = _model_to_dict(old)
27
+ except sender.DoesNotExist:
28
+ _pre_save_data.data = None
29
+ else:
30
+ _pre_save_data.data = None
31
+
32
+
33
+ @receiver(post_save)
34
+ def handle_task_post_save(sender, instance, created, **kwargs):
35
+ if not _is_task_subclass(sender):
36
+ return
37
+ if created:
38
+ create_and_publish(
39
+ task=instance,
40
+ trigger_event='create',
41
+ )
42
+ else:
43
+ old_data = getattr(_pre_save_data, 'data', None)
44
+ new_data = _model_to_dict(instance)
45
+ if old_data:
46
+ changed_fields = _get_changed_fields(old_data, new_data)
47
+ else:
48
+ changed_fields = list(new_data.keys())
49
+ if not changed_fields:
50
+ return
51
+ if not _should_trigger_update(instance, changed_fields):
52
+ return
53
+ create_and_publish(
54
+ task=instance,
55
+ trigger_event='update',
56
+ extra_context={
57
+ 'old_data': old_data or {},
58
+ 'new_data': new_data,
59
+ 'changed_fields': changed_fields,
60
+ },
61
+ )
62
+
63
+
64
+ def _is_task_subclass(sender):
65
+ if not issubclass(sender, Task):
66
+ return False
67
+ if sender._meta.abstract:
68
+ return False
69
+ return True
70
+
71
+
72
+ def _should_trigger_update(instance, update_fields):
73
+ whitelist = instance.get_trigger_update_fields()
74
+ blacklist = instance.get_trigger_update_blacklist()
75
+
76
+ if whitelist is not None:
77
+ if update_fields:
78
+ return any(f in whitelist for f in update_fields)
79
+ return True
80
+
81
+ if blacklist is not None:
82
+ if update_fields:
83
+ return not all(f in blacklist for f in update_fields)
84
+ return True
85
+
86
+ return True
87
+
88
+
89
+ def _get_changed_field_names(old_data, new_data):
90
+ changed = []
91
+ for key in new_data:
92
+ if key in old_data:
93
+ old_val = old_data[key]
94
+ new_val = new_data[key]
95
+ if old_val != new_val:
96
+ try:
97
+ from decimal import Decimal
98
+ if Decimal(str(old_val)) == Decimal(str(new_val)):
99
+ continue
100
+ except Exception:
101
+ pass
102
+ changed.append(key)
103
+ return changed
104
+
105
+
106
+ def _get_changed_fields(old, new):
107
+ changed = []
108
+ for key in new:
109
+ if key in old:
110
+ old_val = old[key]
111
+ new_val = new[key]
112
+ if old_val != new_val:
113
+ try:
114
+ from decimal import Decimal
115
+ if Decimal(str(old_val)) == Decimal(str(new_val)):
116
+ continue
117
+ except Exception:
118
+ pass
119
+ changed.append(key)
120
+ return changed
@@ -0,0 +1,134 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import socket
5
+ import threading
6
+ import time
7
+
8
+ from django.conf import settings
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ WORKER_TTL = 300
13
+
14
+ _REDIS_UNAVAILABLE = False
15
+
16
+
17
+ def _get_redis_client():
18
+ import redis as _redis
19
+ lock_config = getattr(settings, 'DJANGO_SIMPLETASK_LOCK_CONFIG', {})
20
+ opts = lock_config.get('global_lock_engine_options', {})
21
+ return _redis.Redis(
22
+ host=opts.get('host', '127.0.0.1'),
23
+ port=opts.get('port', 6379),
24
+ db=opts.get('db', 0),
25
+ password=opts.get('password', None) or None,
26
+ decode_responses=True,
27
+ socket_connect_timeout=3,
28
+ )
29
+
30
+
31
+ def _safe_redis_call(fn, default=None, log_level=logging.WARNING):
32
+ global _REDIS_UNAVAILABLE
33
+ try:
34
+ result = fn()
35
+ if _REDIS_UNAVAILABLE:
36
+ _REDIS_UNAVAILABLE = False
37
+ logger.info('Redis connection restored')
38
+ return result
39
+ except Exception as e:
40
+ if not _REDIS_UNAVAILABLE:
41
+ _REDIS_UNAVAILABLE = True
42
+ logger.log(log_level, 'Redis operation failed: %s', e)
43
+ return default
44
+
45
+
46
+ def _worker_key(pid, tid):
47
+ return f'simpletask:worker:{pid}.{tid}'
48
+
49
+
50
+ def _workers_pattern():
51
+ return 'simpletask:worker:*'
52
+
53
+
54
+ def register_worker(queue_name, services=None, pid=None, tid=None):
55
+ pid = pid or os.getpid()
56
+ tid = tid or threading.get_ident()
57
+ key = _worker_key(pid, tid)
58
+ client = _get_redis_client()
59
+ info = {
60
+ 'pid': str(pid),
61
+ 'tid': str(tid),
62
+ 'hostname': socket.gethostname(),
63
+ 'queue': queue_name,
64
+ 'services': json.dumps(services or []),
65
+ 'status': 'idle',
66
+ 'current_execution_id': '',
67
+ 'started_at': str(time.time()),
68
+ 'updated_at': str(time.time()),
69
+ }
70
+ _safe_redis_call(lambda: (client.hset(key, mapping=info), client.expire(key, WORKER_TTL)) and None)
71
+ return key
72
+
73
+
74
+ def unregister_worker(pid=None, tid=None):
75
+ pid = pid or os.getpid()
76
+ tid = tid or threading.get_ident()
77
+ key = _worker_key(pid, tid)
78
+ client = _get_redis_client()
79
+ _safe_redis_call(lambda: client.delete(key))
80
+
81
+
82
+ def update_worker_status(status, execution_id='', pid=None, tid=None):
83
+ pid = pid or os.getpid()
84
+ tid = tid or threading.get_ident()
85
+ key = _worker_key(pid, tid)
86
+ client = _get_redis_client()
87
+ _safe_redis_call(lambda: (client.hset(key, 'status', status), client.hset(key, 'current_execution_id', execution_id or ''), client.hset(key, 'updated_at', str(time.time())), client.expire(key, WORKER_TTL)) and None)
88
+
89
+
90
+ def heartbeat_worker(pid=None, tid=None):
91
+ pid = pid or os.getpid()
92
+ tid = tid or threading.get_ident()
93
+ key = _worker_key(pid, tid)
94
+ client = _get_redis_client()
95
+ _safe_redis_call(lambda: (client.hset(key, 'updated_at', str(time.time())), client.expire(key, WORKER_TTL)) and None)
96
+
97
+
98
+ def get_all_workers():
99
+ client = _get_redis_client()
100
+ keys = _safe_redis_call(lambda: client.keys(_workers_pattern()), default=[])
101
+ if not keys:
102
+ return []
103
+ workers = []
104
+ for key in keys:
105
+ data = _safe_redis_call(lambda k=key: client.hgetall(k), default={})
106
+ if data:
107
+ data['services'] = json.loads(data.get('services', '[]'))
108
+ data['redis_key'] = key
109
+ workers.append(data)
110
+ return workers
111
+
112
+
113
+ def get_workers_by_queue(queue_name):
114
+ return [w for w in get_all_workers() if w.get('queue') == queue_name]
115
+
116
+
117
+ def get_worker_stats():
118
+ workers = get_all_workers()
119
+ total = len(workers)
120
+ by_queue = {}
121
+ by_status = {}
122
+ for w in workers:
123
+ q = w.get('queue', 'unknown')
124
+ s = w.get('status', 'unknown')
125
+ by_queue.setdefault(q, 0)
126
+ by_queue[q] += 1
127
+ by_status.setdefault(s, 0)
128
+ by_status[s] += 1
129
+ return {
130
+ 'total': total,
131
+ 'by_queue': by_queue,
132
+ 'by_status': by_status,
133
+ 'workers': workers,
134
+ }
@@ -0,0 +1,274 @@
1
+ from datetime import date, timedelta
2
+
3
+ from django.db.models import Count
4
+ from django.utils.translation import gettext_lazy as _
5
+
6
+ from django_admin_dashboards.base import Dashboard, Layout, CardComponent, ChartComponent, TableComponent
7
+
8
+ from django_simpletask5.models import TaskExecution, CronJob, TaskExecutionArchive
9
+ from django_simpletask5.core.worker_registry import get_worker_stats as _get_worker_stats
10
+
11
+
12
+ class TaskDashboard(Dashboard):
13
+ title = _("Task Manager Dashboard")
14
+ auto_refresh = 60
15
+
16
+ class Media:
17
+ css = {
18
+ "all": (
19
+ "remixicon/remixicon.css",
20
+ "admin/css/vendor/select2/select2.min.css",
21
+ "django_admin_dashboards/css/dashboard.css",
22
+ "django_simpletask5/css/dashboard.css",
23
+ ),
24
+ }
25
+ js = (
26
+ "django_admin_dashboards/js/chart.umd.js",
27
+ "admin/js/vendor/jquery/jquery.min.js",
28
+ "admin/js/vendor/select2/select2.full.min.js",
29
+ "admin/js/jquery.init.js",
30
+ "django_admin_dashboards/js/dashboard.js",
31
+ )
32
+
33
+ def get_layout(self):
34
+ layout = Layout(columns=12)
35
+
36
+ today = date.today()
37
+
38
+ total_today = TaskExecution.objects.filter(
39
+ created_at__date=today
40
+ ).count()
41
+ success_today = TaskExecution.objects.filter(
42
+ created_at__date=today, status="success"
43
+ ).count()
44
+ pending = TaskExecution.objects.filter(status="pending").count()
45
+ running = TaskExecution.objects.filter(status="running").count()
46
+ active_cronjobs = CronJob.objects.filter(is_active=True).count()
47
+ total_cronjobs = CronJob.objects.count()
48
+ archives_count = TaskExecutionArchive.objects.count()
49
+ failed_today = TaskExecution.objects.filter(
50
+ created_at__date=today, status="failed"
51
+ ).count()
52
+
53
+ worker_stats = _get_worker_stats()
54
+ worker_total = worker_stats["total"]
55
+ worker_idle = worker_stats["by_status"].get("idle", 0)
56
+ worker_running_count = worker_stats["by_status"].get("running", 0)
57
+
58
+ success_rate = 0
59
+ if total_today > 0:
60
+ success_rate = round(success_today / total_today * 100)
61
+
62
+ layout.add_row([
63
+ (CardComponent(
64
+ title=_("Today Executions"),
65
+ value=total_today,
66
+ icon_class="ri-flashlight-line",
67
+ color="primary",
68
+ link="../django_simpletask5/taskexecution/?created_at__date__gte=" + today.isoformat(),
69
+ ), 3),
70
+ (CardComponent(
71
+ title=_("Success Rate"),
72
+ value=f"{success_rate}%",
73
+ change=f"{success_today}/{total_today}",
74
+ trend="up" if success_rate >= 80 else "down",
75
+ color="success" if success_rate >= 80 else "danger",
76
+ icon_class="ri-checkbox-circle-line",
77
+ ), 3),
78
+ (CardComponent(
79
+ title=_("Workers"),
80
+ value=worker_total,
81
+ values=[worker_idle, worker_running_count],
82
+ value_labels=[_("Idle"), _("Running")],
83
+ color="info",
84
+ icon_class="ri-server-line",
85
+ ), 3),
86
+ (CardComponent(
87
+ title=_("Cronjobs"),
88
+ value=active_cronjobs,
89
+ values=[active_cronjobs, total_cronjobs - active_cronjobs],
90
+ value_labels=[_("Active"), _("Inactive")],
91
+ links=[
92
+ "../django_simpletask5/cronjob/?is_active__exact=1",
93
+ "../django_simpletask5/cronjob/?is_active__exact=0",
94
+ ],
95
+ color="info",
96
+ icon_class="ri-calendar-check-line",
97
+ ), 3),
98
+ ])
99
+
100
+ layout.add_row([
101
+ (CardComponent(
102
+ title=_("Pending / Running"),
103
+ value=pending,
104
+ values=[pending, running],
105
+ value_labels=[_("Pending"), _("Running")],
106
+ links=[
107
+ "../django_simpletask5/taskexecution/?status__exact=pending",
108
+ "../django_simpletask5/taskexecution/?status__exact=running",
109
+ ],
110
+ color="warning",
111
+ icon_class="ri-timer-line",
112
+ ), 3),
113
+ (CardComponent(
114
+ title=_("Failed Today"),
115
+ value=failed_today,
116
+ icon_class="ri-error-warning-line",
117
+ color="danger",
118
+ link="../django_simpletask5/taskexecution/?status__exact=failed&created_at__date__gte=" + today.isoformat(),
119
+ ), 3),
120
+ (CardComponent(
121
+ title=_("Archives"),
122
+ value=archives_count,
123
+ icon_class="ri-archive-line",
124
+ color="secondary",
125
+ link="../django_simpletask5/taskexecutionarchive/",
126
+ ), 3),
127
+ (CardComponent(
128
+ title=_("Retries"),
129
+ value=TaskExecution.objects.filter(status="retry").count(),
130
+ icon_class="ri-refresh-line",
131
+ color="warning",
132
+ link="../django_simpletask5/taskexecution/?status__exact=retry",
133
+ ), 3),
134
+ ])
135
+
136
+ status_counts = TaskExecution.objects.values("status").annotate(
137
+ count=Count("execution_id")
138
+ )
139
+ status_labels = []
140
+ status_data = []
141
+ status_colors = []
142
+ color_map = {
143
+ "pending": "#ffc107",
144
+ "running": "#0d6efd",
145
+ "success": "#198754",
146
+ "failed": "#dc3545",
147
+ "retry": "#fd7e14",
148
+ "timeout": "#6c757d",
149
+ }
150
+ label_map = {
151
+ "pending": _("Pending"),
152
+ "running": _("Running"),
153
+ "success": _("Success"),
154
+ "failed": _("Failed"),
155
+ "retry": _("Retry"),
156
+ "timeout": _("Timeout"),
157
+ }
158
+ for item in status_counts:
159
+ status_labels.append(str(label_map.get(item["status"], item["status"])))
160
+ status_data.append(item["count"])
161
+ status_colors.append(color_map.get(item["status"], "#adb5bd"))
162
+
163
+ layout.add_row([
164
+ (ChartComponent(
165
+ title=_("Execution Status Distribution"),
166
+ chart_type="doughnut",
167
+ data={
168
+ "labels": status_labels,
169
+ "datasets": [{
170
+ "data": status_data,
171
+ "backgroundColor": status_colors,
172
+ }],
173
+ },
174
+ options={"responsive": True, "maintainAspectRatio": False},
175
+ height=350,
176
+ ), 6),
177
+ (ChartComponent(
178
+ title=_("Daily Executions (Last 30 Days)"),
179
+ chart_type="bar",
180
+ data=self._daily_executions_chart(),
181
+ options={"responsive": True, "maintainAspectRatio": False, "scales": {"x": {"stacked": True}, "y": {"stacked": True}}},
182
+ height=350,
183
+ ), 6),
184
+ ])
185
+
186
+ recent_failed = TaskExecution.objects.filter(
187
+ status__in=["failed", "timeout"]
188
+ ).order_by("-created_at")[:10]
189
+
190
+ failed_table_data = []
191
+ for ex in recent_failed:
192
+ failed_table_data.append([
193
+ str(ex.execution_id)[:8],
194
+ ex.executor_class,
195
+ ex.trigger_event,
196
+ ex.status,
197
+ ex.created_at.strftime("%m-%d %H:%M"),
198
+ ])
199
+
200
+ layout.add_row([
201
+ (TableComponent(
202
+ title=_("Recent Failed / Timeout Executions"),
203
+ columns=[_("ID"), _("Executor"), _("Event"), _("Status"), _("Time")],
204
+ data=failed_table_data,
205
+ ), 6),
206
+ (TableComponent(
207
+ title=_("Recent Executions"),
208
+ columns=[_("ID"), _("Executor"), _("Event"), _("Status"), _("Time")],
209
+ data=[
210
+ [
211
+ str(ex.execution_id)[:8],
212
+ ex.executor_class,
213
+ ex.trigger_event,
214
+ ex.status,
215
+ ex.created_at.strftime("%m-%d %H:%M"),
216
+ ]
217
+ for ex in TaskExecution.objects.order_by("-created_at")[:10]
218
+ ],
219
+ ), 6),
220
+ ])
221
+
222
+ worker_rows = []
223
+ for w in worker_stats["workers"]:
224
+ worker_rows.append([
225
+ w.get("hostname", "?"),
226
+ w.get("queue", "?"),
227
+ w.get("status", "?"),
228
+ w.get("current_execution_id", "")[:8] or "-",
229
+ w.get("services", "-") if isinstance(w.get("services"), str) else ",".join(w.get("services", [])) or "-",
230
+ ])
231
+
232
+ layout.add_row([
233
+ (TableComponent(
234
+ title=_("Active Workers"),
235
+ columns=[_("Hostname"), _("Queue"), _("Status"), _("Current Execution"), _("Services")],
236
+ data=worker_rows,
237
+ ), 12),
238
+ ])
239
+
240
+ return layout
241
+
242
+ def _daily_executions_chart(self):
243
+ today = date.today()
244
+ labels = []
245
+ success_data = []
246
+ failed_data = []
247
+ for i in range(29, -1, -1):
248
+ d = today - timedelta(days=i)
249
+ labels.append(d.strftime("%m-%d"))
250
+ success_data.append(
251
+ TaskExecution.objects.filter(
252
+ created_at__date=d, status="success"
253
+ ).count()
254
+ )
255
+ failed_data.append(
256
+ TaskExecution.objects.filter(
257
+ created_at__date=d, status__in=["failed", "timeout"]
258
+ ).count()
259
+ )
260
+ return {
261
+ "labels": labels,
262
+ "datasets": [
263
+ {
264
+ "label": str(_("Success")),
265
+ "data": success_data,
266
+ "backgroundColor": "#198754",
267
+ },
268
+ {
269
+ "label": str(_("Failed/Timeout")),
270
+ "data": failed_data,
271
+ "backgroundColor": "#dc3545",
272
+ },
273
+ ],
274
+ }
File without changes
@@ -0,0 +1,24 @@
1
+ import json
2
+ import logging
3
+
4
+ from django.utils import timezone
5
+ from datetime import timedelta
6
+
7
+ from django_simpletask5.models import TaskExecution
8
+ from django_simpletask5.executors.base import BaseExecutor
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class ArchiveExecutor(BaseExecutor):
14
+ def execute(self, execution: TaskExecution) -> str | None:
15
+ from django_simpletask5.services.archive import archive_executions
16
+
17
+ yesterday = timezone.now().date() - timedelta(days=1)
18
+ count = archive_executions(yesterday)
19
+
20
+ return json.dumps({
21
+ 'archived_count': count,
22
+ 'archive_date': yesterday.isoformat(),
23
+ 'success': True,
24
+ })
@@ -0,0 +1,10 @@
1
+ from django_simpletask5.models import TaskExecution
2
+
3
+
4
+ class BaseExecutor:
5
+ def execute(self, execution: TaskExecution) -> str | None:
6
+ raise NotImplementedError('Subclasses must implement execute()')
7
+
8
+ @classmethod
9
+ def get_parameter_schema(cls):
10
+ return []
@@ -0,0 +1,61 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+
5
+ from django.conf import settings
6
+
7
+ from django_simpletask5.models import TaskExecution
8
+ from django_simpletask5.executors.base import BaseExecutor
9
+
10
+
11
+ class BashScriptExecutor(BaseExecutor):
12
+ @classmethod
13
+ def get_parameter_schema(cls):
14
+ return [
15
+ {'name': 'script', 'type': 'text', 'required': False, 'default': '', 'description': 'Shell 脚本内容'},
16
+ {'name': 'script_path', 'type': 'string', 'required': False, 'default': '', 'description': 'Shell 脚本文件路径'},
17
+ {'name': 'args', 'type': 'array', 'required': False, 'default': [], 'description': '脚本参数列表'},
18
+ {'name': 'env', 'type': 'object', 'required': False, 'default': {}, 'description': '环境变量 JSON 对象'},
19
+ ]
20
+
21
+ def execute(self, execution: TaskExecution) -> str | None:
22
+ params = execution.get_context_dict()
23
+ script = params.get('script', '')
24
+ script_path = params.get('script_path', '')
25
+ args = params.get('args', [])
26
+ env = params.get('env', {})
27
+
28
+ if script_path:
29
+ script_whitelist = getattr(settings, 'DJANGO_SIMPLETASK_SCRIPT_WHITELIST', [])
30
+ if script_whitelist:
31
+ abs_path = os.path.abspath(script_path)
32
+ allowed = any(abs_path.startswith(os.path.abspath(p)) for p in script_whitelist)
33
+ if not allowed:
34
+ raise Exception('Script path not in whitelist')
35
+ with open(script_path, 'r') as f:
36
+ script = f.read()
37
+
38
+ if not script:
39
+ raise Exception('No script provided')
40
+
41
+ cmd = ['bash', '-c', script] + list(args)
42
+ merged_env = os.environ.copy()
43
+ merged_env.update(env)
44
+
45
+ result = subprocess.run(
46
+ cmd,
47
+ capture_output=True,
48
+ text=True,
49
+ timeout=execution.timeout_seconds or 300,
50
+ env=merged_env,
51
+ )
52
+
53
+ if result.returncode != 0:
54
+ raise Exception(result.stderr[:2000])
55
+
56
+ return json.dumps({
57
+ 'stdout': result.stdout[:2000],
58
+ 'stderr': result.stderr[:2000],
59
+ 'return_code': result.returncode,
60
+ 'success': True,
61
+ })
@@ -0,0 +1,17 @@
1
+ import importlib
2
+ import logging
3
+
4
+ logger = logging.getLogger(__name__)
5
+
6
+
7
+ def load_executor(executor_class_path):
8
+ if not executor_class_path or '.' not in executor_class_path:
9
+ raise ImportError(f'Cannot load executor {executor_class_path}: invalid path')
10
+ try:
11
+ module_path, class_name = executor_class_path.rsplit('.', 1)
12
+ module = importlib.import_module(module_path)
13
+ executor_cls = getattr(module, class_name)
14
+ return executor_cls()
15
+ except (ImportError, AttributeError) as e:
16
+ logger.exception('Failed to load executor %s', executor_class_path)
17
+ raise ImportError(f'Cannot load executor {executor_class_path}: {e}')
@@ -0,0 +1,12 @@
1
+ import logging
2
+
3
+ from django_simpletask5.models import TaskExecution
4
+ from django_simpletask5.executors.base import BaseExecutor
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class PingPongExecutor(BaseExecutor):
10
+ def execute(self, execution: TaskExecution) -> str | None:
11
+ logger.debug('PingPongExecutor executing execution %s', execution.pk)
12
+ return 'pong'