py-json-analyzer 0.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.
- json_explorer/__init__.py +1 -0
- json_explorer/__main__.py +4 -0
- json_explorer/analyzer.py +196 -0
- json_explorer/cli.py +137 -0
- json_explorer/filter_parser.py +132 -0
- json_explorer/interactive.py +490 -0
- json_explorer/main.py +163 -0
- json_explorer/search.py +423 -0
- json_explorer/stats.py +337 -0
- json_explorer/tree_view.py +161 -0
- json_explorer/utils.py +114 -0
- json_explorer/visualizer.py +1053 -0
- py_json_analyzer-0.1.0.dist-info/METADATA +275 -0
- py_json_analyzer-0.1.0.dist-info/RECORD +25 -0
- py_json_analyzer-0.1.0.dist-info/WHEEL +5 -0
- py_json_analyzer-0.1.0.dist-info/entry_points.txt +2 -0
- py_json_analyzer-0.1.0.dist-info/licenses/LICENSE +21 -0
- py_json_analyzer-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/test_analyzer.py +85 -0
- tests/test_search.py +192 -0
- tests/test_stats.py +197 -0
- tests/test_tree_view.py +188 -0
- tests/test_utils.py +109 -0
- tests/test_visualizer.py +96 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
from collections import Counter
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def analyze_json(data):
|
|
5
|
+
def analyze_node(node):
|
|
6
|
+
if isinstance(node, dict):
|
|
7
|
+
children = {}
|
|
8
|
+
for key, val in node.items():
|
|
9
|
+
children[key] = analyze_node(val)
|
|
10
|
+
return {"type": "object", "children": children}
|
|
11
|
+
elif isinstance(node, list):
|
|
12
|
+
# Skip empty or null-only lists
|
|
13
|
+
non_empty_items = [item for item in node if item not in (None, {}, [], "")]
|
|
14
|
+
if not non_empty_items:
|
|
15
|
+
return {"type": "list", "child_type": "unknown"}
|
|
16
|
+
|
|
17
|
+
sample = non_empty_items[:20]
|
|
18
|
+
element_summaries = [analyze_node(item) for item in sample]
|
|
19
|
+
types = {e["type"] for e in element_summaries}
|
|
20
|
+
|
|
21
|
+
# List of primitives
|
|
22
|
+
if len(types) == 1 and all(
|
|
23
|
+
e["type"] not in {"object", "list"} for e in element_summaries
|
|
24
|
+
):
|
|
25
|
+
return {"type": "list", "child_type": types.pop()}
|
|
26
|
+
|
|
27
|
+
# List of objects
|
|
28
|
+
if all(e["type"] == "object" for e in element_summaries):
|
|
29
|
+
merged, conflicts = merge_object_summaries(element_summaries)
|
|
30
|
+
return {
|
|
31
|
+
"type": "list",
|
|
32
|
+
"child": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"children": merged,
|
|
35
|
+
"conflicts": conflicts,
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# List of lists
|
|
40
|
+
if all(e["type"] == "list" for e in element_summaries):
|
|
41
|
+
# Merge list structures recursively
|
|
42
|
+
merged_list = merge_list_summaries(element_summaries)
|
|
43
|
+
return {
|
|
44
|
+
"type": "list",
|
|
45
|
+
"child": merged_list,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {"type": "list", "child_type": "mixed"}
|
|
49
|
+
else:
|
|
50
|
+
return {"type": type(node).__name__}
|
|
51
|
+
|
|
52
|
+
def merge_object_summaries(summaries):
|
|
53
|
+
key_structures = {}
|
|
54
|
+
key_counts = Counter()
|
|
55
|
+
total = len(summaries)
|
|
56
|
+
|
|
57
|
+
for summary in summaries:
|
|
58
|
+
for key, val in summary.get("children", {}).items():
|
|
59
|
+
key_counts[key] += 1
|
|
60
|
+
if key not in key_structures:
|
|
61
|
+
key_structures[key] = []
|
|
62
|
+
key_structures[key].append(val)
|
|
63
|
+
|
|
64
|
+
merged = {}
|
|
65
|
+
conflicts = {}
|
|
66
|
+
|
|
67
|
+
for key, structures in key_structures.items():
|
|
68
|
+
count = key_counts[key]
|
|
69
|
+
optional = count < total
|
|
70
|
+
|
|
71
|
+
# Get unique types for this key
|
|
72
|
+
types = {s["type"] for s in structures}
|
|
73
|
+
|
|
74
|
+
if len(types) == 1:
|
|
75
|
+
# All structures have the same type
|
|
76
|
+
structure_type = list(types)[0]
|
|
77
|
+
|
|
78
|
+
if structure_type == "object":
|
|
79
|
+
# Recursively merge object structures
|
|
80
|
+
merged_children, child_conflicts = merge_object_summaries(
|
|
81
|
+
structures
|
|
82
|
+
)
|
|
83
|
+
merged[key] = {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"children": merged_children,
|
|
86
|
+
"optional": optional,
|
|
87
|
+
}
|
|
88
|
+
if child_conflicts:
|
|
89
|
+
merged[key]["conflicts"] = child_conflicts
|
|
90
|
+
|
|
91
|
+
elif structure_type == "list":
|
|
92
|
+
# Merge list structures
|
|
93
|
+
merged_list = merge_list_summaries(structures)
|
|
94
|
+
merged[key] = {
|
|
95
|
+
"type": "list",
|
|
96
|
+
"optional": optional,
|
|
97
|
+
**{k: v for k, v in merged_list.items() if k != "type"},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
else:
|
|
101
|
+
# Primitive type
|
|
102
|
+
merged[key] = {"type": structure_type, "optional": optional}
|
|
103
|
+
else:
|
|
104
|
+
# Type conflict
|
|
105
|
+
merged[key] = {"type": "conflict", "optional": optional}
|
|
106
|
+
conflicts[key] = list(types)
|
|
107
|
+
|
|
108
|
+
return merged, conflicts
|
|
109
|
+
|
|
110
|
+
def merge_list_summaries(summaries):
|
|
111
|
+
child_types = set()
|
|
112
|
+
child_structures = []
|
|
113
|
+
|
|
114
|
+
for summary in summaries:
|
|
115
|
+
if "child_type" in summary:
|
|
116
|
+
child_types.add(summary["child_type"])
|
|
117
|
+
elif "child" in summary:
|
|
118
|
+
child_structures.append(summary["child"])
|
|
119
|
+
|
|
120
|
+
if child_structures:
|
|
121
|
+
# All lists contain complex structures
|
|
122
|
+
structure_types = {s["type"] for s in child_structures}
|
|
123
|
+
|
|
124
|
+
if len(structure_types) == 1:
|
|
125
|
+
structure_type = list(structure_types)[0]
|
|
126
|
+
|
|
127
|
+
if structure_type == "object":
|
|
128
|
+
# Merge object structures within lists
|
|
129
|
+
merged_children, child_conflicts = merge_object_summaries(
|
|
130
|
+
child_structures
|
|
131
|
+
)
|
|
132
|
+
return {
|
|
133
|
+
"type": "list",
|
|
134
|
+
"child": {
|
|
135
|
+
"type": "object",
|
|
136
|
+
"children": merged_children,
|
|
137
|
+
"conflicts": child_conflicts,
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
elif structure_type == "list":
|
|
141
|
+
# Nested lists
|
|
142
|
+
merged_nested = merge_list_summaries(child_structures)
|
|
143
|
+
return {"type": "list", "child": merged_nested}
|
|
144
|
+
|
|
145
|
+
return {"type": "list", "child_type": "mixed_complex"}
|
|
146
|
+
|
|
147
|
+
elif child_types:
|
|
148
|
+
# Simple child types
|
|
149
|
+
if len(child_types) == 1:
|
|
150
|
+
return {"type": "list", "child_type": list(child_types)[0]}
|
|
151
|
+
else:
|
|
152
|
+
return {
|
|
153
|
+
"type": "list",
|
|
154
|
+
"child_type": f"mixed: {', '.join(sorted(child_types))}",
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {"type": "list", "child_type": "unknown"}
|
|
158
|
+
|
|
159
|
+
return analyze_node(data)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == "__main__":
|
|
163
|
+
from rich import print as rprint
|
|
164
|
+
from rich.pretty import pretty_repr
|
|
165
|
+
|
|
166
|
+
test_data = {
|
|
167
|
+
"users": [
|
|
168
|
+
{
|
|
169
|
+
"id": 1,
|
|
170
|
+
"name": "Alice",
|
|
171
|
+
"profile": {
|
|
172
|
+
"age": 30,
|
|
173
|
+
"settings": {"theme": "dark", "notifications": True},
|
|
174
|
+
},
|
|
175
|
+
"tags": ["admin", "user"],
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
"id": 2,
|
|
179
|
+
"name": "Bob",
|
|
180
|
+
"profile": {
|
|
181
|
+
"age": 25,
|
|
182
|
+
"settings": {
|
|
183
|
+
"theme": "light",
|
|
184
|
+
"notifications": False,
|
|
185
|
+
"language": "en",
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
"tags": ["user"],
|
|
189
|
+
"email": "bob@example.com",
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
"metadata": {"total": 2, "created": "2024-01-01"},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
summary = analyze_json(test_data)
|
|
196
|
+
rprint(pretty_repr(summary))
|
json_explorer/cli.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from rich.console import Console
|
|
2
|
+
|
|
3
|
+
from .tree_view import print_json_analysis, print_compact_tree
|
|
4
|
+
from .search import JsonSearcher, SearchMode
|
|
5
|
+
from .stats import DataStatsAnalyzer
|
|
6
|
+
from .visualizer import JSONVisualizer
|
|
7
|
+
from .filter_parser import FilterExpressionParser
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CLIHandler:
|
|
11
|
+
"""Handle command-line interface operations."""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.data = None
|
|
15
|
+
self.source = None
|
|
16
|
+
self.console = Console()
|
|
17
|
+
self.searcher = JsonSearcher()
|
|
18
|
+
self.analyzer = DataStatsAnalyzer()
|
|
19
|
+
self.visualizer = JSONVisualizer()
|
|
20
|
+
|
|
21
|
+
def set_data(self, data, source):
|
|
22
|
+
"""Set the data and source for processing."""
|
|
23
|
+
self.data = data
|
|
24
|
+
self.source = source
|
|
25
|
+
|
|
26
|
+
def run(self, args):
|
|
27
|
+
"""Run CLI mode operations based on arguments."""
|
|
28
|
+
if not self.data:
|
|
29
|
+
self.console.print("❌ [red]No data loaded[/red]")
|
|
30
|
+
return 1
|
|
31
|
+
|
|
32
|
+
self.console.print(f"📄 Loaded: {self.source}")
|
|
33
|
+
|
|
34
|
+
# Tree operations
|
|
35
|
+
if args.tree:
|
|
36
|
+
self._handle_tree_display(args.tree)
|
|
37
|
+
|
|
38
|
+
# Search operations
|
|
39
|
+
if args.search:
|
|
40
|
+
self._handle_search(args)
|
|
41
|
+
|
|
42
|
+
# Statistics
|
|
43
|
+
if args.stats:
|
|
44
|
+
self._handle_stats(args)
|
|
45
|
+
|
|
46
|
+
# Visualization
|
|
47
|
+
if args.plot:
|
|
48
|
+
self._handle_visualization(args)
|
|
49
|
+
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
def _handle_tree_display(self, tree_type):
|
|
53
|
+
"""Handle tree display operations."""
|
|
54
|
+
self.console.print(f"\n🌳 JSON Tree Structure ({tree_type.title()}):")
|
|
55
|
+
|
|
56
|
+
if tree_type == "raw":
|
|
57
|
+
print_json_analysis(self.data, self.source, show_raw=True)
|
|
58
|
+
elif tree_type == "analysis":
|
|
59
|
+
print_json_analysis(self.data, self.source)
|
|
60
|
+
elif tree_type == "compact":
|
|
61
|
+
print_compact_tree(self.data, self.source)
|
|
62
|
+
|
|
63
|
+
def _handle_search(self, args):
|
|
64
|
+
"""Handle search operations."""
|
|
65
|
+
search_mode = SearchMode(args.search_mode)
|
|
66
|
+
search_term = args.search
|
|
67
|
+
|
|
68
|
+
if args.search_type == "pair":
|
|
69
|
+
self.console.print(
|
|
70
|
+
f"\n🔍 Searching for key-value pair: '{search_term}' = '{args.search_value}'"
|
|
71
|
+
)
|
|
72
|
+
else:
|
|
73
|
+
self.console.print(f"\n🔍 Searching ({args.search_type}): '{search_term}'")
|
|
74
|
+
|
|
75
|
+
# Perform search based on type
|
|
76
|
+
if args.search_type == "key":
|
|
77
|
+
results = self.searcher.search_keys(self.data, search_term, search_mode)
|
|
78
|
+
elif args.search_type == "value":
|
|
79
|
+
results = self.searcher.search_values(self.data, search_term, search_mode)
|
|
80
|
+
elif args.search_type == "pair":
|
|
81
|
+
if not args.search_value:
|
|
82
|
+
self.console.print(
|
|
83
|
+
"❌ [red]--search-value required for pair search[/red]"
|
|
84
|
+
)
|
|
85
|
+
return
|
|
86
|
+
results = self.searcher.search_key_value_pairs(
|
|
87
|
+
self.data, search_term, args.search_value, search_mode, search_mode
|
|
88
|
+
)
|
|
89
|
+
elif args.search_type == "filter":
|
|
90
|
+
try:
|
|
91
|
+
filter_func = FilterExpressionParser.parse_filter(search_term)
|
|
92
|
+
results = self.searcher.search_with_filter(self.data, filter_func)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
self.console.print(f"❌ [red]Filter error: {e}[/red]")
|
|
95
|
+
return
|
|
96
|
+
else:
|
|
97
|
+
self.console.print(f"❌ [red]Unknown search type: {args.search_type}[/red]")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
# Display results
|
|
101
|
+
if results:
|
|
102
|
+
# Check if tree results display is requested
|
|
103
|
+
show_tree = getattr(args, "tree_results", False)
|
|
104
|
+
self.searcher.print_results(results, show_tree=show_tree, mode=search_mode)
|
|
105
|
+
|
|
106
|
+
self.console.print(f"\n📊 Found {len(results)} result(s)")
|
|
107
|
+
else:
|
|
108
|
+
self.console.print("[yellow]No results found.[/yellow]")
|
|
109
|
+
|
|
110
|
+
def _handle_stats(self, args):
|
|
111
|
+
"""Handle statistics display."""
|
|
112
|
+
self.console.print("\n📊 JSON Statistics:")
|
|
113
|
+
detailed = getattr(args, "detailed", False)
|
|
114
|
+
self.analyzer.print_summary(self.data, detailed=detailed)
|
|
115
|
+
|
|
116
|
+
def _handle_visualization(self, args):
|
|
117
|
+
"""Handle visualization generation."""
|
|
118
|
+
self.console.print("\n📈 Generating visualizations...")
|
|
119
|
+
|
|
120
|
+
plot_format = getattr(args, "plot_format", "matplotlib")
|
|
121
|
+
save_path = getattr(args, "save_path", None)
|
|
122
|
+
detailed = getattr(args, "detailed", False)
|
|
123
|
+
open_browser = not getattr(args, "no_browser", False)
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
self.visualizer.visualize(
|
|
127
|
+
self.data,
|
|
128
|
+
output=plot_format,
|
|
129
|
+
save_path=save_path,
|
|
130
|
+
detailed=detailed,
|
|
131
|
+
open_browser=open_browser,
|
|
132
|
+
)
|
|
133
|
+
self.console.print(
|
|
134
|
+
"✅ [green]Visualizations generated successfully[/green]"
|
|
135
|
+
)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
self.console.print(f"❌ [red]Visualization error: {e}[/red]")
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import operator
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class FilterExpressionParser:
|
|
6
|
+
"""Parse and evaluate filter expressions for search functionality."""
|
|
7
|
+
|
|
8
|
+
SAFE_OPERATORS = {
|
|
9
|
+
ast.Add: operator.add,
|
|
10
|
+
ast.Sub: operator.sub,
|
|
11
|
+
ast.Mult: operator.mul,
|
|
12
|
+
ast.Div: operator.truediv,
|
|
13
|
+
ast.Mod: operator.mod,
|
|
14
|
+
ast.Eq: operator.eq,
|
|
15
|
+
ast.NotEq: operator.ne,
|
|
16
|
+
ast.Lt: operator.lt,
|
|
17
|
+
ast.LtE: operator.le,
|
|
18
|
+
ast.Gt: operator.gt,
|
|
19
|
+
ast.GtE: operator.ge,
|
|
20
|
+
ast.In: lambda x, y: x in y,
|
|
21
|
+
ast.NotIn: lambda x, y: x not in y,
|
|
22
|
+
ast.Is: operator.is_,
|
|
23
|
+
ast.IsNot: operator.is_not,
|
|
24
|
+
ast.And: lambda x, y: x and y,
|
|
25
|
+
ast.Or: lambda x, y: x or y,
|
|
26
|
+
ast.Not: operator.not_,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def parse_filter(cls, expression):
|
|
31
|
+
"""
|
|
32
|
+
Parse a filter expression and return a callable filter function.
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
- "isinstance(value, int) and value > 10"
|
|
36
|
+
- "key.startswith('user')"
|
|
37
|
+
- "depth <= 3 and 'email' in str(value)"
|
|
38
|
+
- "len(str(value)) > 20"
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
# Parse the expression into an AST
|
|
42
|
+
tree = ast.parse(expression, mode="eval")
|
|
43
|
+
|
|
44
|
+
def filter_func(key: str, value, depth):
|
|
45
|
+
env = {
|
|
46
|
+
"key": key,
|
|
47
|
+
"value": value,
|
|
48
|
+
"depth": depth,
|
|
49
|
+
"isinstance": isinstance,
|
|
50
|
+
"len": len,
|
|
51
|
+
"str": str,
|
|
52
|
+
"int": int,
|
|
53
|
+
"float": float,
|
|
54
|
+
"bool": bool,
|
|
55
|
+
"list": list,
|
|
56
|
+
"dict": dict,
|
|
57
|
+
"type": type,
|
|
58
|
+
"hasattr": hasattr,
|
|
59
|
+
"getattr": getattr,
|
|
60
|
+
"NoneType": type(None),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
return cls._eval_node(tree.body, env)
|
|
65
|
+
except Exception:
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
return filter_func
|
|
69
|
+
|
|
70
|
+
except SyntaxError as e:
|
|
71
|
+
raise ValueError(f"Invalid filter expression syntax: {e}")
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def _eval_node(cls, node, env):
|
|
75
|
+
"""Safely evaluate an AST node."""
|
|
76
|
+
if isinstance(node, ast.Constant):
|
|
77
|
+
return node.value
|
|
78
|
+
elif isinstance(node, ast.Name):
|
|
79
|
+
if node.id in env:
|
|
80
|
+
return env[node.id]
|
|
81
|
+
raise NameError(f"Name '{node.id}' is not defined")
|
|
82
|
+
elif isinstance(node, ast.BinOp):
|
|
83
|
+
left = cls._eval_node(node.left, env)
|
|
84
|
+
right = cls._eval_node(node.right, env)
|
|
85
|
+
op_func = cls.SAFE_OPERATORS.get(type(node.op))
|
|
86
|
+
if op_func:
|
|
87
|
+
return op_func(left, right)
|
|
88
|
+
raise ValueError(f"Unsupported operator: {type(node.op)}")
|
|
89
|
+
elif isinstance(node, ast.UnaryOp):
|
|
90
|
+
operand = cls._eval_node(node.operand, env)
|
|
91
|
+
op_func = cls.SAFE_OPERATORS.get(type(node.op))
|
|
92
|
+
if op_func:
|
|
93
|
+
return op_func(operand)
|
|
94
|
+
raise ValueError(f"Unsupported unary operator: {type(node.op)}")
|
|
95
|
+
elif isinstance(node, ast.Compare):
|
|
96
|
+
left = cls._eval_node(node.left, env)
|
|
97
|
+
result = True
|
|
98
|
+
for i, (op, comparator) in enumerate(zip(node.ops, node.comparators)):
|
|
99
|
+
right = cls._eval_node(comparator, env)
|
|
100
|
+
op_func = cls.SAFE_OPERATORS.get(type(op))
|
|
101
|
+
if op_func:
|
|
102
|
+
result = result and op_func(left, right)
|
|
103
|
+
left = right
|
|
104
|
+
else:
|
|
105
|
+
raise ValueError(f"Unsupported comparison operator: {type(op)}")
|
|
106
|
+
return result
|
|
107
|
+
elif isinstance(node, ast.BoolOp):
|
|
108
|
+
values = [cls._eval_node(value, env) for value in node.values]
|
|
109
|
+
if isinstance(node.op, ast.And):
|
|
110
|
+
return all(values)
|
|
111
|
+
elif isinstance(node.op, ast.Or):
|
|
112
|
+
return any(values)
|
|
113
|
+
elif isinstance(node, ast.Call):
|
|
114
|
+
func_name = node.func.id if isinstance(node.func, ast.Name) else None
|
|
115
|
+
if func_name in env and callable(env[func_name]):
|
|
116
|
+
args = [cls._eval_node(arg, env) for arg in node.args]
|
|
117
|
+
kwargs = {kw.arg: cls._eval_node(kw.value, env) for kw in node.keywords}
|
|
118
|
+
return env[func_name](*args, **kwargs)
|
|
119
|
+
raise ValueError(f"Function '{func_name}' is not available")
|
|
120
|
+
elif isinstance(node, ast.Attribute):
|
|
121
|
+
obj = cls._eval_node(node.value, env)
|
|
122
|
+
return getattr(obj, node.attr)
|
|
123
|
+
elif isinstance(node, ast.Subscript):
|
|
124
|
+
obj = cls._eval_node(node.value, env)
|
|
125
|
+
key = cls._eval_node(node.slice, env)
|
|
126
|
+
return obj[key]
|
|
127
|
+
elif isinstance(node, ast.List):
|
|
128
|
+
return [cls._eval_node(item, env) for item in node.elts]
|
|
129
|
+
elif isinstance(node, ast.Tuple):
|
|
130
|
+
return tuple(cls._eval_node(item, env) for item in node.elts)
|
|
131
|
+
|
|
132
|
+
raise ValueError(f"Unsupported AST node type: {type(node)}")
|