shpe 0.2.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.
shpe/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .checker import Checker
2
+ from .diagnostics import ErrorCode
3
+ from .environment import ShapeState
4
+
5
+ __all__ = ["Checker", "ErrorCode", "ShapeState"]
shpe/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from shpe.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
shpe/checker.py ADDED
@@ -0,0 +1,159 @@
1
+ import ast
2
+ from typing import Any
3
+
4
+ from shpe.diagnostics import Diagnostics, ErrorCode
5
+ from shpe.environment import Environment, ShapeState
6
+ from shpe.extractor import Extractor
7
+ from shpe.resolver import Resolver
8
+
9
+
10
+ class Checker(ast.NodeVisitor):
11
+ """AST visitor that walks through Python code to infer and check NumPy shapes"""
12
+
13
+ def __init__(self) -> None:
14
+ self.env = Environment()
15
+ self.diagnostics = Diagnostics()
16
+ self.extractor = Extractor(self.env, self.diagnostics)
17
+ self.resolver = Resolver(
18
+ self.extractor,
19
+ self.env,
20
+ self.diagnostics,
21
+ self,
22
+ )
23
+
24
+ @property
25
+ def shapes(self) -> dict[str, tuple[Any, ...] | ShapeState]:
26
+ """Convenience property for tests and CLI to access global shapes."""
27
+ return self.env.shapes
28
+
29
+ @property
30
+ def scalar_values(self) -> dict[str, int | float | ShapeState]:
31
+ """Convenience property for tests and CLI to access global scalar values."""
32
+ return self.env.scalar_values
33
+
34
+ @property
35
+ def errors(self) -> list[dict[str, Any]]:
36
+ """Convenience property for tests and CLI to access collected errors."""
37
+ return self.diagnostics.errors
38
+
39
+ @property
40
+ def warnings(self) -> list[dict[str, Any]]:
41
+ """Convenience property for tests and CLI to access collected warnings."""
42
+ return self.diagnostics.warnings
43
+
44
+ @property
45
+ def inlay_hints(self) -> list[dict[str, Any]]:
46
+ """Convenience property for tests and CLI to access collected inlay hints."""
47
+ return self.diagnostics.inlay_hints
48
+
49
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
50
+ """Handles explicit annotated variable assignments."""
51
+
52
+ if not isinstance(node.target, ast.Name):
53
+ return
54
+
55
+ var_name = node.target.id
56
+
57
+ if isinstance(node.value, ast.Constant) and isinstance(
58
+ node.value.value, (int, float)
59
+ ):
60
+ self.env.set_scalar(var_name, node.value.value)
61
+
62
+ annotated_shape = self.extractor.annotation_shape(node.annotation)
63
+ inferred_shape = self.resolver.shape(node.value) if node.value else None
64
+
65
+ resolved_shape = (
66
+ annotated_shape if annotated_shape is not None else inferred_shape
67
+ )
68
+ if resolved_shape is None:
69
+ resolved_shape = ShapeState.UNKNOWN
70
+ self.env.set_shape(var_name, resolved_shape)
71
+
72
+ if inferred_shape is not None and inferred_shape is not ShapeState.UNKNOWN:
73
+ self.diagnostics.hint(node, inferred_shape)
74
+ if (
75
+ annotated_shape is not None
76
+ and inferred_shape is not None
77
+ and annotated_shape is not ShapeState.UNKNOWN
78
+ and inferred_shape is not ShapeState.UNKNOWN
79
+ and annotated_shape != inferred_shape
80
+ ):
81
+ self.diagnostics.error(
82
+ node,
83
+ ErrorCode.ANNOTATION,
84
+ (
85
+ f"{var_name} annotated as {annotated_shape}, "
86
+ f"but expression has the shape {inferred_shape}. "
87
+ ),
88
+ )
89
+
90
+ def visit_Assign(self, node: ast.Assign) -> None:
91
+ """Handles implicit variable assignments."""
92
+
93
+ if (
94
+ node.value
95
+ and isinstance(node.value, ast.Constant)
96
+ and isinstance(node.value.value, (int, float))
97
+ ):
98
+ for target in node.targets:
99
+ if isinstance(target, ast.Name):
100
+ self.env.set_scalar(target.id, node.value.value)
101
+
102
+ inferred_shape = self.resolver.shape(node.value) if node.value else None
103
+
104
+ if inferred_shape is not None and inferred_shape is not ShapeState.UNKNOWN:
105
+ self.diagnostics.hint(node, inferred_shape)
106
+
107
+ shape_to_set = (
108
+ inferred_shape if inferred_shape is not None else ShapeState.UNKNOWN
109
+ )
110
+ for target in node.targets:
111
+ if isinstance(target, ast.Name):
112
+ self.env.set_shape(target.id, shape_to_set)
113
+
114
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
115
+ """Tracks custom function definitions."""
116
+ self.env.set_function(node.name, node)
117
+ self.env.push_child()
118
+ self.resolver.user_function_def(node)
119
+ self.env.pop_child()
120
+
121
+ def visit_If(self, node: ast.If) -> None:
122
+ """Handles if statements by tainting variables modified inside branches."""
123
+ test_str = ast.unparse(node.test).replace('"', "'")
124
+
125
+ # Evaluate normally for the standard main block
126
+ if test_str == "__name__ == '__main__'":
127
+ self.generic_visit(node)
128
+ else:
129
+ self._visit_tainted_block(node.body)
130
+ if node.orelse:
131
+ self._visit_tainted_block(node.orelse)
132
+
133
+ def visit_For(self, node: ast.For) -> None:
134
+ """Handles for loops by tainting variables modified inside the loop."""
135
+ self._visit_tainted_block(node.body)
136
+
137
+ def visit_While(self, node: ast.While) -> None:
138
+ """Handles while loops by tainting variables modified inside the loop."""
139
+ self._visit_tainted_block(node.body)
140
+
141
+ def visit_AsyncFor(self, node: ast.AsyncFor) -> None:
142
+ """Handles async for loops."""
143
+ self._visit_tainted_block(node.body)
144
+
145
+ def _visit_tainted_block(self, body: list[ast.stmt]) -> None:
146
+ """Walks a block of code and forces any assigned variables to Unknown.
147
+ Used for complex code blocks where we cannot guarantee shape inference.
148
+ We would rather have Unkown than incorrect shapes.
149
+
150
+ Args:
151
+ body: A list of AST statements representing the code block.
152
+ """
153
+ for stmt in body:
154
+ self.visit(stmt)
155
+ for target in ast.walk(stmt):
156
+ if isinstance(target, ast.Name) and isinstance(target.ctx, ast.Store):
157
+ self.env.set_shape(target.id, ShapeState.UNKNOWN)
158
+ if target.id in self.env.scalar_values:
159
+ self.env.scalar_values[target.id] = ShapeState.UNKNOWN
shpe/cli.py ADDED
@@ -0,0 +1,193 @@
1
+ import argparse
2
+ import ast
3
+ import importlib.metadata
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from shpe.checker import Checker
9
+ from shpe.environment import ShapeState
10
+
11
+ try:
12
+ __version__ = importlib.metadata.version("shpe")
13
+ except importlib.metadata.PackageNotFoundError:
14
+ __version__ = "unknown"
15
+
16
+
17
+ def main() -> None:
18
+ """CLI entry point for the local Python shape checker."""
19
+ parser = argparse.ArgumentParser(description="A local Python shape checker.")
20
+ parser.add_argument(
21
+ "paths", nargs="+", type=Path, help="Files or directories to check"
22
+ )
23
+ parser.add_argument(
24
+ "--version", action="version", version=f"%(prog)s {__version__}"
25
+ )
26
+ parser.add_argument(
27
+ "--show-shapes",
28
+ action="store_true",
29
+ help="Display inferred NumPy shapes for all variables",
30
+ )
31
+ args = parser.parse_args()
32
+
33
+ files = discover_files(args.paths)
34
+ all_errors: list[str] = []
35
+ all_shapes: dict[Path, dict[str, tuple[Any, ...] | ShapeState]] = {}
36
+ all_scalars: dict[Path, dict[str, int | float | ShapeState]] = {}
37
+
38
+ for filepath in files:
39
+ file_errors, shapes, scalars = _process_file(filepath)
40
+ all_errors.extend(file_errors)
41
+ if shapes:
42
+ all_shapes[filepath] = shapes
43
+ if scalars:
44
+ all_scalars[filepath] = scalars
45
+
46
+ if args.show_shapes and all_shapes:
47
+ _print_shape_report(all_shapes, all_scalars)
48
+
49
+ for err in all_errors:
50
+ print(err)
51
+
52
+ if all_errors:
53
+ print(f"\nFound {len(all_errors)} error(s) across {len(files)} file(s).")
54
+ sys.exit(1)
55
+ else:
56
+ print(f"Success: Checked {len(files)} file(s), no shape errors found.")
57
+ sys.exit(0)
58
+
59
+
60
+ def discover_files(paths: list[Path]) -> list[Path]:
61
+ """Discovers and collects all Python (.py) source files from the provided paths.
62
+
63
+ Args:
64
+ paths: A list of file or directory Path objects to scan.
65
+
66
+ Returns:
67
+ A list of resolved Path objects pointing to valid Python source files.
68
+ """
69
+ files_to_check = []
70
+ for path in paths:
71
+ if not path.exists():
72
+ print(f"Error: Path '{path}' not found.", file=sys.stderr)
73
+ continue
74
+
75
+ if path.is_file() and path.suffix == ".py":
76
+ files_to_check.append(path)
77
+ elif path.is_dir():
78
+ for subpath in path.rglob("*.py"):
79
+ # Skip hidden directories like .git, .venv, __pycache__
80
+ if not any(part.startswith(".") for part in subpath.parts):
81
+ files_to_check.append(subpath)
82
+
83
+ return files_to_check
84
+
85
+
86
+ def _process_file(
87
+ filepath: Path,
88
+ ) -> tuple[
89
+ list[str],
90
+ dict[str, tuple[Any, ...] | ShapeState],
91
+ dict[str, int | float | ShapeState],
92
+ ]:
93
+ """Processes a single source file, running syntax checks and shape analysis.
94
+
95
+ Args:
96
+ filepath: The path to the file to check.
97
+
98
+ Returns:
99
+ A tuple containing a list of formatted error messages,
100
+ the extracted shapes dictionary, and the extracted scalars dictionary.
101
+ """
102
+ errors: list[str] = []
103
+ try:
104
+ code = filepath.read_text(encoding="utf-8")
105
+ lines = code.splitlines()
106
+ tree = ast.parse(code, filename=str(filepath))
107
+ except SyntaxError as e:
108
+ errors.append(f"{filepath}:{e.lineno}: [SyntaxError] {e.msg} ")
109
+ return errors, {}, {}
110
+
111
+ checker = Checker()
112
+ checker.visit(tree)
113
+
114
+ for warning in checker.warnings:
115
+ line = warning["line"]
116
+ line_idx = max(0, line - 1)
117
+ if line_idx < len(lines):
118
+ line_content = lines[line_idx]
119
+ if "# shpe: ignore" in line_content:
120
+ continue
121
+
122
+ code = warning["code"]
123
+ msg = warning["message"]
124
+ errors.append(f"{filepath}:{line}: warning: [{code}] {msg}")
125
+
126
+ for error in checker.errors:
127
+ line = error["line"]
128
+ line_idx = max(0, line - 1)
129
+ if line_idx < len(lines):
130
+ line_content = lines[line_idx]
131
+ if "# shpe: ignore" in line_content:
132
+ continue
133
+
134
+ code = error["code"]
135
+ msg = error["message"]
136
+ errors.append(f"{filepath}:{line}: [{code}] {msg}")
137
+
138
+ return errors, checker.env.shapes, checker.env.scalar_values
139
+
140
+
141
+ def _print_shape_report(
142
+ all_shapes: dict[Path, dict[str, tuple[Any, ...] | ShapeState]],
143
+ all_scalars: dict[Path, dict[str, int | float | ShapeState]],
144
+ ) -> None:
145
+ """Prints a formatted report of all inferred shapes and scalar values across files.
146
+
147
+ Args:
148
+ all_shapes: Mapping of file paths to their symbol shape definitions.
149
+ all_scalars: Mapping of file paths to their tracked scalar variables.
150
+ """
151
+ print("\n-------- Symbol State (Shapes & Scalars) --------")
152
+
153
+ for filepath in sorted(all_shapes.keys(), key=str):
154
+ print(f"\n{filepath}:")
155
+ symbols = all_shapes[filepath]
156
+ scalars = all_scalars.get(filepath, {})
157
+
158
+ if scalars:
159
+ print(" Scalars:")
160
+ for name, val in sorted(scalars.items()):
161
+ val_str = "unknown" if val is ShapeState.UNKNOWN else val
162
+ print(f" - {name} = {val_str}")
163
+
164
+ if symbols:
165
+ print(" Shapes:")
166
+ symbols_with_no_shape: list[str] = []
167
+ symbols_with_unknown_shape: list[str] = []
168
+ for var_name, shape in sorted(symbols.items()):
169
+ if shape is ShapeState.UNKNOWN:
170
+ symbols_with_unknown_shape.append(var_name)
171
+ elif shape is not None:
172
+ print(f" - {var_name}: {shape}")
173
+ else:
174
+ symbols_with_no_shape.append(var_name)
175
+
176
+ if symbols_with_unknown_shape:
177
+ print(
178
+ " - unknown shape (tainted) for: "
179
+ f"{', '.join(symbols_with_unknown_shape)}"
180
+ )
181
+ if symbols_with_no_shape:
182
+ print(
183
+ f" - no shape inferred for: {', '.join(symbols_with_no_shape)}"
184
+ )
185
+
186
+ if not symbols and not scalars:
187
+ print(" - (no symbols tracked)")
188
+
189
+ print("\n" + "-" * 30 + "\n")
190
+
191
+
192
+ if __name__ == "__main__":
193
+ main()
shpe/diagnostics.py ADDED
@@ -0,0 +1,85 @@
1
+ import ast
2
+ from enum import Enum
3
+ from typing import Any
4
+
5
+
6
+ class ErrorCode(Enum):
7
+ ANNOTATION = "Annotation"
8
+ ELEMENTWISE = "Elementwise"
9
+ MATMUL = "MatMul"
10
+ RESHAPE = "Reshape"
11
+ VALUE = "Value"
12
+
13
+
14
+ def _position(node: ast.AST) -> tuple[int, int, int]:
15
+ col = getattr(node, "col_offset", 0)
16
+ return getattr(node, "lineno", 0), col, getattr(node, "end_col_offset", col)
17
+
18
+
19
+ class Diagnostics:
20
+ """Collects errors, warnings, and inlay hints during a check pass."""
21
+
22
+ def __init__(self) -> None:
23
+ self.errors: list[dict[str, Any]] = []
24
+ self.warnings: list[dict[str, Any]] = []
25
+ self.inlay_hints: list[dict[str, Any]] = []
26
+ self.active_error_collection = True
27
+ self.active_warning_collection = True
28
+ self.active_hint_collection = True
29
+
30
+ def deactivate_hints(self) -> None:
31
+ """Deactivate inlay hints collection."""
32
+ self.active_hint_collection = False
33
+
34
+ def activate_hints(self) -> None:
35
+ """Activate inlay hints collection."""
36
+ self.active_hint_collection = True
37
+
38
+ def deactivate_errors(self) -> None:
39
+ """Deactivate error collection."""
40
+ self.active_error_collection = False
41
+
42
+ def activate_errors(self) -> None:
43
+ """Activate error collection."""
44
+ self.active_error_collection = True
45
+
46
+ def deactivate_warnings(self) -> None:
47
+ """Deactivate warning collection."""
48
+ self.active_warning_collection = False
49
+
50
+ def activate_warnings(self) -> None:
51
+ """Activate warning collection."""
52
+ self.active_warning_collection = True
53
+
54
+ def error(self, node: ast.AST, code: ErrorCode, message: str) -> None:
55
+ if not self.active_error_collection:
56
+ return
57
+ line, col, end_col = _position(node)
58
+ self.errors.append(
59
+ {
60
+ "line": line,
61
+ "col": col,
62
+ "end_col": end_col,
63
+ "code": code.value,
64
+ "message": message,
65
+ }
66
+ )
67
+
68
+ def warning(self, node: ast.AST, message: str) -> None:
69
+ if not self.active_warning_collection:
70
+ return
71
+ line, col, end_col = _position(node)
72
+ self.warnings.append(
73
+ {"line": line, "col": col, "end_col": end_col, "message": message}
74
+ )
75
+
76
+ def hint(self, node: ast.AST, shape: tuple[Any, ...]) -> None:
77
+ if not self.active_hint_collection:
78
+ return
79
+ self.inlay_hints.append(
80
+ {
81
+ "line": getattr(node, "lineno", 0),
82
+ "col": getattr(node, "end_col_offset", 0),
83
+ "shape": shape,
84
+ }
85
+ )
shpe/environment.py ADDED
@@ -0,0 +1,71 @@
1
+ import ast
2
+ from enum import Enum
3
+ from typing import Any
4
+
5
+
6
+ class ShapeState(Enum):
7
+ UNKNOWN = "UNKNOWN"
8
+
9
+
10
+ class Environment:
11
+ def __init__(self) -> None:
12
+ self.stack: list[dict[str, Any]] = []
13
+ self.push_child() # Initialize the root environment
14
+
15
+ @property
16
+ def shapes(self) -> dict[str, tuple[Any, ...] | ShapeState]:
17
+ return self.stack[-1]["shapes"]
18
+
19
+ @property
20
+ def scalar_values(self) -> dict[str, int | float | ShapeState]:
21
+ return self.stack[-1]["scalar_values"]
22
+
23
+ @property
24
+ def functions(self) -> dict[str, Any]:
25
+ return self.stack[-1]["functions"]
26
+
27
+ def __len__(self) -> int:
28
+ return len(self.stack)
29
+
30
+ def get_shape(self, name: str) -> tuple[Any, ...] | ShapeState | None:
31
+ for frame in reversed(self.stack):
32
+ if name in frame["shapes"]:
33
+ return frame["shapes"][name]
34
+ return None
35
+
36
+ def get_scalar(self, name: str) -> int | float | ShapeState | None:
37
+ for frame in reversed(self.stack):
38
+ if name in frame["scalar_values"]:
39
+ return frame["scalar_values"][name]
40
+ return None
41
+
42
+ def get_function(self, name: str) -> ast.FunctionDef | None:
43
+ for frame in reversed(self.stack):
44
+ if name in frame["functions"]:
45
+ return frame["functions"][name]
46
+ return None
47
+
48
+ def set_shape(self, name: str, shape: tuple[Any, ...] | ShapeState) -> None:
49
+ self.stack[-1]["shapes"][name] = shape
50
+
51
+ def set_scalar(self, name: str, value: int | float | ShapeState) -> None:
52
+ self.stack[-1]["scalar_values"][name] = value
53
+
54
+ def set_function(self, name: str, func: Any) -> None:
55
+ self.stack[-1]["functions"][name] = func
56
+
57
+ def push_child(self) -> None:
58
+ """Creates a new child environment for local variables and shapes."""
59
+ shapes: dict[str, tuple[Any, ...] | ShapeState] = {}
60
+ scalar_values: dict[str, int | float | ShapeState] = {}
61
+ functions: dict[str, ast.FunctionDef] = {}
62
+ self.stack.append(
63
+ {"shapes": shapes, "scalar_values": scalar_values, "functions": functions}
64
+ )
65
+
66
+ def pop_child(self) -> None:
67
+ """Removes the most recent child environment."""
68
+ if len(self.stack) > 1:
69
+ self.stack.pop()
70
+ else:
71
+ raise RuntimeError("Cannot pop the root environment.")