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 +122 -0
- leeth-0.1.0/README.md +112 -0
- leeth-0.1.0/pyproject.toml +21 -0
- leeth-0.1.0/pyproject.toml.orig +22 -0
- leeth-0.1.0/src/leeth/__init__.py +89 -0
- leeth-0.1.0/src/leeth/cli.py +165 -0
- leeth-0.1.0/src/leeth/runner.py +436 -0
- leeth-0.1.0/src/leeth/structures.py +85 -0
- leeth-0.1.0/src/leeth/todo.py +8 -0
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")
|