Skip to content

Commit 201a231

Browse files
committed
feat: complete phase 5 List Commands
1 parent 9516df0 commit 201a231

File tree

11 files changed

+402
-82
lines changed

11 files changed

+402
-82
lines changed

.amazonq/plans/cli-implementation.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -431,11 +431,14 @@ json5 = "^0.9.0" # For parsing tags.json5 with comments
431431
5. ✅ Comprehensive testing with 8 test cases covering all scenarios
432432
6. ✅ Proper error handling for invalid inputs and network failures
433433

434-
### Phase 5: List Commands
435-
436-
1. Implement `lcpy list` basic functionality
437-
2. Add filtering: `lcpy list -t grind-75` and `lcpy list -d easy`
438-
3. Format output for readability (table format with number, title, difficulty, tags)
434+
### Phase 5: List Commands ✅ COMPLETED
435+
436+
1. ✅ Implement `lcpy list` basic functionality
437+
2. ✅ Add filtering: `lcpy list -t grind-75` and `lcpy list -d easy`
438+
3. ✅ Format output for readability (table format with number, title, difficulty, tags)
439+
4. ✅ Rich table formatting with colors and proper alignment
440+
5. ✅ Comprehensive testing with 6 test cases covering all scenarios
441+
6. ✅ Error handling for invalid tags and empty results
439442

440443
### Phase 6: Testing & Documentation
441444

leetcode_py/cli/commands/gen.py

Lines changed: 92 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,132 @@
1+
import json
12
from pathlib import Path
23

34
import typer
45

56
from leetcode_py.tools.generator import generate_problem
67

7-
from ..utils.problem_finder import find_problem_by_number, find_problems_by_tag, get_problem_json_path
8+
from ..utils.problem_finder import (
9+
find_problem_by_number,
10+
find_problems_by_tag,
11+
get_all_problems,
12+
get_problem_json_path,
13+
)
814
from ..utils.resources import get_template_path
915

10-
ERROR_EXACTLY_ONE_OPTION = (
11-
"Error: Exactly one of --problem-num, --problem-slug, or --problem-tag is required"
12-
)
16+
17+
def _get_problem_difficulty(problem_name: str) -> str | None:
18+
json_path = get_problem_json_path(problem_name)
19+
if not json_path.exists():
20+
return None
21+
22+
try:
23+
with open(json_path) as f:
24+
data = json.load(f)
25+
return data.get("difficulty")
26+
except Exception:
27+
return None
1328

1429

1530
def resolve_problems(
16-
problem_num: int | None, problem_slug: str | None, problem_tag: str | None
31+
problem_nums: list[int],
32+
problem_slugs: list[str],
33+
problem_tag: str | None,
34+
difficulty: str | None,
35+
all_problems: bool,
1736
) -> list[str]:
18-
if problem_num is not None:
19-
problem_name = find_problem_by_number(problem_num)
20-
if not problem_name:
21-
typer.echo(f"Error: Problem number {problem_num} not found", err=True)
22-
raise typer.Exit(1)
23-
return [problem_name]
24-
elif problem_slug is not None:
25-
return [problem_slug]
26-
elif problem_tag is not None:
37+
options_count = sum(
38+
[
39+
len(problem_nums) > 0,
40+
len(problem_slugs) > 0,
41+
problem_tag is not None,
42+
all_problems,
43+
]
44+
)
45+
46+
if options_count != 1:
47+
typer.echo(
48+
"Error: Exactly one of --problem-num, --problem-slug, --problem-tag, or --all is required",
49+
err=True,
50+
)
51+
raise typer.Exit(1)
52+
53+
problems = []
54+
55+
if problem_nums:
56+
for num in problem_nums:
57+
problem_name = find_problem_by_number(num)
58+
if not problem_name:
59+
typer.echo(f"Error: Problem number {num} not found", err=True)
60+
raise typer.Exit(1)
61+
problems.append(problem_name)
62+
elif problem_slugs:
63+
problems = problem_slugs
64+
elif problem_tag:
2765
problems = find_problems_by_tag(problem_tag)
2866
if not problems:
2967
typer.echo(f"Error: No problems found with tag '{problem_tag}'", err=True)
3068
raise typer.Exit(1)
3169
typer.echo(f"Found {len(problems)} problems with tag '{problem_tag}'")
32-
return problems
70+
elif all_problems:
71+
problems = get_all_problems()
72+
typer.echo(f"Found {len(problems)} problems")
73+
74+
# Apply difficulty filter if specified
75+
if difficulty:
76+
filtered_problems = []
77+
for problem_name in problems:
78+
problem_difficulty = _get_problem_difficulty(problem_name)
79+
if problem_difficulty and problem_difficulty.lower() == difficulty.lower():
80+
filtered_problems.append(problem_name)
81+
problems = filtered_problems
82+
typer.echo(f"Filtered to {len(problems)} problems with difficulty '{difficulty}'")
3383

