vulcan-core 1.1.3__py3-none-any.whl → 1.1.4__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 +137 -45
- {vulcan_core-1.1.3.dist-info → vulcan_core-1.1.4.dist-info}/METADATA +1 -1
- {vulcan_core-1.1.3.dist-info → vulcan_core-1.1.4.dist-info}/RECORD +6 -6
- {vulcan_core-1.1.3.dist-info → vulcan_core-1.1.4.dist-info}/LICENSE +0 -0
- {vulcan_core-1.1.3.dist-info → vulcan_core-1.1.4.dist-info}/NOTICE +0 -0
- {vulcan_core-1.1.3.dist-info → vulcan_core-1.1.4.dist-info}/WHEEL +0 -0
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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,
|
|
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,19 +238,48 @@ 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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
280
|
+
try:
|
|
281
|
+
# Get the source file and line number
|
|
282
|
+
filename = self.func.__code__.co_filename
|
|
196
283
|
lambda_lineno = self.func.__code__.co_firstlineno
|
|
197
284
|
|
|
198
285
|
# Read the source file
|
|
@@ -235,20 +322,25 @@ class ASTProcessor[T: Callable]:
|
|
|
235
322
|
end_line = i
|
|
236
323
|
break
|
|
237
324
|
|
|
238
|
-
|
|
325
|
+
source = "\n".join(lines[start_line : end_line + 1])
|
|
239
326
|
|
|
240
327
|
except (OSError, SyntaxError, AttributeError):
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
# Fallback to regular inspect.getsource
|
|
244
|
-
return textwrap.dedent(inspect.getsource(self.func))
|
|
328
|
+
logger.exception("Failed to extract lambda source, attempting fallback.")
|
|
329
|
+
source = inspect.getsource(self.func).strip()
|
|
245
330
|
|
|
246
|
-
|
|
247
|
-
|
|
331
|
+
if source is None or source == "":
|
|
332
|
+
msg = "Could not extract lambda source code"
|
|
333
|
+
raise ASTProcessingError(msg)
|
|
248
334
|
|
|
249
|
-
#
|
|
335
|
+
# Normalize the source: convert line breaks to spaces, collapse whitespace, and dedent
|
|
250
336
|
source = re.sub(r"\r\n|\r|\n", " ", source)
|
|
251
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."""
|
|
252
344
|
|
|
253
345
|
# Find the Nth lambda occurrence using generator expression
|
|
254
346
|
positions = [i for i in range(len(source) - 5) if source[i : i + 6] == "lambda"]
|
|
@@ -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=
|
|
3
|
+
vulcan_core/ast_utils.py,sha256=E0QOr2t49U8kCKtl52KTI8GJCTuBBWYfVLwCzRjZFn0,21340
|
|
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.
|
|
9
|
-
vulcan_core-1.1.
|
|
10
|
-
vulcan_core-1.1.
|
|
11
|
-
vulcan_core-1.1.
|
|
12
|
-
vulcan_core-1.1.
|
|
8
|
+
vulcan_core-1.1.4.dist-info/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
|
|
9
|
+
vulcan_core-1.1.4.dist-info/METADATA,sha256=ppV9WmJ7nFE7zBaBbtkCK7VGAaZCM2rD0I35Ck6OtfY,4425
|
|
10
|
+
vulcan_core-1.1.4.dist-info/NOTICE,sha256=UN1_Gd_Snmu8T62mxExNUdIF2o7DMdGu8bI8rKqhVnc,244
|
|
11
|
+
vulcan_core-1.1.4.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
|
|
12
|
+
vulcan_core-1.1.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|