django-spire 0.23.9__py3-none-any.whl → 0.23.10__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_spire/consts.py CHANGED
@@ -1,4 +1,4 @@
1
- __VERSION__ = '0.23.9'
1
+ __VERSION__ = '0.23.10'
2
2
 
3
3
  MAINTENANCE_MODE_SETTINGS_NAME = 'MAINTENANCE_MODE'
4
4
 
@@ -1,19 +1,13 @@
1
+ from __future__ import annotations
2
+
1
3
  from django_spire.contrib.progress.enums import ProgressStatus
2
- from django_spire.contrib.progress.mixins import ProgressTrackingMixin
3
- from django_spire.contrib.progress.runner import TaskProgressUpdater
4
- from django_spire.contrib.progress.states import TaskState, TrackerState
5
- from django_spire.contrib.progress.task import ProgressMessages, Task, TaskResult
6
- from django_spire.contrib.progress.tracker import ProgressTracker
4
+ from django_spire.contrib.progress.tasks import ParallelTask, SequentialTask
5
+ from django_spire.contrib.progress.session import ProgressSession
7
6
 
8
7
 
9
8
  __all__ = [
10
- 'ProgressMessages',
9
+ 'ParallelTask',
10
+ 'ProgressSession',
11
11
  'ProgressStatus',
12
- 'ProgressTracker',
13
- 'ProgressTrackingMixin',
14
- 'Task',
15
- 'TaskProgressUpdater',
16
- 'TaskResult',
17
- 'TaskState',
18
- 'TrackerState'
12
+ 'SequentialTask',
19
13
  ]
@@ -5,6 +5,7 @@ from enum import StrEnum
5
5
 
6
6
  class ProgressStatus(StrEnum):
7
7
  COMPLETE = 'complete'
8
+ COMPLETING = 'completing'
8
9
  ERROR = 'error'
9
10
  PENDING = 'pending'
