nebu 0.1.45__py3-none-any.whl → 0.1.48__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.
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env python3
2
+ import importlib
2
3
  import json
3
4
  import os
4
5
  import socket
5
6
  import sys
6
7
  import time
7
8
  import traceback
8
- from datetime import datetime
9
- from typing import Dict, TypeVar
9
+ import types # Added for ModuleType
10
+ from datetime import datetime, timezone
11
+ from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar, cast
10
12
 
11
13
  import redis
12
14
  import socks
@@ -18,67 +20,168 @@ T = TypeVar("T")
18
20
  # Environment variable name used as a guard in the decorator
19
21
  _NEBU_INSIDE_CONSUMER_ENV_VAR = "_NEBU_INSIDE_CONSUMER_EXEC"
20
22
 
21
- # Get function and model source code and create them dynamically
22
- try:
23
- function_source = os.environ.get("FUNCTION_SOURCE")
24
- function_name = os.environ.get("FUNCTION_NAME")
25
- stream_message_source = os.environ.get("STREAM_MESSAGE_SOURCE")
26
- input_model_source = os.environ.get("INPUT_MODEL_SOURCE")
27
- output_model_source = os.environ.get("OUTPUT_MODEL_SOURCE")
28
- content_type_source = os.environ.get("CONTENT_TYPE_SOURCE")
29
- is_stream_message = os.environ.get("IS_STREAM_MESSAGE") == "True"
30
- param_type_name = os.environ.get("PARAM_TYPE_NAME")
31
- return_type_name = os.environ.get("RETURN_TYPE_NAME")
32
- param_type_str = os.environ.get("PARAM_TYPE_STR")
33
- return_type_str = os.environ.get("RETURN_TYPE_STR")
34
- content_type_name = os.environ.get("CONTENT_TYPE_NAME")
23
+ # --- Global variables for dynamically loaded code ---
24
+ target_function: Optional[Callable] = None
25
+ init_function: Optional[Callable] = None
26
+ imported_module: Optional[types.ModuleType] = None
27
+ local_namespace: Dict[str, Any] = {} # Namespace for included objects
28
+ last_load_mtime: float = 0.0
29
+ entrypoint_abs_path: Optional[str] = None
30
+
31
+
32
+ # --- Function to Load/Reload User Code ---
33
+ def load_or_reload_user_code(
34
+ module_path: str,
35
+ function_name: str,
36
+ entrypoint_abs_path: str,
37
+ init_func_name: Optional[str] = None,
38
+ included_object_sources: Optional[List[Tuple[str, List[str]]]] = None,
39
+ ) -> Tuple[
40
+ Optional[Callable],
41
+ Optional[Callable],
42
+ Optional[types.ModuleType],
43
+ Dict[str, Any],
44
+ float,
45
+ ]:
46
+ """Loads or reloads the user code module, executes includes, and returns functions/module."""
47
+ global _NEBU_INSIDE_CONSUMER_ENV_VAR # Access the global guard var name
48
+
49
+ current_mtime = 0.0
50
+ loaded_target_func = None
51
+ loaded_init_func = None
52
+ loaded_module = None
53
+ exec_namespace: Dict[str, Any] = {} # Use a local namespace for this load attempt
54
+
55
+ print(f"[Code Loader] Attempting to load/reload module: '{module_path}'")
56
+ os.environ[_NEBU_INSIDE_CONSUMER_ENV_VAR] = "1" # Set guard *before* import/reload
57
+ print(f"[Code Loader] Set environment variable {_NEBU_INSIDE_CONSUMER_ENV_VAR}=1")
35
58
 
36
- # Get source for the file containing the decorated function
37
- decorated_func_file_source = os.environ.get("DECORATED_FUNC_FILE_SOURCE")
38
- # Get sources for the directory containing the decorated function
39
- # decorated_dir_sources_json = os.environ.get("DECORATED_DIR_SOURCES") # Removed
59
+ try:
60
+ current_mtime = os.path.getmtime(entrypoint_abs_path)
61
+
62
+ # Execute included object sources FIRST (if any)
63
+ if included_object_sources:
64
+ print("[Code Loader] Executing @include object sources...")
65
+ # Include necessary imports for the exec context
66
+ exec("from pydantic import BaseModel, Field", exec_namespace)
67
+ exec(
68
+ "from typing import Optional, List, Dict, Any, Generic, TypeVar",
69
+ exec_namespace,
70
+ )
71
+ exec("T_exec = TypeVar('T_exec')", exec_namespace)
72
+ exec("from nebu.processors.models import *", exec_namespace)
73
+ # ... add other common imports if needed by included objects ...
40
74
 
41
- # Get init_func source if provided
42
- init_func_source = os.environ.get("INIT_FUNC_SOURCE")
43
- init_func_name = os.environ.get("INIT_FUNC_NAME")
75
+ for i, (obj_source, args_sources) in enumerate(included_object_sources):
76
+ try:
77
+ exec(obj_source, exec_namespace)
78
+ print(
79
+ f"[Code Loader] Successfully executed included object {i} base source"
80
+ )
81
+ for j, arg_source in enumerate(args_sources):
82
+ try:
83
+ exec(arg_source, exec_namespace)
84
+ print(
85
+ f"[Code Loader] Successfully executed included object {i} arg {j} source"
86
+ )
87
+ except Exception as e_arg:
88
+ print(
89
+ f"Error executing included object {i} arg {j} source: {e_arg}"
90
+ )
91
+ traceback.print_exc() # Log specific error but continue? Or fail reload?
92
+ except Exception as e_base:
93
+ print(f"Error executing included object {i} base source: {e_base}")
94
+ traceback.print_exc() # Log specific error but continue? Or fail reload?
95
+ print("[Code Loader] Finished executing included object sources.")
96
+
97
+ # Check if module is already loaded and needs reload
98
+ if module_path in sys.modules:
99
+ print(
100
+ f"[Code Loader] Module '{module_path}' already imported. Reloading..."
101
+ )
102
+ # Pass the exec_namespace as globals? Usually reload works within its own context.
103
+ # If included objects *modify* the module's global scope upon exec,
104
+ # reload might not pick that up easily. Might need a fresh import instead.
105
+ # Let's try reload first.
106
+ loaded_module = importlib.reload(sys.modules[module_path])
107
+ print(f"[Code Loader] Successfully reloaded module: {module_path}")
108
+ else:
109
+ # Import the main module
110
+ loaded_module = importlib.import_module(module_path)
111
+ print(
112
+ f"[Code Loader] Successfully imported module for the first time: {module_path}"
113
+ )
44
114
 
