quebec 0.1.1__cp39-abi3-win32.whl → 0.2.2__cp39-abi3-win32.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.
quebec/__init__.py CHANGED
@@ -1,12 +1,13 @@
1
1
  from .quebec import * # NOQA
2
+ from . import quebec
3
+ from .quebec import Quebec, ActiveJob
2
4
  import logging
3
5
  import time
4
6
  import queue
5
7
  import threading
6
- from functools import wraps
7
- from typing import Callable, List, Tuple, Type, Any, Optional
8
- import dataclasses
9
- from .logger import job_id_var
8
+ from datetime import datetime, timedelta, timezone
9
+ from typing import List, Type, Any, Optional, Union
10
+ from .logger import job_id_var, queue_var
10
11
 
11
12
  __doc__ = quebec.__doc__
12
13
  if hasattr(quebec, "__all__"):
@@ -15,15 +16,60 @@ if hasattr(quebec, "__all__"):
15
16
  logger = logging.getLogger(__name__)
16
17
 
17
18
 
18
- class TestJob:
19
- def perform(self):
20
- pass
19
+ class JobBuilder:
20
+ """Builder for configuring job options before enqueueing.
21
+
22
+ This allows chaining configuration like:
23
+ MyJob.set(wait=3600).perform_later(qc, arg1, arg2)
24
+ MyJob.set(queue='high', priority=10).perform_later(qc, arg1)
25
+ """
26
+
27
+ def __init__(self, job_class: Type, **options):
28
+ self.job_class = job_class
29
+ self.options = options
30
+
31
+ def _calculate_scheduled_at(self) -> Optional[datetime]:
32
+ """Calculate scheduled_at from wait or wait_until options."""
33
+ wait = self.options.get('wait')
34
+ wait_until = self.options.get('wait_until')
35
+
36
+ if wait_until is not None:
37
+ if isinstance(wait_until, datetime):
38
+ # Ensure timezone-aware for correct timestamp conversion
39
+ if wait_until.tzinfo is None:
40
+ # Assume naive datetime is UTC
41
+ wait_until = wait_until.replace(tzinfo=timezone.utc)
42
+ return wait_until
43
+ raise ValueError("wait_until must be a datetime object")
44
+
45
+ if wait is not None:
46
+ # Use timezone-aware UTC datetime
47
+ now = datetime.now(timezone.utc)
48
+ if isinstance(wait, (int, float)):
49
+ return now + timedelta(seconds=wait)
50
+ elif isinstance(wait, timedelta):
51
+ return now + wait
52
+ raise ValueError("wait must be a number (seconds) or timedelta")
53
+
54
+ return None
55
+
56
+ def perform_later(self, qc: 'Quebec', *args, **kwargs) -> 'ActiveJob':
57
+ """Enqueue the job with configured options."""
58
+ scheduled_at = self._calculate_scheduled_at()
59
+
60
+ # Pass internal options via kwargs (will be filtered out before serialization)
61
+ if scheduled_at is not None:
62
+ kwargs['_scheduled_at'] = scheduled_at.timestamp()
63
+
64
+ if 'queue' in self.options:
65
+ kwargs['_queue'] = self.options['queue']
66
+
67
+ if 'priority' in self.options:
68
+ kwargs['_priority'] = self.options['priority']
69
+
70
+ # Call the original perform_later
71
+ return self.job_class.perform_later(qc, *args, **kwargs)
21
72
 
22
- # @dataclasses.dataclass
23
- # class RetryStrategy:
24
- # wait: int = 3 # seconds
25
- # attempts: int = 5 # attempts
26
- # # exceptions: tuple = (Exception,)
27
73
 
28
74
  class NoNewOverrideMeta(type):
29
75
  def __new__(cls, name, bases, dct):
@@ -34,7 +80,37 @@ class NoNewOverrideMeta(type):
34
80
  return super().__new__(cls, name, bases, dct)
35
81
 
36
82
  class BaseClass(ActiveJob, metaclass=NoNewOverrideMeta):