10
- PROCESSING = 'processing'
11
+ RUNNING = 'running'
@@ -0,0 +1,283 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ import time
6
+ import uuid
7
+
8
+ from typing import TYPE_CHECKING
9
+
10
+ from django.core.cache import cache
11
+
12
+ from django_spire.contrib.progress.enums import ProgressStatus
13
+ from django_spire.contrib.progress.tasks import ParallelTask, SequentialTask
14
+
15
+ if TYPE_CHECKING:
16
+ from typing_extensions import Any, Callable, Generator
17
+
18
+
19
+ SIMULATION_MESSAGES = [
20
+ 'Initializing...',
21
+ 'Processing...',
22
+ 'Analyzing...',
23
+ 'Generating...',
24
+ 'Finalizing...',
25
+ ]
26
+
27
+
28
+ class ProgressSession:
29
+ _CACHE_PREFIX = 'progress_session:'
30
+ _CACHE_TIMEOUT = 300
31
+ _COMPLETION_INCREMENT = 3.0
32
+ _COMPLETION_INTERVAL = 0.03
33
+ _MAX_SIMULATED_PERCENT = 85
34
+ _SIMULATION_INTERVAL = 0.1
35
+
36
+ def __init__(self, session_id: str, tasks: dict[str, str]) -> None:
37
+ self._lock = threading.Lock()
38
+ self._simulation_threads: dict[str, tuple[threading.Event, threading.Thread]] = {}
39
+
40
+ self._tasks: dict[str, dict[str, Any]] = {
41
+ task_id: {
42
+ 'complete_message': '',
43
+ 'message': '',
44
+ 'name': name,
45
+ 'percent': 0.0,
46
+ 'status': ProgressStatus.PENDING,
47
+ }
48
+ for task_id, name in tasks.items()
49
+ }
50
+
51
+ self.session_id = session_id
52
+
53
+ def _calculate_increment(self, current_percent: float) -> float:
54
+ remaining = self._MAX_SIMULATED_PERCENT - current_percent
55
+ ratio = remaining / self._MAX_SIMULATED_PERCENT
56
+ eased = ratio * ratio
57
+ increment = 0.8 * eased
58
+
59
+ return max(increment, 0.05)
60
+
61
+ def _calculate_message_index(self, percent: float) -> int:
62
+ message_index = int((percent / 100) * len(SIMULATION_MESSAGES))
63
+
64
+ return min(message_index, len(SIMULATION_MESSAGES) - 1)
65
+
66
+ def _delete(self) -> None:
67
+ cache.delete(f'{self._CACHE_PREFIX}{self.session_id}')
68
+
69
+ def _save(self) -> None:
70
+ data = {
71
+ 'session_id': self.session_id,
72
+ 'tasks': {
73
+ task_id: {
74
+ 'complete_message': task['complete_message'],
75
+ 'message': task['message'],
76
+ 'name': task['name'],
77
+ 'percent': task['percent'],
78
+ 'status': task['status'].value,
79
+ }
80
+ for task_id, task in self._tasks.items()
81
+ },
82
+ }
83
+
84
+ cache.set(f'{self._CACHE_PREFIX}{self.session_id}', data, timeout=self._CACHE_TIMEOUT)
85
+
86
+ def _simulate_progress(
87
+ self,
88
+ task_id: str,
89
+ stop_event: threading.Event,
90
+ ) -> None:
91
+ while not stop_event.is_set():
92
+ with self._lock:
93
+ if task_id not in self._tasks:
94
+ continue
95
+
96
+ task = self._tasks[task_id]
97
+
98
+ if task['status'] == ProgressStatus.RUNNING:
99
+ self._tick_running(task)
100
+
101
+ if task['status'] == ProgressStatus.COMPLETING:
102
+ if self._tick_completing(task):
103
+ return
104
+
105
+ interval = self._COMPLETION_INTERVAL if task['status'] == ProgressStatus.COMPLETING else self._SIMULATION_INTERVAL
106
+ stop_event.wait(interval)
107
+
108
+ def _tick_completing(self, task: dict[str, Any]) -> bool:
109
+ if task['percent'] < 100.0:
110
+ task['percent'] = min(task['percent'] + self._COMPLETION_INCREMENT, 100.0)
111
+ self._save()
112
+ return False
113
+
114
+ task['status'] = ProgressStatus.COMPLETE
115
+ task['message'] = task['complete_message']
116
+ self._save()
117
+
118
+ return True
119
+
120
+ def _tick_running(self, task: dict[str, Any]) -> None:
121
+ if task['percent'] >= self._MAX_SIMULATED_PERCENT:
122
+ return
123
+
124
+ increment = self._calculate_increment(task['percent'])
125
+ task['percent'] = min(task['percent'] + increment, self._MAX_SIMULATED_PERCENT)
126
+ task['message'] = SIMULATION_MESSAGES[self._calculate_message_index(task['percent'])]
127
+
128
+ self._save()
129
+
130
+ @property
131
+ def has_error(self) -> bool:
132
+ tasks = [
133
+ task['status'] == ProgressStatus.ERROR
134
+ for task in self._tasks.values()
135
+ ]
136
+
137
+ return any(tasks)
138
+
139
+ @property
140
+ def is_complete(self) -> bool:
141
+ tasks = [
142
+ task['status'] == ProgressStatus.COMPLETE
143
+ for task in self._tasks.values()
144
+ ]
145
+
146
+ return all(tasks)
147
+
148
+ @property
149
+ def is_running(self) -> bool:
150
+ tasks = [
151
+ task['status'] in (ProgressStatus.RUNNING, ProgressStatus.COMPLETING)
152
+ for task in self._tasks.values()
153
+ ]
154
+
155
+ return any(tasks)
156
+
157
+ @property
158
+ def overall_percent(self) -> int:
159
+ if not self._tasks:
160
+ return 0
161
+
162
+ total_percent = sum(task['percent'] for task in self._tasks.values())
163
+
164
+ return int(total_percent / len(self._tasks))
165
+
166
+ @property
167
+ def status(self) -> ProgressStatus:
168
+ if self.has_error:
169
+ return ProgressStatus.ERROR
170
+
171
+ if self.is_complete:
172
+ return ProgressStatus.COMPLETE
173
+
174
+ if self.is_running:
175
+ return ProgressStatus.RUNNING
176
+
177
+ return ProgressStatus.PENDING
178
+
179
+ def add_parallel(self, task_id: str, future: Any) -> ParallelTask:
180
+ return ParallelTask(self, task_id, future)
181
+
182
+ def add_sequential(self, task_id: str, func: Callable, *args: Any, **kwargs: Any) -> SequentialTask:
183
+ return SequentialTask(self, task_id, func, *args, **kwargs)
184
+
185
+ def complete(self, task_id: str, message: str | None = None) -> None:
186
+ with self._lock:
187
+ if task_id in self._tasks:
188
+ task = self._tasks[task_id]
189
+ task['status'] = ProgressStatus.COMPLETING
190
+ task['complete_message'] = message or f'{task["name"]} complete'
191
+ task['message'] = 'Completing...'
192
+ self._save()
193
+
194
+ def error(self, task_id: str, message: str | None = None) -> None:
195
+ if task_id in self._simulation_threads:
196
+ stop_event, _ = self._simulation_threads[task_id]
197
+ stop_event.set()
198
+ del self._simulation_threads[task_id]
199
+
200
+ with self._lock:
201
+ if task_id in self._tasks:
202
+ task = self._tasks[task_id]
203
+ task['status'] = ProgressStatus.ERROR
204
+ task['message'] = message or f'{task["name"]} failed'
205
+ self._save()
206
+
207
+ def start(self, task_id: str) -> None:
208
+ with self._lock:
209
+ if task_id in self._tasks:
210
+ task = self._tasks[task_id]
211
+ task['percent'] = 0.0
212
+ task['status'] = ProgressStatus.RUNNING
213
+ task['message'] = SIMULATION_MESSAGES[0]
214
+ self._save()
215
+
216
+ stop_event = threading.Event()
217
+
218
+ thread = threading.Thread(
219
+ target=self._simulate_progress,
220
+ args=(task_id, stop_event),
221
+ daemon=True,
222
+ )
223
+
224
+ self._simulation_threads[task_id] = (stop_event, thread)
225
+ thread.start()
226
+
227
+ def stream(self, poll_interval: float = 0.05) -> Generator[str, None, None]:
228
+ while True:
229
+ with self._lock:
230
+ data = self.to_dict()
231
+
232
+ yield f'{json.dumps(data)}\n'
233
+
234
+ if self.is_complete or self.has_error:
235
+ self._delete()
236
+ break
237
+
238
+ time.sleep(poll_interval)
239
+
240
+ def to_dict(self) -> dict[str, Any]:
241
+ return {
242
+ 'overall_percent': self.overall_percent,
243
+ 'session_id': self.session_id,
244
+ 'status': self.status.value,
245
+ 'tasks': {
246
+ task_id: {
247
+ 'message': task['message'],
248
+ 'name': task['name'],
249
+ 'percent': int(task['percent']),
250
+ 'status': task['status'].value if task['status'] != ProgressStatus.COMPLETING else ProgressStatus.RUNNING.value,
251
+ }
252
+ for task_id, task in self._tasks.items()
253
+ },
254
+ }
255
+
256
+ @classmethod
257
+ def create(cls, tasks: dict[str, str]) -> ProgressSession:
258
+ session_id = str(uuid.uuid4())
259
+ session = cls(session_id, tasks)
260
+ session._save()
261
+ return session
262
+
263
+ @classmethod
264
+ def get(cls, session_id: str) -> ProgressSession | None:
265
+ data = cache.get(f'{cls._CACHE_PREFIX}{session_id}')
266
+
267
+ if data is None:
268
+ return None
269
+
270
+ session = cls(data['session_id'], {})
271
+
272
+ session._tasks = {
273
+ task_id: {
274
+ 'complete_message': task['complete_message'],
275
+ 'message': task['message'],
276
+ 'name': task['name'],
277
+ 'percent': task['percent'],
278
+ 'status': ProgressStatus(task['status']),
279
+ }
280
+ for task_id, task in data['tasks'].items()
281
+ }
282
+
283
+ return session
@@ -1,87 +1,65 @@
1
1
  class ProgressStream {
2
2
  constructor(url, config = {}) {
3
3
  this.url = url;
4
- this.is_running = false;
5
- this.poll_interval = null;
6
4
 
7
5
  this.config = {
8
- on_update: config.on_update || (() => {}),
9
6
  on_complete: config.on_complete || (() => {}),
10
7
  on_error: config.on_error || (() => {}),
11
- redirect_on_complete: config.redirect_on_complete || null,
8
+ on_update: config.on_update || (() => {}),
12
9
  redirect_delay: config.redirect_delay || 1000,
13
- poll_interval: config.poll_interval || 1000
10
+ redirect_url: config.redirect_url || null,
14
11
  };
15
12
  }
16
13
 
17
- async start() {
18
- if (this.is_running) return;
14
+ _redirect() {
15
+ if (!this.config.redirect_url) {
16
+ return;
17
+ }
19
18
 
20
- this.is_running = true;
21
- this._start_polling();
19
+ setTimeout(() => {
20
+ window.location.href = this.config.redirect_url;
21
+ }, this.config.redirect_delay);
22
22
  }
23
23
 
24
- stop() {
25
- if (!this.is_running) return;
26
-
27
- this.is_running = false;
24
+ async start() {
25
+ let response = await fetch(this.url);
28
26
 
29
- if (this.poll_interval) {
30
- clearInterval(this.poll_interval);
31
- this.poll_interval = null;
27
+ if (!response.ok) {
28
+ this.config.on_error({ message: `HTTP ${response.status}` });
29
+ return;
32
30
  }
33
- }
34
-
35
- _start_polling() {
36
- this._poll();
37
31
 
38
- this.poll_interval = setInterval(() => {
39
- if (this.is_running) {
40
- this._poll();
41
- }
42
- }, this.config.poll_interval);
43
- }
32
+ let reader = response.body.getReader();
33
+ let decoder = new TextDecoder();
44
34
 
45
- async _poll() {
46
- try {
47
- let response = await fetch(this.url, {
48
- method: 'POST',
49
- headers: {
50
- 'X-CSRFToken': get_cookie('csrftoken'),
51
- }
52
- });
35
+ while (true) {
36
+ let { done, value } = await reader.read();
53
37
 
54
- if (!response.ok) {
55
- throw new Error(`HTTP ${response.status}`);
38
+ if (done) {
39
+ break;
56
40
  }
57
41
 
58
- let data = await response.json();
42
+ let chunk = decoder.decode(value, { stream: true });
43
+ let lines = chunk.split('\n').filter(line => line.trim());
59
44
 
60
- this.config.on_update(data);
45
+ for (let line of lines) {
46
+ let data = JSON.parse(line);
61
47
 
62
- if (data.step === 'error') {
63
- this.config.on_error(data);
64
- this.stop();
65
- return;
66
- }
48
+ this.config.on_update(data);
67
49
 
68
- if (data.progress >= 100) {
69
- this.config.on_complete(data);
70
- this.stop();
71
- this._redirect();
50
+ if (data.status === 'error') {
51
+ this.config.on_error(data);
52
+ return;
53
+ }
54
+
55
+ if (data.status === 'complete') {
56
+ this.config.on_complete(data);
57
+ this._redirect();
58
+ return;
59
+ }
72
60
  }
73
- } catch (error) {
74
- console.warn('Poll error:', error.message);
75
61
  }
76
62
  }
77
-
78
- _redirect() {
79
- if (!this.config.redirect_on_complete) return;
80
-
81
- setTimeout(() => {
82
- window.location.href = this.config.redirect_on_complete;
83
- }, this.config.redirect_delay);
84
- }
85
63
  }
86
64
 
87
65
  window.ProgressStream = ProgressStream;
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from typing import Any, Callable
9
+
10
+ from django_spire.contrib.progress.session import ProgressSession
11
+
12
+
13
+ class ParallelTask:
14
+ def __init__(self, session: ProgressSession, task_id: str, future: Any) -> None:
15
+ self._future = future
16
+ self._session = session
17
+ self._task_id = task_id
18
+
19
+ self._session.start(task_id)
20
+
21
+ thread = threading.Thread(
22
+ target=self._wait_for_completion,
23
+ daemon=True,
24
+ )
25
+ thread.start()
26
+
27
+ def _wait_for_completion(self) -> None:
28
+ try:
29
+ _ = self._future.result
30
+ self._session.complete(self._task_id)
31
+ except Exception:
32
+ self._session.error(self._task_id)
33
+
34
+ @property
35
+ def result(self) -> Any:
36
+ return self._future.result
37
+
38
+
39
+ class SequentialTask:
40
+ def __init__(
41
+ self,
42
+ session: ProgressSession,
43
+ task_id: str,
44
+ func: Callable,
45
+ *args: Any,
46
+ **kwargs: Any,
47
+ ) -> None:
48
+ self._exception: Exception | None = None
49
+ self._result: Any = None
50
+ self._session = session
51
+ self._task_id = task_id
52
+
53
+ self._session.start(task_id)
54
+
55
+ try:
56
+ self._result = func(*args, **kwargs)
57
+ self._session.complete(task_id)
58
+ except Exception as e:
59
+ self._exception = e
60
+ self._session.error(task_id)
61
+
62
+ @property
63
+ def result(self) -> Any:
64
+ if self._exception is not None:
65
+ raise self._exception
66
+
67
+ return self._result
@@ -7,35 +7,36 @@
7
7
 
8
8
  {% block card_title_content %}
9
9
  <div
10
- data-stream-url="{% block progress_stream_url %}{% endblock %}"
11
- data-redirect-url="{% block progress_redirect_url %}{% endblock %}"
12
10
  data-redirect-delay="{% block progress_redirect_delay %}1000{% endblock %}"
11
+ data-redirect-url="{% block progress_redirect_url %}{% endblock %}"
12
+ data-stream-url="{% block progress_stream_url %}{% endblock %}"
13
13
  x-data="{
14
- step: 'starting',
15
- message: 'Initializing...',
16
- progress: 0,
17
14
  has_error: false,
18
- stream: null,
19
- init() {
20
- const stream_url = this.$el.dataset.streamUrl;
21
- const redirect_url = this.$el.dataset.redirectUrl;
22
- const redirect_delay = parseInt(this.$el.dataset.redirectDelay);
15
+ message: 'Initializing...',
16
+ overall_percent: 0,
23
17
 
24
- this.stream = new ProgressStream(stream_url, {
25
- on_update: (data) => {
26
- this.step = data.step;
27
- this.message = data.message;
28
- this.progress = data.progress;
29
- },
30
- on_complete: (data) => {},
18
+ async init() {
19
+ let stream = new ProgressStream(this.$el.dataset.streamUrl, {
20
+ on_complete: () => {},
31
21
  on_error: (data) => {
32
22
  this.has_error = true;
33
- this.message = data.message;
23
+ this.message = data.message || 'An error occurred';
24
+ },
25
+ on_update: (data) => {
26
+ this.overall_percent = data.overall_percent;
27
+
28
+ let tasks = Object.values(data.tasks);
29
+ let running_task = tasks.find(t => t.status === 'running');
30
+
31
+ if (running_task) {
32
+ this.message = running_task.message;
33
+ }
34
34
  },
35
- redirect_on_complete: redirect_url,
36
- redirect_delay: redirect_delay
35
+ redirect_delay: parseInt(this.$el.dataset.redirectDelay),
36
+ redirect_url: this.$el.dataset.redirectUrl,
37
37
  });
38
- this.stream.start();
38
+
39
+ await stream.start();
39
40
  }
40
41
  }"
41
42
  >
@@ -43,14 +44,14 @@
43
44
  <div class="col-12" x-show="!has_error">
44
45
  <div class="d-flex justify-content-between align-items-center mb-2">
45
46
  <span class="fs-6" x-text="message"></span>
46
- {% include 'django_spire/badge/accent_badge.html' with x_badge_text="progress + '%'" %}
47
+ {% include 'django_spire/badge/accent_badge.html' with x_badge_text="overall_percent + '%'" %}
47
48
  </div>
48
49
  <div class="progress" style="height: 8px;">
49
50
  <div
50
51
  class="progress-bar bg-app-accent"
51
52
  role="progressbar"
52
- :style="'width: ' + progress + '%'"
53
- :aria-valuenow="progress"
53
+ :style="'width: ' + overall_percent + '%'"
54
+ :aria-valuenow="overall_percent"
54
55
  aria-valuemin="0"
55
56
  aria-valuemax="100"
56
57
  ></div>