45
- # Check for generic type arguments
46
- input_model_args = []
47
- output_model_args = []
48
- content_type_args = []
115
+ # Get the target function from the loaded/reloaded module
116
+ loaded_target_func = getattr(loaded_module, function_name)
117
+ print(
118
+ f"[Code Loader] Successfully loaded function '{function_name}' from module '{module_path}'"
119
+ )
49
120
 
50
- # Get input model arg sources
51
- i = 0
52
- while True:
53
- arg_source = os.environ.get(f"INPUT_MODEL_ARG_{i}_SOURCE")
54
- if arg_source:
55
- input_model_args.append(arg_source)
56
- i += 1
57
- else:
58
- break
121
+ # Get the init function if specified
122
+ if init_func_name:
123
+ loaded_init_func = getattr(loaded_module, init_func_name)
124
+ print(
125
+ f"[Code Loader] Successfully loaded init function '{init_func_name}' from module '{module_path}'"
126
+ )
127
+ # Execute init_func
128
+ print(f"[Code Loader] Executing init_func: {init_func_name}...")
129
+ loaded_init_func() # Call the function
130
+ print(f"[Code Loader] Successfully executed init_func: {init_func_name}")
131
+
132
+ print("[Code Loader] Code load/reload successful.")
133
+ return (
134
+ loaded_target_func,
135
+ loaded_init_func,
136
+ loaded_module,
137
+ exec_namespace,
138
+ current_mtime,
139
+ )
59
140
 
60
- # Get output model arg sources
61
- i = 0
62
- while True:
63
- arg_source = os.environ.get(f"OUTPUT_MODEL_ARG_{i}_SOURCE")
64
- if arg_source:
65
- output_model_args.append(arg_source)
66
- i += 1
67
- else:
68
- break
141
+ except FileNotFoundError:
142
+ print(
143
+ f"[Code Loader] Error: Entrypoint file not found at '{entrypoint_abs_path}'. Cannot load/reload."
144
+ )
145
+ return None, None, None, {}, 0.0 # Indicate failure
146
+ except ImportError as e:
147
+ print(f"[Code Loader] Error importing/reloading module '{module_path}': {e}")
148
+ traceback.print_exc()
149
+ return None, None, None, {}, 0.0 # Indicate failure
150
+ except AttributeError as e:
151
+ print(
152
+ f"[Code Loader] Error accessing function '{function_name}' or '{init_func_name}' in module '{module_path}': {e}"
153
+ )
154
+ traceback.print_exc()
155
+ return None, None, None, {}, 0.0 # Indicate failure
156
+ except Exception as e:
157
+ print(f"[Code Loader] Unexpected error during code load/reload: {e}")
158
+ traceback.print_exc()
159
+ return None, None, None, {}, 0.0 # Indicate failure
160
+ finally:
161
+ # Unset the guard environment variable
162
+ os.environ.pop(_NEBU_INSIDE_CONSUMER_ENV_VAR, None)
163
+ print(
164
+ f"[Code Loader] Unset environment variable {_NEBU_INSIDE_CONSUMER_ENV_VAR}"
165
+ )
69
166
 
70
- # Get content type arg sources
71
- i = 0
72
- while True:
73
- arg_source = os.environ.get(f"CONTENT_TYPE_ARG_{i}_SOURCE")
74
- if arg_source:
75
- content_type_args.append(arg_source)
76
- i += 1
77
- else:
78
- break
79
167
 
80
- # Get included object sources
81
- included_object_sources = []
168
+ # --- Get Environment Variables ---
169
+ try:
170
+ # Core function info
171
+ _function_name = os.environ.get("FUNCTION_NAME")
172
+ _entrypoint_rel_path = os.environ.get("NEBU_ENTRYPOINT_MODULE_PATH")
173
+
174
+ # Type info
175
+ is_stream_message = os.environ.get("IS_STREAM_MESSAGE") == "True"
176
+ param_type_str = os.environ.get("PARAM_TYPE_STR")
177
+ return_type_str = os.environ.get("RETURN_TYPE_STR")
178
+ content_type_name = os.environ.get("CONTENT_TYPE_NAME")
179
+
180
+ # Init func info
181
+ _init_func_name = os.environ.get("INIT_FUNC_NAME")
182
+
183
+ # Included object sources
184
+ _included_object_sources = []
82
185
  i = 0
83
186
  while True:
84
187
  obj_source = os.environ.get(f"INCLUDED_OBJECT_{i}_SOURCE")
@@ -92,213 +195,87 @@ try:
92
195
  j += 1
93
196
  else:
94
197
  break
95
- included_object_sources.append((obj_source, args))
198
+ _included_object_sources.append((obj_source, args))
96
199
  i += 1
97
200
  else:
98
201
  break
99
202
 
100
- if not function_source or not function_name:
101
- print("FUNCTION_SOURCE or FUNCTION_NAME environment variables not set")
203
+ if not _function_name or not _entrypoint_rel_path:
204
+ print(
205
+ "FATAL: FUNCTION_NAME or NEBU_ENTRYPOINT_MODULE_PATH environment variables not set"
206
+ )
102
207
  sys.exit(1)
103
208
 
