vulcan-core 1.1.3__py3-none-any.whl → 1.1.5__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.

Potentially problematic release.


This version of vulcan-core might be problematic. Click here for more details.

vulcan_core/ast_utils.py CHANGED
@@ -3,18 +3,21 @@
3
3
 
4
4
  import ast
5
5
  import inspect
6
+ import logging
6
7
  import re
7
8
  import textwrap
8
- import threading
9
9
  from ast import Attribute, Module, Name, NodeTransformer, NodeVisitor
10
+ from collections import OrderedDict
10
11
  from collections.abc import Callable
11
12
  from dataclasses import dataclass, field
12
13
  from functools import cached_property
13
14
  from types import MappingProxyType
14
- from typing import Any, TypeAliasType, get_type_hints
15
+ from typing import Any, ClassVar, TypeAliasType, get_type_hints
15
16
 
16
17
  from vulcan_core.models import Fact, HasSource
17
18
 
19
+ logger = logging.getLogger(__name__)
20
+
18
21
 
19
22
  class ASTProcessingError(RuntimeError):
20
23
  """Internal error encountered while processing AST."""
@@ -84,16 +87,63 @@ class AttributeTransformer(NodeTransformer):
84
87
  return node
85
88
 
86
89
 
87
- # Global index to cache and track lambda function positions within the same source lines.
88
- # Tuple format: (source code, last processed index)
89
- # TODO: Consider if a redesign is possible to have a single ASTProcessor handle the entire source line, perhaps eagerly
90
- # processing all lambdas found in the line before the correspondign `condition` call.
91
- _lambda_index_lock = threading.Lock()
92
- lambda_index: dict[Any, tuple[str, int | None]] = {}
90
+ @dataclass(slots=True)
91
+ class LambdaSource:
92
+ """Index entry for tracking the parsing position of lambda functions in source lines.
93
+
94
+ Attributes:
95
+ source (str): The source code string containing lambda functions
96
+ count (int): The number of lambda functions found in the source string.
97
+ pos (int): The current parsing position within the source string.
98
+ """
99
+
100
+ source: str
101
+ count: int
102
+ pos: int = field(default=0)
103
+ in_use: bool = field(default=True)
93
104
 
94
105
 
95
106
  @dataclass
96
107
  class ASTProcessor[T: Callable]:
108
+ """
109
+ This class extracts source code from functions or lambda expressions, parses them into
110
+ Abstract Syntax Trees (AST), and performs various validations and transformations.
111
+
112
+ The processor validates that:
113
+ - Functions have proper type hints for parameters and return types
114
+ - All parameters are subclasses of Fact
115
+ - No nested attribute access (e.g., X.y.z) is used
116
+ - No async functions are processed
117
+ - Lambda expressions do not contain parameters
118
+ - No duplicate parameter types in function signatures
119
+
120
+ For lambda expressions, it automatically transforms attribute access patterns
121
+ (e.g., ClassName.attribute) into parameterized functions for easier execution.
122
+
123
+ Note: This class is not thread-safe and should not be used concurrently across multiple threads.
124
+
125
+ Type Parameters:
126
+ T: The type signature the processor is working with, this varies based on a condition or action being processed.
127
+
128
+ Attributes:
129
+ func: The callable to process, a lambda or a function
130
+ decorator: The decorator type that initiated the processing (e.g., `condition` or `action`)
131
+ return_type: Expected return type for the callable
132
+ source: Extracted source code of func (set during post-init)
133
+ tree: Parsed AST of the source code (set during post-init)
134
+ facts: Tuple of fact strings discovered in the callable (set during post-init)
135
+
136
+ Properties:
137
+ is_lambda: True if the callable is a lambda expression
138
+
139
+ Raises:
140
+ OSError: When source code cannot be extracted
141
+ ScopeAccessError: When accessing undefined classes or using nested attributes
142
+ CallableSignatureError: When function signature doesn't meet requirements
143
+ NotAFactError: When parameter types are not Fact subclasses
144
+ ASTProcessingError: When AST processing encounters internal errors
145
+ """
146
+
97
147
  func: T
98
148
  decorator: Callable
99
149
  return_type: type | TypeAliasType
@@ -101,6 +151,10 @@ class ASTProcessor[T: Callable]:
101
151
  tree: Module = field(init=False)
102
152
  facts: tuple[str, ...] = field(init=False)
103
153
 
154
+ # Class-level tracking of lambdas across parsing calls to handle multiple lambdas on the same line
155
+ _lambda_cache: ClassVar[OrderedDict[str, LambdaSource]] = OrderedDict()
156
+ _MAX_LAMBDA_CACHE_SIZE: ClassVar[int] = 1024
157
+
104
158
  @cached_property
105
159
  def is_lambda(self) -> bool:
106
160
  return isinstance(self.func, type(lambda: None)) and self.func.__name__ == "<lambda>"
@@ -113,30 +167,34 @@ class ASTProcessor[T: Callable]:
113
167
  try:
114
168
  if self.is_lambda:
115
169
  # As of Python 3.12, there is no way to determine to which lambda self.func refers in an
116
- # expression containing multiple lambdas. Therefore we use a global dict to track the index of each
170
+ # expression containing multiple lambdas. Therefore we use a dict to track the index of each
117
171
  # lambda function encountered, as the order will correspond to the order of ASTProcessor
118
172
  # invocations for that line. An additional benefit is that we can also use this as a cache to
119
173
  # avoid re-reading the source code for lambda functions sharing the same line.
120
- #
121
- # The key for the index is a hash of the stack trace plus line number, which will be
122
- # unique for each call of a list of lambdas on the same line.
123
- frames = inspect.stack()[1:] # Exclude current frame
124
- key = "".join(f"{f.filename}:{f.lineno}" for f in frames)
125
-
126
- # Use a lock to ensure thread safety when accessing the global lambda index
127
- with _lambda_index_lock:
128
- index = lambda_index.get(key)
129
- if index is None or index[1] is None:
130
- self.source = self._get_lambda_source()
131
- index = (self.source, 0)
132
- lambda_index[key] = index
133
- else:
134
- self.source = index[0]
135
- index = (self.source, index[1] + 1)
136
- lambda_index[key] = index
174
+ source_line = f"{self.func.__code__.co_filename}:{self.func.__code__.co_firstlineno}"
175
+ lambda_src = self._lambda_cache.get(source_line)
176
+
177
+ if lambda_src is None:
178
+ self.source = self._get_lambda_source()
179
+ lambda_count = self._count_lambdas(self.source)
180
+ lambda_src = LambdaSource(self.source, lambda_count)
181
+ self._lambda_cache[source_line] = lambda_src
182
+ self._trim_lambda_cache()
183
+ else:
184
+ self.source = lambda_src.source
185
+ lambda_src.pos += 1
186
+
187
+ # Reset the position if it exceeds the count of lambda expressions
188
+ if lambda_src.pos >= lambda_src.count:
189
+ lambda_src.pos = 0
137
190
 
138
191
  # Normalize the lambda source and extract the next lambda expression from the last index
139
- self.source = self._normalize_lambda_source(self.source, index[1])
192
+ self.source = self._normalize_lambda_source(self.source, lambda_src.pos)
193
+
194
+ # If done processing lambdas in the source, mark as not processing anymore
195
+ if lambda_src.pos >= lambda_src.count - 1:
196
+ lambda_src.in_use = False
197
+
140
198
  else:
141
199
  self.source = textwrap.dedent(inspect.getsource(self.func))
142
200
  except OSError as e:
@@ -180,25 +238,51 @@ class ASTProcessor[T: Callable]:
180
238
 
181
239
  self.facts = tuple(facts)
182
240
 
241
+ def _trim_lambda_cache(self) -> None:
242
+ """Clean up lambda cache by removing oldest unused entries when cache size exceeds limit."""
243
+ if len(self._lambda_cache) <= self._MAX_LAMBDA_CACHE_SIZE:
244
+ return
245
+
246
+ # Calculate how many entries to remove (excess + 20% buffer to avoid thrashing)
247
+ excess_count = len(self._lambda_cache) - self._MAX_LAMBDA_CACHE_SIZE
248
+ buffer_count = int(self._MAX_LAMBDA_CACHE_SIZE * 0.2)
249
+ target_count = excess_count + buffer_count
250
+
251
+ # Find and remove unused entries
252
+ removed_count = 0
253
+ for key in list(self._lambda_cache):
254
+ if removed_count >= target_count:
255
+ break
256
+ if not self._lambda_cache[key].in_use:
257
+ del self._lambda_cache[key]
258
+ removed_count += 1
259
+
260
+ def _count_lambdas(self, source: str) -> int:
261
+ """Count lambda expressions in source code using AST parsing."""
262
+ tree = ast.parse(source)
263
+
264
+ class LambdaCounter(ast.NodeVisitor):
265
+ def __init__(self):
266
+ self.count = 0
267
+
268
+ def visit_Lambda(self, node): # noqa: N802 - Case sensitive for AST
269
+ self.count += 1
270
+ self.generic_visit(node)
271
+
272
+ counter = LambdaCounter()
273
+ counter.visit(tree)
274
+ return counter.count
275
+
183
276
  def _get_lambda_source(self) -> str:
184
277
  """Get single and multiline lambda source using AST parsing of the source file."""
185
- try:
186
- # Get caller frame to find the source file
187
- frame = inspect.currentframe()
188
- while frame and frame.f_code.co_name != self.decorator.__name__:
189
- frame = frame.f_back
190
-
191
- if not frame or not frame.f_back:
192
- return textwrap.dedent(inspect.getsource(self.func))
278
+ source = None
193
279
 