37
- pass
83
+ @classmethod
84
+ def set(cls, wait: Union[int, float, timedelta] = None,
85
+ wait_until: datetime = None,
86
+ queue: str = None,
87
+ priority: int = None) -> JobBuilder:
88
+ """Configure job options before enqueueing.
89
+
90
+ Args:
91
+ wait: Delay in seconds (int/float) or timedelta before running
92
+ wait_until: Specific datetime when the job should run
93
+ queue: Queue name to enqueue the job to
94
+ priority: Job priority (lower number = higher priority)
95
+
96
+ Returns:
97
+ JobBuilder instance for chaining with perform_later
98
+
99
+ Example:
100
+ MyJob.set(wait=3600).perform_later(qc, arg1) # Run in 1 hour
101
+ MyJob.set(wait_until=tomorrow).perform_later(qc, arg1)
102
+ MyJob.set(queue='critical', priority=1).perform_later(qc, arg1)
103
+ """
104
+ options = {}
105
+ if wait is not None:
106
+ options['wait'] = wait
107
+ if wait_until is not None:
108
+ options['wait_until'] = wait_until
109
+ if queue is not None:
110
+ options['queue'] = queue
111
+ if priority is not None:
112
+ options['priority'] = priority
113
+ return JobBuilder(cls, **options)
38
114
 
39
115
 
40
116
  class ThreadedRunner:
@@ -54,14 +130,16 @@ class ThreadedRunner:
54
130
  self.queue.task_done()
55
131
  self.execution.tid = str(threading.get_ident())
56
132
 
57
- # Inject job_id into the context before execution, clean up after execution.
58
- token = job_id_var.set(str(self.execution.jid))
133
+ # Inject jid and queue into context before execution, clean up after
134
+ jid_token = job_id_var.set(self.execution.jid)
135
+ queue_token = queue_var.set(self.execution.queue)
59
136
  self.execution.perform()
60
137
  logger.debug(self.execution.metric)
61
- job_id_var.reset(token)
138
+ queue_var.reset(queue_token)
139
+ job_id_var.reset(jid_token)
62
140
  except queue.Empty:
63
- time.sleep(0.1)
64
- except (queue.ShutDown, KeyboardInterrupt) as e:
141
+ pass # No job available, just continue waiting
142
+ except (queue.ShutDown, KeyboardInterrupt):
65
143
  break
66
144
  except Exception as e:
67
145
  logger.error(f"Unexpected exception in ThreadedRunner: {e}", exc_info=True)
@@ -78,28 +156,153 @@ class ThreadedRunner:
78
156
  except Exception as e:
79
157
  logger.error(f"Error in cleanup: {e}", exc_info=True)
80
158
 