104
- # Create a local namespace for executing the function
105
- local_namespace = {}
106
-
107
- # Include pydantic BaseModel and typing tools for type annotations
108
- exec("from pydantic import BaseModel, Field", local_namespace)
109
- exec(
110
- "from typing import Optional, List, Dict, Any, Generic, TypeVar",
111
- local_namespace,
112
- )
113
- exec("T = TypeVar('T')", local_namespace)
114
- exec("from nebu.processors.models import *", local_namespace)
115
- exec("from nebu.processors.processor import *", local_namespace)
116
- # Add import for the processor decorator itself
117
- exec("from nebu.processors.decorate import processor", local_namespace)
118
- # Add import for chatx openai types
119
- exec("from nebu.chatx.openai import *", local_namespace)
120
-
121
- # Set the guard environment variable before executing any source code
122
- os.environ[_NEBU_INSIDE_CONSUMER_ENV_VAR] = "1"
123
- print(f"[Consumer] Set environment variable {_NEBU_INSIDE_CONSUMER_ENV_VAR}=1")
124
-
125
- try:
126
- # Execute the source file of the decorated function FIRST
127
- if decorated_func_file_source:
128
- print("[Consumer] Executing decorated function's file source...")
129
- try:
130
- exec(decorated_func_file_source, local_namespace)
131
- print(
132
- "[Consumer] Successfully executed decorated function's file source."
133
- )
134
- except Exception as e:
135
- print(f"Error executing decorated function's file source: {e}")
136
- traceback.print_exc() # Warn and continue
137
- else:
138
- print(
139
- "[Consumer] No decorated function's file source found in environment."
140
- )
141
-
142
- # Execute the sources from the decorated function's directory
143
- # if decorated_dir_sources_json:
144
- # print("[Consumer] Executing decorated function's directory sources...")
145
- # try:
146
- # dir_sources = json.loads(decorated_dir_sources_json)
147
- # # Sort by relative path for some predictability (e.g., __init__.py first)
148
- # for rel_path, source_code in sorted(dir_sources.items()):
149
- # print(f"[Consumer] Executing source from: {rel_path}...")
150
- # try:
151
- # exec(source_code, local_namespace)
152
- # print(f"[Consumer] Successfully executed source from: {rel_path}")
153
- # except Exception as e:
154
- # print(f"Error executing source from {rel_path}: {e}")
155
- # traceback.print_exc() # Warn and continue
156
- # except json.JSONDecodeError as e:
157
- # print(f"Error decoding DECORATED_DIR_SOURCES JSON: {e}")
158
- # traceback.print_exc()
159
- # except Exception as e:
160
- # print(f"Unexpected error processing directory sources: {e}")
161
- # traceback.print_exc()
162
- # else:
163
- # print("[Consumer] No decorated function's directory sources found in environment.")
164
-
165
- # Execute included object sources NEXT, as they might define types needed by others
166
- print("[Consumer] Executing included object sources...")
167
- for i, (obj_source, args_sources) in enumerate(included_object_sources):
168
- try:
169
- exec(obj_source, local_namespace)
209
+ # Calculate absolute path for modification time checking
210
+ # Assuming CWD or PYTHONPATH allows finding the relative path
211
+ # This might need adjustment based on deployment specifics
212
+ entrypoint_abs_path = os.path.abspath(_entrypoint_rel_path)
213
+ if not os.path.exists(entrypoint_abs_path):
214
+ # Try constructing path based on PYTHONPATH if direct abspath fails
215
+ python_path = os.environ.get("PYTHONPATH", "").split(os.pathsep)
216
+ found_path = False
217
+ for p_path in python_path:
218
+ potential_path = os.path.abspath(os.path.join(p_path, _entrypoint_rel_path))
219
+ if os.path.exists(potential_path):
220
+ entrypoint_abs_path = potential_path
221
+ found_path = True
170
222
  print(
171
- f"[Consumer] Successfully executed included object {i} base source"
223
+ f"[Consumer] Found entrypoint absolute path via PYTHONPATH: {entrypoint_abs_path}"
172
224
  )
173
- for j, arg_source in enumerate(args_sources):
174
- try:
175
- exec(arg_source, local_namespace)
176
- print(
177
- f"[Consumer] Successfully executed included object {i} arg {j} source"
178
- )
179
- except Exception as e:
180
- print(
181
- f"Error executing included object {i} arg {j} source: {e}"
182
- )
183
- traceback.print_exc()
184
- except Exception as e:
185
- print(f"Error executing included object {i} base source: {e}")
186
- traceback.print_exc()
187
- print("[Consumer] Finished executing included object sources.")
188
-
189
- # First try to import the module to get any needed dependencies
190
- # This is a fallback in case the module is available
191
- module_name = os.environ.get("MODULE_NAME")
192
- try:
193
- if module_name:
194
- exec(f"import {module_name}", local_namespace)
195
- print(f"Successfully imported module {module_name}")
196
- except Exception as e:
197
- print(f"Warning: Could not import module {module_name}: {e}")
225
+ break
226
+ if not found_path:
198
227
  print(
199
- "This is expected if running in a Jupyter notebook. Will use dynamic execution."
228
+ f"FATAL: Could not find entrypoint file via relative path '{_entrypoint_rel_path}' or in PYTHONPATH."
200
229
  )
230
+ # Attempting abspath anyway for the error message in load function
231
+ entrypoint_abs_path = os.path.abspath(_entrypoint_rel_path)
232
+
233
+ # Convert entrypoint file path to module path
234
+ _module_path = _entrypoint_rel_path.replace(os.sep, ".")
235
+ if _module_path.endswith(".py"):
236
+ _module_path = _module_path[:-3]
237
+ if _module_path.endswith(".__init__"):
238
+ _module_path = _module_path[: -len(".__init__")]
239
+ elif _module_path == "__init__":
240
+ print(
241
+ f"FATAL: Entrypoint '{_entrypoint_rel_path}' resolves to ambiguous top-level __init__. Please use a named file or package."
242
+ )
243
+ sys.exit(1)
244
+ if not _module_path:
245
+ print(
246
+ f"FATAL: Could not derive a valid module path from entrypoint '{_entrypoint_rel_path}'"
247
+ )
248
+ sys.exit(1)
201
249
 
