88from __future__ import annotations
99
1010import asyncio
11- import copy
1211import dataclasses
13- import inspect
1412import logging
1513import threading
16- import time
1714from collections .abc import AsyncIterator
1815from typing import Any , Callable
1916
2017import pydantic
2118
2219from apcore .acl import ACL
23- from apcore .context_keys import REDACTED_OUTPUT
2420from apcore .approval import ApprovalHandler , ApprovalRequest , ApprovalResult
2521from apcore .cancel import ExecutionCancelledError
2622from apcore .config import Config
5147 StrategyNotFoundError ,
5248)
5349from apcore .registry import MODULE_ID_PATTERN , Registry
54- from apcore .utils .call_chain import guard_call_chain
5550
56- __all__ = [ "redact_sensitive" , " REDACTED_VALUE" , "Executor" ]
51+ from apcore . utils . redaction import REDACTED_VALUE , redact_sensitive
5752
58- REDACTED_VALUE : str = "***REDACTED***"
53+ __all__ = [ "redact_sensitive" , "REDACTED_VALUE" , "Executor" ]
5954
6055_logger = logging .getLogger (__name__ )
6156
@@ -123,71 +118,8 @@ def _convert_validation_errors(error: pydantic.ValidationError) -> list[dict[str
123118# =============================================================================
124119
125120
126- def redact_sensitive (data : dict [str , Any ], schema_dict : dict [str , Any ]) -> dict [str , Any ]:
127- """Redact fields marked with x-sensitive in the schema.
128-
129- Implements Algorithm A13 from PROTOCOL_SPEC section 9.5.
130- Returns a deep copy of data with sensitive values replaced by "***REDACTED***".
131- Also redacts any keys starting with "_secret_" regardless of schema.
132-
133- Args:
134- data: The data dict to redact.
135- schema_dict: A JSON Schema dict that may contain "x-sensitive": true
136- on individual properties.
137-
138- Returns:
139- A new dict with sensitive values replaced. Original data is not modified.
140- """
141- redacted = copy .deepcopy (data )
142- _redact_fields (redacted , schema_dict )
143- _redact_secret_prefix (redacted )
144- return redacted
145-
146-
147- def _redact_fields (data : dict [str , Any ], schema_dict : dict [str , Any ]) -> None :
148- """In-place redaction based on schema x-sensitive markers."""
149- properties = schema_dict .get ("properties" )
150- if not properties :
151- return
152-
153- for field_name , field_schema in properties .items ():
154- if field_name not in data :
155- continue
156-
157- value = data [field_name ]
158-
159- # x-sensitive: true on this property
160- if field_schema .get ("x-sensitive" ) is True :
161- if value is not None :
162- data [field_name ] = REDACTED_VALUE
163- continue
164-
165- # Nested object: recurse
166- if field_schema .get ("type" ) == "object" and "properties" in field_schema and isinstance (value , dict ):
167- _redact_fields (value , field_schema )
168- continue
169-
170- # Array: redact items
171- if field_schema .get ("type" ) == "array" and "items" in field_schema and isinstance (value , list ):
172- items_schema = field_schema ["items" ]
173- if items_schema .get ("x-sensitive" ) is True :
174- for i , item in enumerate (value ):
175- if item is not None :
176- value [i ] = REDACTED_VALUE
177- elif items_schema .get ("type" ) == "object" and "properties" in items_schema :
178- for item in value :
179- if isinstance (item , dict ):
180- _redact_fields (item , items_schema )
181-
182-
183- def _redact_secret_prefix (data : dict [str , Any ]) -> None :
184- """In-place redaction of keys starting with _secret_."""
185- for key in data :
186- value = data [key ]
187- if key .startswith ("_secret_" ) and value is not None :
188- data [key ] = REDACTED_VALUE
189- elif isinstance (value , dict ):
190- _redact_secret_prefix (value )
121+ # redact_sensitive and REDACTED_VALUE moved to apcore.utils.redaction in v0.17
122+ # Re-exported here for backward compatibility.
191123
192124
193125# =============================================================================
@@ -480,12 +412,8 @@ def validate(
480412 if loop is None :
481413 if self ._sync_loop is None or self ._sync_loop .is_closed ():
482414 self ._sync_loop = asyncio .new_event_loop ()
483- return self ._sync_loop .run_until_complete (
484- self ._validate_async (module_id , inputs , context )
485- )
486- return self ._run_in_new_thread (
487- self ._validate_async (module_id , inputs , context ), module_id , None
488- )
415+ return self ._sync_loop .run_until_complete (self ._validate_async (module_id , inputs , context ))
416+ return self ._run_in_new_thread (self ._validate_async (module_id , inputs , context ), module_id , None )
489417
490418 async def _validate_async (
491419 self ,
@@ -550,7 +478,11 @@ async def _validate_async(
550478 requires_approval = self ._needs_approval (pipe_ctx .module )
551479
552480 # Module-level preflight (optional)
553- if pipe_ctx .module is not None and hasattr (pipe_ctx .module , "preflight" ) and callable (pipe_ctx .module .preflight ):
481+ if (
482+ pipe_ctx .module is not None
483+ and hasattr (pipe_ctx .module , "preflight" )
484+ and callable (pipe_ctx .module .preflight )
485+ ):
554486 try :
555487 preflight_warnings = pipe_ctx .module .preflight (inputs , pipe_ctx .context )
556488 if isinstance (preflight_warnings , list ) and preflight_warnings :
@@ -719,6 +651,7 @@ def _translate_abort(self, abort: PipelineAbortError) -> ModuleError:
719651 if step == "execute" :
720652 if "cancelled" in explanation .lower ():
721653 from apcore .cancel import ExecutionCancelledError
654+
722655 return ExecutionCancelledError ()
723656 if "deadline" in explanation .lower () or "timed out" in explanation .lower ():
724657 return ModuleTimeoutError (module_id = "" , timeout_ms = 0 )
@@ -876,7 +809,10 @@ async def stream(
876809 wrapped = propagate_error (exc , module_id , ctx_obj ) if ctx_obj else exc
877810 if pipe_ctx .executed_middlewares :
878811 recovery = await self ._middleware_manager .execute_on_error_async (
879- module_id , pipe_ctx .inputs , wrapped , ctx_obj ,
812+ module_id ,
813+ pipe_ctx .inputs ,
814+ wrapped ,
815+ ctx_obj ,
880816 pipe_ctx .executed_middlewares ,
881817 )
882818 if recovery is not None :
@@ -902,7 +838,10 @@ async def stream(
902838 wrapped = propagate_error (exc , module_id , ctx_obj ) if ctx_obj else exc
903839 if pipe_ctx .executed_middlewares :
904840 recovery = await self ._middleware_manager .execute_on_error_async (
905- module_id , pipe_ctx .inputs , wrapped , ctx_obj ,
841+ module_id ,
842+ pipe_ctx .inputs ,
843+ wrapped ,
844+ ctx_obj ,
906845 pipe_ctx .executed_middlewares ,
907846 )
908847 if recovery is not None :
@@ -912,8 +851,9 @@ async def stream(
912851
913852 # Phase 3: Output validation + middleware_after on accumulated result
914853 pipe_ctx .output = accumulated
915- post_steps = [s for s in self ._strategy .steps
916- if s .name in ("output_validation" , "middleware_after" , "return_result" )]
854+ post_steps = [
855+ s for s in self ._strategy .steps if s .name in ("output_validation" , "middleware_after" , "return_result" )
856+ ]
917857 if post_steps :
918858 post_strategy = ExecutionStrategy ("post_stream" , post_steps )
919859 try :
0 commit comments