81
- # def rescue_from(func):
82
- # @wraps(func)
83
- # def wrapper(self, *args, **kwargs):
84
- # # 这里可以访问到 self
85
- # print(f"Before calling {func.__name__}")
86
- # result = func(self, *args, **kwargs)
87
- # print(f"After calling {func.__name__}")
88
- # return result
89
- # return wrapper
90
-
91
- # def rescue_from(*exceptions: Type[Exception]):
92
- # print(f">>>>>>>>>>>>> {exceptions}")
93
- # def decorator(func: Callable[[Any, Exception], None]):
94
- # @wraps(func)
95
- # def wrapper(self, *args, **kwargs):
96
- # print(f"registering {self} with {exceptions}")
97
- # print(self.rescue_strategies)
98
- # return func(self, *args, **kwargs)
99
- # # cls.rescue_strategies.append(RescueStrategy(exceptions, func))
100
- # print(decorator.__class__)
101
- # # print(dir(func))
102
- # # print(dir(wrapper))
103
- # print(f"--------------------------- rescue_from {exceptions} {func}")
104
- # return wrapper
105
- # return decorator
159
+
160
+ # Runtime state for Quebec instances (PyO3 classes don't support dynamic attributes)
161
+ _quebec_state: dict = {}
162
+
163
+
164
+ def _quebec_start(
165
+ self,
166
+ *,
167
+ create_tables: bool = False,
168
+ control_plane: Optional[str] = None,
169
+ spawn: Optional[List[str]] = None,
170
+ threads: int = 1,
171
+ ):
172
+ """Non-blocking start. Returns immediately after all components are started.
173
+
174
+ Args:
175
+ create_tables: Whether to create database tables (default False).
176
+ Set to True only if the current user has DDL permissions.
177
+ control_plane: Control plane listen address, e.g. '127.0.0.1:5006'.
178
+ spawn: List of components to spawn. Options: 'worker', 'dispatcher', 'scheduler'.
179
+ None means spawn all components.
180
+ threads: Number of worker threads to run jobs (default 1).
181
+
182
+ Example:
183
+ qc.start()
184
+ # ... do other work ...
185
+ qc.wait() # Block until shutdown
186
+ """
187
+ if create_tables:
188
+ self.create_table()
189
+
190
+ self.setup_signal_handler()
191
+
192
+ if control_plane:
193
+ self.start_control_plane(control_plane)
194
+
195
+ # Spawn components based on spawn parameter
196
+ if spawn is None:
197
+ self.spawn_all()
198
+ else:
199
+ for component in spawn:
200
+ if component == 'worker':
201
+ self.spawn_job_claim_poller()
202
+ elif component == 'dispatcher':
203
+ self.spawn_dispatcher()
204
+ elif component == 'scheduler':
205
+ self.spawn_scheduler()
206
+ else:
207
+ raise ValueError(f"Unknown component: {component}")
208
+
209
+ # Set up threading infrastructure
210
+ shutdown_event = threading.Event()
211
+ job_queue = queue.Queue()
212
+
213
+ # Register internal shutdown handler to signal the event
214
+ @self.on_shutdown
215
+ def _internal_shutdown_handler():
216
+ shutdown_event.set()
217
+
218
+ if threads > 0:
219
+ self.feed_jobs_to_queue(job_queue)
220
+
221
+ def run_worker():
222
+ runner = ThreadedRunner(job_queue, shutdown_event)
223
+ runner.run()
224
+
225
+ # Start worker threads as daemon so program can exit after start()
226
+ worker_threads = []
227
+ for i in range(threads):
228
+ t = threading.Thread(target=run_worker, name=f'quebec-worker-{i}', daemon=True)
229
+ t.start()
230
+ worker_threads.append(t)
231
+
232
+ # Store state by instance id
233
+ _quebec_state[id(self)] = {
234
+ 'shutdown_event': shutdown_event,
235
+ 'job_queue': job_queue,
236
+ 'worker_threads': worker_threads,
237
+ }
238
+
239
+ return self # Enable chaining: qc.start().wait()
240
+
241
+
242
+ def _quebec_wait(self):
243
+ """Block until shutdown signal is received.
244
+
245
+ Call this after start() to wait for graceful shutdown.
246
+
247
+ Example:
248
+ qc.start()
249
+ # ... do other work ...
250
+ qc.wait()
251
+ """
252
+ state = _quebec_state.get(id(self))
253
+ if state is None:
254
+ raise RuntimeError("Quebec not started. Call start() first.")
255
+
256
+ try:
257
+ while not state['shutdown_event'].is_set():
258
+ time.sleep(0.5)
259
+ except KeyboardInterrupt:
260
+ logger.debug('KeyboardInterrupt, shutting down...')
261
+ self.graceful_shutdown()
262
+ finally:
263
+ # Wait for worker threads to finish
264
+ for t in state['worker_threads']:
265
+ t.join(timeout=5.0)
266
+ _quebec_state.pop(id(self), None)
267
+
268
+
269
+ def _quebec_run(
270
+ self,
271
+ *,
272
+ create_tables: bool = False,
273
+ control_plane: Optional[str] = None,
274
+ spawn: Optional[List[str]] = None,
275
+ threads: int = 1,
276
+ ):
277
+ """Blocking run. Starts all components and waits until shutdown.
278
+
279
+ This is equivalent to calling start() followed by wait().
280
+
281
+ Args:
282
+ create_tables: Whether to create database tables (default False).
283
+ Set to True only if the current user has DDL permissions.
284
+ control_plane: Control plane listen address, e.g. '127.0.0.1:5006'.
285
+ spawn: List of components to spawn. Options: 'worker', 'dispatcher', 'scheduler'.
286
+ None means spawn all components.
287
+ threads: Number of worker threads to run jobs (default 1).
288
+
289
+ Example:
290
+ qc.run() # Start all components and block
291
+ qc.run(create_tables=True) # Create tables and start all
292
+ qc.run(spawn=['worker']) # Only start worker
293
+ qc.run(spawn=['worker', 'dispatcher'], control_plane='127.0.0.1:5006')
294
+ qc.run(threads=4) # Run with 4 worker threads
295
+ """
296
+ self.start(
297
+ create_tables=create_tables,
298
+ control_plane=control_plane,
299
+ spawn=spawn,
300
+ threads=threads,
301
+ )
302
+ self.wait()
303
+
304
+
305
+ # Attach methods to Quebec class
306
+ Quebec.start = _quebec_start
307
+ Quebec.wait = _quebec_wait
308
+ Quebec.run = _quebec_run
quebec/logger.py CHANGED
@@ -4,13 +4,16 @@ from datetime import datetime, timezone
4
4
  from typing import Optional