34-
typer.echo(ERROR_EXACTLY_ONE_OPTION, err=True)
35-
raise typer.Exit(1)
84+
return problems
3685

3786

3887
def generate(
39-
problem_num: int | None = typer.Option(None, "-n", "--problem-num", help="Problem number"),
40-
problem_slug: str | None = typer.Option(None, "-s", "--problem-slug", help="Problem slug"),
88+
problem_nums: list[int] = typer.Option(
89+
[], "-n", "--problem-num", help="Problem number(s) (use multiple -n flags)"
90+
),
91+
problem_slugs: list[str] = typer.Option(
92+
[], "-s", "--problem-slug", help="Problem slug(s) (use multiple -s flags)"
93+
),
4194
problem_tag: str | None = typer.Option(None, "-t", "--problem-tag", help="Problem tag (bulk)"),
42-
output: str = typer.Option("leetcode", "-o", "--output", help="Output directory"),
95+
difficulty: str | None = typer.Option(
96+
None, "-d", "--difficulty", help="Filter by difficulty (Easy/Medium/Hard)"
97+
),
98+
all_problems: bool = typer.Option(False, "--all", help="Generate all problems"),
99+
output: str = typer.Option(".", "-o", "--output", help="Output directory"),
43100
force: bool = typer.Option(False, "--force", help="Force overwrite existing files"),
44101
):
45-
options_provided = sum(x is not None for x in [problem_num, problem_slug, problem_tag])
46-
if options_provided != 1:
47-
typer.echo(ERROR_EXACTLY_ONE_OPTION, err=True)
48-
raise typer.Exit(1)
49-
50102
template_dir = get_template_path()
51103
output_dir = Path(output)
52104

53105
# Determine which problems to generate
54-
problems = resolve_problems(problem_num, problem_slug, problem_tag)
106+
problems = resolve_problems(problem_nums, problem_slugs, problem_tag, difficulty, all_problems)
55107

56108
# Generate each problem
109+
success_count = 0
110+
failed_count = 0
111+
57112
for problem_name in problems:
58113
json_path = get_problem_json_path(problem_name)
59114
if not json_path.exists():
60115
typer.echo(f"Warning: JSON file not found for problem '{problem_name}', skipping", err=True)
116+
failed_count += 1
61117
continue
62118

63119
try:
64120
generate_problem(json_path, template_dir, output_dir, force)
121+
success_count += 1
122+
except typer.Exit:
123+
# typer.Exit was already handled with proper error message
124+
failed_count += 1
65125
except Exception as e:
66126
typer.echo(f"Error generating problem '{problem_name}': {e}", err=True)
67-
if len(problems) == 1:
68-
raise typer.Exit(1)
127+
failed_count += 1
128+
129+
typer.echo(f"Completed: {success_count} successful, {failed_count} failed")
130+
131+
if failed_count > 0:
132+
raise typer.Exit(1)

