dwyeapi 0.4.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.
@@ -0,0 +1,97 @@
1
+ """Async task processing system for FastAPI (ARQ-based).
2
+
3
+ Quick start::
4
+
5
+ # 1. Install
6
+ pip install dwyeapi[tasks]
7
+
8
+ # 2. Register tasks
9
+ from dwyeapi.tasks import register, TaskContext
10
+
11
+ @register("process_data")
12
+ async def process_data(ctx: TaskContext, params: dict):
13
+ await ctx.log("Processing...")
14
+ await ctx.update_progress(50)
15
+ return {"done": True}
16
+
17
+ # 3. Mount in FastAPI
18
+ from dwyeapi.tasks import setup_tasks, task_router
19
+
20
+ await setup_tasks(app, settings, session_factory)
21
+ app.include_router(task_router)
22
+
23
+ # 4. Run worker
24
+ # arq app.worker.WorkerSettings
25
+
26
+ Public API:
27
+ setup_tasks — One-call initialization (ARQ pool + store session factory)
28
+ task_router — Ready-to-use APIRouter with CRUD endpoints
29
+ register — Decorator to register an async task function
30
+ TaskContext — Context object injected into task functions
31
+ TaskStatus — Task lifecycle status enum
32
+ create_worker_settings — Factory to generate ARQ WorkerSettings from BaseSettings
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from fastapi import FastAPI
38
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
39
+
40
+ from dwyeapi.config import BaseSettings
41
+ from dwyeapi.tasks.context import TaskContext
42
+ from dwyeapi.tasks.model import TaskStatus
43
+ from dwyeapi.tasks.registry import register
44
+ from dwyeapi.tasks.router import task_router
45
+ from dwyeapi.tasks.worker import create_worker_settings
46
+
47
+ # Re-export for convenient imports
48
+ __all__ = [
49
+ "TaskContext",
50
+ "TaskStatus",
51
+ "create_worker_settings",
52
+ "register",
53
+ "setup_tasks",
54
+ "task_router",
55
+ ]
56
+
57
+
58
+ async def setup_tasks(
59
+ app: FastAPI,
60
+ settings: BaseSettings,
61
+ session_factory: async_sessionmaker[AsyncSession],
62
+ ) -> None:
63
+ """Initialize the task system.
64
+
65
+ Must be called during application startup (e.g. inside a lifespan).
66
+
67
+ 1. Configures the ARQ Redis pool from ``settings.redis_url``
68
+ 2. Stores the session_factory for TaskContext database access
69
+ 3. Registers a shutdown hook to close the pool
70
+
71
+ Args:
72
+ app: The FastAPI application instance.
73
+ settings: An eapi BaseSettings instance with redis_url and task_* fields.
74
+ session_factory: Async SQLAlchemy session factory for database access.
75
+
76
+ Example::
77
+
78
+ @asynccontextmanager
79
+ async def lifespan(app: FastAPI):
80
+ await setup_tasks(app, settings, session_factory)
81
+ yield
82
+
83
+ app = FastAPI(lifespan=lifespan)
84
+ app.include_router(task_router)
85
+ """
86
+ from dwyeapi.dependencies import create_get_db
87
+ from dwyeapi.tasks import pool as _pool
88
+ from dwyeapi.tasks import router as _router
89
+
90
+ # Configure and warm up the ARQ pool
91
+ _pool.configure(settings.redis_url)
92
+
93
+ # Wire the db dependency into the router
94
+ _router._get_db = create_get_db(session_factory)
95
+
96
+ # Store session_factory on app state for worker access
97
+ app.state.task_session_factory = session_factory
@@ -0,0 +1,180 @@
1
+ """TaskContext — rich context object injected into task functions.
2
+
3
+ Provides database access, logging, progress tracking, and cancellation
4
+ checking so that business task code stays minimal.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import AsyncIterator
10
+ from contextlib import asynccontextmanager
11
+ from typing import Any
12
+
13
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
14
+
15
+ from dwyeapi.tasks import service
16
+ from dwyeapi.tasks.model import TaskStatus
17
+
18
+
19
+ class TaskContext:
20
+ """Execution context passed to every registered task function.
21
+
22
+ Provides helpers for logging, progress updates, cancellation checks,
23
+ and database access so that task functions only contain business logic.
24
+
25
+ Attributes:
26
+ task_id: The unique identifier of the current task.
27
+ task_type: The registered task type string.
28
+ params: The parameters submitted with the task.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ *,
34
+ task_id: str,
35
+ task_type: str,
36
+ params: dict[str, Any],
37
+ session_factory: async_sessionmaker[AsyncSession],
38
+ redis: Any,
39
+ ) -> None:
40
+ """Initialize TaskContext.
41
+
42
+ Args:
43
+ task_id: Unique task identifier.
44
+ task_type: Registered task type name.
45
+ params: Task input parameters.
46
+ session_factory: Async SQLAlchemy session factory.
47
+ redis: ARQ Redis connection for cancel-flag checks.
48
+ """
49
+ self.task_id = task_id
50
+ self.task_type = task_type
51
+ self.params = params
52
+ self._session_factory = session_factory
53
+ self._redis = redis
54
+ self._cancelled = False
55
+
56
+ async def log(self, message: str) -> None:
57
+ """Append a timestamped log entry.
58
+
59
+ Args:
60
+ message: The log message to record.
61
+ """
62
+ async with self._session() as session:
63
+ await service.append_task_log(session, self.task_id, message)
64
+
65
+ async def update_progress(self, value: int, message: str = "") -> None:
66
+ """Update task progress (0-100) and optionally append a log.
67
+
68
+ Args:
69
+ value: Progress percentage (clamped to 0-100).
70
+ message: Optional log message to append alongside the progress update.
71
+ """
72
+ async with self._session() as session:
73
+ await service.update_task_progress(session, self.task_id, value)
74
+ if message:
75
+ await service.append_task_log(session, self.task_id, message)
76
+
77
+ async def is_cancelled(self) -> bool:
78
+ """Check whether a cancel request has been issued for this task.
79
+
80
+ Reads a Redis key ``task_cancel:{task_id}``. Once detected, the
81
+ result is cached so subsequent calls do not hit Redis again.
82
+
83
+ Returns:
84
+ True if the task has been marked for cancellation.
85
+ """
86
+ if self._cancelled:
87
+ return True
88
+ val = await self._redis.get(f"task_cancel:{self.task_id}")
89
+ if val is not None:
90
+ self._cancelled = True
91
+ return self._cancelled
92
+
93
+ @asynccontextmanager
94
+ async def db(self) -> AsyncIterator[AsyncSession]:
95
+ """Provide an async database session via context manager.
96
+
97
+ Usage::
98
+
99
+ async with ctx.db() as session:
100
+ result = await session.execute(select(MyModel))
101
+
102
+ Yields:
103
+ An AsyncSession bound to the application's database.
104
+ """
105
+ async with self._session_factory() as session:
106
+ yield session
107
+
108
+ @asynccontextmanager
109
+ async def _session(self) -> AsyncIterator[AsyncSession]:
110
+ """Internal helper — short-lived session for framework operations."""
111
+ async with self._session_factory() as session:
112
+ yield session
113
+
114
+
115
+ async def run_task_with_context(
116
+ task_id: str,
117
+ task_type: str,
118
+ params: dict[str, Any],
119
+ func: Any,
120
+ session_factory: async_sessionmaker[AsyncSession],
121
+ redis: Any,
122
+ ) -> None:
123
+ """Execute a task function wrapped in lifecycle management.
124
+
125
+ Handles the full task lifecycle:
126
+ 1. Set status to RUNNING
127
+ 2. Create TaskContext and call the business function
128
+ 3. On success → set status to SUCCESS with returned result
129
+ 4. On exception → set status to FAILED with error info
130
+ 5. On cancellation → set status to CANCELED
131
+
132
+ Args:
133
+ task_id: The task's unique identifier.
134
+ task_type: The registered task type string.
135
+ params: The task input parameters.
136
+ func: The async business function to execute.
137
+ session_factory: Async SQLAlchemy session factory.
138
+ redis: ARQ Redis connection.
139
+ """
140
+ ctx = TaskContext(
141
+ task_id=task_id,
142
+ task_type=task_type,
143
+ params=params,
144
+ session_factory=session_factory,
145
+ redis=redis,
146
+ )
147
+
148
+ # Mark as running
149
+ async with session_factory() as session:
150
+ await service.update_task_status(session, task_id, TaskStatus.RUNNING)
151
+ await service.append_task_log(session, task_id, "任务开始执行")
152
+
153
+ try:
154
+ result = await func(ctx, params)
155
+
156
+ # Check if cancelled during execution
157
+ if ctx._cancelled:
158
+ async with session_factory() as session:
159
+ await service.update_task_status(session, task_id, TaskStatus.CANCELED)
160
+ await service.append_task_log(session, task_id, "任务已取消")
161
+ return
162
+
163
+ # Success
164
+ result_data = result if isinstance(result, dict) else {"result": result}
165
+ async with session_factory() as session:
166
+ await service.update_task_status(session, task_id, TaskStatus.SUCCESS, result=result_data)
167
+ await service.update_task_progress(session, task_id, 100)
168
+ await service.append_task_log(session, task_id, "任务执行成功")
169
+
170
+ except Exception as exc:
171
+ # Failed
172
+ async with session_factory() as session:
173
+ await service.update_task_status(
174
+ session,
175
+ task_id,
176
+ TaskStatus.FAILED,
177
+ result={"error": str(exc)},
178
+ )
179
+ await service.append_task_log(session, task_id, f"任务执行失败: {exc}")
180
+ raise
dwyeapi/tasks/model.py ADDED
@@ -0,0 +1,42 @@
1
+ """Task ORM model and status enumeration."""
2
+
3
+ import enum
4
+ import uuid as uuid_lib
5
+
6
+ from sqlalchemy import JSON, Enum, Integer, String, Text
7
+ from sqlalchemy.orm import Mapped, mapped_column
8
+
9
+ from dwyeapi.database import Base, TimestampMixin
10
+
11
+
12
+ class TaskStatus(enum.StrEnum):
13
+ """Task lifecycle states."""
14
+
15
+ PENDING = "pending"
16
+ RUNNING = "running"
17
+ SUCCESS = "success"
18
+ FAILED = "failed"
19
+ CANCELED = "canceled"
20
+
21
+
22
+ def _generate_task_id() -> str:
23
+ """Generate a globally unique task ID."""
24
+ return f"task_{uuid_lib.uuid4().hex}"
25
+
26
+
27
+ class Task(Base, TimestampMixin):
28
+ """Persistent task record.
29
+
30
+ Stores task metadata, execution state, progress, logs, and results.
31
+ Uses the same database as the host application via eapi's Base.
32
+ """
33
+
34
+ __tablename__ = "tasks"
35
+
36
+ id: Mapped[str] = mapped_column(String(64), primary_key=True, default=_generate_task_id)
37
+ task_type: Mapped[str] = mapped_column(String(50), index=True)
38
+ status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus), default=TaskStatus.PENDING)
39
+ params: Mapped[dict] = mapped_column(JSON)
40
+ result: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=None)
41
+ progress: Mapped[int] = mapped_column(Integer, default=0)
42
+ logs: Mapped[str] = mapped_column(Text, default="")
dwyeapi/tasks/pool.py ADDED
@@ -0,0 +1,81 @@
1
+ """ARQ Redis connection pool management (internal module).
2
+
3
+ Manages the lifecycle of the ARQ async Redis pool used for enqueuing
4
+ and executing tasks. Not intended for direct use by integrators.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from urllib.parse import urlparse
10
+
11
+ from arq import ArqRedis, create_pool
12
+ from arq.connections import RedisSettings
13
+
14
+ _pool: ArqRedis | None = None
15
+ _redis_settings: RedisSettings | None = None
16
+
17
+
18
+ def _parse_redis_url(redis_url: str) -> RedisSettings:
19
+ """Convert a redis:// URL to ARQ RedisSettings.
20
+
21
+ Args:
22
+ redis_url: Standard Redis connection URL.
23
+
24
+ Returns:
25
+ ARQ RedisSettings instance.
26
+ """
27
+ parsed = urlparse(redis_url)
28
+ return RedisSettings(
29
+ host=parsed.hostname or "localhost",
30
+ port=parsed.port or 6379,
31
+ database=int(parsed.path.lstrip("/") or "0"),
32
+ password=parsed.password,
33
+ )
34
+
35
+
36
+ def configure(redis_url: str) -> None:
37
+ """Store Redis settings for later pool creation.
38
+
39
+ Args:
40
+ redis_url: Standard Redis connection URL (e.g. redis://localhost:6379/0).
41
+ """
42
+ global _redis_settings
43
+ _redis_settings = _parse_redis_url(redis_url)
44
+
45
+
46
+ def get_redis_settings() -> RedisSettings:
47
+ """Return the configured ARQ RedisSettings.
48
+
49
+ Returns:
50
+ The RedisSettings instance.
51
+
52
+ Raises:
53
+ RuntimeError: If configure() has not been called.
54
+ """
55
+ if _redis_settings is None:
56
+ msg = "Task pool not configured. Call setup_tasks() first."
57
+ raise RuntimeError(msg)
58
+ return _redis_settings
59
+
60
+
61
+ async def get_pool() -> ArqRedis:
62
+ """Get or create the ARQ async Redis pool.
63
+
64
+ Returns:
65
+ The ArqRedis connection pool.
66
+
67
+ Raises:
68
+ RuntimeError: If configure() has not been called.
69
+ """
70
+ global _pool
71
+ if _pool is None:
72
+ _pool = await create_pool(get_redis_settings())
73
+ return _pool
74
+
75
+
76
+ async def close_pool() -> None:
77
+ """Close the ARQ pool if it exists. Safe to call multiple times."""
78
+ global _pool
79
+ if _pool is not None:
80
+ await _pool.aclose()
81
+ _pool = None
@@ -0,0 +1,83 @@
1
+ """Task function registry with decorator-based registration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Coroutine
6
+ from typing import Any
7
+
8
+ # Type alias for an async task function: (TaskContext, dict) -> Any
9
+ TaskFunction = Callable[..., Coroutine[Any, Any, Any]]
10
+
11
+
12
+ class TaskRegistry:
13
+ """Singleton registry that maps task_type strings to async functions.
14
+
15
+ Usage::
16
+
17
+ from dwyeapi.tasks import register
18
+
19
+ @register("process_data")
20
+ async def process_data(ctx: TaskContext, params: dict):
21
+ ...
22
+ """
23
+
24
+ def __init__(self) -> None:
25
+ """Initialize an empty registry."""
26
+ self._tasks: dict[str, TaskFunction] = {}
27
+
28
+ def register(self, task_type: str) -> Callable[[TaskFunction], TaskFunction]:
29
+ """Decorator that registers an async function under a task_type.
30
+
31
+ Args:
32
+ task_type: Unique string identifier for this task.
33
+
34
+ Returns:
35
+ The original function, unmodified.
36
+
37
+ Raises:
38
+ ValueError: If task_type is already registered.
39
+ """
40
+
41
+ def decorator(func: TaskFunction) -> TaskFunction:
42
+ if task_type in self._tasks:
43
+ msg = f"Task type '{task_type}' is already registered"
44
+ raise ValueError(msg)
45
+ self._tasks[task_type] = func
46
+ return func
47
+
48
+ return decorator
49
+
50
+ def get(self, task_type: str) -> TaskFunction | None:
51
+ """Look up a registered task function by type.
52
+
53
+ Args:
54
+ task_type: The task type to look up.
55
+
56
+ Returns:
57
+ The registered function, or None if not found.
58
+ """
59
+ return self._tasks.get(task_type)
60
+
61
+ def list_types(self) -> list[str]:
62
+ """Return all registered task type strings.
63
+
64
+ Returns:
65
+ Sorted list of registered task types.
66
+ """
67
+ return sorted(self._tasks.keys())
68
+
69
+ def has(self, task_type: str) -> bool:
70
+ """Check if a task type is registered.
71
+
72
+ Args:
73
+ task_type: The task type to check.
74
+
75
+ Returns:
76
+ True if registered.
77
+ """
78
+ return task_type in self._tasks
79
+
80
+
81
+ # Module-level singleton
82
+ registry = TaskRegistry()
83
+ register = registry.register
@@ -0,0 +1,196 @@
1
+ """Ready-to-use FastAPI router for the task system.
2
+
3
+ Provides endpoints to submit, query, list, and cancel tasks.
4
+ Integrators mount it via ``app.include_router(task_router)``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import AsyncGenerator, Callable
10
+
11
+ from fastapi import APIRouter, Depends, Query
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+
14
+ from dwyeapi.exceptions import BusinessError, NotFoundError
15
+ from dwyeapi.response import success
16
+ from dwyeapi.tasks import pool
17
+ from dwyeapi.tasks.model import TaskStatus
18
+ from dwyeapi.tasks.registry import registry
19
+ from dwyeapi.tasks.schema import TaskCreate, TaskListResponse, TaskResponse
20
+ from dwyeapi.tasks.service import append_task_log, create_task, get_task, list_tasks, update_task_progress, update_task_status
21
+
22
+ task_router = APIRouter(prefix="/tasks", tags=["tasks"])
23
+
24
+ # This will be set by setup_tasks() at application startup.
25
+ _get_db: Callable[[], AsyncGenerator[AsyncSession]] | None = None
26
+
27
+
28
+ async def _db_dependency() -> AsyncGenerator[AsyncSession]:
29
+ """Async generator that delegates to the actual get_db dependency.
30
+
31
+ Yields:
32
+ An AsyncSession from the configured session factory.
33
+
34
+ Raises:
35
+ RuntimeError: If setup_tasks() has not been called.
36
+ """
37
+ if _get_db is None:
38
+ msg = "Task system not initialized. Call setup_tasks() first."
39
+ raise RuntimeError(msg)
40
+ async for session in _get_db():
41
+ yield session
42
+
43
+
44
+ @task_router.post("", summary="提交耗时任务")
45
+ async def submit_task(
46
+ body: TaskCreate,
47
+ db: AsyncSession = Depends(_db_dependency),
48
+ ) -> dict:
49
+ """Submit a new async task for background execution.
50
+
51
+ Args:
52
+ body: Task creation payload with task_type and params.
53
+ db: Injected database session.
54
+
55
+ Returns:
56
+ Unified success response containing the created task.
57
+ """
58
+ if not registry.has(body.task_type):
59
+ raise BusinessError(f"不支持的任务类型: {body.task_type}", code="INVALID_TASK_TYPE")
60
+
61
+ task = await create_task(db, body)
62
+
63
+ # Enqueue into ARQ
64
+ arq_pool = await pool.get_pool()
65
+ await arq_pool.enqueue_job("_task_executor", task.id, body.task_type, body.params)
66
+
67
+ return success(data=TaskResponse.model_validate(task).model_dump(mode="json"))
68
+
69
+
70
+ @task_router.get("/{task_id}", summary="查询任务状态")
71
+ async def query_task(
72
+ task_id: str,
73
+ db: AsyncSession = Depends(_db_dependency),
74
+ ) -> dict:
75
+ """Query the current state of a specific task.
76
+
77
+ Args:
78
+ task_id: The task's unique identifier.
79
+ db: Injected database session.
80
+
81
+ Returns:
82
+ Unified success response containing the task details.
83
+ """
84
+ task = await get_task(db, task_id)
85
+ if not task:
86
+ raise NotFoundError("任务")
87
+ return success(data=TaskResponse.model_validate(task).model_dump(mode="json"))
88
+
89
+
90
+ @task_router.get("", summary="任务列表")
91
+ async def query_task_list(
92
+ db: AsyncSession = Depends(_db_dependency),
93
+ page: int = Query(default=1, ge=1, description="页码"),
94
+ page_size: int = Query(default=20, ge=1, le=100, description="每页条数"),
95
+ status: TaskStatus | None = Query(default=None, description="状态筛选"),
96
+ task_type: str | None = Query(default=None, description="任务类型筛选"),
97
+ ) -> dict:
98
+ """List tasks with pagination and optional filters.
99
+
100
+ Args:
101
+ db: Injected database session.
102
+ page: 1-based page number.
103
+ page_size: Items per page (1-100).
104
+ status: Optional status filter.
105
+ task_type: Optional task type filter.
106
+
107
+ Returns:
108
+ Unified success response containing paginated task list.
109
+ """
110
+ items, total = await list_tasks(
111
+ db,
112
+ page=page,
113
+ page_size=page_size,
114
+ status=status,
115
+ task_type=task_type,
116
+ )
117
+ data = TaskListResponse(
118
+ items=[TaskResponse.model_validate(t) for t in items],
119
+ total=total,
120
+ )
121
+ return success(data=data.model_dump(mode="json"))
122
+
123
+
124
+ @task_router.post("/{task_id}/cancel", summary="取消任务")
125
+ async def cancel_task(
126
+ task_id: str,
127
+ db: AsyncSession = Depends(_db_dependency),
128
+ ) -> dict:
129
+ """Request cancellation of a running or pending task.
130
+
131
+ Sets a Redis flag that the task function can check via ``ctx.is_cancelled()``.
132
+ Tasks that have already completed cannot be cancelled.
133
+
134
+ Args:
135
+ task_id: The task's unique identifier.
136
+ db: Injected database session.
137
+
138
+ Returns:
139
+ Unified success response with updated task info.
140
+ """
141
+ task = await get_task(db, task_id)
142
+ if not task:
143
+ raise NotFoundError("任务")
144
+
145
+ terminal_states = {TaskStatus.SUCCESS, TaskStatus.FAILED, TaskStatus.CANCELED}
146
+ if task.status in terminal_states:
147
+ raise BusinessError(f"任务已结束, 状态为 {task.status.value}", code="TASK_ALREADY_FINISHED")
148
+
149
+ # Set cancel flag in Redis with TTL
150
+ arq_pool = await pool.get_pool()
151
+ await arq_pool.set(f"task_cancel:{task_id}", b"1", ex=7200)
152
+
153
+ return success(data=TaskResponse.model_validate(task).model_dump(mode="json"))
154
+
155
+
156
+ @task_router.post("/{task_id}/retry", summary="重试任务")
157
+ async def retry_task(
158
+ task_id: str,
159
+ db: AsyncSession = Depends(_db_dependency),
160
+ ) -> dict:
161
+ """Retry a failed or canceled task.
162
+
163
+ Resets status to PENDING, clears result, resets progress, and re-enqueues to ARQ.
164
+
165
+ Args:
166
+ task_id: The task's unique identifier.
167
+ db: Injected database session.
168
+
169
+ Returns:
170
+ Unified success response with updated task info.
171
+ """
172
+ task = await get_task(db, task_id)
173
+ if not task:
174
+ raise NotFoundError("任务")
175
+
176
+ retryable_states = {TaskStatus.FAILED, TaskStatus.CANCELED}
177
+ if task.status not in retryable_states:
178
+ raise BusinessError(f"只有失败或已取消的任务可以重试, 当前状态为 {task.status.value}", code="TASK_NOT_RETRYABLE")
179
+
180
+ # Clear cancel flag from Redis
181
+ arq_pool = await pool.get_pool()
182
+ await arq_pool.delete(f"task_cancel:{task_id}")
183
+
184
+ # Reset task state
185
+ await update_task_status(db, task_id, TaskStatus.PENDING)
186
+ await update_task_progress(db, task_id, 0)
187
+ await append_task_log(db, task_id, "任务重试, 重新入队")
188
+ task.result = None
189
+ await db.commit()
190
+
191
+ # Re-enqueue
192
+ await arq_pool.enqueue_job("_task_executor", task.id, task.task_type, task.params)
193
+
194
+ # Refresh to get latest state
195
+ await db.refresh(task)
196
+ return success(data=TaskResponse.model_validate(task).model_dump(mode="json"))