194
- caller_frame = frame.f_back
195
- filename = caller_frame.f_code.co_filename
280
+ try:
281
+ # Get the source file and line number
282
+ # Avoid reading source from files directly, as it may fail in some cases (e.g., lambdas in REPL)
283
+ file_content = "".join(inspect.findsource(self.func)[0])
196
284
  lambda_lineno = self.func.__code__.co_firstlineno
197
285
 
198
- # Read the source file
199
- with open(filename, encoding="utf-8") as f:
200
- file_content = f.read()
201
-
202
286
  # Parse the AST of the source file
203
287
  file_ast = ast.parse(file_content)
204
288
 
@@ -235,20 +319,25 @@ class ASTProcessor[T: Callable]:
235
319
  end_line = i
236
320
  break
237
321
 
238
- return "\n".join(lines[start_line : end_line + 1])
322
+ source = "\n".join(lines[start_line : end_line + 1])
239
323
 
240
324
  except (OSError, SyntaxError, AttributeError):
241
- pass
242
-
243
- # Fallback to regular inspect.getsource
244
- return textwrap.dedent(inspect.getsource(self.func))
325
+ logger.exception("Failed to extract lambda source, attempting fallback.")
326
+ source = inspect.getsource(self.func).strip()
245
327
 
246
- def _normalize_lambda_source(self, source: str, index: int) -> str:
247
- """Extracts just the lambda expression from source code."""
328
+ if source is None or source == "":
329
+ msg = "Could not extract lambda source code"
330
+ raise ASTProcessingError(msg)
248
331
 
249
- # Remove line endings and extra whitespace
332
+ # Normalize the source: convert line breaks to spaces, collapse whitespace, and dedent
250
333
  source = re.sub(r"\r\n|\r|\n", " ", source)
251
334
  source = re.sub(r"\s+", " ", source)
335
+ source = textwrap.dedent(source)
336
+
337
+ return source
338
+
339
+ def _normalize_lambda_source(self, source: str, index: int) -> str:
340
+ """Extracts just the lambda expression from source code."""
252
341
 
253
342
  # Find the Nth lambda occurrence using generator expression
254
343
  positions = [i for i in range(len(source) - 5) if source[i : i + 6] == "lambda"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: vulcan-core
3
- Version: 1.1.3
3
+ Version: 1.1.5
4
4
  Summary: AI-Hybrid Rules Engine for Logical Reasoning.
5
5
  License: Apache-2.0
6
6
  Keywords: rules,logic,reasoning,ai,artificial intelligence,RAG,LLM
@@ -1,12 +1,12 @@
1
1
  vulcan_core/__init__.py,sha256=pjCnmbMjrsp672WXRQeOV0aSKUEoA_mj0o7q_ouMWs8,1057
2
2
  vulcan_core/actions.py,sha256=RO5w5X-drxtDY_mVv0xR2njasWkGPt1AZo9RXsBi8X0,917
3
- vulcan_core/ast_utils.py,sha256=PD3IKU4Cz5-BdIBoparIKh-YHJ4ik78FGBy3bXxZkmk,17700
3
+ vulcan_core/ast_utils.py,sha256=pD-l2DYsP7CZrMkvQxQIu2-7uiC9vSEgMG0X_SmujF4,21332
4
4
  vulcan_core/conditions.py,sha256=ZK4plEO2dB7gq0esroEhL29naB_qAsoU4AVSv0rXClk,15670
5
5
  vulcan_core/engine.py,sha256=WjayTDEjKaIEVkkSZyDjdbu1Xy1XIvPewI83l6Sjo9g,9672
6
6
  vulcan_core/models.py,sha256=7um3u-rAy9gg2otTnZFGlKfHJKfsvGEosU3mGq_0jyg,8964
7
7
  vulcan_core/util.py,sha256=Uq5uWhrfWd8fNv6IeeTFZRGeLBAECPZUx63UjbbSMrA,3420
8
- vulcan_core-1.1.3.dist-info/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
9
- vulcan_core-1.1.3.dist-info/METADATA,sha256=VD7h3KcJn2THORJ0g-IEz3kZdekwq3cDaLqGJpGc_N4,4425
10
- vulcan_core-1.1.3.dist-info/NOTICE,sha256=UN1_Gd_Snmu8T62mxExNUdIF2o7DMdGu8bI8rKqhVnc,244
11
- vulcan_core-1.1.3.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
12
- vulcan_core-1.1.3.dist-info/RECORD,,
8
+ vulcan_core-1.1.5.dist-info/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
9
+ vulcan_core-1.1.5.dist-info/METADATA,sha256=9eM53T0S7-euOXHCQAALdwphuyp34jcrBAGyRpVNo-w,4425
10
+ vulcan_core-1.1.5.dist-info/NOTICE,sha256=UN1_Gd_Snmu8T62mxExNUdIF2o7DMdGu8bI8rKqhVnc,244
11
+ vulcan_core-1.1.5.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
12
+ vulcan_core-1.1.5.dist-info/RECORD,,