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.
- dwyeapi/__init__.py +20 -0
- dwyeapi/cache.py +30 -0
- dwyeapi/config.py +39 -0
- dwyeapi/database.py +42 -0
- dwyeapi/dependencies.py +16 -0
- dwyeapi/dt.py +52 -0
- dwyeapi/exceptions.py +75 -0
- dwyeapi/logger.py +205 -0
- dwyeapi/masking.py +107 -0
- dwyeapi/pagination.py +25 -0
- dwyeapi/response.py +19 -0
- dwyeapi/security.py +50 -0
- dwyeapi/tasks/__init__.py +97 -0
- dwyeapi/tasks/context.py +180 -0
- dwyeapi/tasks/model.py +42 -0
- dwyeapi/tasks/pool.py +81 -0
- dwyeapi/tasks/registry.py +83 -0
- dwyeapi/tasks/router.py +196 -0
- dwyeapi/tasks/schema.py +38 -0
- dwyeapi/tasks/service.py +145 -0
- dwyeapi/tasks/worker.py +118 -0
- dwyeapi-0.4.0.dist-info/METADATA +23 -0
- dwyeapi-0.4.0.dist-info/RECORD +25 -0
- dwyeapi-0.4.0.dist-info/WHEEL +5 -0
- dwyeapi-0.4.0.dist-info/top_level.txt +1 -0
dwyeapi/tasks/schema.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Pydantic schemas for task API requests and responses."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict
|
|
7
|
+
|
|
8
|
+
from dwyeapi.tasks.model import TaskStatus
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TaskCreate(BaseModel):
|
|
12
|
+
"""Request body for submitting a new task."""
|
|
13
|
+
|
|
14
|
+
task_type: str
|
|
15
|
+
params: dict[str, Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TaskResponse(BaseModel):
|
|
19
|
+
"""Single task detail returned by the API."""
|
|
20
|
+
|
|
21
|
+
id: str
|
|
22
|
+
task_type: str
|
|
23
|
+
status: TaskStatus
|
|
24
|
+
params: dict[str, Any]
|
|
25
|
+
result: dict[str, Any] | None
|
|
26
|
+
progress: int
|
|
27
|
+
logs: str
|
|
28
|
+
created_at: datetime
|
|
29
|
+
updated_at: datetime
|
|
30
|
+
|
|
31
|
+
model_config = ConfigDict(from_attributes=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TaskListResponse(BaseModel):
|
|
35
|
+
"""Paginated task list returned by the API."""
|
|
36
|
+
|
|
37
|
+
items: list[TaskResponse]
|
|
38
|
+
total: int
|
dwyeapi/tasks/service.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Task CRUD operations and state transitions."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import func, select
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
from dwyeapi import dt
|
|
7
|
+
from dwyeapi.tasks.model import Task, TaskStatus
|
|
8
|
+
from dwyeapi.tasks.schema import TaskCreate
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def create_task(session: AsyncSession, task_in: TaskCreate) -> Task:
|
|
12
|
+
"""Create a new task record in the database.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
session: Async database session.
|
|
16
|
+
task_in: Task creation payload.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
The newly created Task instance.
|
|
20
|
+
"""
|
|
21
|
+
task = Task(
|
|
22
|
+
task_type=task_in.task_type,
|
|
23
|
+
params=task_in.params,
|
|
24
|
+
logs=f"[{_now_str()}] 任务已创建, 等待执行\n",
|
|
25
|
+
)
|
|
26
|
+
session.add(task)
|
|
27
|
+
await session.commit()
|
|
28
|
+
await session.refresh(task)
|
|
29
|
+
return task
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def get_task(session: AsyncSession, task_id: str) -> Task | None:
|
|
33
|
+
"""Query a single task by ID.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
session: Async database session.
|
|
37
|
+
task_id: The task's unique identifier.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The Task instance, or None if not found.
|
|
41
|
+
"""
|
|
42
|
+
result = await session.execute(select(Task).where(Task.id == task_id))
|
|
43
|
+
return result.scalar_one_or_none()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def list_tasks(
|
|
47
|
+
session: AsyncSession,
|
|
48
|
+
*,
|
|
49
|
+
page: int = 1,
|
|
50
|
+
page_size: int = 20,
|
|
51
|
+
status: TaskStatus | None = None,
|
|
52
|
+
task_type: str | None = None,
|
|
53
|
+
) -> tuple[list[Task], int]:
|
|
54
|
+
"""List tasks with pagination and optional filters.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
session: Async database session.
|
|
58
|
+
page: 1-based page number.
|
|
59
|
+
page_size: Number of items per page.
|
|
60
|
+
status: Optional status filter.
|
|
61
|
+
task_type: Optional task type filter.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Tuple of (task list, total count).
|
|
65
|
+
"""
|
|
66
|
+
query = select(Task)
|
|
67
|
+
count_query = select(func.count()).select_from(Task)
|
|
68
|
+
|
|
69
|
+
if status is not None:
|
|
70
|
+
query = query.where(Task.status == status)
|
|
71
|
+
count_query = count_query.where(Task.status == status)
|
|
72
|
+
if task_type is not None:
|
|
73
|
+
query = query.where(Task.task_type == task_type)
|
|
74
|
+
count_query = count_query.where(Task.task_type == task_type)
|
|
75
|
+
|
|
76
|
+
# Total count
|
|
77
|
+
total_result = await session.execute(count_query)
|
|
78
|
+
total = total_result.scalar_one()
|
|
79
|
+
|
|
80
|
+
# Paginated items
|
|
81
|
+
offset = (max(page, 1) - 1) * page_size
|
|
82
|
+
query = query.order_by(Task.created_at.desc()).offset(offset).limit(page_size)
|
|
83
|
+
result = await session.execute(query)
|
|
84
|
+
items = list(result.scalars().all())
|
|
85
|
+
|
|
86
|
+
return items, total
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def update_task_status(
|
|
90
|
+
session: AsyncSession,
|
|
91
|
+
task_id: str,
|
|
92
|
+
status: TaskStatus,
|
|
93
|
+
result: dict | None = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Update a task's status and optionally its result.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
session: Async database session.
|
|
99
|
+
task_id: The task's unique identifier.
|
|
100
|
+
status: New status to set.
|
|
101
|
+
result: Optional result data to store.
|
|
102
|
+
"""
|
|
103
|
+
task = await get_task(session, task_id)
|
|
104
|
+
if not task:
|
|
105
|
+
return
|
|
106
|
+
task.status = status
|
|
107
|
+
if result is not None:
|
|
108
|
+
task.result = result
|
|
109
|
+
task.updated_at = dt.now()
|
|
110
|
+
await session.commit()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def update_task_progress(session: AsyncSession, task_id: str, progress: int) -> None:
|
|
114
|
+
"""Update a task's progress value (0-100).
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
session: Async database session.
|
|
118
|
+
task_id: The task's unique identifier.
|
|
119
|
+
progress: Progress percentage, clamped to 0-100.
|
|
120
|
+
"""
|
|
121
|
+
task = await get_task(session, task_id)
|
|
122
|
+
if not task:
|
|
123
|
+
return
|
|
124
|
+
task.progress = max(0, min(100, progress))
|
|
125
|
+
await session.commit()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def append_task_log(session: AsyncSession, task_id: str, message: str) -> None:
|
|
129
|
+
"""Append a timestamped log entry to a task.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
session: Async database session.
|
|
133
|
+
task_id: The task's unique identifier.
|
|
134
|
+
message: Log message to append.
|
|
135
|
+
"""
|
|
136
|
+
task = await get_task(session, task_id)
|
|
137
|
+
if not task:
|
|
138
|
+
return
|
|
139
|
+
task.logs += f"[{_now_str()}] {message}\n"
|
|
140
|
+
await session.commit()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _now_str() -> str:
|
|
144
|
+
"""Return current time formatted as a string."""
|
|
145
|
+
return dt.now_str()
|
dwyeapi/tasks/worker.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Worker configuration factory.
|
|
2
|
+
|
|
3
|
+
Generates an ARQ ``WorkerSettings`` class from eapi's ``BaseSettings``,
|
|
4
|
+
bridging configuration between the two systems.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from dwyeapi.config import BaseSettings
|
|
12
|
+
from dwyeapi.logger import get_logger
|
|
13
|
+
from dwyeapi.tasks.pool import _parse_redis_url
|
|
14
|
+
|
|
15
|
+
_log = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_worker_settings(
|
|
19
|
+
settings: BaseSettings,
|
|
20
|
+
session_factory: Any = None,
|
|
21
|
+
) -> type:
|
|
22
|
+
"""Create an ARQ WorkerSettings class from eapi BaseSettings.
|
|
23
|
+
|
|
24
|
+
The returned class can be used directly with ``arq`` CLI::
|
|
25
|
+
|
|
26
|
+
# app/worker.py
|
|
27
|
+
import app.tasks # trigger @register decorators
|
|
28
|
+
from app.settings import settings
|
|
29
|
+
from dwyeapi.tasks import create_worker_settings
|
|
30
|
+
from dwyeapi.database import create_async_engine_factory, create_session_factory
|
|
31
|
+
|
|
32
|
+
engine = create_async_engine_factory(settings.database_url)
|
|
33
|
+
sf = create_session_factory(engine)
|
|
34
|
+
WorkerSettings = create_worker_settings(settings, session_factory=sf)
|
|
35
|
+
|
|
36
|
+
# Run: arq app.worker.WorkerSettings
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
settings: An eapi BaseSettings instance (or subclass) with
|
|
40
|
+
redis_url, task_max_jobs, task_job_timeout, task_failure_ttl.
|
|
41
|
+
session_factory: Async SQLAlchemy session factory for TaskContext
|
|
42
|
+
database access inside worker tasks.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
A class suitable as ARQ WorkerSettings.
|
|
46
|
+
"""
|
|
47
|
+
from dwyeapi.tasks.context import run_task_with_context
|
|
48
|
+
from dwyeapi.tasks.registry import registry
|
|
49
|
+
|
|
50
|
+
_redis_settings = _parse_redis_url(settings.redis_url)
|
|
51
|
+
_session_factory = session_factory
|
|
52
|
+
|
|
53
|
+
async def _task_executor(ctx: dict, task_id: str, task_type: str, params: dict) -> None:
|
|
54
|
+
"""ARQ entry point — dispatches to the registered task function.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
ctx: ARQ worker context dict (contains 'redis').
|
|
58
|
+
task_id: Unique task identifier.
|
|
59
|
+
task_type: Registered task type string.
|
|
60
|
+
params: Task input parameters.
|
|
61
|
+
"""
|
|
62
|
+
func = registry.get(task_type)
|
|
63
|
+
if func is None:
|
|
64
|
+
msg = f"Unknown task type: {task_type}"
|
|
65
|
+
raise ValueError(msg)
|
|
66
|
+
|
|
67
|
+
await run_task_with_context(
|
|
68
|
+
task_id=task_id,
|
|
69
|
+
task_type=task_type,
|
|
70
|
+
params=params,
|
|
71
|
+
func=func,
|
|
72
|
+
session_factory=_session_factory,
|
|
73
|
+
redis=ctx["redis"],
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
from arq.worker import func as arq_func
|
|
77
|
+
|
|
78
|
+
async def _on_startup(ctx: dict) -> None:
|
|
79
|
+
"""Worker 启动时恢复未完成的任务。
|
|
80
|
+
|
|
81
|
+
扫描 PostgreSQL 中 pending/running 状态的任务,重新入队到 ARQ。
|
|
82
|
+
running 状态的任务说明上次 Worker 中断,重置为 pending 后重新执行。
|
|
83
|
+
"""
|
|
84
|
+
from sqlalchemy import select
|
|
85
|
+
|
|
86
|
+
from dwyeapi.tasks.model import Task, TaskStatus
|
|
87
|
+
from dwyeapi.tasks.service import append_task_log, update_task_status
|
|
88
|
+
|
|
89
|
+
redis = ctx["redis"]
|
|
90
|
+
async with _session_factory() as session:
|
|
91
|
+
result = await session.execute(
|
|
92
|
+
select(Task).where(Task.status.in_([TaskStatus.PENDING, TaskStatus.RUNNING]))
|
|
93
|
+
)
|
|
94
|
+
stale_tasks = list(result.scalars().all())
|
|
95
|
+
|
|
96
|
+
if not stale_tasks:
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
for task in stale_tasks:
|
|
100
|
+
if task.status == TaskStatus.RUNNING:
|
|
101
|
+
async with _session_factory() as session:
|
|
102
|
+
await update_task_status(session, task.id, TaskStatus.PENDING)
|
|
103
|
+
await append_task_log(session, task.id, "Worker 重启,任务重新入队")
|
|
104
|
+
|
|
105
|
+
await redis.enqueue_job("_task_executor", task.id, task.task_type, task.params)
|
|
106
|
+
|
|
107
|
+
_log.info("恢复了 {count} 个未完成的任务", count=len(stale_tasks))
|
|
108
|
+
|
|
109
|
+
class WorkerSettings:
|
|
110
|
+
"""ARQ WorkerSettings generated from eapi BaseSettings."""
|
|
111
|
+
|
|
112
|
+
functions = [arq_func(_task_executor, name="_task_executor")]
|
|
113
|
+
max_jobs = settings.task_max_jobs
|
|
114
|
+
job_timeout = settings.task_job_timeout
|
|
115
|
+
on_startup = _on_startup
|
|
116
|
+
redis_settings = _redis_settings
|
|
117
|
+
|
|
118
|
+
return WorkerSettings
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dwyeapi
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Lightweight FastAPI infrastructure — exceptions, database, config, security, cache, pagination, logger
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: fastapi>=0.100.0
|
|
8
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.0
|
|
9
|
+
Requires-Dist: pydantic>=2.0.0
|
|
10
|
+
Requires-Dist: pydantic-settings>=2.0.0
|
|
11
|
+
Requires-Dist: python-jose[cryptography]>=3.3.0
|
|
12
|
+
Requires-Dist: bcrypt>=4.0.0
|
|
13
|
+
Requires-Dist: redis>=5.0.0
|
|
14
|
+
Requires-Dist: loguru>=0.7.3
|
|
15
|
+
Provides-Extra: tasks
|
|
16
|
+
Requires-Dist: arq>=0.26.0; extra == "tasks"
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
|
|
20
|
+
Requires-Dist: httpx>=0.27.0; extra == "dev"
|
|
21
|
+
Requires-Dist: aiosqlite>=0.20.0; extra == "dev"
|
|
22
|
+
Requires-Dist: ruff>=0.6.0; extra == "dev"
|
|
23
|
+
Requires-Dist: arq>=0.26.0; extra == "dev"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
dwyeapi/__init__.py,sha256=jXW4AJVfYV_vWtQWd1UJmgMkBdbF8iEL3ovBvLxRwoQ,848
|
|
2
|
+
dwyeapi/cache.py,sha256=uMxe5PLPPlOQuErtUp4e8sObD7eUC8y80aXvBjvBcCw,834
|
|
3
|
+
dwyeapi/config.py,sha256=Ok7te4m8052cPk2MkURlh282SW8H4-qSmErbDaWONr0,1149
|
|
4
|
+
dwyeapi/database.py,sha256=Irgmm5KRzBLJmf0SjDvvOa3UspR9GcyIJlMkYdgfp7Y,1330
|
|
5
|
+
dwyeapi/dependencies.py,sha256=yKAER6zm24VKNfey_ieBQeTNIuc3xG3GKWIoQAY0jKU,525
|
|
6
|
+
dwyeapi/dt.py,sha256=3YQ_wk4WE40KeWzfu45RcLCVKWePw8JL_l7bSfhKOmE,1633
|
|
7
|
+
dwyeapi/exceptions.py,sha256=ltXrso0YNbL71CFucG5dPypZsey6lPF5I8vrt_GG4Ow,2549
|
|
8
|
+
dwyeapi/logger.py,sha256=McL9beGzqM9cDLDFm1Pi0mDpZJBUJACoXRbdemp2xnQ,7142
|
|
9
|
+
dwyeapi/masking.py,sha256=xwCmH9g5gBsRI_72SZyrPR56ErD3PkPYr-0vwWIeKFU,2966
|
|
10
|
+
dwyeapi/pagination.py,sha256=udvjNfzQeB8YDimD27Dz53gCRHEN9pEPEJZE4cvjHvU,708
|
|
11
|
+
dwyeapi/response.py,sha256=Jw3faYmMGT58oTpAOGUWeKzWQFM4fi8tkDgeaHFALX4,714
|
|
12
|
+
dwyeapi/security.py,sha256=aqs4CaRy7XnESHYMuSeN3FnJdEvbf3mUKWkBt-CJGDY,1367
|
|
13
|
+
dwyeapi/tasks/__init__.py,sha256=1b-HT81KvHIRhtqLr4GwXEbIGI5bB7dCAidLivZU6nk,2990
|
|
14
|
+
dwyeapi/tasks/context.py,sha256=zlXmShu-zZzrF9Dkw-Vu8j5wSpYvP3IDbkqxpHLKjCY,6180
|
|
15
|
+
dwyeapi/tasks/model.py,sha256=HtzRRC-V7uGIPjTpejHVxMXzhyy33zBg3QUPlwOe3jQ,1297
|
|
16
|
+
dwyeapi/tasks/pool.py,sha256=tW_YSJAVbZ0O4kJdhWZb9sH1ykDnqKzOd5D5v7YIjBA,2064
|
|
17
|
+
dwyeapi/tasks/registry.py,sha256=nUS3k2J1WUtYddUFVAprw9NyszRagaR2xRVjNuLhs7k,2249
|
|
18
|
+
dwyeapi/tasks/router.py,sha256=Pq_6kPdzej3SLdlfFTK7qSWZp8oHjauPF5mQRDDcXFw,6492
|
|
19
|
+
dwyeapi/tasks/schema.py,sha256=tZlG486_kgM_jq6bxthqA9-KT4tYNIzJCCR6i-yxTcw,798
|
|
20
|
+
dwyeapi/tasks/service.py,sha256=eK6bZDGX3PFkYkJaMBZGQMoX0irQPR4_Aj9yNj6uIqE,4064
|
|
21
|
+
dwyeapi/tasks/worker.py,sha256=P12fBgmvMgwMbDNdWYvjZe3D_WVs-xSCGDTeblvJwJM,4161
|
|
22
|
+
dwyeapi-0.4.0.dist-info/METADATA,sha256=1RJjGIuPRlpoeCHkNd4ZROAiAAWYwlRORhKtGHOftW0,847
|
|
23
|
+
dwyeapi-0.4.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
24
|
+
dwyeapi-0.4.0.dist-info/top_level.txt,sha256=92XFFm-WEEOa91KThjNTbNoSdFnhqu0Wtl1UHD0tPW0,8
|
|
25
|
+
dwyeapi-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dwyeapi
|