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 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
+