agentexec 0.1.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.
- agentexec/__init__.py +73 -0
- agentexec/activity/__init__.py +50 -0
- agentexec/activity/models.py +294 -0
- agentexec/activity/schemas.py +70 -0
- agentexec/activity/tracker.py +267 -0
- agentexec/config.py +72 -0
- agentexec/core/__init__.py +0 -0
- agentexec/core/models.py +23 -0
- agentexec/core/queue.py +109 -0
- agentexec/core/redis_client.py +40 -0
- agentexec/core/task.py +132 -0
- agentexec/core/worker.py +304 -0
- agentexec/runners/__init__.py +13 -0
- agentexec/runners/base.py +135 -0
- agentexec/runners/openai.py +237 -0
- agentexec-0.1.0.dist-info/METADATA +370 -0
- agentexec-0.1.0.dist-info/RECORD +18 -0
- agentexec-0.1.0.dist-info/WHEEL +4 -0
agentexec/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Agent Runner - Production-ready orchestration for OpenAI Agents.
|
|
2
|
+
|
|
3
|
+
This library provides:
|
|
4
|
+
- Background worker pool with Redis-backed task queue
|
|
5
|
+
- Activity tracking with pluggable storage backends
|
|
6
|
+
- OpenAI Agents SDK runner with max turns recovery
|
|
7
|
+
- Coordination primitives (counters, locks, barriers)
|
|
8
|
+
- Workflow engine for complex agent orchestration
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
from agents import Agent
|
|
12
|
+
import agentexec as ax
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# create a pool to manage background tasks
|
|
16
|
+
pool = ax.Pool()
|
|
17
|
+
|
|
18
|
+
# register a background task
|
|
19
|
+
@pool.task("research_company")
|
|
20
|
+
async def research_company(payload: dict, agent_id: str):
|
|
21
|
+
runner = ax.OpenAIRunner(
|
|
22
|
+
status_tracking=True,
|
|
23
|
+
max_turns=5,
|
|
24
|
+
)
|
|
25
|
+
agent = Agent(tools=[runner.tools.report_status], ...)
|
|
26
|
+
result = await runner.run(
|
|
27
|
+
agent,
|
|
28
|
+
input=f"Research {payload['company_name']}",
|
|
29
|
+
)
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
# fork and start the worker pool
|
|
33
|
+
pool.start()
|
|
34
|
+
|
|
35
|
+
# queue a task from anywhere
|
|
36
|
+
task = ax.Task(task_type="research_company", payload={"company_name": "Acme Corp"})
|
|
37
|
+
agent_id = ax.enqueue(task)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
41
|
+
|
|
42
|
+
from agentexec.config import CONF
|
|
43
|
+
from agentexec.core.models import Base
|
|
44
|
+
from agentexec.core.queue import Priority, dequeue, enqueue
|
|
45
|
+
from agentexec.core.task import Task, TaskHandler, TaskHandlerKwargs
|
|
46
|
+
from agentexec.core.worker import WorkerPool
|
|
47
|
+
from agentexec.runners import BaseAgentRunner
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
__version__ = version("agent-runner")
|
|
51
|
+
except PackageNotFoundError:
|
|
52
|
+
__version__ = "0.0.0.dev"
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"CONF",
|
|
56
|
+
"Base",
|
|
57
|
+
"WorkerPool",
|
|
58
|
+
"TaskHandler",
|
|
59
|
+
"Task",
|
|
60
|
+
"TaskHandlerKwargs",
|
|
61
|
+
"Priority",
|
|
62
|
+
"enqueue",
|
|
63
|
+
"dequeue",
|
|
64
|
+
"BaseAgentRunner",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
# OpenAI runner is only available if agents package is installed
|
|
68
|
+
try:
|
|
69
|
+
from agentexec.runners import OpenAIRunner
|
|
70
|
+
|
|
71
|
+
__all__.append("OpenAIRunner")
|
|
72
|
+
except ImportError:
|
|
73
|
+
pass
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Activity tracking for agent execution."""
|
|
2
|
+
|
|
3
|
+
from agentexec.activity import tracker as activity
|
|
4
|
+
from agentexec.activity.models import Activity, ActivityLog, Status
|
|
5
|
+
from agentexec.activity.schemas import (
|
|
6
|
+
ActivityDetailSchema,
|
|
7
|
+
ActivityListItemSchema,
|
|
8
|
+
ActivityListSchema,
|
|
9
|
+
ActivityLogSchema,
|
|
10
|
+
)
|
|
11
|
+
from agentexec.activity.tracker import (
|
|
12
|
+
cancel_pending,
|
|
13
|
+
complete,
|
|
14
|
+
create,
|
|
15
|
+
detail,
|
|
16
|
+
error,
|
|
17
|
+
generate_agent_id,
|
|
18
|
+
list,
|
|
19
|
+
normalize_agent_id,
|
|
20
|
+
update,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
# Namespace for activity tracking
|
|
25
|
+
"activity",
|
|
26
|
+
# Models
|
|
27
|
+
"Activity",
|
|
28
|
+
"ActivityLog",
|
|
29
|
+
"Status",
|
|
30
|
+
# Schemas
|
|
31
|
+
"ActivityLogSchema",
|
|
32
|
+
"ActivityDetailSchema",
|
|
33
|
+
"ActivityListItemSchema",
|
|
34
|
+
"ActivityListSchema",
|
|
35
|
+
# UUID Helpers
|
|
36
|
+
"generate_agent_id",
|
|
37
|
+
"normalize_agent_id",
|
|
38
|
+
# Lifecycle API
|
|
39
|
+
"create",
|
|
40
|
+
"update",
|
|
41
|
+
"complete",
|
|
42
|
+
"error",
|
|
43
|
+
"cancel_pending",
|
|
44
|
+
# Query API
|
|
45
|
+
"list",
|
|
46
|
+
"detail",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
# Users manage their own database setup with SQLAlchemy
|
|
50
|
+
# See examples/fastapi-app/ for a complete example
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from enum import Enum as PyEnum
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import (
|
|
7
|
+
DateTime,
|
|
8
|
+
Enum,
|
|
9
|
+
ForeignKey,
|
|
10
|
+
Integer,
|
|
11
|
+
String,
|
|
12
|
+
Text,
|
|
13
|
+
Uuid,
|
|
14
|
+
case,
|
|
15
|
+
func,
|
|
16
|
+
insert,
|
|
17
|
+
select,
|
|
18
|
+
)
|
|
19
|
+
from sqlalchemy.engine import RowMapping
|
|
20
|
+
from sqlalchemy.orm import Mapped, Session, aliased, mapped_column, relationship, declared_attr
|
|
21
|
+
|
|
22
|
+
from agentexec.config import CONF
|
|
23
|
+
from agentexec.core.models import Base
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Status(str, PyEnum):
|
|
27
|
+
"""Agent execution status."""
|
|
28
|
+
|
|
29
|
+
QUEUED = "queued"
|
|
30
|
+
RUNNING = "running"
|
|
31
|
+
COMPLETE = "complete"
|
|
32
|
+
ERROR = "error"
|
|
33
|
+
CANCELED = "canceled"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Activity(Base):
|
|
37
|
+
"""Tracks background agent execution sessions.
|
|
38
|
+
|
|
39
|
+
Each record represents a single agent run. The current status is inferred
|
|
40
|
+
from the latest log message.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
@declared_attr.directive
|
|
44
|
+
def __tablename__(cls) -> str:
|
|
45
|
+
return f"{CONF.table_prefix}activity"
|
|
46
|
+
|
|
47
|
+
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
|
48
|
+
agent_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, unique=True, index=True)
|
|
49
|
+
agent_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
50
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
51
|
+
DateTime(timezone=True),
|
|
52
|
+
nullable=False,
|
|
53
|
+
default=lambda: datetime.now(UTC),
|
|
54
|
+
)
|
|
55
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
56
|
+
DateTime(timezone=True),
|
|
57
|
+
nullable=False,
|
|
58
|
+
default=lambda: datetime.now(UTC),
|
|
59
|
+
onupdate=lambda: datetime.now(UTC),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
logs: Mapped[list[ActivityLog]] = relationship(
|
|
63
|
+
"ActivityLog",
|
|
64
|
+
back_populates="activity",
|
|
65
|
+
cascade="all, delete-orphan",
|
|
66
|
+
order_by="ActivityLog.created_at",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def append_log(
|
|
71
|
+
cls,
|
|
72
|
+
session: Session,
|
|
73
|
+
agent_id: uuid.UUID,
|
|
74
|
+
message: str,
|
|
75
|
+
status: Status,
|
|
76
|
+
completion_percentage: int | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Append a log entry to the activity for the given agent_id.
|
|
79
|
+
|
|
80
|
+
This uses a single query to look up the activity_id and insert the log,
|
|
81
|
+
avoiding the need to load the Activity record first.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
session: SQLAlchemy session
|
|
85
|
+
agent_id: The agent_id to append the log to
|
|
86
|
+
message: Log message
|
|
87
|
+
status: Current status of the agent
|
|
88
|
+
completion_percentage: Optional completion percentage (0-100)
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValueError: If agent_id not found (foreign key constraint will fail)
|
|
92
|
+
"""
|
|
93
|
+
# Scalar subquery to get activity.id from agent_id
|
|
94
|
+
activity_id_subq = select(cls.id).where(cls.agent_id == agent_id).scalar_subquery()
|
|
95
|
+
|
|
96
|
+
# Insert the log using the subquery for activity_id
|
|
97
|
+
stmt = insert(ActivityLog).values(
|
|
98
|
+
activity_id=activity_id_subq,
|
|
99
|
+
message=message,
|
|
100
|
+
status=status,
|
|
101
|
+
completion_percentage=completion_percentage,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
session.execute(stmt)
|
|
106
|
+
session.commit()
|
|
107
|
+
except Exception as e:
|
|
108
|
+
session.rollback()
|
|
109
|
+
raise ValueError(f"Failed to append log for agent_id {agent_id}") from e
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def get_by_agent_id(
|
|
113
|
+
cls,
|
|
114
|
+
session: Session,
|
|
115
|
+
agent_id: str | uuid.UUID,
|
|
116
|
+
) -> Activity | None:
|
|
117
|
+
"""Get an activity by agent_id.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
session: SQLAlchemy session
|
|
121
|
+
agent_id: The agent_id to look up (string or UUID)
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Activity object or None if not found
|
|
125
|
+
|
|
126
|
+
Example:
|
|
127
|
+
activity = Activity.get_by_agent_id(session, "abc-123")
|
|
128
|
+
# Or with UUID object
|
|
129
|
+
activity = Activity.get_by_agent_id(session, uuid.UUID("abc-123..."))
|
|
130
|
+
if activity:
|
|
131
|
+
print(f"Found activity: {activity.agent_type}")
|
|
132
|
+
"""
|
|
133
|
+
# Normalize to UUID if string
|
|
134
|
+
if isinstance(agent_id, str):
|
|
135
|
+
agent_id = uuid.UUID(agent_id)
|
|
136
|
+
return session.query(cls).filter_by(agent_id=agent_id).first()
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def get_list(
|
|
140
|
+
cls,
|
|
141
|
+
session: Session,
|
|
142
|
+
page: int = 1,
|
|
143
|
+
page_size: int = 50,
|
|
144
|
+
) -> list[RowMapping]:
|
|
145
|
+
"""Get a paginated list of activities with summary information.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
session: SQLAlchemy session to use for the query
|
|
149
|
+
page: Page number (1-indexed)
|
|
150
|
+
page_size: Number of items per page
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
List of RowMapping objects (dict-like) with keys matching ActivitySummarySchema:
|
|
154
|
+
agent_id, agent_type, latest_log_message, status, latest_log_timestamp,
|
|
155
|
+
completion_percentage, started_at
|
|
156
|
+
|
|
157
|
+
Example:
|
|
158
|
+
results = Activity.get_list(session, page=1, page_size=20)
|
|
159
|
+
for row in results:
|
|
160
|
+
print(f"{row['agent_id']}: {row['latest_log_message']}")
|
|
161
|
+
"""
|
|
162
|
+
# Subquery to get the latest log for each agent
|
|
163
|
+
latest_log_subq = select(
|
|
164
|
+
ActivityLog.activity_id,
|
|
165
|
+
ActivityLog.message,
|
|
166
|
+
ActivityLog.status,
|
|
167
|
+
ActivityLog.created_at,
|
|
168
|
+
ActivityLog.completion_percentage,
|
|
169
|
+
func.row_number()
|
|
170
|
+
.over(
|
|
171
|
+
partition_by=ActivityLog.activity_id,
|
|
172
|
+
order_by=ActivityLog.created_at.desc(),
|
|
173
|
+
)
|
|
174
|
+
.label("rn"),
|
|
175
|
+
).subquery()
|
|
176
|
+
|
|
177
|
+
# Subquery to get start time (first log timestamp)
|
|
178
|
+
started_at_subq = (
|
|
179
|
+
select(
|
|
180
|
+
ActivityLog.activity_id,
|
|
181
|
+
func.min(ActivityLog.created_at).label("started_at"),
|
|
182
|
+
)
|
|
183
|
+
.group_by(ActivityLog.activity_id)
|
|
184
|
+
.subquery()
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Alias for the subqueries
|
|
188
|
+
latest_log = aliased(latest_log_subq)
|
|
189
|
+
started_at = aliased(started_at_subq)
|
|
190
|
+
|
|
191
|
+
# Build base query - select only the columns we need with aliases matching schema
|
|
192
|
+
query = (
|
|
193
|
+
select(
|
|
194
|
+
cls.agent_id,
|
|
195
|
+
cls.agent_type,
|
|
196
|
+
latest_log.c.message.label("latest_log_message"),
|
|
197
|
+
latest_log.c.status,
|
|
198
|
+
latest_log.c.created_at.label("latest_log_timestamp"),
|
|
199
|
+
latest_log.c.completion_percentage,
|
|
200
|
+
started_at.c.started_at,
|
|
201
|
+
)
|
|
202
|
+
.outerjoin(
|
|
203
|
+
latest_log,
|
|
204
|
+
(cls.id == latest_log.c.activity_id) & (latest_log.c.rn == 1),
|
|
205
|
+
)
|
|
206
|
+
.outerjoin(started_at, cls.id == started_at.c.activity_id)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Custom ordering: active agents (running, queued) at the top
|
|
210
|
+
is_active = case(
|
|
211
|
+
(latest_log.c.status.in_([Status.RUNNING, Status.QUEUED]), 0),
|
|
212
|
+
else_=1,
|
|
213
|
+
)
|
|
214
|
+
active_priority = case(
|
|
215
|
+
(latest_log.c.status == Status.RUNNING, 1),
|
|
216
|
+
(latest_log.c.status == Status.QUEUED, 2),
|
|
217
|
+
else_=3,
|
|
218
|
+
)
|
|
219
|
+
query = query.order_by(
|
|
220
|
+
is_active, active_priority, started_at.c.started_at.desc().nullslast()
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Apply pagination and execute
|
|
224
|
+
offset = (page - 1) * page_size
|
|
225
|
+
return list(session.execute(query.offset(offset).limit(page_size)).mappings().all())
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def get_pending_ids(cls, session: Session) -> list[uuid.UUID]:
|
|
229
|
+
"""Get agent_ids for all activities with QUEUED or RUNNING status.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
session: SQLAlchemy session to use for the query
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
List of agent_id UUIDs for pending (queued or running) activities
|
|
236
|
+
|
|
237
|
+
Example:
|
|
238
|
+
pending_ids = Activity.get_pending_ids(session)
|
|
239
|
+
for agent_id in pending_ids:
|
|
240
|
+
print(f"Pending agent: {agent_id}")
|
|
241
|
+
"""
|
|
242
|
+
# Subquery to get the latest log status for each activity
|
|
243
|
+
latest_log_subq = select(
|
|
244
|
+
ActivityLog.activity_id,
|
|
245
|
+
ActivityLog.status,
|
|
246
|
+
func.row_number()
|
|
247
|
+
.over(
|
|
248
|
+
partition_by=ActivityLog.activity_id,
|
|
249
|
+
order_by=ActivityLog.created_at.desc(),
|
|
250
|
+
)
|
|
251
|
+
.label("rn"),
|
|
252
|
+
).subquery()
|
|
253
|
+
|
|
254
|
+
# Query for agent_ids where latest status is queued or running
|
|
255
|
+
result = (
|
|
256
|
+
session.query(cls.agent_id)
|
|
257
|
+
.join(
|
|
258
|
+
latest_log_subq,
|
|
259
|
+
(cls.id == latest_log_subq.c.activity_id) & (latest_log_subq.c.rn == 1),
|
|
260
|
+
)
|
|
261
|
+
.filter(latest_log_subq.c.status.in_([Status.QUEUED, Status.RUNNING]))
|
|
262
|
+
.all()
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Extract UUIDs from result tuples
|
|
266
|
+
return [agent_id for (agent_id,) in result]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class ActivityLog(Base):
|
|
270
|
+
"""Individual log messages from background agents.
|
|
271
|
+
|
|
272
|
+
Each log entry represents a single update/message from an agent
|
|
273
|
+
during its execution, including the agent's status at that point in time.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
@declared_attr.directive
|
|
277
|
+
def __tablename__(cls) -> str:
|
|
278
|
+
return f"{CONF.table_prefix}activity_log"
|
|
279
|
+
|
|
280
|
+
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
|
281
|
+
activity_id: Mapped[uuid.UUID] = mapped_column(
|
|
282
|
+
Uuid, ForeignKey("agentexec_activity.id"), nullable=False, index=True
|
|
283
|
+
)
|
|
284
|
+
message: Mapped[str] = mapped_column(Text, nullable=False)
|
|
285
|
+
status: Mapped[Status] = mapped_column(Enum(Status), nullable=False, index=True)
|
|
286
|
+
completion_percentage: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
287
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
288
|
+
DateTime(timezone=True),
|
|
289
|
+
nullable=False,
|
|
290
|
+
default=lambda: datetime.now(UTC),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Relationship back to activity
|
|
294
|
+
activity: Mapped[Activity] = relationship("Activity", back_populates="logs")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
|
5
|
+
|
|
6
|
+
from agentexec.activity.models import Status
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ActivityLogSchema(BaseModel):
|
|
10
|
+
"""Schema for an agent activity log entry."""
|
|
11
|
+
|
|
12
|
+
model_config = ConfigDict(from_attributes=True)
|
|
13
|
+
|
|
14
|
+
id: uuid.UUID
|
|
15
|
+
message: str
|
|
16
|
+
status: Status
|
|
17
|
+
completion_percentage: int = 0
|
|
18
|
+
created_at: datetime
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ActivityDetailSchema(BaseModel):
|
|
22
|
+
"""Schema for an agent activity record with optional logs."""
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(from_attributes=True)
|
|
25
|
+
|
|
26
|
+
id: uuid.UUID
|
|
27
|
+
agent_id: uuid.UUID
|
|
28
|
+
agent_type: str
|
|
29
|
+
created_at: datetime
|
|
30
|
+
updated_at: datetime
|
|
31
|
+
logs: list[ActivityLogSchema] = Field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ActivityListItemSchema(BaseModel):
|
|
35
|
+
"""Lightweight summary of agent activity showing only latest update.
|
|
36
|
+
|
|
37
|
+
Note: Elapsed time can be calculated on the frontend as:
|
|
38
|
+
latest_log_timestamp - started_at
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(from_attributes=True)
|
|
42
|
+
|
|
43
|
+
agent_id: uuid.UUID
|
|
44
|
+
agent_type: str
|
|
45
|
+
status: Status
|
|
46
|
+
latest_log_message: str | None = None
|
|
47
|
+
latest_log_timestamp: datetime | None = None
|
|
48
|
+
completion_percentage: int = 0
|
|
49
|
+
started_at: datetime | None = None
|
|
50
|
+
|
|
51
|
+
@computed_field
|
|
52
|
+
def elapsed_time_seconds(self) -> int:
|
|
53
|
+
if self.latest_log_timestamp and self.started_at:
|
|
54
|
+
return int((self.latest_log_timestamp - self.started_at).total_seconds())
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ActivityListSchema(BaseModel):
|
|
59
|
+
"""Paginated list of activity summaries."""
|
|
60
|
+
|
|
61
|
+
model_config = ConfigDict(from_attributes=True)
|
|
62
|
+
|
|
63
|
+
items: list[ActivityListItemSchema]
|
|
64
|
+
total: int
|
|
65
|
+
page: int
|
|
66
|
+
page_size: int
|
|
67
|
+
|
|
68
|
+
@computed_field
|
|
69
|
+
def total_pages(self) -> int:
|
|
70
|
+
return (self.total + self.page_size - 1) // self.page_size
|