soprano-sdk 0.1.99__py3-none-any.whl → 0.2.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.
File without changes
@@ -0,0 +1,153 @@
1
+ import requests
2
+ from typing import TypedDict, Literal, NotRequired
3
+ from soprano_sdk.core.constants import MFARestAuthorizerEnv
4
+
5
+
6
+ class MFAChallenge(TypedDict):
7
+ value: str
8
+
9
+
10
+ class MFAState(TypedDict):
11
+ challengeType: Literal['OTP', 'dob']
12
+ post_payload: dict[str, str]
13
+ otpValue: NotRequired[str]
14
+ status: Literal['IN_PROGRESS', 'COMPLETED', 'ERRORED', 'FAILED'] | None
15
+ message: str
16
+ retry_count: int
17
+
18
+
19
+
20
+ def get_response(response: requests.Response):
21
+ if response.ok:
22
+ return response.json(), None
23
+ else:
24
+ return None, response.json()
25
+
26
+
27
+ def build_path(base_url: str, path: str):
28
+ return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
29
+
30
+
31
+ def enforce_mfa_if_required(state: dict):
32
+ _mfa : MFAState = state['_mfa']
33
+ if _mfa['status'] == 'COMPLETED':
34
+ return True
35
+ generate_token_response = requests.post(
36
+ build_path(
37
+ base_url=MFARestAuthorizerEnv.GENERATE_TOKEN_BASE_URL.get_from_env(),
38
+ path=MFARestAuthorizerEnv.GENERATE_TOKEN_PATH.get_from_env()
39
+ ), json=_mfa['post_payload'], timeout=30, headers={"Authorization": f"Bearer {state['bearer_token']}"}
40
+ )
41
+ _, error = get_response(generate_token_response)
42
+
43
+ challenge_type = error['additionalData']['challengeType']
44
+ _mfa['challengeType'] = challenge_type
45
+ _mfa['status'] = 'IN_PROGRESS'
46
+ _mfa['retry_count'] = 0
47
+ _mfa['message'] = f"Please enter the {challenge_type}"
48
+ if delivery_methods := error['additionalData'].get(f"{challenge_type.lower()}SentTo"):
49
+ _mfa['message'] += f" sent via {','.join(delivery_methods)}"
50
+ return False
51
+
52
+
53
+ def mfa_validate_user_input(**state: dict):
54
+ _mfa : MFAState = state['_mfa']
55
+ input_field_name = state['_active_input_field']
56
+ if not state[input_field_name]:
57
+ return False
58
+
59
+ post_payload = _mfa['post_payload']
60
+ challenge_field_name = f"{_mfa['challengeType'].lower()}Challenge"
61
+ post_payload.update({challenge_field_name: {"value": state[input_field_name]}})
62
+ validate_token_response = requests.post(
63
+ build_path(
64
+ base_url=MFARestAuthorizerEnv.VALIDATE_TOKEN_BASE_URL.get_from_env(),
65
+ path=MFARestAuthorizerEnv.VALIDATE_TOKEN_PATH.get_from_env()
66
+ ), json=post_payload, timeout=30, headers={"Authorization": f"Bearer {state['bearer_token']}"}
67
+ )
68
+ _mfa['retry_count'] += 1
69
+ response, error = get_response(validate_token_response)
70
+ if error:
71
+ if _mfa['retry_count'] == 1:
72
+ _mfa['status'] = 'ERRORED'
73
+ _mfa['message'] = (
74
+ f"You Have Entered Invalid {_mfa['challengeType']}. {_mfa['message']}"
75
+ )
76
+ return False
77
+
78
+ if response and 'token' in response:
79
+ token = response['token']
80
+ post_payload['token'] = token
81
+
82
+ authorize = requests.post(
83
+ build_path(
84
+ base_url=MFARestAuthorizerEnv.AUTHORIZE_TOKEN_BASE_URL.get_from_env(),
85
+ path=MFARestAuthorizerEnv.AUTHORIZE_TOKEN_PATH.get_from_env()
86
+ ),
87
+ json=post_payload,
88
+ timeout=30,
89
+ headers={"Authorization": f"Bearer {state['bearer_token']}"}
90
+ )
91
+ if authorize.status_code == 204:
92
+ _mfa['status'] = 'COMPLETED'
93
+ return True
94
+ else:
95
+ _mfa['status'] = 'FAILED'
96
+ return False
97
+
98
+
99
+ class MFANodeConfig:
100
+
101
+ @classmethod
102
+ def get_call_function_template(cls, source_node: str, next_node: str, mfa: dict):
103
+ return dict(
104
+ id=f"{source_node}_mfa_start",
105
+ action="call_function",
106
+ function="conversational_sop.authenticators.mfa.enforce_mfa_if_required",
107
+ output=f"{source_node}_mfa_start",
108
+ mfa=mfa,
109
+ transitions=[
110
+ dict(
111
+ condition=True,
112
+ next=source_node,
113
+ ),
114
+ dict(
115
+ condition=False,
116
+ next=next_node,
117
+ ),
118
+ ]
119
+ )
120
+
121
+ @classmethod
122
+ def get_validate_user_input(cls, source_node: str, next_node: str, model_name: str):
123
+ input_field_name = f"{source_node}_mfa_input"
124
+ return dict(
125
+ id=f"{source_node}_mfa_validate",
126
+ action="collect_input_with_agent",
127
+ description="Collect Input for MFA value",
128
+ field=input_field_name,
129
+ max_attempts=3,
130
+ validator="conversational_sop.authenticators.mfa.mfa_validate_user_input",
131
+ agent=dict(
132
+ name="MFA Input Data Collector",
133
+ model=model_name,
134
+ initial_message="{{_mfa.message}}",
135
+ instructions="""
136
+ You are an authentication value extractor. Your job is to identify and extract MFA codes from user input.
137
+
138
+ **Task:**
139
+ - Read the user's message
140
+ - Extract ONLY the OTP code value
141
+ - Output in the exact format shown below
142
+
143
+ **Output Format:**
144
+ MFA_CAPTURED:
145
+
146
+ """),
147
+ transitions=[
148
+ dict(
149
+ pattern="MFA_CAPTURED:",
150
+ next=next_node
151
+ )
152
+ ]
153
+ )
@@ -1,3 +1,4 @@
1
+ import os
1
2
  from enum import Enum
2
3
 
3
4
 
@@ -57,3 +58,19 @@ DEFAULT_TIMEOUT = 300
57
58
 
58
59
  MAX_ATTEMPTS_MESSAGE = "I'm having trouble understanding your {field}. Please contact customer service for assistance."