202
- # Define the models
203
- # First define stream message class if needed
204
- if stream_message_source:
205
- try:
206
- exec(stream_message_source, local_namespace)
207
- print("Successfully defined Message class")
208
- except Exception as e:
209
- print(f"Error defining Message: {e}")
210
- traceback.print_exc()
211
-
212
- # Define content type if available
213
- if content_type_source:
214
- try:
215
- exec(content_type_source, local_namespace)
216
- print(f"Successfully defined content type {content_type_name}")
217
-
218
- # Define any content type args
219
- for arg_source in content_type_args:
220
- try:
221
- exec(arg_source, local_namespace)
222
- print("Successfully defined content type argument")
223
- except Exception as e:
224
- print(f"Error defining content type argument: {e}")
225
- traceback.print_exc()
226
- except Exception as e:
227
- print(f"Error defining content type: {e}")
228
- traceback.print_exc()
229
-
230
- # Define input model if different from stream message
231
- if input_model_source and (
232
- not is_stream_message or input_model_source != stream_message_source
233
- ):
234
- try:
235
- exec(input_model_source, local_namespace)
236
- print(f"Successfully defined input model {param_type_str}")
237
-
238
- # Define any input model args
239
- for arg_source in input_model_args:
240
- try:
241
- exec(arg_source, local_namespace)
242
- print("Successfully defined input model argument")
243
- except Exception as e:
244
- print(f"Error defining input model argument: {e}")
245
- traceback.print_exc()
246
- except Exception as e:
247
- print(f"Error defining input model: {e}")
248
- traceback.print_exc()
249
-
250
- # Define output model
251
- if output_model_source:
252
- try:
253
- exec(output_model_source, local_namespace)
254
- print(f"Successfully defined output model {return_type_str}")
250
+ print(
251
+ f"[Consumer] Initializing. Entrypoint: '{_entrypoint_rel_path}', Module: '{_module_path}', Function: '{_function_name}', Init: '{_init_func_name}'"
252
+ )
255
253
 
256
- # Define any output model args
257
- for arg_source in output_model_args:
258
- try:
259
- exec(arg_source, local_namespace)
260
- print("Successfully defined output model argument")
261
- except Exception as e:
262
- print(f"Error defining output model argument: {e}")
263
- traceback.print_exc()
264
- except Exception as e:
265
- print(f"Error defining output model: {e}")
266
- traceback.print_exc()
254
+ # --- Initial Load of User Code ---
255
+ (
256
+ target_function,
257
+ init_function,
258
+ imported_module,
259
+ local_namespace,
260
+ last_load_mtime,
261
+ ) = load_or_reload_user_code(
262
+ _module_path,
263
+ _function_name,
264
+ entrypoint_abs_path,
265
+ _init_func_name,
266
+ _included_object_sources,
267
+ )
267
268
 
268
- # Finally, execute the function code
269
- try:
270
- exec(function_source, local_namespace)
271
- target_function = local_namespace[function_name]
272
- print(f"Successfully loaded function {function_name}")
273
- except Exception as e:
274
- print(f"Error creating function from source: {e}")
275
- traceback.print_exc()
276
- sys.exit(1)
269
+ if target_function is None or imported_module is None:
270
+ print("FATAL: Initial load of user code failed. Exiting.")
271
+ sys.exit(1)
272
+ print(
273
+ f"[Consumer] Initial code load successful. Last modified time: {last_load_mtime}"
274
+ )
277
275
 
278
- # Execute init_func if provided
279
- if init_func_source and init_func_name:
280
- print(f"Executing init_func: {init_func_name}...")
281
- try:
282
- exec(init_func_source, local_namespace)
283
- init_function = local_namespace[init_func_name]
284
- print(
285
- f"[Consumer] Environment before calling init_func {init_func_name}: {os.environ}"
286
- )
287
- init_function() # Call the function
288
- print(f"Successfully executed init_func: {init_func_name}")
289
- except Exception as e:
290
- print(f"Error executing init_func '{init_func_name}': {e}")
291
- traceback.print_exc()
292
- # Decide if failure is critical. For now, let's exit.
293
- print("Exiting due to init_func failure.")
294
- sys.exit(1)
295
- finally:
296
- # Unset the guard environment variable after all execs are done
297
- os.environ.pop(_NEBU_INSIDE_CONSUMER_ENV_VAR, None)
298
- print(f"[Consumer] Unset environment variable {_NEBU_INSIDE_CONSUMER_ENV_VAR}")
299
276
 
300
277
  except Exception as e:
301
- print(f"Error setting up function: {e}")
278
+ print(f"FATAL: Error during initial environment setup or code load: {e}")
302
279
  traceback.print_exc()
303
280
  sys.exit(1)
304
281
 
@@ -349,46 +326,66 @@ except ResponseError as e:
349
326
 
350
327
  # Function to process messages
351
328
  def process_message(message_id: str, message_data: Dict[str, str]) -> None:
352
- # Initialize variables that need to be accessible in the except block
329
+ # Access the globally managed user code elements
330
+ global target_function, imported_module, local_namespace
331
+
332
+ # Check if target_function is loaded (might be None if reload failed)
333
+ if target_function is None or imported_module is None:
334
+ print(
335
+ f"Error processing message {message_id}: User code (target_function or module) is not loaded. Skipping."
336
+ )
337
+ # Decide how to handle this - skip and ack? Send error?
338
+ # Sending error for now, but not acking yet.
339
+ # This requires the main loop to handle potential ack failure later if needed.
340
+ _send_error_response(
341
+ message_id,
342
+ "User code is not loaded (likely due to a failed reload)",
343
+ traceback.format_exc(),
344
+ None,
345
+ None,
346
+ ) # Pass None for user_id if unavailable here
347
+ return # Skip processing
348
+
353
349
  return_stream = None