5
5
 
6
6
  job_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("job_id", default=None)
7
+ queue_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("queue", default=None)
7
8
 
8
9
  class ContextFilter(logging.Filter):
9
10
  def filter(self, record: logging.LogRecord) -> bool:
10
11
  try:
11
12
  record.job_id = job_id_var.get(None)
13
+ record.queue = queue_var.get(None)
12
14
  except Exception:
13
15
  record.job_id = None
16
+ record.queue = None
14
17
  return True
15
18
 
16
19
  class QuebecFormatter(logging.Formatter):
@@ -22,8 +25,12 @@ class QuebecFormatter(logging.Formatter):
22
25
  record.asctime = self.formatTime(record)
23
26
  record.message = record.getMessage()
24
27
  jid = getattr(record, 'job_id', None)
25
- ctx = f" [jid={jid}]" if jid not in (None, '', '-') else ""
26
- origin = f"[{record.name}:{record.filename}:{record.lineno}:{record.thread}]"
28
+ queue = getattr(record, 'queue', None)
29
+ if jid not in (None, '', '-'):
30
+ ctx = f' {{queue="{queue}" jid="{jid}" tid="{record.thread}"}}:'
31
+ else:
32
+ ctx = ""
33
+ origin = f"{record.name}:{record.filename}: {record.lineno}:"
27
34
  return f"{record.asctime} {record.levelname:>5}{ctx} {origin} {record.message}"
28
35
 
29
36
 
quebec/quebec.pyd CHANGED
Binary file
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quebec
3
- Version: 0.1.1
3
+ Version: 0.2.2
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Programming Language :: Python :: Implementation :: PyPy
@@ -17,9 +17,13 @@ Classifier: Operating System :: Microsoft :: Windows
17
17
  Classifier: Operating System :: POSIX
18
18
  Classifier: Operating System :: Unix
19
19
  Classifier: Operating System :: MacOS
20
- Requires-Dist: pytest<5.0.0 ; extra == 'test'
21
- Requires-Dist: pytest-cov[all] ; extra == 'test'
20
+ Requires-Dist: pytest>=7.0.0 ; extra == 'test'
21
+ Requires-Dist: pytest-cov ; extra == 'test'
22
+ Requires-Dist: sphinx>=7.0 ; extra == 'docs'
23
+ Requires-Dist: shibuya ; extra == 'docs'
24
+ Requires-Dist: myst-parser ; extra == 'docs'
22
25
  Provides-Extra: test
26
+ Provides-Extra: docs
23
27
  License-File: LICENSE
24
28
  Summary: Quebec is a simple background task queue for processing asynchronous tasks.
25
29
  Keywords: solid_queue,postgresql,mysql,sqlite,queue
