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 +138 -49
- {vulcan_core-1.1.3.dist-info → vulcan_core-1.1.5.dist-info}/METADATA +1 -1
- {vulcan_core-1.1.3.dist-info → vulcan_core-1.1.5.dist-info}/RECORD +6 -6
- {vulcan_core-1.1.3.dist-info → vulcan_core-1.1.5.dist-info}/LICENSE +0 -0
- {vulcan_core-1.1.3.dist-info → vulcan_core-1.1.5.dist-info}/NOTICE +0 -0
- {vulcan_core-1.1.3.dist-info → vulcan_core-1.1.5.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,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
|
-
|
|
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
|
+
# 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
|
-
|
|
322
|
+
source = "\n".join(lines[start_line : end_line + 1])
|
|
239
323
|
|
|
240
324
|
except (OSError, SyntaxError, AttributeError):
|
|
241
|
-
|
|
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
|
-
|
|
247
|
-
|
|
328
|
+
if source is None or source == "":
|
|
329
|
+
msg = "Could not extract lambda source code"
|
|
330
|
+
raise ASTProcessingError(msg)
|
|
248
331
|
|
|
249
|
-
#
|
|
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,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=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.
|
|
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.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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|