lucidicai 2.0.1__py3-none-any.whl → 2.1.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.
Files changed (39) hide show
  1. lucidicai/__init__.py +351 -876
  2. lucidicai/api/__init__.py +1 -0
  3. lucidicai/api/client.py +218 -0
  4. lucidicai/api/resources/__init__.py +1 -0
  5. lucidicai/api/resources/dataset.py +192 -0
  6. lucidicai/api/resources/event.py +88 -0
  7. lucidicai/api/resources/session.py +126 -0
  8. lucidicai/client.py +4 -1
  9. lucidicai/core/__init__.py +1 -0
  10. lucidicai/core/config.py +223 -0
  11. lucidicai/core/errors.py +60 -0
  12. lucidicai/core/types.py +35 -0
  13. lucidicai/dataset.py +2 -0
  14. lucidicai/errors.py +6 -0
  15. lucidicai/feature_flag.py +8 -0
  16. lucidicai/sdk/__init__.py +1 -0
  17. lucidicai/sdk/context.py +144 -0
  18. lucidicai/sdk/decorators.py +187 -0
  19. lucidicai/sdk/error_boundary.py +299 -0
  20. lucidicai/sdk/event.py +122 -0
  21. lucidicai/sdk/event_builder.py +304 -0
  22. lucidicai/sdk/features/__init__.py +1 -0
  23. lucidicai/sdk/features/dataset.py +605 -0
  24. lucidicai/sdk/features/feature_flag.py +383 -0
  25. lucidicai/sdk/init.py +271 -0
  26. lucidicai/sdk/shutdown_manager.py +302 -0
  27. lucidicai/telemetry/context_bridge.py +82 -0
  28. lucidicai/telemetry/context_capture_processor.py +25 -9
  29. lucidicai/telemetry/litellm_bridge.py +18 -24
  30. lucidicai/telemetry/lucidic_exporter.py +51 -36
  31. lucidicai/telemetry/utils/model_pricing.py +278 -0
  32. lucidicai/utils/__init__.py +1 -0
  33. lucidicai/utils/images.py +337 -0
  34. lucidicai/utils/logger.py +168 -0
  35. lucidicai/utils/queue.py +393 -0
  36. {lucidicai-2.0.1.dist-info → lucidicai-2.1.0.dist-info}/METADATA +1 -1
  37. {lucidicai-2.0.1.dist-info → lucidicai-2.1.0.dist-info}/RECORD +39 -12
  38. {lucidicai-2.0.1.dist-info → lucidicai-2.1.0.dist-info}/WHEEL +0 -0
  39. {lucidicai-2.0.1.dist-info → lucidicai-2.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,223 @@
1
+ """Centralized configuration management for Lucidic SDK.
2
+
3
+ This module provides a single source of truth for all SDK configuration,
4
+ including environment variables, defaults, and runtime settings.
5
+ """
6
+ import os
7
+ from dataclasses import dataclass, field
8
+ from typing import Optional, Dict, Any, List
9
+ from enum import Enum
10
+
11
+
12
+ class Environment(Enum):
13
+ """SDK environment modes"""
14
+ PRODUCTION = "production"
15
+ DEVELOPMENT = "development"
16
+ DEBUG = "debug"
17
+
18
+
19
+ @dataclass
20
+ class NetworkConfig:
21
+ """Network and connection settings"""
22
+ base_url: str = "https://api.lucidic.ai/api"
23
+ timeout: int = 30
24
+ max_retries: int = 3
25
+ backoff_factor: float = 0.5
26
+ connection_pool_size: int = 20
27
+ connection_pool_maxsize: int = 100
28
+
29
+ @classmethod
30
+ def from_env(cls) -> 'NetworkConfig':
31
+ """Load network configuration from environment variables"""
32
+ debug = os.getenv("LUCIDIC_DEBUG", "False").lower() == "true"
33
+ return cls(
34
+ base_url="http://localhost:8000/api" if debug else "https://api.lucidic.ai/api",
35
+ timeout=int(os.getenv("LUCIDIC_TIMEOUT", "30")),
36
+ max_retries=int(os.getenv("LUCIDIC_MAX_RETRIES", "3")),
37
+ backoff_factor=float(os.getenv("LUCIDIC_BACKOFF_FACTOR", "0.5")),
38
+ connection_pool_size=int(os.getenv("LUCIDIC_CONNECTION_POOL_SIZE", "20")),
39
+ connection_pool_maxsize=int(os.getenv("LUCIDIC_CONNECTION_POOL_MAXSIZE", "100"))
40
+ )
41
+
42
+
43
+ @dataclass
44
+ class EventQueueConfig:
45
+ """Event queue processing settings"""
46
+ max_queue_size: int = 100000
47
+ flush_interval_ms: int = 100
48
+ flush_at_count: int = 100
49
+ blob_threshold: int = 65536
50
+ daemon_mode: bool = True
51
+ max_parallel_workers: int = 10
52
+ retry_failed: bool = True
53
+
54
+ @classmethod
55
+ def from_env(cls) -> 'EventQueueConfig':
56
+ """Load event queue configuration from environment variables"""
57
+ return cls(
58
+ max_queue_size=int(os.getenv("LUCIDIC_MAX_QUEUE_SIZE", "100000")),
59
+ flush_interval_ms=int(os.getenv("LUCIDIC_FLUSH_INTERVAL", "1000")),
60
+ flush_at_count=int(os.getenv("LUCIDIC_FLUSH_AT", "50")),
61
+ blob_threshold=int(os.getenv("LUCIDIC_BLOB_THRESHOLD", "65536")),
62
+ daemon_mode=os.getenv("LUCIDIC_DAEMON_QUEUE", "true").lower() == "true",
63
+ max_parallel_workers=int(os.getenv("LUCIDIC_MAX_PARALLEL", "10")),
64
+ retry_failed=os.getenv("LUCIDIC_RETRY_FAILED", "true").lower() == "true"
65
+ )
66
+
67
+
68
+ @dataclass
69
+ class ErrorHandlingConfig:
70
+ """Error handling and suppression settings"""
71
+ suppress_errors: bool = True
72
+ cleanup_on_error: bool = True
73
+ log_suppressed: bool = True
74
+ capture_uncaught: bool = True
75
+
76
+ @classmethod
77
+ def from_env(cls) -> 'ErrorHandlingConfig':
78
+ """Load error handling configuration from environment variables"""
79
+ return cls(
80
+ suppress_errors=os.getenv("LUCIDIC_SUPPRESS_ERRORS", "true").lower() == "true",
81
+ cleanup_on_error=os.getenv("LUCIDIC_CLEANUP_ON_ERROR", "true").lower() == "true",
82
+ log_suppressed=os.getenv("LUCIDIC_LOG_SUPPRESSED", "true").lower() == "true",
83
+ capture_uncaught=os.getenv("LUCIDIC_CAPTURE_UNCAUGHT", "true").lower() == "true"
84
+ )
85
+
86
+
87
+ @dataclass
88
+ class TelemetryConfig:
89
+ """Telemetry and instrumentation settings"""
90
+ providers: List[str] = field(default_factory=list)
91
+ verbose: bool = False
92
+
93
+ @classmethod
94
+ def from_env(cls) -> 'TelemetryConfig':
95
+ """Load telemetry configuration from environment variables"""
96
+ return cls(
97
+ providers=[], # Set during initialization
98
+ verbose=os.getenv("LUCIDIC_VERBOSE", "False").lower() == "true"
99
+ )
100
+
101
+
102
+ @dataclass
103
+ class SDKConfig:
104
+ """Main SDK configuration container"""
105
+ # Required settings
106
+ api_key: Optional[str] = None
107
+ agent_id: Optional[str] = None
108
+
109
+ # Feature flags
110
+ auto_end: bool = True
111
+ production_monitoring: bool = False
112
+
113
+ # Sub-configurations
114
+ network: NetworkConfig = field(default_factory=NetworkConfig)
115
+ event_queue: EventQueueConfig = field(default_factory=EventQueueConfig)
116
+ error_handling: ErrorHandlingConfig = field(default_factory=ErrorHandlingConfig)
117
+ telemetry: TelemetryConfig = field(default_factory=TelemetryConfig)
118
+
119
+ # Runtime settings
120
+ environment: Environment = Environment.PRODUCTION
121
+ debug: bool = False
122
+
123
+ @classmethod
124
+ def from_env(cls, **overrides) -> 'SDKConfig':
125
+ """Create configuration from environment variables with optional overrides"""
126
+ from dotenv import load_dotenv
127
+ load_dotenv()
128
+
129
+ debug = os.getenv("LUCIDIC_DEBUG", "False").lower() == "true"
130
+
131
+ config = cls(
132
+ api_key=os.getenv("LUCIDIC_API_KEY"),
133
+ agent_id=os.getenv("LUCIDIC_AGENT_ID"),
134
+ auto_end=os.getenv("LUCIDIC_AUTO_END", "true").lower() == "true",
135
+ production_monitoring=False,
136
+ network=NetworkConfig.from_env(),
137
+ event_queue=EventQueueConfig.from_env(),
138
+ error_handling=ErrorHandlingConfig.from_env(),
139
+ telemetry=TelemetryConfig.from_env(),
140
+ environment=Environment.DEBUG if debug else Environment.PRODUCTION,
141
+ debug=debug
142
+ )
143
+
144
+ # Apply any overrides
145
+ config.update(**overrides)
146
+ return config
147
+
148
+ def update(self, **kwargs):
149
+ """Update configuration with new values"""
150
+ for key, value in kwargs.items():
151
+ # Only update if value is not None (to preserve env defaults)
152
+ if value is not None:
153
+ if hasattr(self, key):
154
+ setattr(self, key, value)
155
+ elif key == "providers" and hasattr(self.telemetry, "providers"):
156
+ self.telemetry.providers = value
157
+
158
+ def validate(self) -> List[str]:
159
+ """Validate configuration and return list of errors"""
160
+ errors = []
161
+
162
+ if not self.api_key:
163
+ errors.append("API key is required (LUCIDIC_API_KEY)")
164
+
165
+ if not self.agent_id:
166
+ errors.append("Agent ID is required (LUCIDIC_AGENT_ID)")
167
+
168
+ if self.event_queue.max_parallel_workers < 1:
169
+ errors.append("Max parallel workers must be at least 1")
170
+
171
+ if self.event_queue.flush_interval_ms < 10:
172
+ errors.append("Flush interval must be at least 10ms")
173
+
174
+ return errors
175
+
176
+ def to_dict(self) -> Dict[str, Any]:
177
+ """Convert configuration to dictionary"""
178
+ return {
179
+ "api_key": self.api_key[:8] + "..." if self.api_key else None,
180
+ "agent_id": self.agent_id,
181
+ "environment": self.environment.value,
182
+ "debug": self.debug,
183
+ "auto_end": self.auto_end,
184
+ "network": {
185
+ "base_url": self.network.base_url,
186
+ "timeout": self.network.timeout,
187
+ "max_retries": self.network.max_retries,
188
+ "connection_pool_size": self.network.connection_pool_size
189
+ },
190
+ "event_queue": {
191
+ "max_workers": self.event_queue.max_parallel_workers,
192
+ "flush_interval_ms": self.event_queue.flush_interval_ms,
193
+ "flush_at_count": self.event_queue.flush_at_count
194
+ },
195
+ "error_handling": {
196
+ "suppress": self.error_handling.suppress_errors,
197
+ "cleanup": self.error_handling.cleanup_on_error
198
+ }
199
+ }
200
+
201
+
202
+ # Global configuration instance
203
+ _config: Optional[SDKConfig] = None
204
+
205
+
206
+ def get_config() -> SDKConfig:
207
+ """Get the current SDK configuration"""
208
+ global _config
209
+ if _config is None:
210
+ _config = SDKConfig.from_env()
211
+ return _config
212
+
213
+
214
+ def set_config(config: SDKConfig):
215
+ """Set the SDK configuration"""
216
+ global _config
217
+ _config = config
218
+
219
+
220
+ def reset_config():
221
+ """Reset configuration to defaults"""
222
+ global _config
223
+ _config = None
@@ -0,0 +1,60 @@
1
+ from typing import Optional
2
+ import sys
3
+ import traceback
4
+
5
+
6
+ class LucidicError(Exception):
7
+ """Base exception for all Lucidic SDK errors"""
8
+ pass
9
+
10
+
11
+ class APIKeyVerificationError(LucidicError):
12
+ """Exception for API key verification errors"""
13
+ def __init__(self, message):
14
+ super().__init__(f"Could not verify Lucidic API key: {message}")
15
+
16
+ class LucidicNotInitializedError(LucidicError):
17
+ """Exception for calling Lucidic functions before Lucidic Client is initialized (lai.init())"""
18
+ def __init__(self):
19
+ super().__init__("Client is not initialized. Make sure to call lai.init() to initialize the client before calling other functions.")
20
+
21
+ class PromptError(LucidicError):
22
+ "Exception for errors related to prompt management"
23
+ def __init__(self, message: str):
24
+ super().__init__(f"Error getting Lucidic prompt: {message}")
25
+
26
+ class InvalidOperationError(LucidicError):
27
+ "Exception for errors resulting from attempting an invalid operation"
28
+ def __init__(self, message: str):
29
+ super().__init__(f"An invalid Lucidic operation was attempted: {message}")
30
+
31
+
32
+ class FeatureFlagError(LucidicError):
33
+ """Exception for feature flag fetch failures"""
34
+ def __init__(self, message: str):
35
+ super().__init__(f"Failed to fetch feature flag: {message}")
36
+
37
+
38
+ def install_error_handler():
39
+ """Install global handler to create ERROR_TRACEBACK events for uncaught exceptions."""
40
+ from .sdk.event import create_event
41
+ from .sdk.init import get_session_id
42
+ from .context import current_parent_event_id
43
+
44
+ def handle_exception(exc_type, exc_value, exc_traceback):
45
+ try:
46
+ if get_session_id():
47
+ tb = ''.join(traceback.format_exception(exc_type, exc_value, exc_traceback))
48
+ create_event(
49
+ type="error_traceback",
50
+ error=str(exc_value),
51
+ traceback=tb
52
+ )
53
+ except Exception:
54
+ pass
55
+ try:
56
+ sys.__excepthook__(exc_type, exc_value, exc_traceback)
57
+ except Exception:
58
+ pass
59
+
60
+ sys.excepthook = handle_exception
@@ -0,0 +1,35 @@
1
+ """Type definitions for the Lucidic SDK."""
2
+ from enum import Enum
3
+ from typing import Literal
4
+
5
+
6
+ class EventType(Enum):
7
+ """Supported event types."""
8
+ LLM_GENERATION = "llm_generation"
9
+ FUNCTION_CALL = "function_call"
10
+ ERROR_TRACEBACK = "error_traceback"
11
+ GENERIC = "generic"
12
+
13
+
14
+ # Provider type literals
15
+ ProviderType = Literal[
16
+ "openai",
17
+ "anthropic",
18
+ "langchain",
19
+ "pydantic_ai",
20
+ "openai_agents",
21
+ "litellm",
22
+ "bedrock",
23
+ "aws_bedrock",
24
+ "amazon_bedrock",
25
+ "vertexai",
26
+ "vertex_ai",
27
+ "google",
28
+ "google_generativeai",
29
+ "cohere",
30
+ "groq",
31
+ ]
32
+
33
+
34
+ # Deprecated type aliases (for backward compatibility)
35
+ StepType = EventType # Steps are now events
lucidicai/dataset.py CHANGED
@@ -37,6 +37,7 @@ def get_dataset(
37
37
  APIKeyVerificationError: If API key or agent ID is missing or invalid.
38
38
  ValueError: If dataset_id is not provided.
39
39
  """
40
+ return # no op for now
40
41
  load_dotenv()
41
42
 
42
43
  # Validation
@@ -108,5 +109,6 @@ def get_dataset_items(
108
109
  APIKeyVerificationError: If API key or agent ID is missing or invalid.
109
110
  ValueError: If dataset_id is not provided.
110
111
  """
112
+ return # no op for now
111
113
  dataset = get_dataset(dataset_id, api_key, agent_id)
112
114
  return dataset.get('items', [])
lucidicai/errors.py CHANGED
@@ -23,6 +23,12 @@ class InvalidOperationError(Exception):
23
23
  super().__init__(f"An invalid Lucidic operation was attempted: {message}")
24
24
 
25
25
 
26
+ class FeatureFlagError(Exception):
27
+ """Exception for feature flag fetch failures"""
28
+ def __init__(self, message: str):
29
+ super().__init__(f"Failed to fetch feature flag: {message}")
30
+
31
+
26
32
  def install_error_handler():
27
33
  """Install global handler to create ERROR_TRACEBACK events for uncaught exceptions."""
28
34
  from .client import Client
lucidicai/feature_flag.py CHANGED
@@ -134,6 +134,9 @@ def get_feature_flag(
134
134
  defaults={"max_retries": 3}
135
135
  )
136
136
  """
137
+
138
+ return # no op for now
139
+
137
140
  load_dotenv()
138
141
 
139
142
  # Determine if single or batch
@@ -259,6 +262,7 @@ def get_bool_flag(flag_name: str, default: Optional[bool] = None, **kwargs) -> b
259
262
  FeatureFlagError: If fetch fails and no default provided
260
263
  TypeError: If flag value is not a boolean
261
264
  """
265
+ return # no op for now
262
266
  value = get_feature_flag(flag_name, default=default if default is not None else MISSING, **kwargs)
263
267
  if not isinstance(value, bool):
264
268
  if default is not None:
@@ -276,6 +280,7 @@ def get_int_flag(flag_name: str, default: Optional[int] = None, **kwargs) -> int
276
280
  FeatureFlagError: If fetch fails and no default provided
277
281
  TypeError: If flag value is not an integer
278
282
  """
283
+ return # no op for now
279
284
  value = get_feature_flag(flag_name, default=default if default is not None else MISSING, **kwargs)
280
285
  if not isinstance(value, int) or isinstance(value, bool): # bool is subclass of int
281
286
  if default is not None:
@@ -293,6 +298,7 @@ def get_float_flag(flag_name: str, default: Optional[float] = None, **kwargs) ->
293
298
  FeatureFlagError: If fetch fails and no default provided
294
299
  TypeError: If flag value is not a float
295
300
  """
301
+ return # no op for now
296
302
  value = get_feature_flag(flag_name, default=default if default is not None else MISSING, **kwargs)
297
303
  if not isinstance(value, (int, float)) or isinstance(value, bool):
298
304
  if default is not None:
@@ -310,6 +316,7 @@ def get_string_flag(flag_name: str, default: Optional[str] = None, **kwargs) ->
310
316
  FeatureFlagError: If fetch fails and no default provided
311
317
  TypeError: If flag value is not a string
312
318
  """
319
+ return # no op for now
313
320
  value = get_feature_flag(flag_name, default=default if default is not None else MISSING, **kwargs)
314
321
  if not isinstance(value, str):
315
322
  if default is not None:
@@ -326,6 +333,7 @@ def get_json_flag(flag_name: str, default: Optional[dict] = None, **kwargs) -> d
326
333
  Raises:
327
334
  FeatureFlagError: If fetch fails and no default provided
328
335
  """
336
+ return # no op for now
329
337
  value = get_feature_flag(flag_name, default=default if default is not None else MISSING, **kwargs)
330
338
  return value
331
339
 
@@ -0,0 +1 @@
1
+ """SDK module for Lucidic AI."""
@@ -0,0 +1,144 @@
1
+ """Async-safe context helpers for session (and step, extensible).
2
+
3
+ This module exposes context variables and helpers to bind a Lucidic
4
+ session to the current execution context (threads/async tasks), so
5
+ OpenTelemetry spans can be deterministically attributed to the correct
6
+ session under concurrency.
7
+ """
8
+
9
+ from contextlib import contextmanager, asynccontextmanager
10
+ import contextvars
11
+ from typing import Optional, Iterator, AsyncIterator, Callable, Any, Dict
12
+ import logging
13
+ import os
14
+
15
+
16
+ # Context variable for the active Lucidic session id
17
+ current_session_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
18
+ "lucidic.session_id", default=None
19
+ )
20
+
21
+
22
+ # NEW: Context variable for parent event nesting
23
+ current_parent_event_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
24
+ "lucidic.parent_event_id", default=None
25
+ )
26
+
27
+
28
+ def set_active_session(session_id: Optional[str]) -> None:
29
+ """Bind the given session id to the current execution context."""
30
+ current_session_id.set(session_id)
31
+
32
+
33
+ def clear_active_session() -> None:
34
+ """Clear any active session binding in the current execution context."""
35
+ current_session_id.set(None)
36
+
37
+
38
+ @contextmanager
39
+ def bind_session(session_id: str) -> Iterator[None]:
40
+ """Context manager to temporarily bind an active session id."""
41
+ token = current_session_id.set(session_id)
42
+ try:
43
+ yield
44
+ finally:
45
+ current_session_id.reset(token)
46
+
47
+
48
+ @asynccontextmanager
49
+ async def bind_session_async(session_id: str) -> AsyncIterator[None]:
50
+ """Async context manager to temporarily bind an active session id."""
51
+ token = current_session_id.set(session_id)
52
+ try:
53
+ yield
54
+ finally:
55
+ current_session_id.reset(token)
56
+
57
+
58
+ # NEW: Parent event context managers
59
+ @contextmanager
60
+ def event_context(event_id: str) -> Iterator[None]:
61
+ token = current_parent_event_id.set(event_id)
62
+ try:
63
+ yield
64
+ finally:
65
+ current_parent_event_id.reset(token)
66
+
67
+
68
+ @asynccontextmanager
69
+ async def event_context_async(event_id: str) -> AsyncIterator[None]:
70
+ token = current_parent_event_id.set(event_id)
71
+ try:
72
+ yield
73
+ finally:
74
+ current_parent_event_id.reset(token)
75
+
76
+
77
+ @contextmanager
78
+ def session(**init_params) -> Iterator[None]:
79
+ """All-in-one context manager: init → bind → yield → clear → end.
80
+
81
+ Notes:
82
+ - Ignores any provided auto_end parameter and ends the session on context exit.
83
+ - If LUCIDIC_DEBUG is true, logs a warning about ignoring auto_end.
84
+ """
85
+ # Lazy import to avoid circular imports
86
+ import lucidicai as lai # type: ignore
87
+
88
+ # Force auto_end to False inside a context manager to control explicit end
89
+ user_auto_end = init_params.get('auto_end', None)
90
+ init_params = dict(init_params)
91
+ init_params['auto_end'] = False
92
+
93
+ if os.getenv('LUCIDIC_DEBUG', 'False') == 'True' and user_auto_end is not None:
94
+ logging.getLogger('Lucidic').warning('session(...) ignores auto_end and will end the session at context exit')
95
+
96
+ session_id = lai.init(**init_params)
97
+ token = current_session_id.set(session_id)
98
+ try:
99
+ yield
100
+ finally:
101
+ current_session_id.reset(token)
102
+ try:
103
+ lai.end_session()
104
+ except Exception:
105
+ # Avoid masking the original exception from the with-block
106
+ pass
107
+
108
+
109
+ @asynccontextmanager
110
+ async def session_async(**init_params) -> AsyncIterator[None]:
111
+ """Async counterpart of session(...)."""
112
+ import lucidicai as lai # type: ignore
113
+
114
+ user_auto_end = init_params.get('auto_end', None)
115
+ init_params = dict(init_params)
116
+ init_params['auto_end'] = False
117
+
118
+ if os.getenv('LUCIDIC_DEBUG', 'False') == 'True' and user_auto_end is not None:
119
+ logging.getLogger('Lucidic').warning('session_async(...) ignores auto_end and will end the session at context exit')
120
+
121
+ session_id = lai.init(**init_params)
122
+ token = current_session_id.set(session_id)
123
+ try:
124
+ yield
125
+ finally:
126
+ current_session_id.reset(token)
127
+ try:
128
+ lai.end_session()
129
+ except Exception:
130
+ pass
131
+
132
+
133
+ def run_session(fn: Callable[..., Any], *fn_args: Any, init_params: Optional[Dict[str, Any]] = None, **fn_kwargs: Any) -> Any:
134
+ """Run a callable within a full Lucidic session lifecycle context."""
135
+ with session(**(init_params or {})):
136
+ return fn(*fn_args, **fn_kwargs)
137
+
138
+
139
+ def run_in_session(session_id: str, fn: Callable[..., Any], *fn_args: Any, **fn_kwargs: Any) -> Any:
140
+ """Run a callable with a bound session id. Does not end the session."""
141
+ with bind_session(session_id):
142
+ return fn(*fn_args, **fn_kwargs)
143
+
144
+