vulcan-core 1.1.2__tar.gz → 1.1.4__tar.gz

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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: vulcan-core
3
- Version: 1.1.2
3
+ Version: 1.1.4
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
@@ -65,7 +65,7 @@ engine.rule(
65
65
 
66
66
  # Use computed logic for operations that must be correct:
67
67
  engine.rule(
68
- when=condition(Apple.baking && lambda: Inventory.apples < 10),
68
+ when=condition(lambda: Apple.baking and Inventory.apples < 10),
69
69
  then=action(Order(apples=10)),
70
70
  )
71
71
 
@@ -42,7 +42,7 @@ engine.rule(
42
42
 
43
43
  # Use computed logic for operations that must be correct:
44
44
  engine.rule(
45
- when=condition(Apple.baking && lambda: Inventory.apples < 10),
45
+ when=condition(lambda: Apple.baking and Inventory.apples < 10),
46
46
  then=action(Order(apples=10)),
47
47
  )
48
48
 
@@ -20,7 +20,7 @@ requires = ["poetry-core"]
20
20
  build-backend = "poetry.core.masonry.api"
21
21
 
22
22
  [tool.poetry]
23
- version = "1.1.2" # Update manually, or use plugin
23
+ version = "1.1.4" # Update manually, or use plugin
24
24
  packages = [{ include = "vulcan_core", from="src" }]
25
25
  requires-poetry = "~2.1.1"
26
26
  classifiers = [
@@ -3,17 +3,21 @@
3
3
 
4
4
  import ast
5
5
  import inspect
6
+ import logging
6
7
  import re
7
8
  import textwrap
8
9
  from ast import Attribute, Module, Name, NodeTransformer, NodeVisitor
10
+ from collections import OrderedDict
9
11
  from collections.abc import Callable
10
12
  from dataclasses import dataclass, field
11
13
  from functools import cached_property
12
14
  from types import MappingProxyType
13
- from typing import Any, TypeAliasType, get_type_hints
15
+ from typing import Any, ClassVar, TypeAliasType, get_type_hints
14
16
 
15
17
  from vulcan_core.models import Fact, HasSource
16
18
 
19
+ logger = logging.getLogger(__name__)
20
+
17
21
 
18
22
  class ASTProcessingError(RuntimeError):
19
23
  """Internal error encountered while processing AST."""
@@ -83,15 +87,63 @@ class AttributeTransformer(NodeTransformer):
83
87
  return node
84
88
 
85
89
 
86
- # Global index to cache and track lambda functions position in lambda source lines.
87
- # Tuple format: (source code, last processed index)
88
- # TODO: Note in documentation that thread safety is not guaranteed when loading the same ruleset.
89
- # TODO: Consider updating to use a thread-safe structure like threading.local() if needed.
90
- 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)
91
104
 
92
105
 
93
106
  @dataclass
94
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
+
95
147
  func: T
96
148
  decorator: Callable
97
149
  return_type: type | TypeAliasType
@@ -99,6 +151,10 @@ class ASTProcessor[T: Callable]:
99
151
  tree: Module = field(init=False)
100
152
  facts: tuple[str, ...] = field(init=False)
101
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
+
102
158
  @cached_property
103
159
  def is_lambda(self) -> bool:
104
160
  return isinstance(self.func, type(lambda: None)) and self.func.__name__ == "<lambda>"
@@ -111,24 +167,34 @@ class ASTProcessor[T: Callable]:
111
167
  try:
112
168
  if self.is_lambda:
113
169
  # As of Python 3.12, there is no way to determine to which lambda self.func refers in an
114
- # expression containing multiple lambdas. Therefore we use a global to track the index of each
170
+ # expression containing multiple lambdas. Therefore we use a dict to track the index of each
115
171
  # lambda function encountered, as the order will correspond to the order of ASTProcessor
116
172
  # invocations for that line. An additional benefit is that we can also use this as a cache to
117
173
  # avoid re-reading the source code for lambda functions sharing the same line.
118
- key = self.func.__code__.co_filename + ":" + str(self.func.__code__.co_firstlineno)
174
+ source_line = f"{self.func.__code__.co_filename}:{self.func.__code__.co_firstlineno}"
175
+ lambda_src = self._lambda_cache.get(source_line)
119
176
 
120
- index = lambda_index.get(key)
121
- if index is None or index[1] is None:
177
+ if lambda_src is None:
122
178
  self.source = self._get_lambda_source()
123
- index = (self.source, 0)
124
- lambda_index[key] = index
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()
125
183
  else:
126
- self.source = index[0]
127
- index = (self.source, index[1] + 1)
128
- lambda_index[key] = index
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
129
190
 
130
191
  # Normalize the lambda source and extract the next lambda expression from the last index
131
- 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
+
132
198
  else:
133
199
  self.source = textwrap.dedent(inspect.getsource(self.func))
134
200
  except OSError as e:
@@ -172,19 +238,48 @@ class ASTProcessor[T: Callable]:
172
238
 
173
239
  self.facts = tuple(facts)
174
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
+
175
276
  def _get_lambda_source(self) -> str:
176
277
  """Get single and multiline lambda source using AST parsing of the source file."""
177
- try:
178
- # Get caller frame to find the source file
179
- frame = inspect.currentframe()
180
- while frame and frame.f_code.co_name != self.decorator.__name__:
181
- frame = frame.f_back
278
+ source = None
182
279
 
183
- if not frame or not frame.f_back:
184
- return textwrap.dedent(inspect.getsource(self.func))
185
-
186
- caller_frame = frame.f_back
187
- filename = caller_frame.f_code.co_filename
280
+ try:
281
+ # Get the source file and line number
282
+ filename = self.func.__code__.co_filename
188
283
  lambda_lineno = self.func.__code__.co_firstlineno
189
284
 
190
285
  # Read the source file
@@ -227,20 +322,25 @@ class ASTProcessor[T: Callable]:
227
322
  end_line = i
228
323
  break
229
324
 
230
- return "\n".join(lines[start_line : end_line + 1])
325
+ source = "\n".join(lines[start_line : end_line + 1])
231
326
 
232
327
  except (OSError, SyntaxError, AttributeError):
233
- pass
234
-
235
- # Fallback to regular inspect.getsource
236
- return textwrap.dedent(inspect.getsource(self.func))
328
+ logger.exception("Failed to extract lambda source, attempting fallback.")
329
+ source = inspect.getsource(self.func).strip()
237
330
 
238
- def _normalize_lambda_source(self, source: str, index: int) -> str:
239
- """Extracts just the lambda expression from source code."""
331
+ if source is None or source == "":
332
+ msg = "Could not extract lambda source code"
333
+ raise ASTProcessingError(msg)
240
334
 
241
- # Remove line endings and extra whitespace
335
+ # Normalize the source: convert line breaks to spaces, collapse whitespace, and dedent
242
336
  source = re.sub(r"\r\n|\r|\n", " ", source)
243
337
  source = re.sub(r"\s+", " ", source)
338
+ source = textwrap.dedent(source)
339
+
340
+ return source
341
+
342
+ def _normalize_lambda_source(self, source: str, index: int) -> str:
343
+ """Extracts just the lambda expression from source code."""
244
344
 
245
345
  # Find the Nth lambda occurrence using generator expression
246
346
  positions = [i for i in range(len(source) - 5) if source[i : i + 6] == "lambda"]
File without changes
File without changes