@@ -57,6 +61,12 @@ This project is inspired by [Solid Queue](https://github.com/rails/solid_queue).
57
61
  - Signal handling
58
62
  - Lifecycle hooks
59
63
 
64
+ ### Control Plane
65
+
66
+ Built-in web dashboard for monitoring jobs, queues, and workers in real-time.
67
+
68
+ ![Control Plane](docs/images/control-plane.png)
69
+
60
70
  ## Database Support
61
71
 
62
72
  - SQLite
@@ -65,11 +75,67 @@ This project is inspired by [Solid Queue](https://github.com/rails/solid_queue).
65
75
 
66
76
  ## Quick Start
67
77
 
78
+ ```python
79
+ import logging
80
+ from pathlib import Path
81
+ from quebec.logger import setup_logging
82
+
83
+ setup_logging(level=logging.DEBUG)
84
+
85
+ import quebec
86
+
87
+ db_path = Path('demo.db')
88
+ qc = quebec.Quebec(f'sqlite://{db_path}?mode=rwc')
89
+
90
+
91
+ @qc.register_job
92
+ class FakeJob(quebec.BaseClass):
93
+ def perform(self, *args, **kwargs):
94
+ self.logger.info(f"Processing job {self.id}: args={args}, kwargs={kwargs}")
95
+
96
+
97
+ if __name__ == "__main__":
98
+ # Enqueue a job
99
+ FakeJob.perform_later(qc, 123, foo='bar')
100
+
101
+ # Start Quebec (handles signal, spawns workers, runs main loop)
102
+ qc.run(
103
+ create_tables=not db_path.exists(),
104
+ control_plane='127.0.0.1:5006', # Optional: web dashboard
105
+ )
106
+ ```
107
+
108
+ Or run the quickstart script directly:
109
+
68
110
  ```bash
69
111
  curl -O https://raw.githubusercontent.com/ratazzi/quebec/refs/heads/master/quickstart.py
70
112
  uv run quickstart.py
71
113
  ```
72
114
 
115
+ ### `qc.run()` Options
116
+
117
+ | Parameter | Type | Default | Description |
118
+ |-----------|------|---------|-------------|
119
+ | `create_tables` | `bool` | `False` | Create database tables (requires DDL permissions) |
120
+ | `control_plane` | `str` | `None` | Web dashboard address, e.g. `'127.0.0.1:5006'` |
121
+ | `spawn` | `list[str]` | `None` | Components to spawn: `['worker', 'dispatcher', 'scheduler']`. `None` = all |
122
+ | `threads` | `int` | `1` | Number of worker threads to run jobs |
123
+
124
+ ### Delayed Jobs
125
+
126
+ ```python
127
+ from datetime import timedelta
128
+
129
+ # Run after 1 hour
130
+ FakeJob.set(wait=3600).perform_later(qc, arg1)
131
+
132
+ # Run at specific time
133
+ FakeJob.set(wait_until=tomorrow_9am).perform_later(qc, arg1)
134
+
135
+ # Override queue and priority
136
+ FakeJob.set(queue='critical', priority=1).perform_later(qc, arg1)
137
+ ```
138
+
73
139
  ## Lifecycle Hooks
74
140
 
75
141
  Quebec provides several lifecycle hooks that you can use to execute code at different stages of the application lifecycle:
@@ -0,0 +1,7 @@
1
+ quebec-0.2.2.dist-info/METADATA,sha256=-23MlZqQYsff0fHoRV6UP1CQdPpe7oycKU0Fz4MpbVk,5061
2
+ quebec-0.2.2.dist-info/WHEEL,sha256=l-zZhOZ2a5ZXnfk1pQvieCLMom_QD7Et38SJZ8iK5h8,91
3
+ quebec-0.2.2.dist-info/licenses/LICENSE,sha256=EMUpCdp2I-buVSjzgRTpd6TZDSnUcm1Pi4w8vOiwsQk,1095
4
+ quebec/__init__.py,sha256=382fYpy2xb2oAoEG-sRJVLXVjHr7ltkWcy7WBhSv3zM,10665
5
+ quebec/logger.py,sha256=gX0O1S77HWEQ2bkTFBV3kolSonvTP0p41xYV7WLmBNY,1806
6
+ quebec/quebec.pyd,sha256=UUVOB2fna46GGV2eiWQdekxVmxZF_b79iwANBzUM9r0,20869632
7
+ quebec-0.2.2.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: maturin (1.9.6)
2
+ Generator: maturin (1.10.2)
3
3
  Root-Is-Purelib: false
4
4
  Tag: cp39-abi3-win32
@@ -1,7 +0,0 @@
1
- quebec-0.1.1.dist-info/METADATA,sha256=nkuSuqBEUtMBB3KLLOXkEIPGpOiHrAtrCwoozOiOwDE,3187
2
- quebec-0.1.1.dist-info/WHEEL,sha256=dUxCBhMoHV4oNiRdv8KVWy21UDIEyTxfgkMSLmeuy-I,90
3
- quebec-0.1.1.dist-info/licenses/LICENSE,sha256=EMUpCdp2I-buVSjzgRTpd6TZDSnUcm1Pi4w8vOiwsQk,1095
4
- quebec/__init__.py,sha256=m8fQfMwoOohuOiIwqxXHSpvZ6mNBShYladtzmaHSh18,3575
5
- quebec/logger.py,sha256=NuT2rxzUNaoRRF0eR5K-vKBgEY_JJr12NuXJtJBW7eM,1509
6
- quebec/quebec.pyd,sha256=CQF46wkzCdNGftNc-RILNkdw9-r54moVx1aiZwi2aHw,20909568
7
- quebec-0.1.1.dist-info/RECORD,,