leeth 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.
leeth-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.3
2
+ Name: leeth
3
+ Version: 0.1.0
4
+ Summary: A local LeetCode harness: scaffold, run and test solutions from the terminal
5
+ Author: Zihan Jin
6
+ Author-email: Zihan Jin <admin@zihanjin.com>
7
+ Requires-Dist: icecream>=2.2.0
8
+ Requires-Python: >=3.14
9
+ Description-Content-Type: text/markdown
10
+
11
+ # leeth
12
+
13
+ local leetcode harness to maximise efficiency
14
+
15
+ - Problem scaffolding
16
+ - Run & edit most recently modified file
17
+ - Custom validation functions
18
+ - Supports LeetCode design questions
19
+ - Paste LeetCode inputs as-is: `[1, 2, 3]` becomes a `ListNode` or `TreeNode` when the type hint asks for one
20
+ - LeetCode's default imports, plus `ic` and `todo()`
21
+ - Colored PASS/FAIL output with diffs and timings
22
+
23
+ ## Install
24
+
25
+ Requires Python 3.14+.
26
+
27
+ ```sh
28
+ uv tool install leeth # or: pipx install leeth
29
+ ```
30
+
31
+ This installs the `leet` command. Solution files `from leeth import *`, so run them
32
+ with `leet run`, or with a Python that has `leeth` installed.
33
+
34
+ ### Where problems live
35
+
36
+ `leet` uses the first of:
37
+
38
+ 1. `$LEET_DIR`, if set (e.g. `export LEET_DIR=~/leetcode/problems`)
39
+ 2. the nearest `problems/` directory in or above the current directory
40
+ 3. `./problems`, created by `leet new`
41
+
42
+ ### Development
43
+
44
+ ```sh
45
+ uv sync && source .venv/bin/activate
46
+ ```
47
+
48
+ ## Workflow
49
+
50
+ ```sh
51
+ leet new 1 two sum # -> problems/0001_two_sum.py
52
+ leet new https://leetcode.com/problems/two-sum/
53
+ leet new 146 lru cache -d # design problem
54
+
55
+ leet e # edit the last-edited problem
56
+ leet # run it
57
+ leet run 1 # run by number or part of the name
58
+ ```
59
+
60
+ ## Solution file
61
+
62
+ ```python
63
+ from leeth import *
64
+
65
+
66
+ class Solution:
67
+ def twoSum(self, nums: list[int], target: int) -> list[int]:
68
+ seen = {}
69
+ for i, n in enumerate(nums):
70
+ if target - n in seen:
71
+ return [seen[target - n], i]
72
+ seen[n] = i
73
+
74
+
75
+ tests = [
76
+ test([2, 7, 11, 15], 9, expected=[0, 1]),
77
+ test(nums=[3, 3], target=6, expected=[1, 0]),
78
+ test([3, 2, 4], 6, expected=[2, 1], any_order=True),
79
+ test([1, 2], 3), # no expected: just print
80
+ ]
81
+
82
+ if __name__ == "__main__":
83
+ run()
84
+ ```
85
+
86
+ ```
87
+ $ leet
88
+ Solution.twoSum (4 cases)
89
+ PASS #1 nums=[2, 7, 11, 15], target=9 0.01 ms
90
+ FAIL #2 nums=[3, 3], target=6 0.00 ms
91
+ expected: [1, 0]
92
+ got: [0, 1]
93
+ PASS #3 nums=[3, 2, 4], target=6 0.00 ms
94
+ OUT #4 nums=[1, 2], target=3 0.00 ms
95
+ got: [0, 1]
96
+
97
+ 2/3 passed (0.02 ms total)
98
+ ```
99
+
100
+ ### More test options
101
+
102
+ ```python
103
+ # several valid answers / mutated input: check gets the result (+ args after the call)
104
+ test([3, 2, 2, 3], 3, check=lambda k, nums, val: sorted(nums[:k]) == [2, 2])
105
+
106
+ # `-> None` methods: the first argument is compared to expected
107
+ test([1, 2, 3], expected=[3, 2, 1])
108
+
109
+ # pick a method other than the first one
110
+ run(method="twoSumSorted")
111
+ ```
112
+
113
+ ### Design problems
114
+
115
+ ```python
116
+ tests = [
117
+ test(["LRUCache", "put", "get"], [[2], [1, 1], [1]], expected=[None, None, 1]),
118
+ ]
119
+
120
+ if __name__ == "__main__":
121
+ run_design()
122
+ ```
leeth-0.1.0/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # leeth
2
+
3
+ local leetcode harness to maximise efficiency
4
+
5
+ - Problem scaffolding
6
+ - Run & edit most recently modified file
7
+ - Custom validation functions
8
+ - Supports LeetCode design questions
9
+ - Paste LeetCode inputs as-is: `[1, 2, 3]` becomes a `ListNode` or `TreeNode` when the type hint asks for one
10
+ - LeetCode's default imports, plus `ic` and `todo()`
11
+ - Colored PASS/FAIL output with diffs and timings
12
+
13
+ ## Install
14
+
15
+ Requires Python 3.14+.
16
+
17
+ ```sh
18
+ uv tool install leeth # or: pipx install leeth
19
+ ```
20
+
21
+ This installs the `leet` command. Solution files `from leeth import *`, so run them
22
+ with `leet run`, or with a Python that has `leeth` installed.
23
+
24
+ ### Where problems live
25
+
26
+ `leet` uses the first of:
27
+
28
+ 1. `$LEET_DIR`, if set (e.g. `export LEET_DIR=~/leetcode/problems`)
29
+ 2. the nearest `problems/` directory in or above the current directory
30
+ 3. `./problems`, created by `leet new`
31
+
32
+ ### Development
33
+
34
+ ```sh
35
+ uv sync && source .venv/bin/activate
36
+ ```
37
+
38
+ ## Workflow
39
+
40
+ ```sh
41
+ leet new 1 two sum # -> problems/0001_two_sum.py
42
+ leet new https://leetcode.com/problems/two-sum/
43
+ leet new 146 lru cache -d # design problem
44
+
45
+ leet e # edit the last-edited problem
46
+ leet # run it
47
+ leet run 1 # run by number or part of the name
48
+ ```
49
+
50
+ ## Solution file
51
+
52
+ ```python
53
+ from leeth import *
54
+
55
+
56
+ class Solution:
57
+ def twoSum(self, nums: list[int], target: int) -> list[int]:
58
+ seen = {}
59
+ for i, n in enumerate(nums):
60
+ if target - n in seen:
61
+ return [seen[target - n], i]
62
+ seen[n] = i
63
+
64
+
65
+ tests = [
66
+ test([2, 7, 11, 15], 9, expected=[0, 1]),
67
+ test(nums=[3, 3], target=6, expected=[1, 0]),
68
+ test([3, 2, 4], 6, expected=[2, 1], any_order=True),
69
+ test([1, 2], 3), # no expected: just print
70
+ ]
71
+
72
+ if __name__ == "__main__":
73
+ run()
74
+ ```
75
+
76
+ ```
77
+ $ leet
78
+ Solution.twoSum (4 cases)
79
+ PASS #1 nums=[2, 7, 11, 15], target=9 0.01 ms
80
+ FAIL #2 nums=[3, 3], target=6 0.00 ms
81
+ expected: [1, 0]
82
+ got: [0, 1]
83
+ PASS #3 nums=[3, 2, 4], target=6 0.00 ms
84
+ OUT #4 nums=[1, 2], target=3 0.00 ms
85
+ got: [0, 1]
86
+
87
+ 2/3 passed (0.02 ms total)
88
+ ```
89
+
90
+ ### More test options
91
+
92
+ ```python
93
+ # several valid answers / mutated input: check gets the result (+ args after the call)
94
+ test([3, 2, 2, 3], 3, check=lambda k, nums, val: sorted(nums[:k]) == [2, 2])
95
+
96
+ # `-> None` methods: the first argument is compared to expected
97
+ test([1, 2, 3], expected=[3, 2, 1])
98
+
99
+ # pick a method other than the first one
100
+ run(method="twoSumSorted")
101
+ ```
102
+
103
+ ### Design problems
104
+
105
+ ```python
106
+ tests = [
107
+ test(["LRUCache", "put", "get"], [[2], [1, 1], [1]], expected=[None, None, 1]),
108
+ ]
109
+
110
+ if __name__ == "__main__":
111
+ run_design()
112
+ ```
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "leeth"
3
+ version = "0.1.0"
4
+ description = "A local LeetCode harness: scaffold, run and test solutions from the terminal"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = ["icecream>=2.2.0"]
8
+
9
+ [[project.authors]]
10
+ name = "Zihan Jin"
11
+ email = "admin@zihanjin.com"
12
+
13
+ [project.scripts]
14
+ leeth = "leeth.cli:main"
15
+
16
+ [dependency-groups]
17
+ dev = ["pytest>=9.1.1"]
18
+
19
+ [build-system]
20
+ requires = ["uv_build>=0.13.0,<0.14"]
21
+ build-backend = "uv_build"
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "leeth"
3
+ version = "0.1.0"
4
+ description = "A local LeetCode harness: scaffold, run and test solutions from the terminal"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ authors = [{ name = "Zihan Jin", email = "admin@zihanjin.com" }]
8
+ dependencies = [
9
+ "icecream>=2.2.0",
10
+ ]
11
+
12
+ [project.scripts]
13
+ leeth = "leeth.cli:main"
14
+
15
+ [dependency-groups]
16
+ dev = [
17
+ "pytest>=9.1.1",
18
+ ]
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.13.0,<0.14"]
22
+ build-backend = "uv_build"
@@ -0,0 +1,89 @@
1
+ """`from leeth import *` gives a solution file the same names LeetCode pre-imports,
2
+ plus the test helpers."""
3
+
4
+ import bisect
5
+ import collections
6
+ import functools
7
+ import heapq
8
+ import itertools
9
+ import math
10
+ import operator
11
+ import random
12
+ import re
13
+ import string
14
+ from bisect import bisect_left, bisect_right, insort
15
+ from collections import Counter, OrderedDict, defaultdict, deque
16
+ from functools import cache, lru_cache, reduce
17
+ from heapq import (
18
+ heapify,
19
+ heappop,
20
+ heappush,
21
+ heappushpop,
22
+ heapreplace,
23
+ nlargest,
24
+ nsmallest,
25
+ )
26
+ from itertools import accumulate, combinations, permutations, product
27
+ from math import inf
28
+
29
+ from icecream import ic
30
+
31
+ from .runner import run, run_design, test
32
+ from .structures import (
33
+ ListNode,
34
+ TreeNode,
35
+ linked_to_list,
36
+ list_to_linked,
37
+ list_to_tree,
38
+ tree_to_list,
39
+ )
40
+ from .todo import todo
41
+
42
+ __all__ = [
43
+ "Counter",
44
+ "ListNode",
45
+ "OrderedDict",
46
+ "TreeNode",
47
+ "accumulate",
48
+ # stdlib modules
49
+ "bisect",
50
+ # stdlib names
51
+ "bisect_left",
52
+ "bisect_right",
53
+ "cache",
54
+ "collections",
55
+ "combinations",
56
+ "defaultdict",
57
+ "deque",
58
+ "functools",
59
+ "heapify",
60
+ "heappop",
61
+ "heappush",
62
+ "heappushpop",
63
+ "heapq",
64
+ "heapreplace",
65
+ "ic",
66
+ "inf",
67
+ "insort",
68
+ "itertools",
69
+ "linked_to_list",
70
+ "list_to_linked",
71
+ "list_to_tree",
72
+ "lru_cache",
73
+ "math",
74
+ "nlargest",
75
+ "nsmallest",
76
+ "operator",
77
+ "permutations",
78
+ "product",
79
+ "random",
80
+ "re",
81
+ "reduce",
82
+ # leeth
83
+ "run",
84
+ "run_design",
85
+ "string",
86
+ "test",
87
+ "todo",
88
+ "tree_to_list",
89
+ ]
@@ -0,0 +1,165 @@
1
+ """`leet new <name>` scaffolds a solution file; `leet run [name]` runs one;
2
+ `leet edit [name]` opens one in $EDITOR. Bare `leet` runs the most recently edited problem."""
3
+
4
+ import argparse
5
+ import os
6
+ import re
7
+ import runpy
8
+ import shlex
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import cast
12
+
13
+
14
+ TEMPLATE = """\
15
+ from leeth import *
16
+
17
+
18
+ class Solution:
19
+ def solve(self, nums: list[int]) -> int:
20
+ todo()
21
+
22
+
23
+ tests = [
24
+ test([1, 2, 3], expected=6),
25
+ ]
26
+
27
+ if __name__ == "__main__":
28
+ run()
29
+ """
30
+
31
+ DESIGN_TEMPLATE = """\
32
+ from leeth import *
33
+
34
+
35
+ class {cls}:
36
+ def __init__(self):
37
+ todo()
38
+
39
+
40
+ tests = [
41
+ test(["{cls}"], [[]], expected=[None]),
42
+ ]
43
+
44
+ if __name__ == "__main__":
45
+ run_design()
46
+ """
47
+
48
+
49
+ def slugify(words: list[str]) -> str:
50
+ """'1 Two Sum' / 'https://leetcode.com/problems/two-sum/' -> '0001_two_sum'"""
51
+ text = " ".join(words).strip()
52
+ if m := re.search(r"leetcode\.(?:com|cn)/problems/([\w-]+)", text):
53
+ text = m.group(1)
54
+ text = text.lstrip("#")
55
+ number = ""
56
+ if m := re.match(r"(\d+)[.\s_-]*", text):
57
+ number, text = m.group(1).zfill(4), text[m.end() :]
58
+ slug = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
59
+ return "_".join(p for p in (number, slug) if p) or "untitled"
60
+
61
+
62
+ def problems_dir() -> Path:
63
+ """$LEET_DIR, else the nearest `problems/` in or above the cwd, else ./problems."""
64
+ if env := os.environ.get("LEET_DIR"):
65
+ return Path(env).expanduser()
66
+ cwd = Path.cwd()
67
+ for d in (cwd, *cwd.parents):
68
+ if (d / "problems").is_dir():
69
+ return d / "problems"
70
+ return cwd / "problems"
71
+
72
+
73
+ def find(target: str | None) -> Path:
74
+ problems = problems_dir()
75
+ if target is None:
76
+ files = sorted(problems.glob("*.py"), key=lambda p: p.stat().st_mtime)
77
+ if not files:
78
+ sys.exit("no problems yet - create one with `leet new <name>`")
79
+ return files[-1]
80
+ if (path := Path(target)).is_file():
81
+ return path
82
+ needle = slugify([target])
83
+ matches = sorted(p for p in problems.glob("*.py") if needle in p.stem)
84
+ if len(matches) == 1:
85
+ return matches[0]
86
+ if not matches:
87
+ sys.exit(f"no problem matching {target!r} in {problems}")
88
+ sys.exit(f"{target!r} is ambiguous:\n " + "\n ".join(p.name for p in matches))
89
+
90
+
91
+ def cmd_new(words: list[str], design: bool) -> None:
92
+ problems = problems_dir()
93
+ problems.mkdir(parents=True, exist_ok=True)
94
+ name = slugify(words)
95
+ path = problems / f"{name}.py"
96
+ if path.exists():
97
+ sys.exit(f"{path} already exists")
98
+ if design:
99
+ cls = (
100
+ "".join(w.capitalize() for w in re.sub(r"^\d+_?", "", name).split("_"))
101
+ or "Design"
102
+ )
103
+ _ = path.write_text(DESIGN_TEMPLATE.format(cls=cls))
104
+ else:
105
+ _ = path.write_text(TEMPLATE)
106
+ print(path)
107
+
108
+
109
+ def cmd_run(target: str | None) -> None:
110
+ path = find(target)
111
+ sys.argv = [str(path)]
112
+ sys.path.insert(0, str(path.parent))
113
+ _ = runpy.run_path(str(path), run_name="__main__")
114
+
115
+
116
+ def cmd_edit(target: str | None) -> None:
117
+ path = find(target)
118
+ editor = shlex.split(os.environ.get("EDITOR") or "vi")
119
+ os.execvp(editor[0], [*editor, str(path)])
120
+
121
+
122
+ def main() -> None:
123
+ parser = argparse.ArgumentParser(
124
+ prog="leet",
125
+ description="With no command, runs the most recently edited problem.",
126
+ )
127
+ sub = parser.add_subparsers(dest="command")
128
+
129
+ new = sub.add_parser("new", help="create a solution file in problems/ (or $LEET_DIR)")
130
+ _ = new.add_argument(
131
+ "name", nargs="+", help="e.g. `1 two sum`, `two-sum`, or a leetcode.com URL"
132
+ )
133
+ _ = new.add_argument(
134
+ "-d",
135
+ "--design",
136
+ action="store_true",
137
+ help="design problem (LRUCache, MinStack, ...)",
138
+ )
139
+
140
+ run = sub.add_parser(
141
+ "run", help="run a solution file (default: most recently edited)"
142
+ )
143
+ _ = run.add_argument(
144
+ "target", nargs="?", help="path, problem number, or part of the name"
145
+ )
146
+
147
+ edit = sub.add_parser(
148
+ "edit",
149
+ aliases=["e"],
150
+ help="open a solution file in $EDITOR (default: most recently edited)",
151
+ )
152
+ _ = edit.add_argument(
153
+ "target", nargs="?", help="path, problem number, or part of the name"
154
+ )
155
+
156
+ args = parser.parse_args()
157
+ command = cast(str | None, args.command)
158
+ if command == "new":
159
+ cmd_new(cast(list[str], args.name), cast(bool, args.design))
160
+ elif command == "run":
161
+ cmd_run(cast(str | None, args.target))
162
+ elif command in ("edit", "e"):
163
+ cmd_edit(cast(str | None, args.target))
164
+ else:
165
+ cmd_run(None)
@@ -0,0 +1,436 @@
1
+ """Run a Solution against test cases and print a readable report."""
2
+
3
+ import annotationlib
4
+ import copy
5
+ import inspect
6
+ import math
7
+ import sys
8
+ import time
9
+ import traceback
10
+ import types
11
+ import typing
12
+ from collections.abc import Callable, Iterable
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Union, cast # pyright: ignore[reportDeprecated]
15
+
16
+ from .structures import (
17
+ ListNode,
18
+ TreeNode,
19
+ linked_to_list,
20
+ list_to_linked,
21
+ list_to_tree,
22
+ tree_to_list,
23
+ )
24
+
25
+ _MISSING = object()
26
+
27
+
28
+ @dataclass
29
+ class Case:
30
+ args: tuple[object, ...]
31
+ kwargs: dict[str, object] = field(default_factory=dict)
32
+ expected: object = _MISSING
33
+ any_order: bool = False
34
+ check: Callable[..., bool] | None = None
35
+
36
+
37
+ def test(
38
+ *args: object,
39
+ expected: object = _MISSING,
40
+ any_order: bool = False,
41
+ check: Callable[..., bool] | None = None,
42
+ **kwargs: object,
43
+ ) -> Case:
44
+ """One test case. Positional/keyword args are passed straight to the method.
45
+
46
+ expected -- the answer, in LeetCode's plain form (lists for linked lists/trees).
47
+ Omit it to just print what the solution returns.
48
+ any_order -- treat lists (at every depth) as unordered when comparing.
49
+ check -- custom validator for problems with several valid answers or that
50
+ mutate their input. Either `check(got)` or
51
+ `check(got, *args, **kwargs)`, where args are the method's arguments
52
+ *after* the call (so in-place changes are visible).
53
+ """
54
+ return Case(
55
+ args=args, kwargs=kwargs, expected=expected, any_order=any_order, check=check
56
+ )
57
+
58
+
59
+ # Keep pytest from collecting the helper when it's imported into a test module.
60
+ test.__test__ = False # pyright: ignore[reportFunctionMemberAccess]
61
+
62
+
63
+ # --- output -----------------------------------------------------------------
64
+
65
+ _COLOR = sys.stdout.isatty()
66
+
67
+ # repr() truncation limits: values in result lines, arguments in a case's label,
68
+ # and an operation's arguments in a design-problem mismatch.
69
+ _REPR_LIMIT = 300
70
+ _ARG_LIMIT = 120
71
+ _OP_ARG_LIMIT = 80
72
+
73
+ _STATUS_COLOR = {"PASS": "1;32", "FAIL": "1;31", "ERR ": "1;31", "OUT ": "1;33"}
74
+
75
+
76
+ def _c(code: str, text: str) -> str:
77
+ return f"\033[{code}m{text}\033[0m" if _COLOR else text
78
+
79
+
80
+ def _short(value: object, limit: int = _REPR_LIMIT) -> str:
81
+ s = repr(value)
82
+ return s if len(s) <= limit else s[: limit - 3] + "..."
83
+
84
+
85
+ def _cases(n: int) -> str:
86
+ return f"{n} case" + ("" if n == 1 else "s")
87
+
88
+
89
+ def _format_error(e: Exception) -> str:
90
+ # Skip the first frame: it's the runner's own call into the solution.
91
+ tb = e.__traceback__.tb_next if e.__traceback__ else None
92
+ text = "".join(traceback.format_exception(type(e), e, tb)).strip()
93
+ return _c("31", " " + text.replace("\n", "\n "))
94
+
95
+
96
+ @dataclass
97
+ class _Outcome:
98
+ label: str # what's shown after "#i"
99
+ ok: bool | None # None: no expectation, just print the result (OUT)
100
+ elapsed: float = 0.0
101
+ error: Exception | None = None
102
+ details: list[str] = field(default_factory=list) # lines under the status line
103
+
104
+
105
+ def _status(outcome: _Outcome) -> str:
106
+ if outcome.error is not None:
107
+ return "ERR "
108
+ if outcome.ok is None:
109
+ return "OUT "
110
+ return "PASS" if outcome.ok else "FAIL"
111
+
112
+
113
+ def _run_cases(title: str, count: int, outcomes: Iterable[_Outcome]) -> bool:
114
+ """Print a header, one block per outcome and a summary. True if nothing failed."""
115
+ print(_c("1", title) + _c("2", f" ({_cases(count)})"))
116
+ results: list[bool] = []
117
+ total_time = 0.0
118
+ for i, outcome in enumerate(outcomes, 1):
119
+ status = _status(outcome)
120
+ line = f"{_c(_STATUS_COLOR[status], status)} #{i} {outcome.label}"
121
+ if outcome.error is not None:
122
+ print(line)
123
+ print(_format_error(outcome.error))
124
+ else:
125
+ print(line + _c("2", f" {outcome.elapsed * 1000:.2f} ms"))
126
+ for detail in outcome.details:
127
+ print(f" {detail}")
128
+ total_time += outcome.elapsed
129
+ if outcome.ok is not None:
130
+ results.append(outcome.ok)
131
+
132
+ passed, total = sum(results), len(results)
133
+ color = "32" if passed == total else "31"
134
+ print(
135
+ _c(f"1;{color}", f"\n{passed}/{total} passed")
136
+ + _c("2", f" ({total_time * 1000:.2f} ms total)")
137
+ )
138
+ return passed == total
139
+
140
+
141
+ # --- conversion between LeetCode plain values and node structures ---------
142
+
143
+
144
+ def _to_input(value: object, hint: object) -> object:
145
+ origin = typing.get_origin(hint)
146
+ hint_args = cast(tuple[object, ...], typing.get_args(hint))
147
+ # typing.Union covers Optional[X] / Union[X, None]; UnionType covers X | None.
148
+ if origin in (Union, types.UnionType): # pyright: ignore[reportDeprecated]
149
+ if value is None:
150
+ return None
151
+ hint = next((h for h in hint_args if h is not type(None)), None)
152
+ return _to_input(value, hint)
153
+ if not isinstance(value, list):
154
+ return value
155
+ items = cast(list[object], value)
156
+ if hint is ListNode:
157
+ return list_to_linked(cast(list[int], items))
158
+ if hint is TreeNode:
159
+ return list_to_tree(cast(list[int | None], items))
160
+ if origin is list and hint_args:
161
+ return [_to_input(v, hint_args[0]) for v in items]
162
+ return items
163
+
164
+
165
+ def _is_node_hint(hint: object) -> bool:
166
+ """Whether `hint` is ListNode/TreeNode, or a union/generic that mentions one."""
167
+ return bool({ListNode, TreeNode} & {hint, *typing.get_args(hint)})
168
+
169
+
170
+ def _to_plain(value: object, hint: object = None) -> object:
171
+ # A None ListNode/TreeNode result is an empty list in LeetCode's notation.
172
+ if value is None and hint is not None and _is_node_hint(hint):
173
+ return list[object]()
174
+ if isinstance(value, ListNode):
175
+ return linked_to_list(value)
176
+ if isinstance(value, TreeNode):
177
+ return tree_to_list(value)
178
+ if isinstance(value, (list, tuple)):
179
+ return [_to_plain(v) for v in cast(list[object], value)]
180
+ return value
181
+
182
+
183
+ # --- comparison ---------------------------------------------------------------
184
+
185
+
186
+ def _sort_key(value: object) -> tuple[int, object]:
187
+ # Numbers sort by value rather than repr, so floats that _equal treats as
188
+ # equal land in the same position on both sides.
189
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
190
+ return (0, float(value))
191
+ if isinstance(value, list):
192
+ return (1, tuple(_sort_key(v) for v in cast(list[object], value)))
193
+ return (2, repr(value))
194
+
195
+
196
+ def _unordered(value: object) -> object:
197
+ if isinstance(value, list):
198
+ items = [_unordered(v) for v in cast(list[object], value)]
199
+ return sorted(items, key=_sort_key)
200
+ return value
201
+
202
+
203
+ def _equal(a: object, b: object) -> bool:
204
+ if isinstance(a, float) or isinstance(b, float):
205
+ if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
206
+ return False
207
+ return math.isclose(a, b, rel_tol=1e-5, abs_tol=1e-5)
208
+ if isinstance(a, list) and isinstance(b, list):
209
+ xs, ys = cast(list[object], a), cast(list[object], b)
210
+ return len(xs) == len(ys) and all(_equal(x, y) for x, y in zip(xs, ys))
211
+ return a == b
212
+
213
+
214
+ # --- run ----------------------------------------------------------------------
215
+
216
+
217
+ def _wants_args(check: Callable[..., bool]) -> bool:
218
+ """Whether `check` takes the method's arguments as well as its return value."""
219
+ try:
220
+ params = inspect.signature(check).parameters.values()
221
+ except TypeError, ValueError:
222
+ return True
223
+ positional = (
224
+ inspect.Parameter.POSITIONAL_ONLY,
225
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
226
+ )
227
+ # Parameters with defaults are options of the check, not slots for arguments.
228
+ required = [p for p in params if p.kind in positional and p.default is p.empty] # pyright: ignore[reportAny]
229
+ return any(p.kind is inspect.Parameter.VAR_POSITIONAL for p in params) or (
230
+ len(required) > 1
231
+ )
232
+
233
+
234
+ def _pick_method(cls: type, name: str | None) -> str:
235
+ if name:
236
+ return name
237
+ methods = [n for n, v in vars(cls).items() if callable(v) and not n.startswith("_")] # pyright: ignore[reportAny]
238
+ if not methods:
239
+ raise ValueError(f"{cls.__name__} has no public methods")
240
+ return methods[0]
241
+
242
+
243
+ def _type_hints(func: Callable[..., object]) -> dict[str, object]:
244
+ try:
245
+ return typing.get_type_hints(func)
246
+ except Exception as e: # noqa: BLE001
247
+ print(
248
+ _c(
249
+ "2",
250
+ f"(couldn't resolve type hints, so no ListNode/TreeNode conversion: {e!r})",
251
+ )
252
+ )
253
+ return {}
254
+
255
+
256
+ def _call_args(
257
+ case: Case, params: list[str], hints: dict[str, object]
258
+ ) -> tuple[list[object], dict[str, object]]:
259
+ # Copy so in-place solutions can't mutate the test case itself.
260
+ case_args, case_kwargs = copy.deepcopy((case.args, case.kwargs))
261
+ args = [_to_input(a, hints.get(p)) for p, a in zip(params, case_args)]
262
+ args += case_args[len(params) :]
263
+ kwargs = {k: _to_input(v, hints.get(k)) for k, v in case_kwargs.items()}
264
+ return args, kwargs
265
+
266
+
267
+ def _verdict(
268
+ case: Case, got: object, args: list[object], kwargs: dict[str, object]
269
+ ) -> bool | None:
270
+ """Whether `got` is right, or None when the case has nothing to compare against."""
271
+ if case.check is not None:
272
+ if not _wants_args(case.check):
273
+ return bool(case.check(got))
274
+ after = [_to_plain(a) for a in args]
275
+ after_kw = {k: _to_plain(v) for k, v in kwargs.items()}
276
+ return bool(case.check(got, *after, **after_kw))
277
+ if case.expected is _MISSING:
278
+ return None
279
+ expected = _to_plain(case.expected)
280
+ if case.any_order:
281
+ return _equal(_unordered(got), _unordered(expected))
282
+ return _equal(got, expected)
283
+
284
+
285
+ def _failure_details(
286
+ case: Case, got: object, params: list[str], args: list[object], in_place: bool
287
+ ) -> list[str]:
288
+ details: list[str] = []
289
+ if case.check is None:
290
+ details.append(f"expected: {_short(_to_plain(case.expected))}")
291
+ details.append(f"got: {_short(got)}")
292
+ # Show arguments the solution mutated, since a check may depend on them.
293
+ if not in_place:
294
+ for p, before, after in zip(params, case.args, args):
295
+ if not _equal(_to_plain(after), before):
296
+ details.append(f"{p} after: {_short(_to_plain(after))}")
297
+ return details
298
+
299
+
300
+ def _run_case(
301
+ solution: type,
302
+ name: str,
303
+ case: Case,
304
+ params: list[str],
305
+ hints: dict[str, object],
306
+ ) -> _Outcome:
307
+ label = ", ".join(
308
+ [f"{p}={_short(a, _ARG_LIMIT)}" for p, a in zip(params, case.args)]
309
+ + [f"{k}={_short(v, _ARG_LIMIT)}" for k, v in case.kwargs.items()]
310
+ )
311
+ args, kwargs = _call_args(case, params, hints)
312
+ try:
313
+ start = time.perf_counter()
314
+ instance = cast(object, solution())
315
+ method = cast(Callable[..., object], getattr(instance, name))
316
+ got = method(*args, **kwargs)
317
+ elapsed = time.perf_counter() - start
318
+ except Exception as e: # noqa: BLE001
319
+ return _Outcome(label, ok=False, error=e)
320
+
321
+ # Methods annotated `-> None` work in place: their result is the first argument.
322
+ in_place = "return" in hints and hints["return"] is type(None)
323
+ if in_place:
324
+ got = args[0] if args else next(iter(kwargs.values()), None)
325
+ got = _to_plain(got, hints.get("return"))
326
+
327
+ ok = _verdict(case, got, args, kwargs)
328
+ if ok is None:
329
+ details = [f"got: {_short(got)}"]
330
+ elif ok:
331
+ details = []
332
+ else:
333
+ details = _failure_details(case, got, params, args, in_place)
334
+ return _Outcome(label, ok, elapsed, details=details)
335
+
336
+
337
+ def run(
338
+ solution: type | None = None,
339
+ tests: list[Case] | None = None,
340
+ *,
341
+ method: str | None = None,
342
+ ) -> None:
343
+ """Run `tests` against `solution`. Both default to `Solution` / `tests` in the calling file.
344
+
345
+ The method under test is the first public method on the class unless `method` is given.
346
+ Arguments are converted to ListNode/TreeNode based on the method's type hints, and
347
+ ListNode/TreeNode results are converted back to lists before comparing.
348
+ Methods annotated `-> None` are treated as in-place: the first argument is checked.
349
+ """
350
+ caller: dict[str, Any] = sys._getframe(1).f_globals # pyright: ignore[reportPrivateUsage, reportExplicitAny]
351
+ solution = solution or cast(type, caller["Solution"])
352
+ tests = tests if tests is not None else cast(list[Case], caller.get("tests", []))
353
+ name = _pick_method(solution, method)
354
+
355
+ unbound = cast(Callable[..., object], getattr(solution, name))
356
+ hints = _type_hints(unbound)
357
+ # FORWARDREF: only parameter names are needed, so don't let a bad hint raise.
358
+ signature = inspect.signature(
359
+ unbound, annotation_format=annotationlib.Format.FORWARDREF
360
+ )
361
+ params = list(signature.parameters)[1:] # drop self
362
+
363
+ outcomes = (_run_case(solution, name, case, params, hints) for case in tests)
364
+ if not _run_cases(f"{solution.__name__}.{name}", len(tests), outcomes):
365
+ sys.exit(1)
366
+
367
+
368
+ # --- run_design -----------------------------------------------------------------
369
+
370
+
371
+ def _run_design_case(
372
+ cls: type | None, caller: dict[str, object], case: Case
373
+ ) -> _Outcome:
374
+ try:
375
+ ops, op_args = cast(tuple[list[str], list[list[object]]], case.args)
376
+ target = cls or cast(type, caller[ops[0]])
377
+ except (ValueError, IndexError, KeyError) as e:
378
+ e.add_note("design cases look like test([ops...], [args...], expected=[...])")
379
+ return _Outcome("malformed case", ok=False, error=e)
380
+ label = f"{len(ops)} ops"
381
+
382
+ raw: list[object] = []
383
+ try:
384
+ start = time.perf_counter()
385
+ obj = cast(object, target(*op_args[0]))
386
+ raw.append(None)
387
+ for op, a in zip(ops[1:], op_args[1:]):
388
+ raw.append(cast(Callable[..., object], getattr(obj, op))(*a))
389
+ elapsed = time.perf_counter() - start
390
+ except Exception as e: # noqa: BLE001
391
+ got_so_far = [_to_plain(r) for r in raw]
392
+ label = f"after {len(raw)} ops, got so far: {_short(got_so_far)}"
393
+ return _Outcome(label, ok=False, error=e)
394
+ got = [_to_plain(r) for r in raw]
395
+
396
+ if case.expected is _MISSING:
397
+ return _Outcome(label, None, elapsed, details=[f"got: {_short(got)}"])
398
+ expected = cast(list[object], case.expected)
399
+ if _equal(got, expected):
400
+ return _Outcome(label, True, elapsed)
401
+
402
+ for j, (op, a, e, r) in enumerate(zip(ops, op_args, expected, got)):
403
+ if not _equal(e, r):
404
+ details = [
405
+ f"first mismatch at op #{j}: {op}({_short(a, _OP_ARG_LIMIT)[1:-1]})",
406
+ f"expected: {_short(e)}",
407
+ f"got: {_short(r)}",
408
+ ]
409
+ break
410
+ else:
411
+ details = [f"expected {len(expected)} results, got {len(got)}"]
412
+ return _Outcome(label, False, elapsed, details=details)
413
+
414
+
415
+ def run_design(cls: type | None = None, tests: list[Case] | None = None) -> None:
416
+ """Run LeetCode "design" problems (LRUCache, MinStack, ...).
417
+
418
+ Each case uses LeetCode's format:
419
+ test(["LRUCache", "put", "get"], [[2], [1, 1], [1]], expected=[None, None, 1])
420
+ `cls` defaults to the class named by the first operation in the calling file.
421
+ """
422
+ caller: dict[str, Any] = sys._getframe(1).f_globals # pyright: ignore[reportPrivateUsage, reportExplicitAny]
423
+ tests = tests if tests is not None else cast(list[Case], caller.get("tests", []))
424
+ for case in tests:
425
+ if case.check is not None or case.any_order:
426
+ raise ValueError("run_design doesn't support check= or any_order=")
427
+
428
+ if cls is not None:
429
+ title = cls.__name__
430
+ else:
431
+ # The class named by the first case's constructor op, if it has one.
432
+ first = cast(tuple[list[object], ...], tests[0].args) if tests else ()
433
+ title = str(first[0][0]) if first and first[0] else "design"
434
+ outcomes = (_run_design_case(cls, caller, case) for case in tests)
435
+ if not _run_cases(title, len(tests), outcomes):
436
+ sys.exit(1)
@@ -0,0 +1,85 @@
1
+ """LeetCode's built-in data structures plus helpers to convert to/from plain lists."""
2
+
3
+ from collections import deque
4
+ from typing import override
5
+
6
+
7
+ class ListNode:
8
+ def __init__(self, val: int = 0, next: ListNode | None = None):
9
+ self.val: int = val
10
+ self.next: ListNode | None = next
11
+
12
+ @override
13
+ def __repr__(self) -> str:
14
+ return f"ListNode({linked_to_list(self)})"
15
+
16
+
17
+ class TreeNode:
18
+ def __init__(
19
+ self,
20
+ val: int = 0,
21
+ left: TreeNode | None = None,
22
+ right: TreeNode | None = None,
23
+ ):
24
+ self.val: int = val
25
+ self.left: TreeNode | None = left
26
+ self.right: TreeNode | None = right
27
+
28
+ @override
29
+ def __repr__(self) -> str:
30
+ return f"TreeNode({tree_to_list(self)})"
31
+
32
+
33
+ def list_to_linked(values: list[int]) -> ListNode | None:
34
+ """[1, 2, 3] -> 1 -> 2 -> 3"""
35
+ dummy = tail = ListNode()
36
+ for v in values:
37
+ tail.next = ListNode(v)
38
+ tail = tail.next
39
+ return dummy.next
40
+
41
+
42
+ def linked_to_list(head: ListNode | None, limit: int = 10_000) -> list[int | str]:
43
+ """1 -> 2 -> 3 -> [1, 2, 3]. Stops after `limit` nodes to survive cycles."""
44
+ out: list[int | str] = []
45
+ while head is not None and len(out) < limit:
46
+ out.append(head.val)
47
+ head = head.next
48
+ if head is not None:
49
+ out.append("...")
50
+ return out
51
+
52
+
53
+ def list_to_tree(values: list[int | None]) -> TreeNode | None:
54
+ """LeetCode level-order form, e.g. [3, 9, 20, None, None, 15, 7]."""
55
+ if not values or values[0] is None:
56
+ return None
57
+ it = iter(values[1:])
58
+ root = TreeNode(values[0])
59
+ queue = deque([root])
60
+ while queue:
61
+ node = queue.popleft()
62
+ if (v := next(it, None)) is not None:
63
+ node.left = TreeNode(v)
64
+ queue.append(node.left)
65
+ if (v := next(it, None)) is not None:
66
+ node.right = TreeNode(v)
67
+ queue.append(node.right)
68
+ return root
69
+
70
+
71
+ def tree_to_list(root: TreeNode | None) -> list[int | None]:
72
+ """Inverse of list_to_tree, with trailing Nones trimmed."""
73
+ out: list[int | None] = []
74
+ queue: deque[TreeNode | None] = deque([root])
75
+ while queue:
76
+ node = queue.popleft()
77
+ if node is None:
78
+ out.append(None)
79
+ continue
80
+ out.append(node.val)
81
+ queue.append(node.left)
82
+ queue.append(node.right)
83
+ while out and out[-1] is None:
84
+ _ = out.pop()
85
+ return out
@@ -0,0 +1,8 @@
1
+ from typing import NoReturn
2
+
3
+
4
+ def todo(msg: str = "", *args: object, **kwargs: object) -> NoReturn:
5
+ """Mark unfinished code, like Rust's todo!()."""
6
+ if msg:
7
+ raise NotImplementedError(f"not yet implemented: {msg.format(*args, **kwargs)}")
8
+ raise NotImplementedError("not yet implemented")