59
60
  WORKFLOW_COMPLETE_MESSAGE = "Workflow completed."
61
+
62
+
63
+ class MFARestAuthorizerEnv(Enum):
64
+ GENERATE_TOKEN_BASE_URL = 'GENERATE_TOKEN_BASE_URL'
65
+ GENERATE_TOKEN_PATH = 'GENERATE_TOKEN_PATH'
66
+ VALIDATE_TOKEN_BASE_URL = 'VALIDATE_TOKEN_BASE_URL'
67
+ VALIDATE_TOKEN_PATH = 'VALIDATE_TOKEN_PATH'
68
+ AUTHORIZE_TOKEN_BASE_URL = 'AUTHORIZE_TOKEN_BASE_URL'
69
+ AUTHORIZE_TOKEN_PATH = 'AUTHORIZE_TOKEN_PATH'
70
+
71
+ API_TIMEOUT = 'API_TIMEOUT'
72
+
73
+ def get_from_env(self):
74
+ if self == MFARestAuthorizerEnv.API_TIMEOUT:
75
+ return int(os.getenv(self.value, '30'))
76
+ return os.getenv(self.value)
@@ -15,9 +15,10 @@ from ..utils.function import FunctionRepository
15
15
  from ..utils.logger import logger
16
16
  from ..utils.tool import ToolRepository
17
17
  from ..validation import validate_workflow
18
-
18
+ from soprano_sdk.authenticators.mfa import MFANodeConfig
19
19
 
20
20
  class WorkflowEngine:
21
+
21
22
  def __init__(self, yaml_path: str, configs: dict):
22
23
  self.yaml_path = yaml_path
23
24
  self.configs = configs or {}
@@ -33,14 +34,16 @@ class WorkflowEngine:
33
34
  self.workflow_name = self.config['name']
34
35
  self.workflow_description = self.config['description']
35
36
  self.workflow_version = self.config['version']
36
- self.data_fields = self.config['data']
37
- self.steps = self.config['steps']
37
+ self.mfa_node_indexes: list[int] = []
38
+ self.steps: list = self.load_steps()
39
+ self.step_map = {step['id']: step for step in self.steps}
40
+ self.data_fields = self.load_data()
41
+
38
42
  self.outcomes = self.config['outcomes']
39
43
  self.metadata = self.config.get('metadata', {})
40
44
 
41
45
  self.StateType = create_state_model(self.data_fields)
42
46
 
43
- self.step_map = {step['id']: step for step in self.steps}
44
47
  self.outcome_map = {outcome['id']: outcome for outcome in self.outcomes}
45
48
 
46
49
  self.function_repository = FunctionRepository()
@@ -196,6 +199,51 @@ class WorkflowEngine:
196
199
  return tool_config.get('usage_policy')
197
200
 
198
201
 
202
+ def load_steps(self):
203
+ prepared_steps: list = []
204
+ previous_step: dict | None = None
205
+ for step in self.config['steps']:
206
+ step_id = step['id']
207
+
208
+ if mfa_config := step.get('mfa'):
209
+ mfa_data_collector = MFANodeConfig.get_validate_user_input(
210
+ next_node=step_id, model_name=mfa_config['model'],
211
+ source_node=step_id
212
+ )
213
+ mfa_start = MFANodeConfig.get_call_function_template(
214
+ source_node=step_id,
215
+ next_node=mfa_data_collector['id'],
216
+ mfa=mfa_config
217
+ )
218
+ prepared_steps.append(mfa_start)
219
+ prepared_steps.append(mfa_data_collector)
220
+ self.mfa_node_indexes.append(len(prepared_steps) - 1)
221
+
222
+ if previous_step and previous_step.get('transitions'):
223
+ for transition in previous_step.get('transitions'):
224
+ if transition.get('next') == step_id:
225
+ transition['next'] = mfa_start['id']
226
+
227
+ del step['mfa']
228
+
229
+ prepared_steps.append(step)
230
+ previous_step = step
231
+ return prepared_steps
232
+
233
+ def load_data(self):
234
+ data: list = self.config['data']
235
+ for mfa_node_index in self.mfa_node_indexes:
236
+ data.append(
237
+ dict(
238
+ name=f'{self.steps[mfa_node_index]['field']}',
239
+ type='text',
240
+ description='Input Recieved from the user during MFA'
241
+ )
242
+ )
243
+
244
+ return data
245
+
246
+
199
247
  def load_workflow(yaml_path: str, checkpointer=None, config=None) -> Tuple[CompiledStateGraph, WorkflowEngine]:
200
248
  """
201
249
  Load a workflow from YAML configuration.
soprano_sdk/core/state.py CHANGED
@@ -47,6 +47,8 @@ def create_state_model(data_fields: List[dict]):
47
47
  fields['_node_field_map'] = Annotated[Dict[str, str], replace]
48
48
  fields['_computed_fields'] = Annotated[List[str], replace]
49
49
  fields['error'] = Annotated[Optional[Dict[str, str]], replace]
50
+ fields['_mfa'] = Annotated[Optional[Dict[str, str]], replace]
51
+ fields['mfa_input'] = Annotated[Optional[Dict[str, str]], replace]
50
52
 
51
53
  return types.new_class('WorkflowState', (TypedDict,), {}, lambda ns: ns.update({'__annotations__': fields}))
52
54
 
@@ -61,7 +63,13 @@ def initialize_state(state: Dict[str, Any]) -> Dict[str, Any]:
61
63
  '_node_execution_order': [],
62
64
  '_node_field_map': {},
63
65
  '_computed_fields': [],
64
- 'error': None
66
+ 'error': None,
67
+ '_mfa': {
68
+ 'retry_count': 0,
69
+ 'challengeType': None,
70
+ 'status': None,
71
+ 'message': None,
72
+ }
65
73
  }
66
74
 
67
75
  for field_name, default_value in fields_to_initialize.items():
soprano_sdk/nodes/base.py CHANGED
@@ -18,10 +18,15 @@ class ActionStrategy(ABC):
18
18
  def execute(self, state: Dict[str, Any]) -> Dict[str, Any]:
19
19
  pass
20
20
 
21
+ @abstractmethod
22
+ def pre_execute(self, state: Dict[str, Any]) -> Dict[str, Any]:
23
+ pass
24
+
21
25
  def get_node_function(self) -> Callable[[Dict[str, Any]], Dict[str, Any]]:
22
26
  def node_fn(state: Dict[str, Any]) -> Dict[str, Any]:
23
27
  logger.info(f"Executing node: {self.step_id} (action: {self.action})")
24
28
  try:
29
+ self.pre_execute(state)
25
30
  result = self.execute(state)
26
31
  logger.info(f"Node {self.step_id} completed successfully")
27
32
  logger.info(f"Result: {result}")
@@ -0,0 +1,651 @@
1
+ # # api_response_handler.py
2
+ # import re
3
+ # import os
4
+ # import json
5
+ # import time
6
+ # from typing import Any, Dict, List, Optional, Union, Callable
7
+ # from dataclasses import dataclass
8
+ # from enum import Enum
9
+ # import requests
10
+ # from requests.adapters import HTTPAdapter
11
+ # from urllib3.util.retry import Retry
12
+ # import importlib
13
+ # import operator
14
+
15
+ # class ErrorSeverity(Enum):
16
+ # """Error severity levels"""
17
+
18
+ # INFO = "info"
19
+ # WARNING = "warning"
20
+ # ERROR = "error"
21
+ # CRITICAL = "critical"
22
+
23
+
24
+ # @dataclass
25
+ # class APIError:
26
+ # """Structured API error"""
27
+
28
+ # step_id: str
29
+ # status_code: int
30
+ # error_code: Optional[str] = None
31
+ # message: str = ""
32
+ # severity: ErrorSeverity = ErrorSeverity.ERROR
33
+ # response_data: Optional[Dict] = None
34
+ # retry_after: Optional[int] = None
35
+
36
+ # def to_dict(self) -> Dict[str, Any]:
37
+ # return {
38
+ # "error": True,
39
+ # "step_id": self.step_id,
40
+ # "status_code": self.status_code,
41
+ # "error_code": self.error_code,
42
+ # "message": self.message,
43
+ # "severity": self.severity.value,
44
+ # "response_data": self.response_data,
45
+ # "retry_after": self.retry_after,
46
+ # }
47
+
48
+
49
+ # class ValueResolver:
50
+ # """Resolves references and variables in values"""
51
+
52
+ # @staticmethod
53
+ # def resolve(value: Any, state: Dict[str, Any]) -> Any:
54
+ # """Resolve a value with state context"""
55
+ # if not isinstance(value, str):
56
+ # return value
57
+
58
+ # # Handle ${env.VAR_NAME}
59
+ # value = ValueResolver._resolve_env_vars(value)
60
+
61
+ # # Handle ${timestamp}
62
+ # if "${timestamp}" in value:
63
+ # value = value.replace("${timestamp}", str(int(time.time())))
64
+
65
+ # # Handle {state.variable} or {variable.nested.path}
66
+ # value = ValueResolver._resolve_state_references(value, state)
67
+
68
+ # return value
69
+
70
+ # @staticmethod
71
+ # def _resolve_env_vars(value: str) -> str:
72
+ # """Resolve environment variables"""
73
+ # env_pattern = r"\$\{env\.([^}]+)\}"
74
+ # matches = re.findall(env_pattern, value)
75
+ # for match in matches:
76
+ # env_value = os.environ.get(match, "")
77
+ # value = value.replace(f"${{env.{match}}}", env_value)
78
+ # return value
79
+
80
+ # @staticmethod
81
+ # def _resolve_state_references(value: str, state: Dict[str, Any]) -> str:
82
+ # """Resolve state references like {username} or {user.profile.name}"""
83
+ # pattern = r"\{([^}]+)\}"
84
+ # matches = re.findall(pattern, value)
85
+
86
+ # for match in matches:
87
+ # resolved = ValueResolver._extract_nested_value(state, match)
88
+ # if resolved is not None:
89
+ # value = value.replace(f"{{{match}}}", str(resolved))
90
+
91
+ # return value
92
+
93
+ # @staticmethod
94
+ # def _extract_nested_value(data: Any, path: str) -> Any:
95
+ # """Extract value from nested structure using dot notation"""
96
+ # parts = path.split(".")
97
+ # result = data
98
+
99
+ # for part in parts:
100
+ # if isinstance(result, dict):
101
+ # result = result.get(part)
102
+ # elif isinstance(result, list) and part.isdigit():
103
+ # try:
104
+ # result = result[int(part)]
105
+ # except (IndexError, ValueError):
106
+ # return None
107
+ # else:
108
+ # return None
109
+
110
+ # if result is None:
111
+ # return None
112
+
113
+ # return result
114
+
115
+
116
+ # class ConditionEvaluator:
117
+ # OPERATORS = {
118
+ # "==": operator.eq,
119
+ # "!=": operator.ne,
120
+ # ">": operator.gt,
121
+ # "<": operator.lt,
122
+ # ">=": operator.ge,
123
+ # "<=": operator.le,
124
+ # "contains": lambda a, b: b in str(a) if a is not None else False,
125
+ # "not_contains": lambda a, b: b not in str(a) if a is not None else True,
126
+ # "in": lambda a, b: a in b if isinstance(b, (list, tuple)) else False,
127
+ # "not_in": lambda a, b: a not in b if isinstance(b, (list, tuple)) else True,
128
+ # "starts_with": (
129
+ # lambda a, b: str(a).startswith(str(b))
130
+ # if a is not None else False
131
+ # ),
132
+ # "ends_with": lambda a, b: str(a).endswith(str(b)) if a is not None else False,
133
+ # "is_null": lambda a, b: a is None,
134
+ # "is_not_null": lambda a, b: a is not None,
135
+ # "is_empty": lambda a, b: not a if isinstance(a, (list, dict, str)) else False,
136
+ # "is_not_empty": (
137
+ # lambda a, b: bool(a)
138
+ # if isinstance(a, (list, dict, str)) else False
139
+ # ),
140
+ # }
141
+
142
+ # @staticmethod
143
+ # def evaluate(
144
+ # condition: Dict[str, Any], response: Dict[str, Any], state: Dict[str, Any]) -> bool:
145
+
146
+ # if "status_code" in condition:
147
+ # expected = condition["status_code"]
148
+ # actual = response.get("status")
149
+
150
+ # if isinstance(expected, list):
151
+ # if actual not in expected:
152
+ # return False
153
+ # elif actual != expected:
154
+ # return False
155
+
156
+ # if "field" in condition:
157
+ # field_path = condition["field"]
158
+ # operator = condition.get("operator", "==")
159
+ # expected_value = condition.get("value")
160
+
161
+ # actual_value = ValueResolver._extract_nested_value(response, field_path)
162
+
163
+ # if isinstance(expected_value, str):
164
+ # expected_value = ValueResolver.resolve(expected_value, state)
165
+
166
+ # if operator not in ConditionEvaluator.OPERATORS:
167
+ # raise ValueError(f"Unknown operator: {operator}")
168
+
169
+ # return ConditionEvaluator.OPERATORS[operator](actual_value, expected_value)
170
+
171
+ # if "and" in condition:
172
+ # return all(
173
+ # ConditionEvaluator.evaluate(sub_cond, response, state)
174
+ # for sub_cond in condition["and"]
175
+ # )
176
+
177
+ # if "or" in condition:
178
+ # return any(
179
+ # ConditionEvaluator.evaluate(sub_cond, response, state)
180
+ # for sub_cond in condition["or"]
181
+ # )
182
+
183
+ # return True
184
+
185
+ # @staticmethod
186
+ # def evaluate_all(
187
+ # conditions: List[Dict[str, Any]],
188
+ # response: Dict[str, Any],
189
+ # state: Dict[str, Any],
190
+ # ) -> Optional[Dict[str, Any]]:
191
+ # """Find first matching condition from a list"""
192
+ # for condition in conditions:
193
+ # # Skip default conditions for now
194
+ # if condition.get("default"):
195
+ # continue
196
+
197
+ # if ConditionEvaluator.evaluate(condition, response, state):
198
+ # return condition
199
+
200
+ # # Return default if no match
201
+ # for condition in conditions:
202
+ # if condition.get("default"):
203
+ # return condition
204
+
205
+ # return None
206
+
207
+
208
+ # class ResponseMapper:
209
+ # """Maps response data to state variables"""
210
+
211
+ # @staticmethod
212
+ # def apply_mappings(
213
+ # response: Dict[str, Any], mappings: List[Dict[str, Any]], state: Dict[str, Any]
214
+ # ) -> None:
215
+ # """Apply response mappings to state"""
216
+ # for mapping in mappings:
217
+ # from_path = mapping.get("from", "")
218
+ # to_field = mapping.get("to", "")
219
+ # transform = mapping.get("transform") # Optional transformation function
220
+ # default = mapping.get("default") # Default value if extraction fails
221
+
222
+ # # Extract value
223
+ # value = ValueResolver._extract_nested_value(response, from_path)
224
+
225
+ # # Apply default if needed
226
+ # if value is None and default is not None:
227
+ # value = default
228
+
229
+ # # Apply transformation if specified
230
+ # if value is not None and transform:
231
+ # value = ResponseMapper._apply_transform(value, transform, state)
232
+
233
+ # # Store in state
234
+ # if to_field:
235
+ # state[to_field] = value
236
+
237
+ # @staticmethod
238
+ # def _apply_transform(
239
+ # value: Any, transform: Union[str, Dict], state: Dict[str, Any]
240
+ # ) -> Any:
241
+ # """Apply transformation to a value"""
242
+ # if isinstance(transform, str):
243
+ # # Simple transformations
244
+ # if transform == "lowercase":
245
+ # return str(value).lower()
246
+ # elif transform == "uppercase":
247
+ # return str(value).upper()
248
+ # elif transform == "trim":
249
+ # return str(value).strip()
250
+ # elif transform == "int":
251
+ # return int(value)
252
+ # elif transform == "float":
253
+ # return float(value)
254
+ # elif transform == "bool":
255
+ # return bool(value)
256
+ # elif transform == "json":
257
+ # return json.loads(value) if isinstance(value, str) else value
258
+
259
+ # elif isinstance(transform, dict):
260
+ # # Function-based transformation
261
+ # if "function" in transform:
262
+ # func_path = transform["function"]
263
+ # func_args = transform.get("args", {})
264
+
265
+ # # Resolve arguments
266
+ # resolved_args = {
267
+ # k: ValueResolver.resolve(v, state) for k, v in func_args.items()
268
+ # }
269
+ # resolved_args["value"] = value
270
+
271
+ # # Execute transformation function
272
+ # return FunctionExecutor.execute_transform(func_path, resolved_args)
273
+
274
+ # return value
275
+
276
+
277
+ # class ErrorHandler:
278
+ # """Handles API errors with configurable responses"""
279
+
280
+ # @staticmethod
281
+ # def handle_error(
282
+ # step_id: str,
283
+ # response: Dict[str, Any],
284
+ # error_config: Dict[str, Any],
285
+ # state: Dict[str, Any],
286
+ # ) -> APIError:
287
+ # """Handle an error based on configuration"""
288
+ # status_code = response.get("status", 0)
289
+
290
+ # message = error_config.get("message", "An error occurred")
291
+ # message = ValueResolver.resolve(message, {**state, "response": response})
292
+
293
+ # error_code_path = error_config.get("error_code_field", "error_code")
294
+ # error_code = ValueResolver._extract_nested_value(
295
+ # response, f"data.{error_code_path}"
296
+ # )
297
+
298
+ # severity_str = error_config.get("severity", "error")
299
+ # severity = ErrorSeverity(severity_str)
300
+
301
+ # retry_after = error_config.get("retry_after")
302
+ # if retry_after:
303
+ # retry_after = ValueResolver.resolve(retry_after, state)
304
+
305
+ # return APIError(
306
+ # step_id=step_id,
307
+ # status_code=status_code,
308
+ # error_code=error_code,
309
+ # message=message,
310
+ # severity=severity,
311
+ # response_data=response.get("data"),
312
+ # retry_after=retry_after,
313
+ # )
314
+
315
+
316
+ # class PostProcessor:
317
+ # """Handles post-processing of API responses"""
318
+
319
+ # @staticmethod
320
+ # def process(
321
+ # response: Dict[str, Any], post_config: Dict[str, Any], state: Dict[str, Any]
322
+ # ) -> Any:
323
+ # """Execute post-processing on response"""
324
+ # function_path = post_config.get("function")
325
+ # inputs = post_config.get("inputs", {})
326
+ # output_field = post_config.get("output")
327
+
328
+ # resolved_inputs = {"response": response}
329
+ # for key, value in inputs.items():
330
+ # resolved_inputs[key] = ValueResolver.resolve(value, state)
331
+
332
+ # result = FunctionExecutor.execute(function_path, resolved_inputs)
333
+
334
+ # if output_field:
335
+ # state[output_field] = result
336
+
337
+ # return result
338
+
339
+
340
+ # class FunctionExecutor:
341
+ # """Executes functions from module paths"""
342
+
343
+ # @staticmethod
344
+ # def execute(function_path: str, inputs: Dict[str, Any]) -> Any:
345
+ # """Execute a function given its module path"""
346
+ # try:
347
+ # # Parse module and function name
348
+ # parts = function_path.rsplit(".", 1)
349
+ # if len(parts) != 2:
350
+ # raise ValueError(f"Invalid function path: {function_path}")
351
+
352
+ # module_name, function_name = parts
353
+
354
+ # module = importlib.import_module(module_name)
355
+ # function = getattr(module, function_name)
356
+
357
+ # return function(**inputs)
358
+
359
+ # except Exception as e:
360
+ # raise Exception(f"Failed to execute function {function_path}: {str(e)}")
361
+
362
+ # @staticmethod
363
+ # def execute_transform(function_path: str, inputs: Dict[str, Any]) -> Any:
364
+ # """Execute a transformation function"""
365
+ # return FunctionExecutor.execute(function_path, inputs)
366
+
367
+
368
+ # class APIClient:
369
+ # """Handles API requests with comprehensive error handling"""
370
+
371
+ # def __init__(self, config: Dict[str, Any]):
372
+ # self.name = config.get("name")
373
+ # self.base_url = ValueResolver._resolve_env_vars(config.get("base_url", ""))
374
+ # self.headers = self._resolve_headers(config.get("headers", {}))
375
+ # self.timeout = config.get("timeout", 30)
376
+ # self.retry_config = config.get("retry", {})
377
+
378
+ # self.session = self._create_session()
379
+
380
+ # def _resolve_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
381
+ # """Resolve all header values"""
382
+ # return {k: ValueResolver._resolve_env_vars(v) for k, v in headers.items()}
383
+
384
+ # def _create_session(self) -> requests.Session:
385
+ # """Create requests session with retry logic"""
386
+ # session = requests.Session()
387
+
388
+ # if self.retry_config:
389
+ # retry_strategy = Retry(
390
+ # total=self.retry_config.get("max_attempts", 3),
391
+ # backoff_factor=1 if self.retry_config.get("backoff") == "exponential" else 0,
392
+ # status_forcelist=self.retry_config.get("for_status_codes", [429, 500, 502, 503, 504]),
393
+ # )
394
+ # adapter = HTTPAdapter(max_retries=retry_strategy)
395
+ # session.mount("http://", adapter)
396
+ # session.mount("https://", adapter)
397
+
398
+ # return session
399
+
400
+ # def execute(
401
+ # self,
402
+ # request_config: Dict[str, Any],
403
+ # state: Dict[str, Any],
404
+ # step_id: str = "api_call",
405
+ # ) -> Dict[str, Any]:
406
+ # """Execute API request with full error handling"""
407
+
408
+ # method = request_config.get("method", "GET").upper()
409
+ # path = self._prepare_path(request_config, state)
410
+ # url = f"{self.base_url}{path}"
411
+
412
+ # query_params = self._prepare_params(
413
+ # request_config.get("query_params", {}), state
414
+ # )
415
+ # headers = self._prepare_headers(request_config, state)
416
+ # body = self._prepare_body(request_config, state)
417
+
418
+ # try:
419
+ # response = self.session.request(
420
+ # method=method,
421
+ # url=url,
422
+ # headers=headers,
423
+ # params=query_params,
424
+ # json=body if request_config.get("body_type", "json") == "json" else None,
425
+ # data=body if request_config.get("body_type") != "json" else None,
426
+ # timeout=self.timeout,
427
+ # )
428
+
429
+ # try:
430
+ # response_data = response.json()
431
+ # except:
432
+ # response_data = {"raw": response.text}
433
+
434
+ # return {
435
+ # "status": response.status_code,
436
+ # "data": response_data,
437
+ # "headers": dict(response.headers),
438
+ # "success": response.ok,
439
+ # "url": url,
440
+ # "method": method,
441
+ # }
442
+
443
+ # except requests.exceptions.Timeout:
444
+ # return {
445
+ # "status": 0,
446
+ # "error": "Request timeout",
447
+ # "error_type": "timeout",
448
+ # "success": False,
449
+ # }
450
+ # except requests.exceptions.ConnectionError:
451
+ # return {
452
+ # "status": 0,
453
+ # "error": "Connection error",
454
+ # "error_type": "connection",
455
+ # "success": False,
456
+ # }
457
+ # except requests.exceptions.RequestException as e:
458
+ # return {
459
+ # "status": 0,
460
+ # "error": str(e),
461
+ # "error_type": "request",
462
+ # "success": False,
463
+ # }
464
+
465
+ # def _prepare_path(
466
+ # self, request_config: Dict[str, Any], state: Dict[str, Any]
467
+ # ) -> str:
468
+ # """Prepare URL path with variable substitution"""
469
+ # path = request_config.get("path", "")
470
+ # path_params = request_config.get("path_params", {})
471
+
472
+ # for key, value in path_params.items():
473
+ # resolved_value = ValueResolver.resolve(value, state)
474
+ # path = path.replace(f"{{{key}}}", str(resolved_value))
475
+
476
+ # return path
477
+
478
+ # def _prepare_params(
479
+ # self, params: Dict[str, Any], state: Dict[str, Any]
480
+ # ) -> Dict[str, Any]:
481
+ # """Prepare query parameters"""
482
+ # return {k: ValueResolver.resolve(v, state) for k, v in params.items()}
483
+
484
+ # def _prepare_headers(
485
+ # self, request_config: Dict[str, Any], state: Dict[str, Any]
486
+ # ) -> Dict[str, str]:
487
+ # """Prepare request headers"""
488
+ # headers = {**self.headers}
489
+
490
+ # if "headers" in request_config:
491
+ # for key, value in request_config["headers"].items():
492
+ # headers[key] = ValueResolver.resolve(value, state)
493
+
494
+ # return headers
495
+
496
+ # def _prepare_body(
497
+ # self, request_config: Dict[str, Any], state: Dict[str, Any]
498
+ # ) -> Optional[Dict[str, Any]]:
499
+ # """Prepare request body"""
500
+ # if "body" not in request_config:
501
+ # return None
502
+
503
+ # body = request_config["body"]
504
+ # return self._resolve_dict(body, state)
505
+
506
+ # def _resolve_dict(
507
+ # self, data: Dict[str, Any], state: Dict[str, Any]
508
+ # ) -> Dict[str, Any]:
509
+ # """Recursively resolve dictionary values"""
510
+ # result = {}
511
+ # for key, value in data.items():
512
+ # if isinstance(value, dict):
513
+ # result[key] = self._resolve_dict(value, state)
514
+ # elif isinstance(value, list):
515
+ # result[key] = [
516
+ # self._resolve_dict(item, state)
517
+ # if isinstance(item, dict)
518
+ # else ValueResolver.resolve(item, state)
519
+ # for item in value
520
+ # ]
521
+ # else:
522
+ # result[key] = ValueResolver.resolve(value, state)
523
+ # return result
524
+
525
+
526
+ # class APIResponseHandler:
527
+ # """Main handler for API responses with error handling and post-processing"""
528
+
529
+ # def __init__(self, step_id: str, api_client: APIClient):
530
+ # self.step_id = step_id
531
+ # self.api_client = api_client
532
+
533
+ # def execute_with_handling(
534
+ # self,
535
+ # request_config: Dict[str, Any],
536
+ # response_config: Dict[str, Any],
537
+ # state: Dict[str, Any],
538
+ # ) -> Dict[str, Any]:
539
+ # """Execute API call with full response handling"""
540
+
541
+ # response = self.api_client.execute(request_config, state, self.step_id)
542
+
543
+ # error_handlers = response_config.get("error_handlers", [])
544
+ # error = self._check_errors(response, error_handlers, state)
545
+
546
+ # if error:
547
+ # return error.to_dict()
548
+
549
+ # mappings = response_config.get("mappings", [])
550
+ # if mappings:
551
+ # ResponseMapper.apply_mappings(response, mappings, state)
552
+
553
+ # if "post_process" in response_config:
554
+ # post_result = PostProcessor.process(
555
+ # response, response_config["post_process"], state
556
+ # )
557
+
558
+ # conditions = response_config.get("conditions", [])
559
+ # if conditions:
560
+ # matching_condition = ConditionEvaluator.evaluate_all(
561
+ # conditions, response, state
562
+ # )
563
+
564
+ # if matching_condition:
565
+ # if "mappings" in matching_condition:
566
+ # ResponseMapper.apply_mappings(
567
+ # response, matching_condition["mappings"], state
568
+ # )
569
+
570
+ # if "next" in matching_condition:
571
+ # state["_next_step"] = matching_condition["next"]
572
+
573
+ # if "action" in matching_condition:
574
+ # state["_action"] = matching_condition["action"]
575
+
576
+ # return {"success": True, "response": response, "state": state}
577
+
578
+ # def _check_errors(
579
+ # self,
580
+ # response: Dict[str, Any],
581
+ # error_handlers: List[Dict[str, Any]],
582
+ # state: Dict[str, Any],
583
+ # ) -> Optional[APIError]:
584
+ # for error_config in error_handlers:
585
+ # # Evaluate error condition
586
+ # if ConditionEvaluator.evaluate(error_config, response, state):
587
+ # return ErrorHandler.handle_error(
588
+ # self.step_id, response, error_config, state
589
+ # )
590
+
591
+ # return None
592
+
593
+
594
+ # # Example usage
595
+ # if __name__ == "__main__":
596
+ # # Example configuration
597
+ # api_config = {
598
+ # "name": "user_service",
599
+ # "base_url": "https://api.example.com",
600
+ # "headers": {
601
+ # "Authorization": "Bearer ${env.API_TOKEN}",
602
+ # "Content-Type": "application/json",
603
+ # },
604
+ # "timeout": 30,
605
+ # }
606
+
607
+ # request_config = {
608
+ # "method": "GET",
609
+ # "path": "/users/{user_id}",
610
+ # "path_params": {"user_id": "{user_id}"},
611
+ # }
612
+
613
+ # response_config = {
614
+ # "mappings": [
615
+ # {"from": "data.name", "to": "user_name"},
616
+ # {"from": "data.email", "to": "user_email"},
617
+ # ],
618
+ # "error_handlers": [
619
+ # {
620
+ # "status_code": 404,
621
+ # "field": "data.error_code",
622
+ # "operator": "==",
623
+ # "value": "ERR_NOT_FOUND",
624
+ # "message": "Sorry, we could not find the user you requested",
625
+ # "severity": "error",
626
+ # },
627
+ # {
628
+ # "status_code": 200,
629
+ # "field": "data.error_data",
630
+ # "operator": "==",
631
+ # "value": "ERR_NOT_FOUND",
632
+ # "message": "Sorry, we could not find the request",
633
+ # "severity": "warning",
634
+ # },
635
+ # ],
636
+ # "post_process": {
637
+ # "function": "response_processors.format_user_data",
638
+ # "inputs": {"user_id": "{user_id}"},
639
+ # "output": "formatted_user",
640
+ # },
641
+ # }
642
+
643
+ # # Initialize
644
+ # state = {"user_id": "12345"}
645
+ # api_client = APIClient(api_config)
646
+ # handler = APIResponseHandler("fetch_user", api_client)
647
+
648
+ # # Execute
649
+ # result = handler.execute_with_handling(request_config, response_config, state)
650
+
651
+ # print(json.dumps(result, indent=2))
@@ -3,9 +3,23 @@ from typing import Dict, Any
3
3
  from .base import ActionStrategy
4
4
  from ..core.state import set_state_value, get_state_value
5
5
  from ..utils.logger import logger
6
+ from jinja2 import Environment
7
+ import uuid
8
+ from ..core.constants import WorkflowKeys
6
9
  from ..utils.template import get_nested_value
7
10
 
8
11
 
12
+ def compile_values(template_loader, state: dict, values: Any):
13
+ if isinstance(values, dict):
14
+ return {k: compile_values(template_loader, state, v) for k, v in values.items()}
15
+ elif isinstance(values, list):
16
+ return [compile_values(template_loader, state, value) for value in values]
17
+ elif isinstance(values, str):
18
+ return template_loader.from_string(values).render(state)
19
+ else:
20
+ return values
21
+
22
+
9
23
  class CallFunctionStrategy(ActionStrategy):
10
24
  def __init__(self, step_config: Dict[str, Any], engine_context: Any):
11
25
  super().__init__(step_config, engine_context)
@@ -21,6 +35,14 @@ class CallFunctionStrategy(ActionStrategy):
21
35
  if not self.output_field:
22
36
  raise RuntimeError(f"Step '{self.step_id}' missing required 'output' property")
23
37
 
38
+ def pre_execute(self, state: Dict[str, Any]) -> Dict[str, Any]:
39
+ if 'mfa' in self.step_config:
40
+ state['_mfa'] = state.get('_mfa', {})
41
+ state['_mfa']['post_payload'] = dict(transactionId=str(uuid.uuid4()))
42
+ template_loader = self.engine_context.get_config_value("template_loader", Environment())
43
+ for k, v in self.step_config['mfa']['payload'].items():
44
+ state['_mfa']['post_payload'][k] = compile_values(template_loader, state, v)
45
+
24
46
  def execute(self, state: Dict[str, Any]) -> Dict[str, Any]:
25
47
  from ..utils.tracing import trace_node_execution
26
48
 
@@ -58,8 +80,6 @@ class CallFunctionStrategy(ActionStrategy):
58
80
 
59
81
  if self.output_field:
60
82
  set_state_value(state, self.output_field, result)
61
-
62
- from ..core.constants import WorkflowKeys
63
83
  computed_fields = get_state_value(state, WorkflowKeys.COMPUTED_FIELDS, [])
64
84
  if self.output_field not in computed_fields:
65
85
  computed_fields.append(self.output_field)
@@ -1,4 +1,5 @@
1
1
  from typing import Dict, Any, List, Optional, Tuple
2
+ import json
2
3
 
3
4
  from jinja2 import Environment
4
5
  from langgraph.types import interrupt
@@ -113,6 +114,9 @@ class CollectInputStrategy(ActionStrategy):
113
114
  def _conversation_key(self) -> str:
114
115
  return f'{self.field}_conversation'
115
116
 
117
+ def pre_execute(self, state: Dict[str, Any]) -> Dict[str, Any]:
118
+ state['_active_input_field'] = self.step_config.get('field')
119
+
116
120
  @property
117
121
  def _formatted_field_name(self) -> str:
118
122
  return self.field.replace('_', ' ').title()
@@ -463,6 +467,12 @@ class CollectInputStrategy(ActionStrategy):
463
467
  return state
464
468
 
465
469
  def _handle_structured_output_transition(self, state: Dict[str, Any], conversation: List, agent_response: Any) -> Dict[str, Any]:
470
+
471
+ try:
472
+ agent_response = json.loads(agent_response)
473
+ except (json.JSONDecodeError, TypeError, ValueError):
474
+ pass
475
+
466
476
  if target_node := agent_response.get("intent_change"):
467
477
  return self._handle_intent_change(target_node, state)
468
478
 
soprano_sdk/tools.py CHANGED
@@ -4,8 +4,6 @@ Workflow Tools - Wraps workflows as callable tools for agent frameworks
4
4
 
5
5
  import uuid
6
6
  from typing import Optional, Dict, Any
7
-
8
-
9
7
  from .utils.logger import logger
10
8
 
11
9
  from langfuse.langchain import CallbackHandler
@@ -226,7 +226,28 @@ WORKFLOW_SCHEMA = {
226
226
  "minimum": 1,
227
227
  "maximum": 600,
228
228
  "description": "Timeout in seconds"
229
- }
229
+ },
230
+ "mfa": {
231
+ "type": "object",
232
+ "description": "Multi-factor authentication configuration",
233
+ "required": ["type", "model", "payload"],
234
+ "properties": {
235
+ "model": {
236
+ "type": "string",
237
+ "description": "Path to the model which will be used to parse the MFA input from the user"
238
+ },
239
+ "type": {
240
+ "type": "string",
241
+ "enum": ["REST"],
242
+ "description": "API type for MFA"
243
+ },
244
+ "payload": {
245
+ "type": "object",
246
+ "description": "MFA payload data that is posted to the RESTAPI, Apart from the properties provided transactionId is sent by the framework in the post payload as an additional property, transactionId is the same throughout the MFA process",
247
+ "additionalProperties": True
248
+ }
249
+ }
250
+ },
230
251
  }
231
252
  }
232
253
  },
@@ -3,6 +3,7 @@ from typing import List, Set
3
3
  import jsonschema
4
4
 
5
5
  from .schema import WORKFLOW_SCHEMA
6
+ from soprano_sdk.core.constants import MFARestAuthorizerEnv
6
7
 
7
8
 
8
9
  class ValidationResult:
@@ -64,7 +65,7 @@ class WorkflowValidator:
64
65
  if not step_id:
65
66
  self.errors.append(f"Step at index {i} is missing 'id' field")
66
67
  continue
67
-
68
+ self._validate_authorizer(step)
68
69
  if step_id in step_ids:
69
70
  self.errors.append(f"Duplicate step ID: '{step_id}'")
70
71
  step_ids.add(step_id)
@@ -108,9 +109,37 @@ class WorkflowValidator:
108
109
  f"Step '{step_id}' transition {i} references unknown target: '{next_target}'"
109
110
  )
110
111
 
112
+ def _validate_authorizer(self, step):
113
+
114
+ def _validate_rest_fields():
115
+ if mfa_authorizer['type'] == 'REST':
116
+ for field in MFARestAuthorizerEnv:
117
+ is_present = field.get_from_env()
118
+ if not is_present:
119
+ self.errors.append(f"`{field.value}` needs to be set as Environment for REST MFA")
120
+ else:
121
+ self.errors.append(f"step({step['id']}) -> mfa -> type is unsupported")
122
+
123
+ mfa_authorizer = step.get("mfa", None)
124
+ if mfa_authorizer is None:
125
+ return
126
+
127
+ if mfa_authorizer and step['action'] != 'call_function':
128
+ self.errors.append(
129
+ f"MFA is enabled in step({step['id']}). MFA is supported only for `call_function` nodes"
130
+ )
131
+
132
+ model = mfa_authorizer.get('model')
133
+ if not model:
134
+ self.errors.append(f"step({step['id']}) -> mfa -> model is missing")
135
+
136
+ _validate_rest_fields()
137
+
138
+
111
139
  def _validate_data_fields(self):
112
140
  data_fields = {field.get('name') for field in self.config.get('data', [])}
113
141
  steps = self.config.get('steps', [])
142
+ has_mfa = False
114
143
 
115
144
  for step in steps:
116
145
  step_id = step.get('id', 'unknown')
@@ -135,6 +164,9 @@ class WorkflowValidator:
135
164
  output = step.get('output')
136
165
  function = step.get('function')
137
166
 
167
+ if step.get('mfa'):
168
+ has_mfa = True
169
+
138
170
  if not function:
139
171
  self.errors.append(f"Step '{step_id}' is missing 'function' property")
140
172
 
@@ -145,6 +177,11 @@ class WorkflowValidator:
145
177
  f"Step '{step_id}' output field '{output}' not defined in data fields"
146
178
  )
147
179
 
180
+ if has_mfa and 'bearer_token' not in data_fields:
181
+ self.errors.append(
182
+ "`bearer_token` must be defined in `data` section: for MFA authorization"
183
+ )
184
+
148
185
  def _validate_function_references(self):
149
186
  steps = self.config.get('steps', [])
150
187
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soprano-sdk
3
- Version: 0.1.99
3
+ Version: 0.2.0
4
4
  Summary: YAML-driven workflow engine with AI agent integration for building conversational SOPs
5
5
  Author: Arvind Thangamani
6
6
  License: MIT
@@ -1,19 +1,22 @@
1
1
  soprano_sdk/__init__.py,sha256=y3c4i7Q7SAPS2Tee7V0TzWdhgMxBWfDJJ98eqD1HxGI,188
2
2
  soprano_sdk/engine.py,sha256=EFK91iTHjp72otLN6Kg-yeLye2J3CAKN0QH4FI2taL8,14838
3
- soprano_sdk/tools.py,sha256=cYEto0cmQ5GZ4zsH_YPfbaaDbzl5aO3rAOm7KlzTFVk,7444
3
+ soprano_sdk/tools.py,sha256=bjJzapnQp31iLDx8jHp_0X2FcS2kY8uqf6hsgD3uDx4,7442
4
4
  soprano_sdk/agents/__init__.py,sha256=Yzbtv6iP_ABRgZo0IUjy9vDofEvLFbOjuABw758176A,636
5
5
  soprano_sdk/agents/adaptor.py,sha256=Cm02YKFclrESu-Qq4CTknCgU7KaA7Z_2FspnQDkEVfU,3214
6
6
  soprano_sdk/agents/factory.py,sha256=Aucfz4rZVKCXMAQtbGAqp1JR8aYwa66mokRmKkKGhYA,6699
7
7
  soprano_sdk/agents/structured_output.py,sha256=7DSVzfMPsZAqBwI3v6XL15qG5Gh4jJ-qddcVPaa3gdc,3326
8
+ soprano_sdk/authenticators/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ soprano_sdk/authenticators/mfa.py,sha256=bSUiwN2xH9kJlP4C_YTIOIXhmYb2QObAthL9C6vM238,5248
8
10
  soprano_sdk/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
- soprano_sdk/core/constants.py,sha256=pEwW_NeHhxs7aG457uiBCs65czAapozY6r9JAegc01Y,1451
10
- soprano_sdk/core/engine.py,sha256=aN805CtG7TDBkBbIJSbAbLo_5tKqrfve394L_DzZq8s,8347
11
+ soprano_sdk/core/constants.py,sha256=-Ij4bbSDsyxQ_NZ5NiusJeepWhoe13WGuzN3LlnXqWY,2017
12
+ soprano_sdk/core/engine.py,sha256=w1ofIGmBaXBa4UxWwYzRjIdJiCFC6QXqivuo3eESW5M,10085
11
13
  soprano_sdk/core/rollback_strategies.py,sha256=NjDTtBCZlqyDql5PSwI9SMDLK7_BNlTxbW_cq_5gV0g,7783
12
- soprano_sdk/core/state.py,sha256=ICyFhio1VESQxzYNwKrS-gk3QDc0OrwJLQtVnZ9c9LU,2611
14
+ soprano_sdk/core/state.py,sha256=ENf81OOe4C2IEYGWUcaBwJ3O1P6VRg4DVirOXFXjioA,2899
13
15
  soprano_sdk/nodes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
- soprano_sdk/nodes/base.py,sha256=bD8P_SLW65yQ1QaCQ5aN8eQCe50sdHh4wdcQzdY6wGA,2218
15
- soprano_sdk/nodes/call_function.py,sha256=23Q9wUY1F9dLMW2qSAApA8Sw5SyvtS70RBCMhZxvRu0,4506
16
- soprano_sdk/nodes/collect_input.py,sha256=PlcsD9EtNtMtfnihaJSVjW9RC7ztpaV2ETaF6ZbKNbY,22840
16
+ soprano_sdk/nodes/base.py,sha256=idFyOGGPnjsASYnrOF_NIh7eFcSuJqw61EoVN_WCTaU,2360
17
+ soprano_sdk/nodes/call_api.py,sha256=9ApkuKMU8en5pQ7SK0yB8eF6KXY_bYV2Q3DkthibizU,22655
18
+ soprano_sdk/nodes/call_function.py,sha256=Quys2Rf2JitV1fnTpnEIpIoCCOQDzhWBriRXO59UqqI,5469
19
+ soprano_sdk/nodes/collect_input.py,sha256=pR4PrQfnufAZmCpN_1T14zBp3rg8BkSWGP7eMSa3pZE,23140
17
20
  soprano_sdk/nodes/factory.py,sha256=l-Gysfgnao-o2dphhnbjjxcH3ojZanZNYN3CBH9dDbA,1624
18
21
  soprano_sdk/routing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
22
  soprano_sdk/routing/router.py,sha256=SrNciTIXXdC9bAbbO5bX7PN9mlRbITjr4RZdNm4jEVA,3450
@@ -24,9 +27,9 @@ soprano_sdk/utils/template.py,sha256=MG_B9TMx1ShpnSGo7s7TO-VfQzuFByuRNhJTvZ668kM
24
27
  soprano_sdk/utils/tool.py,sha256=hWN826HIKmLdswLCTURLH8hWlb2WU0MB8nIUErbpB-8,1877
25
28
  soprano_sdk/utils/tracing.py,sha256=gSHeBDLe-MbAZ9rkzpCoGFveeMdR9KLaA6tteB0IWjk,1991
26
29
  soprano_sdk/validation/__init__.py,sha256=ImChmO86jYHU90xzTttto2-LmOUOmvY_ibOQaLRz5BA,262
27
- soprano_sdk/validation/schema.py,sha256=HzqRp-5rl3GzX5KtFckAbBh8S7zklvQmRERIyfByS2w,13335
28
- soprano_sdk/validation/validator.py,sha256=l2P24wiCWBNTZ9-dRbgWwK48BGaR1xIdnBxzSCu0RPM,6498
29
- soprano_sdk-0.1.99.dist-info/METADATA,sha256=Jo1IR-exqqTG8MYbC-X-ymu4skcCPPgmJ6Z-SZwXFlE,11298
30
- soprano_sdk-0.1.99.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
31
- soprano_sdk-0.1.99.dist-info/licenses/LICENSE,sha256=A1aBauSjPNtVehOXJe3WuvdU2xvM9H8XmigFMm6665s,1073
32
- soprano_sdk-0.1.99.dist-info/RECORD,,
30
+ soprano_sdk/validation/schema.py,sha256=R6bm4Pzgx8p08o77FN9FFSKTLTKOCf7WuThrR6rV2FI,14578
31
+ soprano_sdk/validation/validator.py,sha256=mUTweaQyDaPqHWQDlsE9AFwnB3_MQAEOBrj1hwKQK68,7888
32
+ soprano_sdk-0.2.0.dist-info/METADATA,sha256=ovCyWtkIXMfuAvs3-5UttlzCEn7r57ZhUNMfm9C6L2o,11297
33
+ soprano_sdk-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
34
+ soprano_sdk-0.2.0.dist-info/licenses/LICENSE,sha256=A1aBauSjPNtVehOXJe3WuvdU2xvM9H8XmigFMm6665s,1073
35
+ soprano_sdk-0.2.0.dist-info/RECORD,,