pyprobe-plots 0.0.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.
- pyprobe_plots-0.0.4/PKG-INFO +31 -0
- pyprobe_plots-0.0.4/pyprobe/__init__.py +3 -0
- pyprobe_plots-0.0.4/pyprobe/__main__.py +121 -0
- pyprobe_plots-0.0.4/pyprobe/analysis/__init__.py +4 -0
- pyprobe_plots-0.0.4/pyprobe/analysis/anchor_mapper.py +220 -0
- pyprobe_plots-0.0.4/pyprobe/analysis/ast_locator.py +265 -0
- pyprobe_plots-0.0.4/pyprobe/analysis/tests/__init__.py +1 -0
- pyprobe_plots-0.0.4/pyprobe/analysis/tests/test_ast_locator.py +50 -0
- pyprobe_plots-0.0.4/pyprobe/core/__init__.py +1 -0
- pyprobe_plots-0.0.4/pyprobe/core/anchor.py +49 -0
- pyprobe_plots-0.0.4/pyprobe/core/anchor_matcher.py +71 -0
- pyprobe_plots-0.0.4/pyprobe/core/capture_manager.py +175 -0
- pyprobe_plots-0.0.4/pyprobe/core/capture_record.py +44 -0
- pyprobe_plots-0.0.4/pyprobe/core/data_classifier.py +328 -0
- pyprobe_plots-0.0.4/pyprobe/core/probe_persistence.py +184 -0
- pyprobe_plots-0.0.4/pyprobe/core/runner.py +391 -0
- pyprobe_plots-0.0.4/pyprobe/core/sequence.py +18 -0
- pyprobe_plots-0.0.4/pyprobe/core/settings.py +48 -0
- pyprobe_plots-0.0.4/pyprobe/core/tracer.py +386 -0
- pyprobe_plots-0.0.4/pyprobe/gui/__init__.py +1 -0
- pyprobe_plots-0.0.4/pyprobe/gui/animations.py +72 -0
- pyprobe_plots-0.0.4/pyprobe/gui/app.py +70 -0
- pyprobe_plots-0.0.4/pyprobe/gui/axis_editor.py +116 -0
- pyprobe_plots-0.0.4/pyprobe/gui/code_gutter.py +209 -0
- pyprobe_plots-0.0.4/pyprobe/gui/code_highlighter.py +309 -0
- pyprobe_plots-0.0.4/pyprobe/gui/code_viewer.py +573 -0
- pyprobe_plots-0.0.4/pyprobe/gui/collapsible_pane.py +92 -0
- pyprobe_plots-0.0.4/pyprobe/gui/color_manager.py +160 -0
- pyprobe_plots-0.0.4/pyprobe/gui/constrained_viewbox.py +56 -0
- pyprobe_plots-0.0.4/pyprobe/gui/control_bar.py +214 -0
- pyprobe_plots-0.0.4/pyprobe/gui/debug_overlay.py +84 -0
- pyprobe_plots-0.0.4/pyprobe/gui/dock_bar.py +171 -0
- pyprobe_plots-0.0.4/pyprobe/gui/drag_helpers.py +68 -0
- pyprobe_plots-0.0.4/pyprobe/gui/edge_strip.py +101 -0
- pyprobe_plots-0.0.4/pyprobe/gui/file_tree.py +199 -0
- pyprobe_plots-0.0.4/pyprobe/gui/file_watcher.py +40 -0
- pyprobe_plots-0.0.4/pyprobe/gui/focus_manager.py +88 -0
- pyprobe_plots-0.0.4/pyprobe/gui/layout_manager.py +96 -0
- pyprobe_plots-0.0.4/pyprobe/gui/lens_dropdown.py +137 -0
- pyprobe_plots-0.0.4/pyprobe/gui/main_window.py +1395 -0
- pyprobe_plots-0.0.4/pyprobe/gui/message_handler.py +235 -0
- pyprobe_plots-0.0.4/pyprobe/gui/panel_container.py +332 -0
- pyprobe_plots-0.0.4/pyprobe/gui/plot_toolbar.py +181 -0
- pyprobe_plots-0.0.4/pyprobe/gui/probe_buffer.py +71 -0
- pyprobe_plots-0.0.4/pyprobe/gui/probe_controller.py +695 -0
- pyprobe_plots-0.0.4/pyprobe/gui/probe_panel.py +708 -0
- pyprobe_plots-0.0.4/pyprobe/gui/probe_registry.py +184 -0
- pyprobe_plots-0.0.4/pyprobe/gui/probe_state.py +10 -0
- pyprobe_plots-0.0.4/pyprobe/gui/probe_state_indicator.py +134 -0
- pyprobe_plots-0.0.4/pyprobe/gui/redraw_throttler.py +52 -0
- pyprobe_plots-0.0.4/pyprobe/gui/scalar_watch_window.py +259 -0
- pyprobe_plots-0.0.4/pyprobe/gui/script_runner.py +376 -0
- pyprobe_plots-0.0.4/pyprobe/gui/theme/__init__.py +29 -0
- pyprobe_plots-0.0.4/pyprobe/gui/theme/anthropic.py +318 -0
- pyprobe_plots-0.0.4/pyprobe/gui/theme/base.py +16 -0
- pyprobe_plots-0.0.4/pyprobe/gui/theme/cyberpunk.py +380 -0
- pyprobe_plots-0.0.4/pyprobe/gui/theme/instrument_panel.py +318 -0
- pyprobe_plots-0.0.4/pyprobe/gui/theme/monokai.py +314 -0
- pyprobe_plots-0.0.4/pyprobe/gui/theme/ocean.py +314 -0
- pyprobe_plots-0.0.4/pyprobe/gui/theme/theme_manager.py +67 -0
- pyprobe_plots-0.0.4/pyprobe/gui/watch_list.py +145 -0
- pyprobe_plots-0.0.4/pyprobe/ipc/__init__.py +1 -0
- pyprobe_plots-0.0.4/pyprobe/ipc/channels.py +227 -0
- pyprobe_plots-0.0.4/pyprobe/ipc/messages.py +186 -0
- pyprobe_plots-0.0.4/pyprobe/ipc/socket_channel.py +134 -0
- pyprobe_plots-0.0.4/pyprobe/ipc/socket_transport.py +125 -0
- pyprobe_plots-0.0.4/pyprobe/logging.py +103 -0
- pyprobe_plots-0.0.4/pyprobe/plots/__init__.py +1 -0
- pyprobe_plots-0.0.4/pyprobe/plots/axis_controller.py +86 -0
- pyprobe_plots-0.0.4/pyprobe/plots/base_plot.py +44 -0
- pyprobe_plots-0.0.4/pyprobe/plots/constellation.py +233 -0
- pyprobe_plots-0.0.4/pyprobe/plots/draw_mode.py +42 -0
- pyprobe_plots-0.0.4/pyprobe/plots/editable_axis.py +69 -0
- pyprobe_plots-0.0.4/pyprobe/plots/pin_indicator.py +190 -0
- pyprobe_plots-0.0.4/pyprobe/plots/pin_layout_mixin.py +74 -0
- pyprobe_plots-0.0.4/pyprobe/plots/plot_factory.py +68 -0
- pyprobe_plots-0.0.4/pyprobe/plots/scalar_display.py +108 -0
- pyprobe_plots-0.0.4/pyprobe/plots/scalar_history_chart.py +248 -0
- pyprobe_plots-0.0.4/pyprobe/plugins/__init__.py +5 -0
- pyprobe_plots-0.0.4/pyprobe/plugins/base.py +80 -0
- pyprobe_plots-0.0.4/pyprobe/plugins/builtins/__init__.py +33 -0
- pyprobe_plots-0.0.4/pyprobe/plugins/builtins/complex_plots.py +462 -0
- pyprobe_plots-0.0.4/pyprobe/plugins/builtins/constellation.py +283 -0
- pyprobe_plots-0.0.4/pyprobe/plugins/builtins/scalar_display.py +112 -0
- pyprobe_plots-0.0.4/pyprobe/plugins/builtins/scalar_history.py +258 -0
- pyprobe_plots-0.0.4/pyprobe/plugins/builtins/waveform.py +730 -0
- pyprobe_plots-0.0.4/pyprobe/plugins/registry.py +78 -0
- pyprobe_plots-0.0.4/pyprobe/state_tracer.py +443 -0
- pyprobe_plots-0.0.4/pyprobe_plots.egg-info/PKG-INFO +31 -0
- pyprobe_plots-0.0.4/pyprobe_plots.egg-info/SOURCES.txt +111 -0
- pyprobe_plots-0.0.4/pyprobe_plots.egg-info/dependency_links.txt +1 -0
- pyprobe_plots-0.0.4/pyprobe_plots.egg-info/entry_points.txt +2 -0
- pyprobe_plots-0.0.4/pyprobe_plots.egg-info/requires.txt +13 -0
- pyprobe_plots-0.0.4/pyprobe_plots.egg-info/top_level.txt +1 -0
- pyprobe_plots-0.0.4/pyproject.toml +60 -0
- pyprobe_plots-0.0.4/setup.cfg +4 -0
- pyprobe_plots-0.0.4/tests/test_axis_controller.py +100 -0
- pyprobe_plots-0.0.4/tests/test_axis_editor.py +66 -0
- pyprobe_plots-0.0.4/tests/test_capture_pipeline.py +179 -0
- pyprobe_plots-0.0.4/tests/test_cli_automation.py +109 -0
- pyprobe_plots-0.0.4/tests/test_code_viewer_highlight.py +137 -0
- pyprobe_plots-0.0.4/tests/test_color_manager.py +134 -0
- pyprobe_plots-0.0.4/tests/test_constellation_verify.py +152 -0
- pyprobe_plots-0.0.4/tests/test_dock_bar.py +66 -0
- pyprobe_plots-0.0.4/tests/test_e2e_capture_pipeline.py +182 -0
- pyprobe_plots-0.0.4/tests/test_e2e_folder_browsing.py +239 -0
- pyprobe_plots-0.0.4/tests/test_focus_manager.py +105 -0
- pyprobe_plots-0.0.4/tests/test_layout_manager.py +91 -0
- pyprobe_plots-0.0.4/tests/test_overlay_drag_drop_single_frame.py +207 -0
- pyprobe_plots-0.0.4/tests/test_overlay_drag_drop_two_frames.py +265 -0
- pyprobe_plots-0.0.4/tests/test_plot_toolbar.py +52 -0
- pyprobe_plots-0.0.4/tests/test_plugin_registry.py +103 -0
- pyprobe_plots-0.0.4/tests/test_signal_overlay.py +52 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyprobe-plots
|
|
3
|
+
Version: 0.0.4
|
|
4
|
+
Summary: Variable probing based debugger for Python DSP debugging
|
|
5
|
+
Author: PyProbe Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: dsp,debugging,visualization,probe
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering
|
|
17
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: PyQt6>=6.4.0
|
|
21
|
+
Requires-Dist: pyqtgraph>=0.13.0
|
|
22
|
+
Requires-Dist: numpy>=1.24.0
|
|
23
|
+
Requires-Dist: pytest>=9.0.2
|
|
24
|
+
Requires-Dist: matplotlib>=3.10.8
|
|
25
|
+
Requires-Dist: scipy>=1.15.3
|
|
26
|
+
Requires-Dist: pytest-qt>=4.5.0
|
|
27
|
+
Requires-Dist: pytest-xdist>=3.8.0
|
|
28
|
+
Requires-Dist: pytest-forked>=1.6.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-qt>=4.0.0; extra == "dev"
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyProbe entry point.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python -m pyprobe [script.py]
|
|
6
|
+
python -m pyprobe --loglevel DEBUG examples/dsp_demo.py
|
|
7
|
+
python -m pyprobe --trace-states examples/dsp_demo.py
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
import os
|
|
12
|
+
import argparse
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main():
|
|
16
|
+
"""Main entry point for PyProbe."""
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
description="PyProbe - Variable probing based debugger for Python DSP debugging"
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"script",
|
|
22
|
+
nargs="?",
|
|
23
|
+
help="Python script to probe (optional, can be loaded from GUI)"
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--auto-run",
|
|
27
|
+
action="store_true",
|
|
28
|
+
help="Automatically run the script after loading"
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--auto-quit",
|
|
32
|
+
action="store_true",
|
|
33
|
+
help="Automatically quit the application when the script finishes"
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--auto-quit-timeout",
|
|
37
|
+
type=float,
|
|
38
|
+
default=None,
|
|
39
|
+
help="Force quit after specified seconds, even if errors prevent normal auto-quit (default: infinite)"
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"-p", "--probe",
|
|
43
|
+
action="append",
|
|
44
|
+
help="Add graphical probe. Format: line:symbol:instance (e.g., 4:x:1)"
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"-w", "--watch",
|
|
48
|
+
action="append",
|
|
49
|
+
help="Add scalar watch. Format: line:symbol:instance (e.g., 4:x:1)"
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--overlay",
|
|
53
|
+
action="append",
|
|
54
|
+
help="Overlay a signal on an existing probe. Format: target_symbol:line:symbol:instance "
|
|
55
|
+
"(e.g., signal_i:75:received_symbols:1 overlays received_symbols onto signal_i)"
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"-l", "--loglevel",
|
|
59
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
|
60
|
+
default="WARNING",
|
|
61
|
+
help="Set logging level (default: WARNING). DEBUG writes to /tmp/pyprobe_debug.log"
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--logfile",
|
|
65
|
+
default="/tmp/pyprobe_debug.log",
|
|
66
|
+
help="Log file path (default: /tmp/pyprobe_debug.log)"
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--log-console",
|
|
70
|
+
action="store_true",
|
|
71
|
+
help="Also log to console (stderr)"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--trace-states",
|
|
76
|
+
action="store_true",
|
|
77
|
+
help="Enable detailed state tracing. Logs all user actions and reactions to /tmp/pyprobe_state_trace.log"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
args = parser.parse_args()
|
|
81
|
+
|
|
82
|
+
# Initialize state tracer FIRST if requested
|
|
83
|
+
if args.trace_states:
|
|
84
|
+
from pyprobe.state_tracer import init_tracer
|
|
85
|
+
init_tracer(enabled=True)
|
|
86
|
+
print("State tracing enabled. Log: /tmp/pyprobe_state_trace.log")
|
|
87
|
+
|
|
88
|
+
# Setup logging before importing anything else
|
|
89
|
+
from pyprobe.logging import setup_logging
|
|
90
|
+
setup_logging(
|
|
91
|
+
level=args.loglevel,
|
|
92
|
+
log_file=args.logfile,
|
|
93
|
+
console=args.log_console
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Import here to avoid slow startup for --help
|
|
97
|
+
from pyprobe.gui.app import run_app
|
|
98
|
+
|
|
99
|
+
# Auto-detect file vs. folder
|
|
100
|
+
script_path = args.script
|
|
101
|
+
folder_path = None
|
|
102
|
+
if script_path and os.path.isdir(script_path):
|
|
103
|
+
folder_path = os.path.abspath(script_path)
|
|
104
|
+
script_path = None
|
|
105
|
+
|
|
106
|
+
# Run the application
|
|
107
|
+
sys.exit(run_app(
|
|
108
|
+
script_path=script_path,
|
|
109
|
+
folder_path=folder_path,
|
|
110
|
+
probes=args.probe,
|
|
111
|
+
watches=args.watch,
|
|
112
|
+
overlays=args.overlay,
|
|
113
|
+
auto_run=args.auto_run,
|
|
114
|
+
auto_quit=args.auto_quit,
|
|
115
|
+
auto_quit_timeout=args.auto_quit_timeout
|
|
116
|
+
))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
main()
|
|
121
|
+
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Anchor mapping for preserving probe locations across file edits."""
|
|
2
|
+
import difflib
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Dict, List, Optional, Set
|
|
5
|
+
|
|
6
|
+
from pyprobe.core.anchor import ProbeAnchor
|
|
7
|
+
from pyprobe.analysis.ast_locator import ASTLocator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class AnchorMapping:
|
|
12
|
+
"""Result of mapping an anchor from old source to new source."""
|
|
13
|
+
old_anchor: ProbeAnchor
|
|
14
|
+
new_anchor: Optional[ProbeAnchor] # None if invalid
|
|
15
|
+
confidence: float # 0.0 to 1.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AnchorMapper:
|
|
19
|
+
"""Maps probe anchors from old source to new source after file edits.
|
|
20
|
+
|
|
21
|
+
Mapping Strategy (4-tier confidence):
|
|
22
|
+
- 1.0 (Exact): Same (line, col, symbol, func) exists after line shift
|
|
23
|
+
- 0.7 (Near): Same (symbol, func) exists nearby (within same function)
|
|
24
|
+
- 0.4 (Weak): Only symbol exists anywhere in file
|
|
25
|
+
- 0.0 (Invalid): Cannot find symbol at all
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, old_source: str, new_source: str, filepath: str):
|
|
29
|
+
"""Initialize mapper with old and new source code.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
old_source: The original source code
|
|
33
|
+
new_source: The modified source code
|
|
34
|
+
filepath: The file path (for creating new anchors)
|
|
35
|
+
"""
|
|
36
|
+
self._old_source = old_source
|
|
37
|
+
self._new_source = new_source
|
|
38
|
+
self._filepath = filepath
|
|
39
|
+
|
|
40
|
+
# Parse both sources
|
|
41
|
+
self._old_locator = ASTLocator(old_source, filepath)
|
|
42
|
+
self._new_locator = ASTLocator(new_source, filepath)
|
|
43
|
+
|
|
44
|
+
# Compute line mapping from old to new
|
|
45
|
+
self._line_map = self._compute_line_map()
|
|
46
|
+
|
|
47
|
+
def _compute_line_map(self) -> Dict[int, int]:
|
|
48
|
+
"""Use difflib to map old line numbers to new line numbers.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Dict mapping old line number (1-indexed) to new line number (1-indexed).
|
|
52
|
+
Only includes lines that exist in both versions (unchanged lines).
|
|
53
|
+
"""
|
|
54
|
+
old_lines = self._old_source.splitlines()
|
|
55
|
+
new_lines = self._new_source.splitlines()
|
|
56
|
+
|
|
57
|
+
matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
|
|
58
|
+
line_map: Dict[int, int] = {}
|
|
59
|
+
|
|
60
|
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
61
|
+
if tag == 'equal':
|
|
62
|
+
# Lines are identical - map each old line to corresponding new line
|
|
63
|
+
for offset in range(i2 - i1):
|
|
64
|
+
old_line = i1 + offset + 1 # 1-indexed
|
|
65
|
+
new_line = j1 + offset + 1 # 1-indexed
|
|
66
|
+
line_map[old_line] = new_line
|
|
67
|
+
|
|
68
|
+
return line_map
|
|
69
|
+
|
|
70
|
+
def map_anchor(self, anchor: ProbeAnchor) -> AnchorMapping:
|
|
71
|
+
"""Map a single anchor to its new location.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
anchor: The probe anchor to map
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
AnchorMapping with new_anchor (or None) and confidence score
|
|
78
|
+
"""
|
|
79
|
+
# Tier 1: Exact match after line shift (confidence 1.0)
|
|
80
|
+
exact_result = self._try_exact_match(anchor)
|
|
81
|
+
if exact_result is not None:
|
|
82
|
+
return AnchorMapping(anchor, exact_result, 1.0)
|
|
83
|
+
|
|
84
|
+
# Tier 2: Symbol in same function nearby (confidence 0.7)
|
|
85
|
+
func_result = self._find_symbol_in_function(anchor)
|
|
86
|
+
if func_result is not None:
|
|
87
|
+
return AnchorMapping(anchor, func_result, 0.7)
|
|
88
|
+
|
|
89
|
+
# Tier 3: Symbol exists anywhere (confidence 0.4)
|
|
90
|
+
anywhere_result = self._find_symbol_anywhere(anchor)
|
|
91
|
+
if anywhere_result is not None:
|
|
92
|
+
return AnchorMapping(anchor, anywhere_result, 0.4)
|
|
93
|
+
|
|
94
|
+
# Tier 4: Invalid - symbol not found (confidence 0.0)
|
|
95
|
+
return AnchorMapping(anchor, None, 0.0)
|
|
96
|
+
|
|
97
|
+
def _try_exact_match(self, anchor: ProbeAnchor) -> Optional[ProbeAnchor]:
|
|
98
|
+
"""Try to find exact match at mapped line position.
|
|
99
|
+
|
|
100
|
+
Returns new anchor if the same symbol exists at the same column
|
|
101
|
+
on the mapped line within the same function.
|
|
102
|
+
"""
|
|
103
|
+
# Check if old line maps to a new line
|
|
104
|
+
if anchor.line not in self._line_map:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
new_line = self._line_map[anchor.line]
|
|
108
|
+
|
|
109
|
+
# Check if same symbol exists at same column on new line
|
|
110
|
+
var_name = self._new_locator.get_var_at_cursor(new_line, anchor.col)
|
|
111
|
+
if var_name != anchor.symbol:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
# Verify same enclosing function
|
|
115
|
+
new_func = self._new_locator.get_enclosing_function(new_line) or ""
|
|
116
|
+
if new_func != anchor.func:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
# Exact match found
|
|
120
|
+
return ProbeAnchor(
|
|
121
|
+
file=self._filepath,
|
|
122
|
+
line=new_line,
|
|
123
|
+
col=anchor.col,
|
|
124
|
+
symbol=anchor.symbol,
|
|
125
|
+
func=new_func,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def _find_symbol_in_function(self, anchor: ProbeAnchor) -> Optional[ProbeAnchor]:
|
|
129
|
+
"""Find the same symbol within the same function.
|
|
130
|
+
|
|
131
|
+
Searches for the symbol in the new source, preferring occurrences
|
|
132
|
+
within the same function.
|
|
133
|
+
"""
|
|
134
|
+
if not anchor.func:
|
|
135
|
+
# No function context to search within
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
# Find all variables in new source
|
|
139
|
+
new_lines = self._new_source.splitlines()
|
|
140
|
+
for line_num in range(1, len(new_lines) + 1):
|
|
141
|
+
# Check if this line is in the same function
|
|
142
|
+
func_name = self._new_locator.get_enclosing_function(line_num)
|
|
143
|
+
if func_name != anchor.func:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
# Check for symbol on this line
|
|
147
|
+
vars_on_line = self._new_locator.get_all_variables_on_line(line_num)
|
|
148
|
+
for var in vars_on_line:
|
|
149
|
+
if var.name == anchor.symbol:
|
|
150
|
+
return ProbeAnchor(
|
|
151
|
+
file=self._filepath,
|
|
152
|
+
line=line_num,
|
|
153
|
+
col=var.col_start,
|
|
154
|
+
symbol=anchor.symbol,
|
|
155
|
+
func=func_name or "",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
def _find_symbol_anywhere(self, anchor: ProbeAnchor) -> Optional[ProbeAnchor]:
|
|
161
|
+
"""Find first occurrence of symbol anywhere in new source."""
|
|
162
|
+
new_lines = self._new_source.splitlines()
|
|
163
|
+
for line_num in range(1, len(new_lines) + 1):
|
|
164
|
+
vars_on_line = self._new_locator.get_all_variables_on_line(line_num)
|
|
165
|
+
for var in vars_on_line:
|
|
166
|
+
if var.name == anchor.symbol:
|
|
167
|
+
func_name = self._new_locator.get_enclosing_function(line_num)
|
|
168
|
+
return ProbeAnchor(
|
|
169
|
+
file=self._filepath,
|
|
170
|
+
line=line_num,
|
|
171
|
+
col=var.col_start,
|
|
172
|
+
symbol=anchor.symbol,
|
|
173
|
+
func=func_name or "",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
def map_all(self, anchors: List[ProbeAnchor]) -> List[AnchorMapping]:
|
|
179
|
+
"""Map all anchors and return list of mappings.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
anchors: List of probe anchors to map
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
List of AnchorMapping results
|
|
186
|
+
"""
|
|
187
|
+
return [self.map_anchor(anchor) for anchor in anchors]
|
|
188
|
+
|
|
189
|
+
def get_invalidated(self, anchors: List[ProbeAnchor]) -> Set[ProbeAnchor]:
|
|
190
|
+
"""Return set of anchors that could not be mapped (invalid).
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
anchors: List of probe anchors to check
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Set of anchors with confidence 0.0 (no new_anchor)
|
|
197
|
+
"""
|
|
198
|
+
invalid: Set[ProbeAnchor] = set()
|
|
199
|
+
for anchor in anchors:
|
|
200
|
+
mapping = self.map_anchor(anchor)
|
|
201
|
+
if mapping.new_anchor is None:
|
|
202
|
+
invalid.add(anchor)
|
|
203
|
+
return invalid
|
|
204
|
+
|
|
205
|
+
def get_valid_mappings(self, anchors: List[ProbeAnchor]) -> Dict[ProbeAnchor, ProbeAnchor]:
|
|
206
|
+
"""Return dict of old anchor -> new anchor for valid mappings.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
anchors: List of probe anchors to map
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Dict mapping old anchors to their new locations
|
|
213
|
+
(only includes anchors with confidence > 0.0)
|
|
214
|
+
"""
|
|
215
|
+
valid: Dict[ProbeAnchor, ProbeAnchor] = {}
|
|
216
|
+
for anchor in anchors:
|
|
217
|
+
mapping = self.map_anchor(anchor)
|
|
218
|
+
if mapping.new_anchor is not None:
|
|
219
|
+
valid[mapping.old_anchor] = mapping.new_anchor
|
|
220
|
+
return valid
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""AST-based source code analysis for variable location."""
|
|
2
|
+
import ast
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Optional, List, Tuple, Set
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from pyprobe.logging import get_logger, trace_print
|
|
8
|
+
|
|
9
|
+
logger = get_logger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SymbolType(Enum):
|
|
13
|
+
"""Classification of symbol types for probeability determination."""
|
|
14
|
+
DATA_VARIABLE = "data" # LHS of assignment → PROBEABLE
|
|
15
|
+
FUNCTION_CALL = "call" # Function being called → NOT probeable
|
|
16
|
+
MODULE_REF = "module" # Module access (np.xyz) → NOT probeable
|
|
17
|
+
FUNCTION_DEF = "func_def" # Function name in def → NOT probeable
|
|
18
|
+
UNKNOWN = "unknown" # Default → NOT probeable
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class VariableLocation:
|
|
23
|
+
"""Location of a variable in source code."""
|
|
24
|
+
name: str
|
|
25
|
+
line: int # 1-indexed
|
|
26
|
+
col_start: int # 0-indexed
|
|
27
|
+
col_end: int # 0-indexed, exclusive
|
|
28
|
+
is_lhs: bool # True if assignment target
|
|
29
|
+
symbol_type: SymbolType = field(default=SymbolType.UNKNOWN)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ASTLocator:
|
|
33
|
+
"""Maps cursor positions to AST nodes and variable names.
|
|
34
|
+
|
|
35
|
+
Key features:
|
|
36
|
+
- Column-aware variable detection
|
|
37
|
+
- LHS preference for ambiguous positions (x = x + 1)
|
|
38
|
+
- Function scope detection
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, source: str, filename: str = "<string>"):
|
|
42
|
+
self._source = source
|
|
43
|
+
self._filename = filename
|
|
44
|
+
self._tree: Optional[ast.AST] = None
|
|
45
|
+
self._variables: List[VariableLocation] = []
|
|
46
|
+
self._functions: List[Tuple[str, int, int]] = [] # (name, start_line, end_line)
|
|
47
|
+
self._parse()
|
|
48
|
+
|
|
49
|
+
def _parse(self) -> None:
|
|
50
|
+
"""Parse source and extract variable locations."""
|
|
51
|
+
try:
|
|
52
|
+
self._tree = ast.parse(self._source, filename=self._filename)
|
|
53
|
+
except SyntaxError:
|
|
54
|
+
self._tree = None
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
self._extract_variables()
|
|
58
|
+
self._extract_functions()
|
|
59
|
+
|
|
60
|
+
def _extract_variables(self) -> None:
|
|
61
|
+
"""Extract all Name nodes with their locations and symbol types."""
|
|
62
|
+
if self._tree is None:
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
# Collect positions for each symbol type
|
|
66
|
+
lhs_positions: Set[Tuple[int, int]] = set()
|
|
67
|
+
call_positions: Set[Tuple[int, int]] = set() # Function calls
|
|
68
|
+
module_positions: Set[Tuple[int, int]] = set() # Attribute base (e.g., np in np.sin)
|
|
69
|
+
func_def_positions: Set[Tuple[int, int]] = set() # Function/class def names
|
|
70
|
+
|
|
71
|
+
self._collect_lhs_positions_from_tree(self._tree, lhs_positions)
|
|
72
|
+
self._collect_special_positions(self._tree, call_positions, module_positions, func_def_positions)
|
|
73
|
+
|
|
74
|
+
# Now collect all Name nodes and classify them
|
|
75
|
+
for node in ast.walk(self._tree):
|
|
76
|
+
if isinstance(node, ast.Name):
|
|
77
|
+
pos = (node.lineno, node.col_offset)
|
|
78
|
+
|
|
79
|
+
# Determine symbol type (priority order matters)
|
|
80
|
+
if pos in lhs_positions:
|
|
81
|
+
symbol_type = SymbolType.DATA_VARIABLE
|
|
82
|
+
elif pos in func_def_positions:
|
|
83
|
+
symbol_type = SymbolType.FUNCTION_DEF
|
|
84
|
+
elif pos in call_positions:
|
|
85
|
+
symbol_type = SymbolType.FUNCTION_CALL
|
|
86
|
+
elif pos in module_positions:
|
|
87
|
+
symbol_type = SymbolType.MODULE_REF
|
|
88
|
+
else:
|
|
89
|
+
symbol_type = SymbolType.UNKNOWN
|
|
90
|
+
|
|
91
|
+
is_lhs = pos in lhs_positions
|
|
92
|
+
var_loc = VariableLocation(
|
|
93
|
+
name=node.id,
|
|
94
|
+
line=node.lineno,
|
|
95
|
+
col_start=node.col_offset,
|
|
96
|
+
col_end=node.end_col_offset or (node.col_offset + len(node.id)),
|
|
97
|
+
is_lhs=is_lhs,
|
|
98
|
+
symbol_type=symbol_type,
|
|
99
|
+
)
|
|
100
|
+
self._variables.append(var_loc)
|
|
101
|
+
# Debug trace for line 72 variables
|
|
102
|
+
if node.lineno == 72 and node.id in ('received_symbols', 'signal_i', 'signal_q'):
|
|
103
|
+
trace_print(f"ASTLocator: {node.id} at L{node.lineno}:C{node.col_offset} pos={pos}, is_lhs={is_lhs}, lhs_positions contains? {pos in lhs_positions}")
|
|
104
|
+
logger.debug(f"Classified {node.id} at L{node.lineno}:C{node.col_offset} as {symbol_type}")
|
|
105
|
+
|
|
106
|
+
def _collect_special_positions(
|
|
107
|
+
self,
|
|
108
|
+
tree: ast.AST,
|
|
109
|
+
call_positions: Set[Tuple[int, int]],
|
|
110
|
+
module_positions: Set[Tuple[int, int]],
|
|
111
|
+
func_def_positions: Set[Tuple[int, int]]
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Collect positions of function calls, module refs, and function defs."""
|
|
114
|
+
for node in ast.walk(tree):
|
|
115
|
+
# Function definitions - the function name itself
|
|
116
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
117
|
+
# Note: node.name is just a string, but we need to find corresponding Name nodes
|
|
118
|
+
# Function defs don't have Name nodes for their own name, so we track line/col
|
|
119
|
+
# Actually FunctionDef.name is a str, not a Name node, so this won't be in _variables
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
# Class definitions - similar, class name is a string attribute
|
|
123
|
+
if isinstance(node, ast.ClassDef):
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
# Function calls
|
|
127
|
+
if isinstance(node, ast.Call):
|
|
128
|
+
func = node.func
|
|
129
|
+
# Direct call: foo()
|
|
130
|
+
if isinstance(func, ast.Name):
|
|
131
|
+
call_positions.add((func.lineno, func.col_offset))
|
|
132
|
+
# Method call: obj.method() - mark 'obj' as module ref
|
|
133
|
+
elif isinstance(func, ast.Attribute):
|
|
134
|
+
self._collect_attribute_base_positions(func, module_positions)
|
|
135
|
+
|
|
136
|
+
# Attribute access (not a call, just np.something)
|
|
137
|
+
if isinstance(node, ast.Attribute):
|
|
138
|
+
# If this attribute is the value of another attribute (nested: np.sin.something)
|
|
139
|
+
# or if it's part of a call (np.sin()), we handle module refs
|
|
140
|
+
self._collect_attribute_base_positions(node, module_positions)
|
|
141
|
+
|
|
142
|
+
def _collect_attribute_base_positions(
|
|
143
|
+
self,
|
|
144
|
+
node: ast.Attribute,
|
|
145
|
+
positions: Set[Tuple[int, int]]
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Recursively collect base Name positions from attribute chains."""
|
|
148
|
+
val = node.value
|
|
149
|
+
if isinstance(val, ast.Name):
|
|
150
|
+
# This is the base of the attribute chain (e.g., 'np' in np.sin)
|
|
151
|
+
positions.add((val.lineno, val.col_offset))
|
|
152
|
+
elif isinstance(val, ast.Attribute):
|
|
153
|
+
# Nested attribute, recurse
|
|
154
|
+
self._collect_attribute_base_positions(val, positions)
|
|
155
|
+
|
|
156
|
+
def _collect_lhs_positions_from_tree(self, tree: ast.AST, positions: Set[Tuple[int, int]]) -> None:
|
|
157
|
+
"""Walk tree and collect all assignment target positions."""
|
|
158
|
+
for node in ast.walk(tree):
|
|
159
|
+
if isinstance(node, ast.Assign):
|
|
160
|
+
for target in node.targets:
|
|
161
|
+
self._collect_lhs_positions(target, positions)
|
|
162
|
+
elif isinstance(node, ast.AnnAssign) and node.target:
|
|
163
|
+
self._collect_lhs_positions(node.target, positions)
|
|
164
|
+
elif isinstance(node, ast.AugAssign):
|
|
165
|
+
self._collect_lhs_positions(node.target, positions)
|
|
166
|
+
elif isinstance(node, (ast.For, ast.comprehension)):
|
|
167
|
+
self._collect_lhs_positions(node.target, positions)
|
|
168
|
+
|
|
169
|
+
def _collect_lhs_positions(self, node: ast.AST, positions: Set[Tuple[int, int]]) -> None:
|
|
170
|
+
"""Recursively collect positions of assignment targets."""
|
|
171
|
+
if isinstance(node, ast.Name):
|
|
172
|
+
positions.add((node.lineno, node.col_offset))
|
|
173
|
+
elif isinstance(node, (ast.Tuple, ast.List)):
|
|
174
|
+
for elt in node.elts:
|
|
175
|
+
self._collect_lhs_positions(elt, positions)
|
|
176
|
+
|
|
177
|
+
def _extract_functions(self) -> None:
|
|
178
|
+
"""Extract function definitions with their line ranges."""
|
|
179
|
+
if self._tree is None:
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
for node in ast.walk(self._tree):
|
|
183
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
184
|
+
end_line = node.end_lineno or node.lineno
|
|
185
|
+
self._functions.append((node.name, node.lineno, end_line))
|
|
186
|
+
|
|
187
|
+
def get_var_at_cursor(self, line: int, col: int) -> Optional[str]:
|
|
188
|
+
"""Return variable name at cursor position, or None.
|
|
189
|
+
|
|
190
|
+
If multiple variables overlap (rare), prefer LHS.
|
|
191
|
+
"""
|
|
192
|
+
candidates = []
|
|
193
|
+
for var in self._variables:
|
|
194
|
+
if var.line == line and var.col_start <= col < var.col_end:
|
|
195
|
+
candidates.append(var)
|
|
196
|
+
|
|
197
|
+
if not candidates:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
# Prefer LHS if ambiguous
|
|
201
|
+
lhs_candidates = [v for v in candidates if v.is_lhs]
|
|
202
|
+
if lhs_candidates:
|
|
203
|
+
return lhs_candidates[0].name
|
|
204
|
+
|
|
205
|
+
return candidates[0].name
|
|
206
|
+
|
|
207
|
+
def get_var_location_at_cursor(self, line: int, col: int) -> Optional[VariableLocation]:
|
|
208
|
+
"""Return full VariableLocation at cursor, or None."""
|
|
209
|
+
for var in self._variables:
|
|
210
|
+
if var.line == line and var.col_start <= col < var.col_end:
|
|
211
|
+
return var
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
def get_all_variables_on_line(self, line: int) -> List[VariableLocation]:
|
|
215
|
+
"""Return all variables on a given line."""
|
|
216
|
+
return [v for v in self._variables if v.line == line]
|
|
217
|
+
|
|
218
|
+
def get_enclosing_function(self, line: int) -> Optional[str]:
|
|
219
|
+
"""Return name of function containing this line, or None."""
|
|
220
|
+
for name, start, end in self._functions:
|
|
221
|
+
if start <= line <= end:
|
|
222
|
+
return name
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
def get_nearest_variable(self, line: int, col: int) -> Optional[VariableLocation]:
|
|
226
|
+
"""Find nearest variable to cursor position.
|
|
227
|
+
|
|
228
|
+
First checks exact match, then looks on same line within 3 chars.
|
|
229
|
+
"""
|
|
230
|
+
# Exact match
|
|
231
|
+
exact = self.get_var_location_at_cursor(line, col)
|
|
232
|
+
if exact:
|
|
233
|
+
return exact
|
|
234
|
+
|
|
235
|
+
# Same line, within proximity
|
|
236
|
+
line_vars = self.get_all_variables_on_line(line)
|
|
237
|
+
if not line_vars:
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
# Find closest by column distance
|
|
241
|
+
def distance(v: VariableLocation) -> int:
|
|
242
|
+
if v.col_start <= col < v.col_end:
|
|
243
|
+
return 0
|
|
244
|
+
return min(abs(col - v.col_start), abs(col - v.col_end))
|
|
245
|
+
|
|
246
|
+
closest = min(line_vars, key=distance)
|
|
247
|
+
if distance(closest) <= 3: # Snap within 3 chars
|
|
248
|
+
return closest
|
|
249
|
+
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
def is_probeable(self, var_loc: VariableLocation) -> bool:
|
|
253
|
+
"""Return True if this variable can be probed.
|
|
254
|
+
|
|
255
|
+
Only DATA_VARIABLE symbols (LHS of assignments) are probeable.
|
|
256
|
+
Function calls, module references, and unknown symbols are not.
|
|
257
|
+
"""
|
|
258
|
+
result = var_loc.symbol_type == SymbolType.DATA_VARIABLE
|
|
259
|
+
logger.debug(f"is_probeable({var_loc.name}): type={var_loc.symbol_type}, result={result}")
|
|
260
|
+
return result
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def is_valid(self) -> bool:
|
|
264
|
+
"""Return True if source was parsed successfully."""
|
|
265
|
+
return self._tree is not None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tests for analysis module."""
|