354
350
  user_id = None
355
351
  try:
356
- # Extract the JSON string payload from the 'data' field
357
352
  payload_str = message_data.get("data")
358
-
359
- # decode_responses=True should mean payload_str is a string if found.
360
- if not payload_str:
353
+ if not payload_str or not isinstance(payload_str, str):
361
354
  raise ValueError(
362
- f"Missing or invalid 'data' field (expected string): {message_data}"
355
+ f"Missing or invalid 'data' field (expected non-empty string): {message_data}"
363
356
  )
364
-
365
- # Parse the JSON string into a dictionary
366
357
  try:
367
358
  raw_payload = json.loads(payload_str)
368
359
  except json.JSONDecodeError as json_err:
369
360
  raise ValueError(f"Failed to parse JSON payload: {json_err}") from json_err
370
-
371
- # Validate that raw_payload is a dictionary as expected
372
361
  if not isinstance(raw_payload, dict):
373
362
  raise TypeError(
374
363
  f"Expected parsed payload to be a dictionary, but got {type(raw_payload)}"
375
364
  )
376
365
 
377
- print(f"Raw payload: {raw_payload}")
366
+ # print(f"Raw payload: {raw_payload}") # Reduce verbosity
378
367
 
379
- # Extract fields from the parsed payload
380
- # These fields are extracted for completeness and potential future use
381
- kind = raw_payload.get("kind", "") # kind
382
- msg_id = raw_payload.get("id", "") # msg_id
368
+ kind = raw_payload.get("kind", "")
369
+ msg_id = raw_payload.get("id", "")
383
370
  content_raw = raw_payload.get("content", {})
384
- created_at = raw_payload.get("created_at", 0) # created_at
371
+ created_at_str = raw_payload.get("created_at") # Get as string or None
372
+ # Attempt to parse created_at, fallback to now()
373
+ try:
374
+ created_at = (
375
+ datetime.fromisoformat(created_at_str)
376
+ if created_at_str
377
+ else datetime.now(timezone.utc)
378
+ )
379
+ except ValueError:
380
+ created_at = datetime.now(timezone.utc)
381
+
385
382
  return_stream = raw_payload.get("return_stream")
386
383
  user_id = raw_payload.get("user_id")
387
- orgs = raw_payload.get("organizations") # organizations
388
- handle = raw_payload.get("handle") # handle
389
- adapter = raw_payload.get("adapter") # adapter
384
+ orgs = raw_payload.get("organizations")
385
+ handle = raw_payload.get("handle")
386
+ adapter = raw_payload.get("adapter")
390
387
 
391
- # --- Health Check Logic based on kind ---
388
+ # --- Health Check Logic (Keep as is) ---
392
389
  if kind == "HealthCheck":
393
390
  print(f"Received HealthCheck message {message_id}")