leetcode_py/cli/commands/list.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""List command for displaying available LeetCode problems."""
2+
3+
import typer
4+
from rich.console import Console
5+
from rich.table import Table
6+
7+
from ..utils.problem_finder import find_problems_by_tag, get_all_problems, get_tags_for_problem
8+
9+
console = Console()
10+
11+
12+
def list_problems(
13+
tag: str | None = typer.Option(None, "-t", "--tag", help="Filter by tag (e.g., 'grind-75')"),
14+
difficulty: str | None = typer.Option(
15+
None, "-d", "--difficulty", help="Filter by difficulty (Easy/Medium/Hard)"
16+
),
17+
) -> None:
18+
19+
# Get problems based on filters
20+
if tag:
21+
problems = find_problems_by_tag(tag)
22+
if not problems:
23+
typer.echo(f"Error: No problems found with tag '{tag}'", err=True)
24+
raise typer.Exit(1)
25+
else:
26+
problems = get_all_problems()
27+
28+
if not problems:
29+
typer.echo("No problems found", err=True)
30+
raise typer.Exit(1)
31+
32+
# Create table
33+
table = Table(title="LeetCode Problems")
34+
table.add_column("Number", style="cyan", no_wrap=True)
35+
table.add_column("Title", style="magenta")
36+
table.add_column("Difficulty", style="green")
37+
table.add_column("Tags", style="blue")
38+
39+
# Get problem data and sort by number
40+
problem_list = []
41+
for problem_name in problems:
42+
try:
43+
problem_data = _get_problem_data(problem_name)
44+
45+
# Apply difficulty filter
46+
if difficulty and problem_data.get("difficulty", "").lower() != difficulty.lower():
47+
continue
48+
49+
problem_list.append((problem_data, problem_name))
50+
except Exception:
51+
# Skip problems that can't be loaded
52+
continue
53+
54+
# Sort by problem number (convert to int for proper numerical sorting)
55+
problem_list.sort(
56+
key=lambda x: int(x[0].get("number", "999999")) if x[0].get("number", "?").isdigit() else 999999
57+
)
58+
59+
# Update table title with count
60+
table.title = f"LeetCode Problems ({len(problem_list)} problems)"
61+
62+
# Add sorted problems to table
63+
for problem_data, problem_name in problem_list:
64+
table.add_row(
65+
problem_data.get("number", "?"),
66+
problem_data.get("title", problem_name),
67+
problem_data.get("difficulty", "Unknown"),
68+
", ".join(problem_data.get("tags", [])),
69+
)
70+
71+
console.print(table)
72+
73+
74+
def _get_problem_data(problem_name: str) -> dict:
75+
import json
76+
77+
from ..utils.problem_finder import get_problem_json_path
78+
79+
json_path = get_problem_json_path(problem_name)
80+
if not json_path.exists():
81+
return {"title": problem_name, "tags": get_tags_for_problem(problem_name)}
82+
83+
try:
84+
with open(json_path) as f:
85+
data = json.load(f)
86+
87+
return {
88+
"number": data.get("problem_number", "?"),
89+
"title": data.get("problem_title", problem_name),
90+
"difficulty": data.get("difficulty", "Unknown"),
91+
"tags": get_tags_for_problem(problem_name),
92+
}
93+
except Exception:
94+
return {"title": problem_name, "tags": get_tags_for_problem(problem_name)}

leetcode_py/cli/main.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import typer
44

55
from .commands.gen import generate
6+
from .commands.list import list_problems
67
from .commands.scrape import scrape
78

89
app = typer.Typer(help="LeetCode problem generator - Generate and list LeetCode problems")
@@ -28,11 +29,7 @@ def main_callback(
2829

2930
app.command(name="gen")(generate)
3031
app.command(name="scrape")(scrape)
31-
32-
33-
@app.command()
34-
def list():
35-
typer.echo("list command - coming soon!")
32+
app.command(name="list")(list_problems)
3633

3734

3835
def main():

leetcode_py/cli/utils/problem_finder.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import json
2+
from functools import lru_cache
23
from pathlib import Path
3-
from typing import List
44

55
import json5
66

77
from .resources import get_problems_json_path, get_tags_path
88

99

10-
def find_problems_by_tag(tag: str) -> List[str]:
10+
def find_problems_by_tag(tag: str) -> list[str]:
1111
tags_file = get_tags_path()
1212

1313
try:
@@ -36,3 +36,35 @@ def find_problem_by_number(number: int) -> str | None:
3636
continue
3737

3838
return None
39+
40+
41+
def get_all_problems() -> list[str]:
42+
json_path = get_problems_json_path()
43+
return [json_file.stem for json_file in json_path.glob("*.json")]
44+
45+
46+
@lru_cache(maxsize=1)
47+
def _build_problem_tags_cache() -> dict[str, list[str]]:
48+
tags_file = get_tags_path()
49+
problem_tags_map: dict[str, list[str]] = {}
50+
51+
try:
52+
with open(tags_file) as f:
53+
tags_data = json5.load(f)
54+
55+
# Build reverse mapping: problem -> list of tags
56+
for tag_name, problems in tags_data.items():
57+
if isinstance(problems, list):
58+
for problem_name in problems:
59+
if problem_name not in problem_tags_map:
60+
problem_tags_map[problem_name] = []
61+
problem_tags_map[problem_name].append(tag_name)
62+
63+
return problem_tags_map
64+
except (ValueError, OSError, KeyError):
65+
return {}
66+
67+
68+
def get_tags_for_problem(problem_name: str) -> list[str]:
69+
cache = _build_problem_tags_cache()
70+
return cache.get(problem_name, [])

leetcode_py/tools/parser.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""HTML parsing utilities for LeetCode problem content."""
22

33
import re
4-
from typing import Any, Dict, List
4+
from typing import Any
55

66

77
class HTMLParser:
@@ -13,7 +13,7 @@ def clean_html(text: str) -> str:
1313
return re.sub(r"<[^>]+>", "", text).strip()
1414

1515
@staticmethod
16-
def parse_content(html_content: str) -> Dict[str, Any]:
16+
def parse_content(html_content: str) -> dict[str, Any]:
1717
"""Parse HTML content to extract description, examples, and constraints."""
1818
# Extract description (everything before first example)
1919
desc_match = re.search(
@@ -43,7 +43,7 @@ def parse_content(html_content: str) -> Dict[str, Any]:
4343
return {"description": description, "examples": examples, "constraints": constraints}
4444

4545
@staticmethod
46-
def parse_test_cases(test_cases_str: str) -> List[List[str]]:
46+
def parse_test_cases(test_cases_str: str) -> list[list[str]]:
4747
"""Parse test cases from the exampleTestcases string."""
4848
if not test_cases_str:
4949
return []

poetry.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ cookiecutter = "^2.6.0"
3232
graphviz = "^0.21"
3333
json5 = "^0.12.1"
3434
requests = "^2.32.5"
35+
rich = "^14.1.0"
3536
typer = "^0.17.0"
3637

3738
[tool.poetry.group.base.dependencies]

0 commit comments

Comments
 (0)