slough 0.1.0__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.
slough-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.3
2
+ Name: slough
3
+ Version: 0.1.0
4
+ Summary: Experimental toy library to dry-run Python code with line-by-line variable tracing
5
+ Author: prakash S
6
+ Author-email: prakash S <prakashsellathurai@gmail.com>
7
+ Requires-Python: >=3.12
8
+ Project-URL: Homepage, https://github.com/prakashsellathurai/slough
9
+ Description-Content-Type: text/markdown
10
+
11
+ # slough
12
+
13
+ Experimental toy library to dry-run Python code traces every variable value line-by-line through execution.
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ slough path/to/solution.py -t test_cases.json
19
+ ```
20
+
21
+ ## How it works
22
+
23
+ Hooks `sys.settrace` before calling your solution function, recording locals at each line. Outputs source code with live variable annotations and pass/fail for each test case.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ uv tool install slough
29
+ ```
30
+
31
+ **Status:** Experimental. Not intended for production use.
slough-0.1.0/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # slough
2
+
3
+ Experimental toy library to dry-run Python code traces every variable value line-by-line through execution.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ slough path/to/solution.py -t test_cases.json
9
+ ```
10
+
11
+ ## How it works
12
+
13
+ Hooks `sys.settrace` before calling your solution function, recording locals at each line. Outputs source code with live variable annotations and pass/fail for each test case.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ uv tool install slough
19
+ ```
20
+
21
+ **Status:** Experimental. Not intended for production use.
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "slough"
3
+ version = "0.1.0"
4
+ description = "Experimental toy library to dry-run Python code with line-by-line variable tracing"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "prakash S", email = "prakashsellathurai@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = []
11
+
12
+ [project.scripts]
13
+ slough = "slough.cli:main"
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/prakashsellathurai/slough"
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.9.26,<0.10.0"]
20
+ build-backend = "uv_build"
21
+
22
+ [tool.pytest.ini_options]
23
+ testpaths = ["tests"]
24
+ filterwarnings = [
25
+ "ignore:cannot collect test class 'TestCase'",
26
+ ]
27
+
28
+ [dependency-groups]
29
+ dev = [
30
+ "pytest>=9.0.3",
31
+ ]
@@ -0,0 +1,2 @@
1
+ def main() -> None:
2
+ print("Hello from slough!")
@@ -0,0 +1,4 @@
1
+ import sys
2
+ from slough.cli import main
3
+
4
+ sys.exit(main())
@@ -0,0 +1,112 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import sys
5
+
6
+ from slough.formatter import format_results
7
+ from slough.models import TestCase
8
+ from slough.parser import parse_md_examples
9
+ from slough.runner import run_test_cases
10
+
11
+
12
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
13
+ parser = argparse.ArgumentParser(
14
+ description="Trace Python variable values through LeetCode-style solution execution.",
15
+ )
16
+ parser.add_argument("solution", help="Path to solution.py or directory with solution + README.md")
17
+ parser.add_argument(
18
+ "-t", "--test-cases",
19
+ default=None,
20
+ help="Path to JSON test cases file (optional if README.md exists)",
21
+ )
22
+ parser.add_argument(
23
+ "-o", "--output",
24
+ default=None,
25
+ help="Write output to file instead of stdout",
26
+ )
27
+ return parser.parse_args(argv)
28
+
29
+
30
+ def _load_test_cases(path: str) -> list[TestCase]:
31
+ with open(path) as f:
32
+ raw = json.load(f)
33
+ test_cases = []
34
+ for item in raw:
35
+ inputs = tuple(item["inputs"])
36
+ expected = item.get("expected")
37
+ test_cases.append(TestCase(inputs=inputs, expected=expected))
38
+ return test_cases
39
+
40
+
41
+ def _resolve_solution_path(solution: str) -> str | None:
42
+ if os.path.isdir(solution):
43
+ py_files = [f for f in os.listdir(solution) if f.endswith(".py")]
44
+ if not py_files:
45
+ return None
46
+ return os.path.join(solution, py_files[0])
47
+ return solution
48
+
49
+
50
+ def _find_readme(solution_path: str) -> str | None:
51
+ solution_dir = solution_path if os.path.isdir(solution_path) else os.path.dirname(solution_path)
52
+ for name in ("README.md", "readme.md", "README", "readme"):
53
+ candidate = os.path.join(solution_dir, name)
54
+ if os.path.isfile(candidate):
55
+ return candidate
56
+ return None
57
+
58
+
59
+ def main(argv: list[str] | None = None) -> int:
60
+ args = parse_args(argv)
61
+ solution_path = args.solution
62
+
63
+ is_dir = os.path.isdir(solution_path)
64
+
65
+ if is_dir:
66
+ solution_path = _resolve_solution_path(solution_path)
67
+ if solution_path is None:
68
+ print(f"Error: No Python solution file found in directory '{args.solution}'")
69
+ return 1
70
+
71
+ try:
72
+ with open(solution_path) as f:
73
+ source_lines = f.readlines()
74
+ except FileNotFoundError as e:
75
+ print(f"Error: Solution file not found: {e}")
76
+ return 1
77
+
78
+ if args.test_cases:
79
+ try:
80
+ test_cases = _load_test_cases(args.test_cases)
81
+ except FileNotFoundError as e:
82
+ print(f"Error: Test cases file not found: {e}")
83
+ return 1
84
+ except json.JSONDecodeError as e:
85
+ print(f"Error: Invalid JSON in test cases file: {e}")
86
+ return 1
87
+ else:
88
+ readme_path = _find_readme(args.solution)
89
+ if readme_path:
90
+ with open(readme_path) as f:
91
+ test_cases = parse_md_examples(f.read())
92
+ if not test_cases:
93
+ print(f"Error: No test cases found in '{readme_path}'")
94
+ return 1
95
+ else:
96
+ print("Error: No test cases provided (use --test-cases or place README.md alongside the solution)")
97
+ return 1
98
+
99
+ results = run_test_cases(solution_path, test_cases)
100
+ output = format_results(results, source_lines)
101
+
102
+ if args.output:
103
+ with open(args.output, "w") as f:
104
+ f.write(output + "\n")
105
+ else:
106
+ print(output)
107
+
108
+ return 0
109
+
110
+
111
+ if __name__ == "__main__":
112
+ sys.exit(main())
@@ -0,0 +1,64 @@
1
+ from slough.models import TraceResult, TraceStep
2
+
3
+
4
+ def _format_vars(vars_dict: dict) -> str:
5
+ items = []
6
+ for k, v in vars_dict.items():
7
+ if k == "self":
8
+ continue
9
+ items.append(f"{k}={repr(v)}")
10
+ return ", ".join(items)
11
+
12
+
13
+ def _format_return_comparison(result: TraceResult) -> str:
14
+ line = f" Returned: {repr(result.return_value)}"
15
+ if result.test_case.expected is not None:
16
+ if result.return_value == result.test_case.expected:
17
+ line += f" (expected: {repr(result.test_case.expected)})"
18
+ else:
19
+ line += f" (expected: {repr(result.test_case.expected)}) ✗ MISMATCH"
20
+ return line
21
+
22
+
23
+ def format_results(results: list[TraceResult], source_lines: list[str]) -> str:
24
+ output_parts: list[str] = []
25
+
26
+ for idx, result in enumerate(results):
27
+ idx_line = f"\n{'=' * 60}"
28
+ output_parts.append(idx_line)
29
+ output_parts.append(f" Test Case {idx + 1}")
30
+
31
+ steps = result.steps
32
+
33
+ # Build step index: for each step lineno, collect var changes
34
+ line_vars: dict[int, list[str]] = {}
35
+ for step in steps:
36
+ if step.event in ("line", "return") and step.vars:
37
+ var_str = _format_vars(step.vars)
38
+ if var_str:
39
+ line_vars.setdefault(step.lineno, []).append(var_str)
40
+ if step.event == "call" and step.vars:
41
+ var_str = _format_vars(step.vars)
42
+ if var_str:
43
+ output_parts.append(f"\n Call: {step.func_name}({var_str})")
44
+
45
+ # Print source with variable annotations
46
+ for line_no, line in enumerate(source_lines, start=1):
47
+ code = line.rstrip("\n")
48
+ output_parts.append(f"\n {line_no:>3} │ {code}")
49
+ if line_no in line_vars:
50
+ for var_str in line_vars[line_no]:
51
+ output_parts.append(f" → {var_str}")
52
+
53
+ # Show return events
54
+ for step in steps:
55
+ if step.event == "return" and step.return_value is not None:
56
+ output_parts.append(f"\n Return: {step.func_name} → {repr(step.return_value)}")
57
+ elif step.event == "exception":
58
+ output_parts.append(f"\n Exception: {repr(step.return_value)}")
59
+
60
+ output_parts.append("")
61
+ output_parts.append(f" {'─' * 58}")
62
+ output_parts.append(f" {_format_return_comparison(result)}")
63
+
64
+ return "\n".join(output_parts)
@@ -0,0 +1,24 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any
3
+
4
+
5
+ @dataclass
6
+ class TraceStep:
7
+ lineno: int
8
+ event: str
9
+ func_name: str
10
+ vars: dict[str, Any] = field(default_factory=dict)
11
+ return_value: Any = None
12
+
13
+
14
+ @dataclass
15
+ class TestCase:
16
+ inputs: tuple[Any, ...] = ()
17
+ expected: Any = None
18
+
19
+
20
+ @dataclass
21
+ class TraceResult:
22
+ test_case: TestCase
23
+ steps: list[TraceStep]
24
+ return_value: Any = None
@@ -0,0 +1,119 @@
1
+ import ast
2
+ import html as html_module
3
+ import re
4
+ from typing import Any
5
+
6
+ from slough.models import TestCase
7
+
8
+
9
+ def _normalize_content(content: str) -> str:
10
+ """Convert HTML content to plain text with preserved line structure."""
11
+ if "<" not in content:
12
+ return content
13
+
14
+ content = re.sub(r"<br\s*/?>", "\n", content, flags=re.IGNORECASE)
15
+ content = re.sub(r"</pre\s*>", "\n", content, flags=re.IGNORECASE)
16
+ content = re.sub(r"<pre[^>]*>", "\n", content, flags=re.IGNORECASE)
17
+ content = re.sub(r"<[^>]+>", "", content)
18
+ content = html_module.unescape(content)
19
+ content = re.sub(r"\n{3,}", "\n\n", content)
20
+ return content.strip()
21
+
22
+
23
+ def _parse_value(raw: str) -> Any:
24
+ raw = raw.strip()
25
+ if raw in ("true", "True"):
26
+ return True
27
+ if raw in ("false", "False"):
28
+ return False
29
+ if raw in ("null", "None"):
30
+ return None
31
+ try:
32
+ return ast.literal_eval(raw)
33
+ except (ValueError, SyntaxError):
34
+ return raw
35
+
36
+
37
+ def _split_input_pairs(line: str) -> dict[str, Any]:
38
+ pairs: dict[str, Any] = {}
39
+ key_pattern = re.compile(r"(\w+)\s*=")
40
+
41
+ # Find positions of all '=' signs with preceding key names
42
+ parts: list[tuple[str, str]] = []
43
+ pos = 0
44
+ while pos < len(line):
45
+ m = key_pattern.search(line, pos)
46
+ if m is None:
47
+ break
48
+ key = m.group(1)
49
+ val_start = m.end()
50
+ # Find the value: everything until the next ',' at depth 0
51
+ depth = 0
52
+ in_quote: str | None = None
53
+ val_end = val_start
54
+ while val_end < len(line):
55
+ ch = line[val_end]
56
+ if in_quote:
57
+ if ch == in_quote and (val_end == 0 or line[val_end - 1] != "\\"):
58
+ in_quote = None
59
+ elif ch in ("\"", "'"):
60
+ in_quote = ch
61
+ elif ch in ("[", "("):
62
+ depth += 1
63
+ elif ch in ("]", ")"):
64
+ depth -= 1
65
+ elif ch == "," and depth == 0:
66
+ break
67
+ val_end += 1
68
+
69
+ value_raw = line[val_start:val_end].strip().rstrip(",")
70
+ pairs[key] = _parse_value(value_raw)
71
+ pos = val_end + 1
72
+
73
+ return pairs
74
+
75
+
76
+ def parse_example_lines(input_line: str, output_line: str | None) -> TestCase:
77
+ input_line = re.sub(r"^Input:\s*", "", input_line, flags=re.IGNORECASE).strip()
78
+ pairs = _split_input_pairs(input_line)
79
+ inputs = tuple(pairs.values())
80
+
81
+ expected = None
82
+ if output_line:
83
+ output_line = re.sub(r"^Output:\s*", "", output_line, flags=re.IGNORECASE).strip()
84
+ expected = _parse_value(output_line)
85
+
86
+ return TestCase(inputs=inputs, expected=expected)
87
+
88
+
89
+ def parse_md_examples(md_content: str) -> list[TestCase]:
90
+ content = _normalize_content(md_content)
91
+ cases: list[TestCase] = []
92
+ lines = content.split("\n")
93
+
94
+ example_header = re.compile(
95
+ r"(?:#+\s*)?Example\s*\d*\s*:?\s*$", re.IGNORECASE,
96
+ )
97
+ example_indices = [i for i, l in enumerate(lines) if example_header.match(l.strip())]
98
+
99
+ for idx, start in enumerate(example_indices):
100
+ end = example_indices[idx + 1] if idx + 1 < len(example_indices) else len(lines)
101
+ block = lines[start:end]
102
+
103
+ input_line = None
104
+ output_line = None
105
+ for l in block:
106
+ stripped = l.strip()
107
+ if not stripped:
108
+ continue
109
+ if re.match(r"Input:", stripped, re.IGNORECASE):
110
+ if input_line is None:
111
+ input_line = stripped
112
+ elif re.match(r"Output:", stripped, re.IGNORECASE):
113
+ if output_line is None:
114
+ output_line = stripped
115
+
116
+ if input_line:
117
+ cases.append(parse_example_lines(input_line, output_line))
118
+
119
+ return cases
@@ -0,0 +1,93 @@
1
+ import inspect
2
+ from typing import Any
3
+
4
+ from slough.models import TestCase, TraceResult
5
+ from slough.tracer import trace_function_call
6
+
7
+
8
+ _COMMON_TYPING_IMPORTS = {
9
+ "List": __import__("typing").List,
10
+ "Dict": __import__("typing").Dict,
11
+ "Optional": __import__("typing").Optional,
12
+ "Tuple": __import__("typing").Tuple,
13
+ "Set": __import__("typing").Set,
14
+ "Deque": __import__("collections").deque,
15
+ }
16
+
17
+
18
+ def _load_solution_class(filepath: str) -> type:
19
+ with open(filepath) as f:
20
+ source = f.read()
21
+
22
+ ns: dict[str, Any] = dict(_COMMON_TYPING_IMPORTS)
23
+ compiled = compile(source, filepath, "exec")
24
+ exec(compiled, ns)
25
+
26
+ for name, obj in ns.items():
27
+ if inspect.isclass(obj):
28
+ methods = [
29
+ m for m in obj.__dict__.values()
30
+ if inspect.isfunction(m) and not m.__name__.startswith("_")
31
+ ]
32
+ if methods:
33
+ return obj
34
+
35
+ raise ValueError("No class with methods found in the solution file")
36
+
37
+
38
+ def _find_method(solution_class: type, test_case: TestCase) -> str:
39
+ for name, method in solution_class.__dict__.items():
40
+ if inspect.isfunction(method) and not name.startswith("_"):
41
+ try:
42
+ sig = inspect.signature(method)
43
+ params = list(sig.parameters.keys())
44
+ if len(params) - 1 == len(test_case.inputs):
45
+ return name
46
+ except (ValueError, NameError):
47
+ continue
48
+ for name in solution_class.__dict__:
49
+ if not name.startswith("_"):
50
+ return name
51
+ raise ValueError("No public method found on Solution class")
52
+
53
+
54
+ def _is_inplace_method(solution_class: type, method_name: str) -> bool:
55
+ method = solution_class.__dict__.get(method_name)
56
+ if not method:
57
+ return False
58
+ try:
59
+ sig = inspect.signature(method)
60
+ return sig.return_annotation is None or sig.return_annotation is inspect.Parameter.empty
61
+ except (ValueError, NameError):
62
+ return False
63
+
64
+
65
+ def run_test_cases(
66
+ filepath: str,
67
+ test_cases: list[TestCase],
68
+ ) -> list[TraceResult]:
69
+ results: list[TraceResult] = []
70
+
71
+ solution_class = _load_solution_class(filepath)
72
+
73
+ for tc in test_cases:
74
+ method_name = _find_method(solution_class, tc)
75
+ instance = solution_class()
76
+ method = getattr(instance, method_name)
77
+
78
+ # Make a mutable copy for in-place methods
79
+ inputs = list(tc.inputs)
80
+ steps, return_value = trace_function_call(
81
+ method, tuple(inputs), filepath
82
+ )
83
+
84
+ if return_value is None and _is_inplace_method(solution_class, method_name):
85
+ return_value = inputs[0]
86
+
87
+ results.append(TraceResult(
88
+ test_case=tc,
89
+ steps=steps,
90
+ return_value=return_value,
91
+ ))
92
+
93
+ return results
@@ -0,0 +1,41 @@
1
+ import sys
2
+ from typing import Any, Callable
3
+
4
+ from slough.models import TraceStep
5
+
6
+
7
+ def _make_trace_callback(target_filename: str, steps: list[TraceStep]):
8
+ def trace_cb(frame, event, arg):
9
+ if frame.f_code.co_filename != target_filename:
10
+ return trace_cb
11
+
12
+ if event in ("line", "call", "return", "exception"):
13
+ steps.append(TraceStep(
14
+ lineno=frame.f_lineno,
15
+ event=event,
16
+ func_name=frame.f_code.co_name,
17
+ vars=dict(frame.f_locals),
18
+ return_value=arg if event in ("return", "exception") else None,
19
+ ))
20
+
21
+ return trace_cb
22
+
23
+ return trace_cb
24
+
25
+
26
+ def trace_function_call(
27
+ fn: Callable,
28
+ args: tuple,
29
+ target_filename: str,
30
+ ) -> tuple[list[TraceStep], Any]:
31
+ steps: list[TraceStep] = []
32
+ trace_cb = _make_trace_callback(target_filename, steps)
33
+
34
+ old_trace = sys.gettrace()
35
+ sys.settrace(trace_cb)
36
+ try:
37
+ result = fn(*args)
38
+ finally:
39
+ sys.settrace(old_trace)
40
+
41
+ return steps, result