394
391
  health_response = {
@@ -413,109 +410,180 @@ def process_message(message_id: str, message_data: Dict[str, str]) -> None:
413
410
  return # Exit early for health checks
414
411
  # --- End Health Check Logic ---
415
412
 
416
- # Parse the content field if it's a string
413
+ # Parse content if it's a string (e.g., double-encoded JSON)
417
414
  if isinstance(content_raw, str):
418
415
  try:
419
416
  content = json.loads(content_raw)
420
417
  except json.JSONDecodeError:
421
- content = content_raw
418
+ content = content_raw # Keep as string if not valid JSON
422
419
  else:
423
420
  content = content_raw
424
421
 
425
- print(f"Content: {content}")
422
+ # print(f"Content: {content}") # Reduce verbosity
426
423
 
427
- # For StreamMessage, construct the proper input object
428
- if is_stream_message and "Message" in local_namespace:
429
- # If we have a content type, try to construct it
430
- if content_type_name and content_type_name in local_namespace:
431
- # Try to create the content type model first
432
- try:
433
- content_model = local_namespace[content_type_name].model_validate(
434
- content
435
- )
436
- print(f"Content model: {content_model}")
437
- input_obj = local_namespace["Message"](
424
+ # --- Construct Input Object using Imported Types ---
425
+ input_obj: Any = None
426
+ input_type_class = None
427
+
428
+ try:
429
+ # Try to get the actual model classes (they should be available via import)
430
+ # Need to handle potential NameErrors if imports failed silently
431
+ # Note: This assumes models are defined in the imported module scope
432
+ # Or imported by the imported module.
433
+ from nebu.processors.models import Message # Import needed message class
434
+
435
+ if is_stream_message:
436
+ message_class = Message # Use imported class
437
+ content_model_class = None
438
+ if content_type_name:
439
+ try:
440
+ # Assume content_type_name refers to a class available in the global scope
441
+ # (either from imported module or included objects)
442
+ # Use the globally managed imported_module and local_namespace
443
+ content_model_class = getattr(
444
+ imported_module, content_type_name, None
445
+ )
446
+ if content_model_class is None:
447
+ # Check in local_namespace from included objects as fallback
448
+ content_model_class = local_namespace.get(content_type_name)
449
+ if content_model_class is None:
450
+ print(
451
+ f"Warning: Content type class '{content_type_name}' not found in imported module or includes."
452
+ )
453
+ else:
454
+ print(f"Found content model class: {content_model_class}")
455
+ except AttributeError:
456
+ print(
457
+ f"Warning: Content type class '{content_type_name}' not found in imported module."
458
+ )
459
+ except Exception as e:
460
+ print(
461
+ f"Warning: Error resolving content type class '{content_type_name}': {e}"
462
+ )
463
+
464
+ if content_model_class:
465
+ try:
466
+ content_model = content_model_class.model_validate(content)
467
+ print(f"Validated content model: {content_model}")
468
+ input_obj = message_class(
469
+ kind=kind,
470
+ id=msg_id,
471
+ content=content_model,
472
+ created_at=int(created_at.timestamp()),
473
+ return_stream=return_stream,
474
+ user_id=user_id,
475
+ orgs=orgs,
476
+ handle=handle,
477
+ adapter=adapter,
478
+ )
479
+ except Exception as e:
480
+ print(
481
+ f"Error validating/creating content model '{content_type_name}': {e}. Falling back."
482
+ )
483
+ # Fallback to raw content in Message
484
+ input_obj = message_class(
485
+ kind=kind,
486
+ id=msg_id,
487
+ content=cast(Any, content),
488
+ created_at=int(created_at.timestamp()),
489
+ return_stream=return_stream,
490
+ user_id=user_id,
491
+ orgs=orgs,
492
+ handle=handle,
493
+ adapter=adapter,
494
+ )
495
+ else:
496
+ # No content type name or class found, use raw content
497
+ input_obj = message_class(
438
498
  kind=kind,
439
499
  id=msg_id,
440
- content=content_model,
441
- created_at=created_at,
500
+ content=cast(Any, content),
501
+ created_at=int(created_at.timestamp()),
442
502
  return_stream=return_stream,
443
503
  user_id=user_id,
444
504
  orgs=orgs,
445
505
  handle=handle,
446
506
  adapter=adapter,
447
507
  )
508
+ else: # Not a stream message, use the function's parameter type
509
+ param_type_name = (
510
+ param_type_str # Assume param_type_str holds the class name
511
+ )
512
+ # Attempt to resolve the parameter type class
513
+ try:
514
+ # Use the globally managed imported_module and local_namespace
515
+ input_type_class = (
516
+ getattr(imported_module, param_type_name, None)
517
+ if param_type_name
518
+ else None
519
+ )
520
+ if input_type_class is None and param_type_name:
521
+ input_type_class = local_namespace.get(param_type_name)
522
+ if input_type_class is None:
523
+ if param_type_name: # Only warn if a name was expected
524
+ print(
525
+ f"Warning: Input type class '{param_type_name}' not found. Passing raw content."
526
+ )
527
+ input_obj = content
528
+ else:
529
+ print(f"Found input model class: {input_type_class}")
530
+ input_obj = input_type_class.model_validate(content)
531
+ print(f"Validated input model: {input_obj}")
532
+ except AttributeError:
533
+ print(
534
+ f"Warning: Input type class '{param_type_name}' not found in imported module."
535
+ )
536
+ input_obj = content
448
537
  except Exception as e:
449
- print(f"Error creating content type model: {e}")
450
- # Fallback to using raw content
451
- input_obj = local_namespace["Message"](
452
- kind=kind,
453
- id=msg_id,
454
- content=content,
455
- created_at=created_at,
456
- return_stream=return_stream,
457
- user_id=user_id,
458
- orgs=orgs,
459
- handle=handle,
460
- adapter=adapter,
538
+ print(
539
+ f"Error resolving/validating input type '{param_type_name}': {e}. Passing raw content."
461
540
  )
462
- else:
463
- # Just use the raw content
464
- print("Using raw content")
465
- input_obj = local_namespace["Message"](
466
- kind=kind,
467
- id=msg_id,
468
- content=content,
469
- created_at=created_at,
470
- return_stream=return_stream,
471
- user_id=user_id,
472
- orgs=orgs,
473
- handle=handle,
474
- adapter=adapter,
475
- )
476
- else:
477
- # Otherwise use the param type directly
478
- try:
479
- if param_type_str in local_namespace:
480
- print(f"Validating content against {param_type_str}")
481
- input_obj = local_namespace[param_type_str].model_validate(content)
482
- else:
483
- # If we can't find the exact type, just pass the content directly
484
541
  input_obj = content
485
- except Exception as e:
486
- print(f"Error creating input model: {e}, using raw content")
487
- raise e
488
542
 
489
- print(f"Input object: {input_obj}")
543
+ except NameError as e:
544
+ print(
545
+ f"Error: Required class (e.g., Message or parameter type) not found. Import failed? {e}"
546
+ )
547
+ # Can't proceed without types, re-raise or handle error response
548
+ raise RuntimeError(f"Required class not found: {e}") from e
549
+ except Exception as e:
550
+ print(f"Error constructing input object: {e}")
551
+ raise # Re-raise unexpected errors during input construction
552
+
553
+ # print(f"Input object: {input_obj}") # Reduce verbosity
490
554
 
491
555
  # Execute the function
556
+ print(f"Executing function...")
492
557
  result = target_function(input_obj)
493
- print(f"Result: {result}")
494
- # If the result is a Pydantic model, convert to dict
495
- if hasattr(result, "model_dump"):
496
- result = result.model_dump()
558
+ print(f"Result: {result}") # Reduce verbosity
559
+
560
+ # Convert result to dict if it's a Pydantic model
561
+ if hasattr(result, "model_dump"): # Use model_dump for Pydantic v2+
562
+ result_content = result.model_dump(mode="json") # Serialize properly
563
+ elif hasattr(result, "dict"): # Fallback for older Pydantic
564
+ result_content = result.dict()
565
+ else:
566
+ result_content = result # Assume JSON-serializable
497
567
 
498
568
  # Prepare the response
499
569
  response = {
500
570
  "kind": "StreamResponseMessage",
501
571
  "id": message_id,
502
- "content": result,
572
+ "content": result_content,
503
573
  "status": "success",
504
574
  "created_at": datetime.now().isoformat(),
505
- "user_id": user_id,
575
+ "user_id": user_id, # Pass user_id back
506
576
  }
507
577
 
508
- print(f"Response: {response}")
578
+ # print(f"Response: {response}") # Reduce verbosity
509
579
 
510
580
  # Send the result to the return stream
511
581
  if return_stream:
512
- # Assert type again closer to usage for type checker clarity
513
582
  assert isinstance(return_stream, str)
514
583
  r.xadd(return_stream, {"data": json.dumps(response)})
515
584
  print(f"Processed message {message_id}, result sent to {return_stream}")
516
585
 
517
586
  # Acknowledge the message
518
- # Assert types again closer to usage for type checker clarity
519
587
  assert isinstance(REDIS_STREAM, str)
520
588
  assert isinstance(REDIS_CONSUMER_GROUP, str)
521
589
  r.xack(REDIS_STREAM, REDIS_CONSUMER_GROUP, message_id)
@@ -523,77 +591,201 @@ def process_message(message_id: str, message_data: Dict[str, str]) -> None:
523
591
  except Exception as e:
524
592
  print(f"Error processing message {message_id}: {e}")
525
593
  traceback.print_exc()
594
+ _send_error_response(
595
+ message_id, str(e), traceback.format_exc(), return_stream, user_id
596
+ )
526
597
 
527
- # Prepare the error response
528
- error_response = {
529
- "kind": "StreamResponseMessage",
530
- "id": message_id,
531
- "content": {
532
- "error": str(e),
533
- "traceback": traceback.format_exc(),
534
- },
535
- "status": "error",
536
- "created_at": datetime.now().isoformat(),
537
- "user_id": user_id,
538
- }
539
-
540
- # Send the error to the return stream
541
- if return_stream:
542
- # Assert type again closer to usage for type checker clarity
543
- assert isinstance(return_stream, str)
544
- r.xadd(return_stream, {"data": json.dumps(error_response)})
545
- else:
546
- # Assert type again closer to usage for type checker clarity
598
+ # Acknowledge the message even if processing failed
599
+ try:
547
600
  assert isinstance(REDIS_STREAM, str)
548
- r.xadd(f"{REDIS_STREAM}.errors", {"data": json.dumps(error_response)})
601
+ assert isinstance(REDIS_CONSUMER_GROUP, str)
602
+ r.xack(REDIS_STREAM, REDIS_CONSUMER_GROUP, message_id)
603
+ print(f"Acknowledged failed message {message_id}")
604
+ except Exception as e_ack:
605
+ print(
606
+ f"CRITICAL: Failed to acknowledge failed message {message_id}: {e_ack}"
607
+ )
549
608
 
550
- # Still acknowledge the message so we don't reprocess it
551
- # Assert types again closer to usage for type checker clarity
552
- assert isinstance(REDIS_STREAM, str)
553
- assert isinstance(REDIS_CONSUMER_GROUP, str)
554
- r.xack(REDIS_STREAM, REDIS_CONSUMER_GROUP, message_id)
609
+
610
+ # --- Helper to Send Error Response ---
611
+ def _send_error_response(
612
+ message_id: str,
613
+ error_msg: str,
614
+ tb: str,
615
+ return_stream: Optional[str],
616
+ user_id: Optional[str],
617
+ ):
618
+ """Sends a standardized error response to Redis."""
619
+ global r, REDIS_STREAM # Access global Redis connection and stream name
620
+
621
+ error_response = {
622
+ "kind": "StreamResponseMessage",
623
+ "id": message_id,
624
+ "content": {
625
+ "error": error_msg,
626
+ "traceback": tb,
627
+ },
628
+ "status": "error",
629
+ "created_at": datetime.now(timezone.utc).isoformat(), # Use UTC
630
+ "user_id": user_id,
631
+ }
632
+
633
+ error_destination = f"{REDIS_STREAM}.errors" # Default error stream
634
+ if return_stream: # Prefer return_stream if available
635
+ error_destination = return_stream
636
+
637
+ try:
638
+ assert isinstance(error_destination, str)
639
+ r.xadd(error_destination, {"data": json.dumps(error_response)})
640
+ print(f"Sent error response for message {message_id} to {error_destination}")
641
+ except Exception as e_redis:
642
+ print(
643
+ f"CRITICAL: Failed to send error response for {message_id} to Redis: {e_redis}"
644
+ )
645
+ traceback.print_exc()
555
646
 
556
647
 
557
648
  # Main loop
558
649
  print(f"Starting consumer for stream {REDIS_STREAM} in group {REDIS_CONSUMER_GROUP}")
559
- consumer_name = f"consumer-{os.getpid()}"
650
+ consumer_name = f"consumer-{os.getpid()}-{socket.gethostname()}" # More unique name
560
651
 
561
- while True:
562
- try:
563
- # Assert types just before use in the loop
564
- assert isinstance(REDIS_STREAM, str)
565
- assert isinstance(REDIS_CONSUMER_GROUP, str)
652
+ try:
653
+ while True:
654
+ try:
655
+ # --- Check for Code Updates ---
656
+ if entrypoint_abs_path: # Should always be set after init
657
+ try:
658
+ current_mtime = os.path.getmtime(entrypoint_abs_path)
659
+ if current_mtime > last_load_mtime:
660
+ print(
661
+ f"[Consumer] Detected change in entrypoint file: {entrypoint_abs_path}. Reloading code..."
662
+ )
663
+ (
664
+ reloaded_target_func,
665
+ reloaded_init_func,
666
+ reloaded_module,
667
+ reloaded_namespace,
668
+ new_mtime,
669
+ ) = load_or_reload_user_code(
670
+ _module_path,
671
+ _function_name,
672
+ entrypoint_abs_path,
673
+ _init_func_name,
674
+ _included_object_sources,
675
+ )
566
676
 
567
- # Read from stream with blocking
568
- streams = {REDIS_STREAM: ">"} # '>' means read only new messages
569
- # The type checker still struggles here, but the runtime types are asserted.
570
- print("reading from stream...")
571
- messages = r.xreadgroup( # type: ignore[arg-type]
572
- REDIS_CONSUMER_GROUP, consumer_name, streams, count=1, block=5000
573
- )
677
+ if (
678
+ reloaded_target_func is not None
679
+ and reloaded_module is not None
680
+ ):
681
+ print(
682
+ "[Consumer] Code reload successful. Updating functions."
683
+ )
684
+ target_function = reloaded_target_func
685
+ init_function = reloaded_init_func # Update init ref too, though it's already run
686
+ imported_module = reloaded_module
687
+ local_namespace = (
688
+ reloaded_namespace # Update namespace from includes
689
+ )
690
+ last_load_mtime = new_mtime
691
+ else:
692
+ print(
693
+ "[Consumer] Code reload failed. Continuing with previously loaded code."
694
+ )
695
+ # Optionally: Send an alert/log prominently that reload failed
696
+
697
+ except FileNotFoundError:
698
+ print(
699
+ f"[Consumer] Error: Entrypoint file '{entrypoint_abs_path}' not found during check. Cannot reload."
700
+ )
701
+ # Mark as non-runnable? Or just log?
702
+ target_function = None # Stop processing until file reappears?
703
+ imported_module = None
704
+ last_load_mtime = 0 # Reset mtime to force check next time
705
+ except Exception as e_reload_check:
706
+ print(f"[Consumer] Error checking/reloading code: {e_reload_check}")
707
+ traceback.print_exc()
708
+ else:
709
+ print(
710
+ "[Consumer] Warning: Entrypoint absolute path not set, cannot check for code updates."
711
+ )
574
712
 
575
- if not messages:
576
- # No messages received, continue waiting
577
- continue
713
+ # --- Read from Redis Stream ---
714
+ if target_function is None:
715
+ # If code failed to load initially or during reload, wait before retrying
716
+ print(
717
+ "[Consumer] Target function not loaded, waiting 5s before checking again..."
718
+ )
719
+ time.sleep(5)
720
+ continue # Skip reading from Redis until code is loaded
578
721
 
579
- # Assert that messages is a list (expected synchronous return type)
580
- assert isinstance(
581
- messages, list
582
- ), f"Expected list from xreadgroup, got {type(messages)}"
583
- assert len(messages) > 0 # Ensure the list is not empty before indexing
722
+ assert isinstance(REDIS_STREAM, str)
723
+ assert isinstance(REDIS_CONSUMER_GROUP, str)
584
724
 
585
- stream_name, stream_messages = messages[0]
725
+ # Simplified xreadgroup call - redis-py handles encoding now
726
+ # With decode_responses=True, redis-py expects str types here
727
+ streams_arg = {REDIS_STREAM: ">"}
728
+ messages = r.xreadgroup(
729
+ REDIS_CONSUMER_GROUP,
730
+ consumer_name,
731
+ streams_arg,
732
+ count=1,
733
+ block=5000, # Use milliseconds for block
734
+ )
586
735
 
587
- for message_id, message_data in stream_messages:
588
- print(f"Processing message {message_id}")
589
- print(f"Message data: {message_data}")
590
- process_message(message_id, message_data)
736
+ if not messages:
737
+ # print("[Consumer] No new messages.") # Reduce verbosity
738
+ continue
591
739
 
592
- except ConnectionError as e:
593
- print(f"Redis connection error: {e}")
594
- time.sleep(5) # Wait before retrying
740
+ # Assert messages is not None to help type checker (already implied by `if not messages`)
741
+ assert messages is not None
595
742
 
596
- except Exception as e:
597
- print(f"Unexpected error: {e}")
598
- traceback.print_exc()
599
- time.sleep(1) # Brief pause before continuing
743
+ # Cast messages to expected type to satisfy type checker
744
+ typed_messages = cast(
745
+ List[Tuple[str, List[Tuple[str, Dict[str, str]]]]], messages
746
+ )
747
+ stream_name_str, stream_messages = typed_messages[0]
748
+
749
+ # for msg_id_bytes, msg_data_bytes_dict in stream_messages: # Original structure
750
+ for (
751
+ message_id_str,
752
+ message_data_str_dict,
753
+ ) in stream_messages: # Structure with decode_responses=True
754
+ # message_id_str = msg_id_bytes.decode('utf-8') # No longer needed
755
+ # Decode keys/values in the message data dict
756
+ # message_data_str_dict = { k.decode('utf-8'): v.decode('utf-8')
757
+ # for k, v in msg_data_bytes_dict.items() } # No longer needed
758
+ # print(f"Processing message {message_id_str}") # Reduce verbosity
759
+ # print(f"Message data: {message_data_str_dict}") # Reduce verbosity
760
+ process_message(message_id_str, message_data_str_dict)
761
+
762
+ except ConnectionError as e:
763
+ print(f"Redis connection error: {e}. Reconnecting in 5s...")
764
+ time.sleep(5)
765
+ # Attempt to reconnect explicitly
766
+ try:
767
+ print("Attempting Redis reconnection...")
768
+ # Close existing potentially broken connection? `r.close()` if available
769
+ r = redis.from_url(REDIS_URL, decode_responses=True)
770
+ r.ping()
771
+ print("Reconnected to Redis.")
772
+ except Exception as recon_e:
773
+ print(f"Failed to reconnect to Redis: {recon_e}")
774
+ # Keep waiting
775
+
776
+ except ResponseError as e:
777
+ print(f"Redis command error: {e}")
778
+ # Should we exit or retry?
779
+ if "NOGROUP" in str(e):
780
+ print("Consumer group seems to have disappeared. Exiting.")
781
+ sys.exit(1)
782
+ time.sleep(1)
783
+
784
+ except Exception as e:
785
+ print(f"Unexpected error in main loop: {e}")
786
+ traceback.print_exc()
787
+ time.sleep(1)
788
+
789
+ finally:
790
+ print("Consumer loop exited.")
791
+ # Any other cleanup needed?