openbox-temporal-sdk-python 1.0.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.
- openbox/__init__.py +107 -0
- openbox/activities.py +163 -0
- openbox/activity_interceptor.py +755 -0
- openbox/config.py +274 -0
- openbox/otel_setup.py +969 -0
- openbox/py.typed +0 -0
- openbox/span_processor.py +361 -0
- openbox/tracing.py +228 -0
- openbox/types.py +166 -0
- openbox/worker.py +257 -0
- openbox/workflow_interceptor.py +264 -0
- openbox_temporal_sdk_python-1.0.0.dist-info/METADATA +1214 -0
- openbox_temporal_sdk_python-1.0.0.dist-info/RECORD +15 -0
- openbox_temporal_sdk_python-1.0.0.dist-info/WHEEL +4 -0
- openbox_temporal_sdk_python-1.0.0.dist-info/licenses/LICENSE +21 -0
openbox/__init__.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""OpenBox SDK - Workflow-Boundary Governance with OpenTelemetry"""
|
|
2
|
+
|
|
3
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
# Simple Factories (recommended)
|
|
5
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
6
|
+
|
|
7
|
+
from .worker import create_openbox_worker
|
|
8
|
+
|
|
9
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
10
|
+
# Core Configuration
|
|
11
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
12
|
+
|
|
13
|
+
from .config import (
|
|
14
|
+
initialize,
|
|
15
|
+
get_global_config,
|
|
16
|
+
GovernanceConfig,
|
|
17
|
+
OpenBoxConfigError,
|
|
18
|
+
OpenBoxAuthError,
|
|
19
|
+
OpenBoxNetworkError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
23
|
+
# Types (sandbox-safe - can be imported in workflow code)
|
|
24
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
25
|
+
|
|
26
|
+
from .types import (
|
|
27
|
+
Verdict,
|
|
28
|
+
WorkflowEventType,
|
|
29
|
+
WorkflowSpanBuffer,
|
|
30
|
+
GovernanceVerdictResponse,
|
|
31
|
+
GuardrailsCheckResult,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
35
|
+
# Span Processor
|
|
36
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
37
|
+
|
|
38
|
+
from .span_processor import WorkflowSpanProcessor
|
|
39
|
+
|
|
40
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
41
|
+
# Interceptors
|
|
42
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
43
|
+
|
|
44
|
+
from .workflow_interceptor import GovernanceInterceptor, GovernanceHaltError
|
|
45
|
+
|
|
46
|
+
# NOTE: ActivityGovernanceInterceptor is NOT imported here because it imports
|
|
47
|
+
# OpenTelemetry which uses importlib_metadata -> os.stat, causing sandbox issues.
|
|
48
|
+
# Users must import directly: from openbox.activity_interceptor import ActivityGovernanceInterceptor
|
|
49
|
+
|
|
50
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
51
|
+
# Activities - DO NOT import here!
|
|
52
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
53
|
+
#
|
|
54
|
+
# IMPORTANT: Do NOT import activities from openbox/__init__.py!
|
|
55
|
+
# activities.py imports httpx which uses os.stat internally. If we re-export them
|
|
56
|
+
# here, it triggers Temporal sandbox restrictions ("Cannot access os.stat.__call__").
|
|
57
|
+
#
|
|
58
|
+
# Users must import directly:
|
|
59
|
+
# from openbox.activities import send_governance_event
|
|
60
|
+
|
|
61
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
62
|
+
# OTel Setup - NOT imported here to avoid sandbox issues!
|
|
63
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
64
|
+
#
|
|
65
|
+
# IMPORTANT: Do NOT import otel_setup here!
|
|
66
|
+
# otel_setup imports OpenTelemetry which uses importlib_metadata -> os.stat
|
|
67
|
+
# This triggers Temporal sandbox restrictions.
|
|
68
|
+
#
|
|
69
|
+
# Users must import directly: from openbox.otel_setup import setup_opentelemetry_for_governance
|
|
70
|
+
|
|
71
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
72
|
+
# Tracing Decorators - NOT imported here to avoid sandbox issues!
|
|
73
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
74
|
+
#
|
|
75
|
+
# Use the @traced decorator to capture internal function calls as spans.
|
|
76
|
+
# Import directly: from openbox.tracing import traced, create_span
|
|
77
|
+
#
|
|
78
|
+
# Example:
|
|
79
|
+
# from openbox.tracing import traced
|
|
80
|
+
#
|
|
81
|
+
# @traced
|
|
82
|
+
# def my_function(data):
|
|
83
|
+
# return process(data)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
__all__ = [
|
|
87
|
+
# Simple Worker Factory (recommended)
|
|
88
|
+
"create_openbox_worker",
|
|
89
|
+
# Configuration
|
|
90
|
+
"initialize",
|
|
91
|
+
"get_global_config",
|
|
92
|
+
"GovernanceConfig",
|
|
93
|
+
"OpenBoxConfigError",
|
|
94
|
+
"OpenBoxAuthError",
|
|
95
|
+
"OpenBoxNetworkError",
|
|
96
|
+
# Types
|
|
97
|
+
"Verdict",
|
|
98
|
+
"WorkflowEventType",
|
|
99
|
+
"WorkflowSpanBuffer",
|
|
100
|
+
"GovernanceVerdictResponse",
|
|
101
|
+
"GuardrailsCheckResult",
|
|
102
|
+
# Span Processor
|
|
103
|
+
"WorkflowSpanProcessor",
|
|
104
|
+
# Interceptors
|
|
105
|
+
"GovernanceInterceptor",
|
|
106
|
+
"GovernanceHaltError",
|
|
107
|
+
]
|
openbox/activities.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# openbox/activities.py
|
|
2
|
+
#
|
|
3
|
+
# IMPORTANT: This module imports httpx which uses os.stat internally.
|
|
4
|
+
# Do NOT import this module from workflow code (workflow_interceptor.py)!
|
|
5
|
+
# The workflow interceptor references this activity by string name "send_governance_event".
|
|
6
|
+
"""
|
|
7
|
+
Governance event activity for workflow-level HTTP calls.
|
|
8
|
+
|
|
9
|
+
CRITICAL: Temporal workflows must be deterministic. HTTP calls are NOT allowed directly
|
|
10
|
+
in workflow code (including interceptors). WorkflowInboundInterceptor sends events via
|
|
11
|
+
workflow.execute_activity() using this activity.
|
|
12
|
+
|
|
13
|
+
Events sent via this activity:
|
|
14
|
+
- WorkflowStarted
|
|
15
|
+
- WorkflowCompleted
|
|
16
|
+
- SignalReceived
|
|
17
|
+
|
|
18
|
+
Note: ActivityStarted/Completed events are sent directly from ActivityInboundInterceptor
|
|
19
|
+
since activities are allowed to make HTTP calls.
|
|
20
|
+
|
|
21
|
+
TIMESTAMP HANDLING: This activity adds the "timestamp" field to the payload when it
|
|
22
|
+
executes. This ensures timestamps are generated in activity context (non-deterministic
|
|
23
|
+
code allowed) rather than workflow context (must be deterministic).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
import logging
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from typing import Dict, Any, Optional
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _rfc3339_now() -> str:
|
|
33
|
+
"""Return current UTC time in RFC3339 format."""
|
|
34
|
+
return datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z'
|
|
35
|
+
|
|
36
|
+
from temporalio import activity
|
|
37
|
+
from temporalio.exceptions import ApplicationError
|
|
38
|
+
|
|
39
|
+
from .types import Verdict
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GovernanceAPIError(Exception):
|
|
45
|
+
"""Raised when governance API fails and policy is fail_closed."""
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def raise_governance_stop(reason: str, policy_id: str = None, risk_score: float = None):
|
|
50
|
+
"""
|
|
51
|
+
Raise a non-retryable ApplicationError when governance blocks an operation.
|
|
52
|
+
|
|
53
|
+
Using ApplicationError with non_retryable=True ensures:
|
|
54
|
+
1. The activity fails immediately (no retries)
|
|
55
|
+
2. The workflow fails with a clear error message
|
|
56
|
+
"""
|
|
57
|
+
details = {"policy_id": policy_id, "risk_score": risk_score}
|
|
58
|
+
raise ApplicationError(
|
|
59
|
+
f"Governance blocked: {reason}",
|
|
60
|
+
details,
|
|
61
|
+
type="GovernanceStop",
|
|
62
|
+
non_retryable=True, # Don't retry - terminate the workflow
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@activity.defn(name="send_governance_event")
|
|
67
|
+
async def send_governance_event(input: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
68
|
+
"""
|
|
69
|
+
Activity that sends governance events to OpenBox Core.
|
|
70
|
+
|
|
71
|
+
This activity is called from WorkflowInboundInterceptor via workflow.execute_activity()
|
|
72
|
+
to maintain workflow determinism. HTTP calls cannot be made directly in workflow context.
|
|
73
|
+
|
|
74
|
+
Args (in input dict):
|
|
75
|
+
api_url: OpenBox Core API URL
|
|
76
|
+
api_key: API key for authentication
|
|
77
|
+
payload: Event payload (without timestamp)
|
|
78
|
+
timeout: Request timeout in seconds
|
|
79
|
+
on_api_error: "fail_open" (default) or "fail_closed"
|
|
80
|
+
|
|
81
|
+
When on_api_error == "fail_closed" and API fails, raises GovernanceAPIError.
|
|
82
|
+
This is caught by the workflow interceptor and re-raised as GovernanceHaltError.
|
|
83
|
+
|
|
84
|
+
Logging is safe here because activities run outside the workflow sandbox.
|
|
85
|
+
"""
|
|
86
|
+
# Extract input fields
|
|
87
|
+
api_url = input.get("api_url", "")
|
|
88
|
+
api_key = input.get("api_key", "")
|
|
89
|
+
event_payload = input.get("payload", {})
|
|
90
|
+
timeout = input.get("timeout", 30.0)
|
|
91
|
+
on_api_error = input.get("on_api_error", "fail_open")
|
|
92
|
+
|
|
93
|
+
# Add timestamp here in activity context (non-deterministic code allowed)
|
|
94
|
+
# Use RFC3339 format: 2024-01-15T10:30:45.123Z
|
|
95
|
+
payload = {**event_payload, "timestamp": _rfc3339_now()}
|
|
96
|
+
event_type = event_payload.get("event_type", "unknown")
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
100
|
+
response = await client.post(
|
|
101
|
+
f"{api_url}/api/v1/governance/evaluate",
|
|
102
|
+
json=payload,
|
|
103
|
+
headers={
|
|
104
|
+
"Authorization": f"Bearer {api_key}",
|
|
105
|
+
"Content-Type": "application/json",
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if response.status_code == 200:
|
|
110
|
+
data = response.json()
|
|
111
|
+
# Parse verdict (v1.1) or action (v1.0)
|
|
112
|
+
verdict = Verdict.from_string(data.get("verdict") or data.get("action", "continue"))
|
|
113
|
+
reason = data.get("reason")
|
|
114
|
+
policy_id = data.get("policy_id")
|
|
115
|
+
risk_score = data.get("risk_score", 0.0)
|
|
116
|
+
|
|
117
|
+
# Check if governance wants to stop the workflow (BLOCK or HALT)
|
|
118
|
+
if verdict.should_stop():
|
|
119
|
+
logger.info(f"Governance blocked {event_type}: {reason} (policy: {policy_id})")
|
|
120
|
+
|
|
121
|
+
# For SignalReceived events, return result instead of raising
|
|
122
|
+
# The workflow interceptor will store verdict for activity interceptor to check
|
|
123
|
+
if event_type == "SignalReceived":
|
|
124
|
+
return {
|
|
125
|
+
"success": True,
|
|
126
|
+
"verdict": verdict.value,
|
|
127
|
+
"action": verdict.value, # backward compat
|
|
128
|
+
"reason": reason,
|
|
129
|
+
"policy_id": policy_id,
|
|
130
|
+
"risk_score": risk_score,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# For other events, raise non-retryable error to terminate workflow immediately
|
|
134
|
+
raise_governance_stop(
|
|
135
|
+
reason=reason or "No reason provided",
|
|
136
|
+
policy_id=policy_id,
|
|
137
|
+
risk_score=risk_score,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
"success": True,
|
|
142
|
+
"verdict": verdict.value,
|
|
143
|
+
"action": verdict.value, # backward compat
|
|
144
|
+
"reason": reason,
|
|
145
|
+
"policy_id": policy_id,
|
|
146
|
+
"risk_score": risk_score,
|
|
147
|
+
}
|
|
148
|
+
else:
|
|
149
|
+
error_msg = f"HTTP {response.status_code}: {response.text}"
|
|
150
|
+
logger.warning(f"Governance API error for {event_type}: {error_msg}")
|
|
151
|
+
if on_api_error == "fail_closed":
|
|
152
|
+
raise GovernanceAPIError(error_msg)
|
|
153
|
+
return {"success": False, "error": error_msg}
|
|
154
|
+
|
|
155
|
+
except (GovernanceAPIError, ApplicationError):
|
|
156
|
+
raise # Re-raise to workflow (ApplicationError is non-retryable)
|
|
157
|
+
except Exception as e:
|
|
158
|
+
logger.warning(f"Failed to send {event_type} event: {e}")
|
|
159
|
+
if on_api_error == "fail_closed":
|
|
160
|
+
raise GovernanceAPIError(str(e))
|
|
161
|
+
return {"success": False, "error": str(e)}
|
|
162
|
+
|
|
163
|
+
|