cap-sdk-python 2.5.2__tar.gz → 2.5.3__tar.gz
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.
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/PKG-INFO +110 -1
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/README.md +107 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/__init__.py +53 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/bus.py +13 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/client.py +9 -0
- cap_sdk_python-2.5.3/cap/errors.py +116 -0
- cap_sdk_python-2.5.3/cap/metrics.py +33 -0
- cap_sdk_python-2.5.3/cap/middleware.py +50 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/runtime.py +78 -15
- cap_sdk_python-2.5.3/cap/testing.py +125 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/worker.py +51 -7
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap_sdk_python.egg-info/PKG-INFO +110 -1
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap_sdk_python.egg-info/SOURCES.txt +8 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap_sdk_python.egg-info/requires.txt +3 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/pyproject.toml +4 -1
- cap_sdk_python-2.5.3/tests/test_errors.py +46 -0
- cap_sdk_python-2.5.3/tests/test_metrics.py +117 -0
- cap_sdk_python-2.5.3/tests/test_middleware.py +162 -0
- cap_sdk_python-2.5.3/tests/test_testing.py +45 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/pb/__init__.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/pb/cordum/__init__.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/pb/cordum/agent/__init__.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/pb/cordum/agent/v1/__init__.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/pb/cordum/agent/v1/alert_pb2.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/pb/cordum/agent/v1/alert_pb2_grpc.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/pb/cordum/agent/v1/buspacket_pb2.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/pb/cordum/agent/v1/buspacket_pb2_grpc.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/pb/cordum/agent/v1/handshake_pb2.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/pb/cordum/agent/v1/handshake_pb2_grpc.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/pb/cordum/agent/v1/heartbeat_pb2.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/pb/cordum/agent/v1/heartbeat_pb2_grpc.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/pb/cordum/agent/v1/job_pb2.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/pb/cordum/agent/v1/job_pb2_grpc.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/pb/cordum/agent/v1/safety_pb2.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/pb/cordum/agent/v1/safety_pb2_grpc.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/subjects.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap/validate.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap_sdk_python.egg-info/dependency_links.txt +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/cap_sdk_python.egg-info/top_level.txt +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/setup.cfg +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/tests/test_conformance.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/tests/test_runtime.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/tests/test_sdk.py +0 -0
- {cap_sdk_python-2.5.2 → cap_sdk_python-2.5.3}/tests/test_validate.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cap-sdk-python
|
|
3
|
-
Version: 2.5.
|
|
3
|
+
Version: 2.5.3
|
|
4
4
|
Summary: CAP (Cordum Agent Protocol) Python SDK
|
|
5
5
|
Author-email: Cordum <eng@cordum.io>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -16,6 +16,8 @@ Requires-Dist: nats-py>=2.6.0
|
|
|
16
16
|
Requires-Dist: cryptography>=41.0.0
|
|
17
17
|
Requires-Dist: pydantic>=2.6.0
|
|
18
18
|
Requires-Dist: redis>=5.0.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pdoc>=14.0; extra == "dev"
|
|
19
21
|
|
|
20
22
|
# CAP Python SDK
|
|
21
23
|
|
|
@@ -97,6 +99,27 @@ Asyncio-first SDK with NATS helpers for CAP workers and clients.
|
|
|
97
99
|
|
|
98
100
|
Swap out `cap.bus` if you need a different transport.
|
|
99
101
|
|
|
102
|
+
## Testing
|
|
103
|
+
|
|
104
|
+
The `cap.testing` module lets you test handlers without running NATS or Redis.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from cap.testing import run_handler
|
|
108
|
+
from cap.pb.cordum.agent.v1 import job_pb2
|
|
109
|
+
|
|
110
|
+
async def test_echo():
|
|
111
|
+
result = await run_handler(
|
|
112
|
+
lambda ctx, data: {"echo": data["prompt"]},
|
|
113
|
+
{"prompt": "hello"},
|
|
114
|
+
topic="job.echo",
|
|
115
|
+
)
|
|
116
|
+
assert result.status == job_pb2.JOB_STATUS_SUCCEEDED
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- `run_handler(handler, input, **options)` — runs a single handler invocation and returns the `JobResult`.
|
|
120
|
+
- `create_test_agent(**options)` — returns `(agent, mock_nats, store)` pre-wired with `MockNATS` + `InMemoryBlobStore`.
|
|
121
|
+
- `MockNATS` — in-memory NATS mock for custom test setups.
|
|
122
|
+
|
|
100
123
|
## Runtime (High-Level SDK)
|
|
101
124
|
The runtime hides NATS/Redis plumbing and gives you typed handlers.
|
|
102
125
|
|
|
@@ -120,6 +143,92 @@ async def summarize(ctx: Context, data: Input) -> Output:
|
|
|
120
143
|
asyncio.run(agent.run())
|
|
121
144
|
```
|
|
122
145
|
|
|
146
|
+
### Middleware
|
|
147
|
+
|
|
148
|
+
Add cross-cutting concerns (logging, auth, metrics) without modifying handlers:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from cap.middleware import logging_middleware
|
|
152
|
+
|
|
153
|
+
# Built-in logging middleware
|
|
154
|
+
agent.use(logging_middleware())
|
|
155
|
+
|
|
156
|
+
# Custom middleware
|
|
157
|
+
async def timing(ctx, data, next_fn):
|
|
158
|
+
import time
|
|
159
|
+
start = time.monotonic()
|
|
160
|
+
result = await next_fn(ctx, data)
|
|
161
|
+
elapsed = time.monotonic() - start
|
|
162
|
+
print(f"job {ctx.job_id} took {elapsed:.3f}s")
|
|
163
|
+
return result
|
|
164
|
+
|
|
165
|
+
agent.use(timing)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Middleware executes in registration order (FIFO). Each can inspect context,
|
|
169
|
+
measure timing, or short-circuit by returning without calling `next_fn`.
|
|
170
|
+
|
|
123
171
|
### Environment
|
|
124
172
|
- `NATS_URL` (default `nats://127.0.0.1:4222`)
|
|
125
173
|
- `REDIS_URL` (default `redis://127.0.0.1:6379/0`)
|
|
174
|
+
|
|
175
|
+
## Generating API Docs
|
|
176
|
+
|
|
177
|
+
Generate HTML API reference locally using [pdoc](https://pdoc.dev/):
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
pip install cap-sdk-python[dev]
|
|
181
|
+
pdoc ./cap --output-dir docs
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Output is written to `docs/` (gitignored). Open `docs/index.html` to browse.
|
|
185
|
+
|
|
186
|
+
## Observability
|
|
187
|
+
|
|
188
|
+
### Structured Logging
|
|
189
|
+
The runtime Agent and Worker use `logging.Logger` (stdlib) for structured logging. All log calls include contextual fields (`job_id`, `trace_id`, `topic`, `sender_id`). Pass a custom logger or leave as default:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
import logging
|
|
193
|
+
from cap.runtime import Agent
|
|
194
|
+
|
|
195
|
+
logger = logging.getLogger("my-agent")
|
|
196
|
+
logger.setLevel(logging.DEBUG)
|
|
197
|
+
agent = Agent(logger=logger)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### MetricsHook
|
|
201
|
+
Implement the `MetricsHook` protocol to integrate with Prometheus, OpenTelemetry, or any metrics system:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
from cap.metrics import MetricsHook
|
|
205
|
+
|
|
206
|
+
class MetricsHook(Protocol):
|
|
207
|
+
def on_job_received(self, job_id: str, topic: str) -> None: ...
|
|
208
|
+
def on_job_completed(self, job_id: str, duration_ms: int, status: str) -> None: ...
|
|
209
|
+
def on_job_failed(self, job_id: str, error_msg: str) -> None: ...
|
|
210
|
+
def on_heartbeat_sent(self, worker_id: str) -> None: ...
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The default is `NoopMetrics` (zero overhead). Example Prometheus integration:
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
from cap.runtime import Agent
|
|
217
|
+
|
|
218
|
+
class PromMetrics:
|
|
219
|
+
def on_job_received(self, job_id, topic):
|
|
220
|
+
jobs_received.labels(topic=topic).inc()
|
|
221
|
+
|
|
222
|
+
def on_job_completed(self, job_id, duration_ms, status):
|
|
223
|
+
job_duration.labels(status=status).observe(duration_ms)
|
|
224
|
+
|
|
225
|
+
def on_job_failed(self, job_id, error_msg):
|
|
226
|
+
jobs_failed.inc()
|
|
227
|
+
|
|
228
|
+
def on_heartbeat_sent(self, worker_id):
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
agent = Agent(metrics=PromMetrics())
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The `trace_id` is propagated through all log and metrics calls for distributed tracing correlation.
|
|
@@ -78,6 +78,27 @@ Asyncio-first SDK with NATS helpers for CAP workers and clients.
|
|
|
78
78
|
|
|
79
79
|
Swap out `cap.bus` if you need a different transport.
|
|
80
80
|
|
|
81
|
+
## Testing
|
|
82
|
+
|
|
83
|
+
The `cap.testing` module lets you test handlers without running NATS or Redis.
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from cap.testing import run_handler
|
|
87
|
+
from cap.pb.cordum.agent.v1 import job_pb2
|
|
88
|
+
|
|
89
|
+
async def test_echo():
|
|
90
|
+
result = await run_handler(
|
|
91
|
+
lambda ctx, data: {"echo": data["prompt"]},
|
|
92
|
+
{"prompt": "hello"},
|
|
93
|
+
topic="job.echo",
|
|
94
|
+
)
|
|
95
|
+
assert result.status == job_pb2.JOB_STATUS_SUCCEEDED
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- `run_handler(handler, input, **options)` — runs a single handler invocation and returns the `JobResult`.
|
|
99
|
+
- `create_test_agent(**options)` — returns `(agent, mock_nats, store)` pre-wired with `MockNATS` + `InMemoryBlobStore`.
|
|
100
|
+
- `MockNATS` — in-memory NATS mock for custom test setups.
|
|
101
|
+
|
|
81
102
|
## Runtime (High-Level SDK)
|
|
82
103
|
The runtime hides NATS/Redis plumbing and gives you typed handlers.
|
|
83
104
|
|
|
@@ -101,6 +122,92 @@ async def summarize(ctx: Context, data: Input) -> Output:
|
|
|
101
122
|
asyncio.run(agent.run())
|
|
102
123
|
```
|
|
103
124
|
|
|
125
|
+
### Middleware
|
|
126
|
+
|
|
127
|
+
Add cross-cutting concerns (logging, auth, metrics) without modifying handlers:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from cap.middleware import logging_middleware
|
|
131
|
+
|
|
132
|
+
# Built-in logging middleware
|
|
133
|
+
agent.use(logging_middleware())
|
|
134
|
+
|
|
135
|
+
# Custom middleware
|
|
136
|
+
async def timing(ctx, data, next_fn):
|
|
137
|
+
import time
|
|
138
|
+
start = time.monotonic()
|
|
139
|
+
result = await next_fn(ctx, data)
|
|
140
|
+
elapsed = time.monotonic() - start
|
|
141
|
+
print(f"job {ctx.job_id} took {elapsed:.3f}s")
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
agent.use(timing)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Middleware executes in registration order (FIFO). Each can inspect context,
|
|
148
|
+
measure timing, or short-circuit by returning without calling `next_fn`.
|
|
149
|
+
|
|
104
150
|
### Environment
|
|
105
151
|
- `NATS_URL` (default `nats://127.0.0.1:4222`)
|
|
106
152
|
- `REDIS_URL` (default `redis://127.0.0.1:6379/0`)
|
|
153
|
+
|
|
154
|
+
## Generating API Docs
|
|
155
|
+
|
|
156
|
+
Generate HTML API reference locally using [pdoc](https://pdoc.dev/):
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
pip install cap-sdk-python[dev]
|
|
160
|
+
pdoc ./cap --output-dir docs
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Output is written to `docs/` (gitignored). Open `docs/index.html` to browse.
|
|
164
|
+
|
|
165
|
+
## Observability
|
|
166
|
+
|
|
167
|
+
### Structured Logging
|
|
168
|
+
The runtime Agent and Worker use `logging.Logger` (stdlib) for structured logging. All log calls include contextual fields (`job_id`, `trace_id`, `topic`, `sender_id`). Pass a custom logger or leave as default:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
import logging
|
|
172
|
+
from cap.runtime import Agent
|
|
173
|
+
|
|
174
|
+
logger = logging.getLogger("my-agent")
|
|
175
|
+
logger.setLevel(logging.DEBUG)
|
|
176
|
+
agent = Agent(logger=logger)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### MetricsHook
|
|
180
|
+
Implement the `MetricsHook` protocol to integrate with Prometheus, OpenTelemetry, or any metrics system:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
from cap.metrics import MetricsHook
|
|
184
|
+
|
|
185
|
+
class MetricsHook(Protocol):
|
|
186
|
+
def on_job_received(self, job_id: str, topic: str) -> None: ...
|
|
187
|
+
def on_job_completed(self, job_id: str, duration_ms: int, status: str) -> None: ...
|
|
188
|
+
def on_job_failed(self, job_id: str, error_msg: str) -> None: ...
|
|
189
|
+
def on_heartbeat_sent(self, worker_id: str) -> None: ...
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The default is `NoopMetrics` (zero overhead). Example Prometheus integration:
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
from cap.runtime import Agent
|
|
196
|
+
|
|
197
|
+
class PromMetrics:
|
|
198
|
+
def on_job_received(self, job_id, topic):
|
|
199
|
+
jobs_received.labels(topic=topic).inc()
|
|
200
|
+
|
|
201
|
+
def on_job_completed(self, job_id, duration_ms, status):
|
|
202
|
+
job_duration.labels(status=status).observe(duration_ms)
|
|
203
|
+
|
|
204
|
+
def on_job_failed(self, job_id, error_msg):
|
|
205
|
+
jobs_failed.inc()
|
|
206
|
+
|
|
207
|
+
def on_heartbeat_sent(self, worker_id):
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
agent = Agent(metrics=PromMetrics())
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The `trace_id` is propagated through all log and metrics calls for distributed tracing correlation.
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
"""CAP (Cordum Agent Protocol) SDK for Python.
|
|
2
|
+
|
|
3
|
+
Provides helpers for submitting jobs, running workers, and building
|
|
4
|
+
high-level agents on the CAP bus.
|
|
5
|
+
"""
|
|
6
|
+
|
|
1
7
|
import sys
|
|
2
8
|
import types
|
|
3
9
|
|
|
@@ -27,12 +33,35 @@ from .client import submit_job
|
|
|
27
33
|
from .worker import run_worker
|
|
28
34
|
from .bus import connect_nats
|
|
29
35
|
from .runtime import Agent, Context, BlobStore, RedisBlobStore, InMemoryBlobStore
|
|
36
|
+
from .middleware import Middleware, NextFn, logging_middleware
|
|
37
|
+
from .metrics import MetricsHook, NoopMetrics
|
|
30
38
|
from .validate import (
|
|
31
39
|
ValidationError,
|
|
32
40
|
validate_job_request,
|
|
33
41
|
validate_job_result,
|
|
34
42
|
validate_bus_packet,
|
|
35
43
|
)
|
|
44
|
+
from .errors import (
|
|
45
|
+
CAPError,
|
|
46
|
+
VersionMismatchError,
|
|
47
|
+
MalformedPacketError,
|
|
48
|
+
UnknownPayloadError,
|
|
49
|
+
SignatureInvalidError,
|
|
50
|
+
SignatureMissingError,
|
|
51
|
+
JobTimeoutError,
|
|
52
|
+
ResourceExhaustedError,
|
|
53
|
+
PermissionDeniedError,
|
|
54
|
+
InvalidInputError,
|
|
55
|
+
JobNotFoundError,
|
|
56
|
+
DuplicateJobError,
|
|
57
|
+
WorkerUnavailableError,
|
|
58
|
+
SafetyDeniedError,
|
|
59
|
+
PolicyViolationError,
|
|
60
|
+
RiskTagBlockedError,
|
|
61
|
+
PublishFailedError,
|
|
62
|
+
SubscribeFailedError,
|
|
63
|
+
ConnectionLostError,
|
|
64
|
+
)
|
|
36
65
|
from .subjects import (
|
|
37
66
|
SUBJECT_SUBMIT,
|
|
38
67
|
SUBJECT_RESULT,
|
|
@@ -54,6 +83,11 @@ __all__ = [
|
|
|
54
83
|
"BlobStore",
|
|
55
84
|
"RedisBlobStore",
|
|
56
85
|
"InMemoryBlobStore",
|
|
86
|
+
"Middleware",
|
|
87
|
+
"NextFn",
|
|
88
|
+
"logging_middleware",
|
|
89
|
+
"MetricsHook",
|
|
90
|
+
"NoopMetrics",
|
|
57
91
|
"ValidationError",
|
|
58
92
|
"validate_job_request",
|
|
59
93
|
"validate_job_result",
|
|
@@ -67,4 +101,23 @@ __all__ = [
|
|
|
67
101
|
"SUBJECT_DLQ",
|
|
68
102
|
"SUBJECT_WORKFLOW_EVENT",
|
|
69
103
|
"SUBJECT_HANDSHAKE",
|
|
104
|
+
"CAPError",
|
|
105
|
+
"VersionMismatchError",
|
|
106
|
+
"MalformedPacketError",
|
|
107
|
+
"UnknownPayloadError",
|
|
108
|
+
"SignatureInvalidError",
|
|
109
|
+
"SignatureMissingError",
|
|
110
|
+
"JobTimeoutError",
|
|
111
|
+
"ResourceExhaustedError",
|
|
112
|
+
"PermissionDeniedError",
|
|
113
|
+
"InvalidInputError",
|
|
114
|
+
"JobNotFoundError",
|
|
115
|
+
"DuplicateJobError",
|
|
116
|
+
"WorkerUnavailableError",
|
|
117
|
+
"SafetyDeniedError",
|
|
118
|
+
"PolicyViolationError",
|
|
119
|
+
"RiskTagBlockedError",
|
|
120
|
+
"PublishFailedError",
|
|
121
|
+
"SubscribeFailedError",
|
|
122
|
+
"ConnectionLostError",
|
|
70
123
|
]
|
|
@@ -3,6 +3,8 @@ from typing import Optional
|
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
class NATSConfig:
|
|
6
|
+
"""NATS connection configuration."""
|
|
7
|
+
|
|
6
8
|
def __init__(
|
|
7
9
|
self,
|
|
8
10
|
url: str,
|
|
@@ -19,6 +21,17 @@ class NATSConfig:
|
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
async def connect_nats(cfg: NATSConfig):
|
|
24
|
+
"""Open a NATS connection using the provided configuration.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
cfg: Connection settings.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
A connected NATS client.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
RuntimeError: If the ``nats-py`` package is not installed.
|
|
34
|
+
"""
|
|
22
35
|
try:
|
|
23
36
|
import nats # type: ignore
|
|
24
37
|
except ImportError as exc:
|
|
@@ -17,6 +17,15 @@ async def submit_job(
|
|
|
17
17
|
sender_id: str,
|
|
18
18
|
private_key: Optional[ec.EllipticCurvePrivateKey] = None,
|
|
19
19
|
):
|
|
20
|
+
"""Publish a JobRequest onto the CAP submit subject.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
nc: An active NATS connection.
|
|
24
|
+
job_request: A protobuf JobRequest message.
|
|
25
|
+
trace_id: Distributed trace identifier propagated through the bus.
|
|
26
|
+
sender_id: Identity of the sender (used in the BusPacket envelope).
|
|
27
|
+
private_key: Optional ECDSA private key for signing the packet.
|
|
28
|
+
"""
|
|
20
29
|
ts = timestamp_pb2.Timestamp()
|
|
21
30
|
ts.GetCurrentTime()
|
|
22
31
|
packet = buspacket_pb2.BusPacket()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Typed error classes matching the CAP ErrorCode registry.
|
|
2
|
+
|
|
3
|
+
See spec/13-error-codes.md for the full taxonomy.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CAPError(Exception):
|
|
8
|
+
"""Base class for all CAP protocol errors."""
|
|
9
|
+
|
|
10
|
+
code: str = "ERROR_CODE_UNSPECIFIED"
|
|
11
|
+
numeric_code: int = 0
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Protocol errors (100-199)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class VersionMismatchError(CAPError):
|
|
21
|
+
code = "ERROR_CODE_PROTOCOL_VERSION_MISMATCH"
|
|
22
|
+
numeric_code = 100
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MalformedPacketError(CAPError):
|
|
26
|
+
code = "ERROR_CODE_PROTOCOL_MALFORMED_PACKET"
|
|
27
|
+
numeric_code = 101
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UnknownPayloadError(CAPError):
|
|
31
|
+
code = "ERROR_CODE_PROTOCOL_UNKNOWN_PAYLOAD"
|
|
32
|
+
numeric_code = 102
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SignatureInvalidError(CAPError):
|
|
36
|
+
code = "ERROR_CODE_PROTOCOL_SIGNATURE_INVALID"
|
|
37
|
+
numeric_code = 103
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SignatureMissingError(CAPError):
|
|
41
|
+
code = "ERROR_CODE_PROTOCOL_SIGNATURE_MISSING"
|
|
42
|
+
numeric_code = 104
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Job errors (200-299)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class JobTimeoutError(CAPError):
|
|
49
|
+
code = "ERROR_CODE_JOB_TIMEOUT"
|
|
50
|
+
numeric_code = 200
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ResourceExhaustedError(CAPError):
|
|
54
|
+
code = "ERROR_CODE_JOB_RESOURCE_EXHAUSTED"
|
|
55
|
+
numeric_code = 201
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PermissionDeniedError(CAPError):
|
|
59
|
+
code = "ERROR_CODE_JOB_PERMISSION_DENIED"
|
|
60
|
+
numeric_code = 202
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class InvalidInputError(CAPError):
|
|
64
|
+
code = "ERROR_CODE_JOB_INVALID_INPUT"
|
|
65
|
+
numeric_code = 203
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class JobNotFoundError(CAPError):
|
|
69
|
+
code = "ERROR_CODE_JOB_NOT_FOUND"
|
|
70
|
+
numeric_code = 204
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class DuplicateJobError(CAPError):
|
|
74
|
+
code = "ERROR_CODE_JOB_DUPLICATE"
|
|
75
|
+
numeric_code = 205
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class WorkerUnavailableError(CAPError):
|
|
79
|
+
code = "ERROR_CODE_JOB_WORKER_UNAVAILABLE"
|
|
80
|
+
numeric_code = 206
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Safety errors (300-399)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class SafetyDeniedError(CAPError):
|
|
87
|
+
code = "ERROR_CODE_SAFETY_DENIED"
|
|
88
|
+
numeric_code = 300
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PolicyViolationError(CAPError):
|
|
92
|
+
code = "ERROR_CODE_SAFETY_POLICY_VIOLATION"
|
|
93
|
+
numeric_code = 301
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class RiskTagBlockedError(CAPError):
|
|
97
|
+
code = "ERROR_CODE_SAFETY_RISK_TAG_BLOCKED"
|
|
98
|
+
numeric_code = 302
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Transport errors (400-499)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class PublishFailedError(CAPError):
|
|
105
|
+
code = "ERROR_CODE_TRANSPORT_PUBLISH_FAILED"
|
|
106
|
+
numeric_code = 400
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class SubscribeFailedError(CAPError):
|
|
110
|
+
code = "ERROR_CODE_TRANSPORT_SUBSCRIBE_FAILED"
|
|
111
|
+
numeric_code = 401
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ConnectionLostError(CAPError):
|
|
115
|
+
code = "ERROR_CODE_TRANSPORT_CONNECTION_LOST"
|
|
116
|
+
numeric_code = 402
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""MetricsHook for CAP SDK observability.
|
|
2
|
+
|
|
3
|
+
Implement the MetricsHook protocol to integrate with Prometheus,
|
|
4
|
+
StatsD, OpenTelemetry, or any other metrics system.
|
|
5
|
+
The default is NoopMetrics which has zero overhead.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MetricsHook(Protocol):
|
|
12
|
+
"""Receives lifecycle events for observability."""
|
|
13
|
+
|
|
14
|
+
def on_job_received(self, job_id: str, topic: str) -> None: ...
|
|
15
|
+
def on_job_completed(self, job_id: str, duration_ms: int, status: str) -> None: ...
|
|
16
|
+
def on_job_failed(self, job_id: str, error_msg: str) -> None: ...
|
|
17
|
+
def on_heartbeat_sent(self, worker_id: str) -> None: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NoopMetrics:
|
|
21
|
+
"""No-op metrics implementation with zero overhead."""
|
|
22
|
+
|
|
23
|
+
def on_job_received(self, job_id: str, topic: str) -> None:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
def on_job_completed(self, job_id: str, duration_ms: int, status: str) -> None:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
def on_job_failed(self, job_id: str, error_msg: str) -> None:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
def on_heartbeat_sent(self, worker_id: str) -> None:
|
|
33
|
+
pass
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Middleware support for CAP agents and workers."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Awaitable, Callable
|
|
6
|
+
|
|
7
|
+
from cap.runtime import Context
|
|
8
|
+
|
|
9
|
+
# NextFn calls the next middleware or the terminal handler.
|
|
10
|
+
NextFn = Callable[[Context, Any], Awaitable[Any]]
|
|
11
|
+
|
|
12
|
+
# Middleware intercepts handler execution. Call ``next_fn(ctx, data)`` to
|
|
13
|
+
# invoke the next middleware or the terminal handler. Middleware is applied
|
|
14
|
+
# in FIFO order: the first registered middleware is the outermost.
|
|
15
|
+
Middleware = Callable[[Context, Any, NextFn], Awaitable[Any]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def logging_middleware(logger: logging.Logger = None) -> "Middleware":
|
|
19
|
+
"""Return a middleware that logs job ID, topic, and duration.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
logger: Logger to use. Defaults to ``logging.getLogger("cap.middleware")``.
|
|
23
|
+
"""
|
|
24
|
+
if logger is None:
|
|
25
|
+
logger = logging.getLogger("cap.middleware")
|
|
26
|
+
|
|
27
|
+
async def middleware(ctx: Context, data: Any, next_fn: NextFn) -> Any:
|
|
28
|
+
start = time.monotonic()
|
|
29
|
+
try:
|
|
30
|
+
result = await next_fn(ctx, data)
|
|
31
|
+
elapsed = int((time.monotonic() - start) * 1000)
|
|
32
|
+
logger.info(
|
|
33
|
+
"middleware: job=%s topic=%s duration=%dms ok",
|
|
34
|
+
ctx.job_id,
|
|
35
|
+
ctx.job.topic,
|
|
36
|
+
elapsed,
|
|
37
|
+
)
|
|
38
|
+
return result
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
elapsed = int((time.monotonic() - start) * 1000)
|
|
41
|
+
logger.info(
|
|
42
|
+
"middleware: job=%s topic=%s duration=%dms error=%s",
|
|
43
|
+
ctx.job_id,
|
|
44
|
+
ctx.job.topic,
|
|
45
|
+
elapsed,
|
|
46
|
+
exc,
|
|
47
|
+
)
|
|
48
|
+
raise
|
|
49
|
+
|
|
50
|
+
return middleware
|