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,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
@@ -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()
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ dwyeapi