diff --git a/.amazonq/plan/compare_template_files.py b/.amazonq/plans/compare_template_files.py similarity index 100% rename from .amazonq/plan/compare_template_files.py rename to .amazonq/plans/compare_template_files.py diff --git a/.amazonq/plan/cookiecutter-template-plan.md b/.amazonq/plans/cookiecutter-template-plan.md similarity index 100% rename from .amazonq/plan/cookiecutter-template-plan.md rename to .amazonq/plans/cookiecutter-template-plan.md diff --git a/.amazonq/rules/problem-creation.md b/.amazonq/rules/problem-creation.md index 42e3793..9407ef3 100644 --- a/.amazonq/rules/problem-creation.md +++ b/.amazonq/rules/problem-creation.md @@ -1,31 +1,108 @@ # Problem Creation Guide -## Quick Steps +## Assistant Workflow -1. Create JSON: `.templates/leetcode/json/{problem_name}.json` -2. Update Makefile: `PROBLEM ?= your_new_problem` -3. Generate: `make p-gen` -4. Verify: `make lint` -5. **If you edit generated files**: Update JSON template, then `make p-gen FORCE=1` to ensure reproducibility +When user requests a problem by **number** or **name/slug**, the assistant will: -## JSON Template Rules +1. **Scrape** problem data using `.templates/leetcode/scrape.py` +2. **Transform** data into proper JSON template format +3. **Create** JSON file in `.templates/leetcode/json/{problem_name}.json` +4. **Update** Makefile with `PROBLEM ?= {problem_name}` +5. **Generate** problem structure using `make p-gen` +6. **Verify** with `make lint` - fix template issues in JSON if possible, or manually fix generated files if template limitations +7. **Iterate** if JSON fixes: re-run `make p-gen PROBLEM={problem_name} FORCE=1` and `make lint` until passes to ensure reproducibility -- **Copy from reference examples** - don't create from scratch -- **Tree problems**: Use `.templates/leetcode/examples/tree.json5` -- **Basic problems**: Use `.templates/leetcode/examples/basic.json5` -- **Don't add extra fields** - templates are complete -- **Python naming convention**: Use snake_case for all parameter names (e.g., `new_interval` not `newInterval`) -- **If lint fails**: Fix JSON and regenerate, don't edit generated files -- **After any manual edits**: Always update JSON template and verify with `make p-gen FORCE=1` +## Scraping Commands -## Tags (Optional) +```bash +# Fetch by number +poetry run python .templates/leetcode/scrape.py -n 1 + +# Fetch by slug +poetry run python .templates/leetcode/scrape.py -s "two-sum" +``` + +## JSON Template Format + +Required fields for `.templates/leetcode/json/{problem_name}.json`: + +**Reference examples in `.templates/leetcode/examples/` for complete templates:** + +- `basic.json5` - Array, string, number problems +- `tree.json5` - Binary tree problems +- `linked_list.json5` - Linked list problems +- `string.json5` - String manipulation problems +- `matrix.json5` - 2D array/matrix problems ```json -"tags": ["grind-75", "blind-75", "neetcode-150", "top-interview"] +{ + "problem_name": "two_sum", + "class_name": "TwoSum", + "method_name": "two_sum", + "problem_number": "1", + "problem_title": "Two Sum", + "difficulty": "Easy", + "topics": "Array, Hash Table", + "tags": ["grind-75"], + "problem_description": "Given an array...", + "examples": [{ "input": "nums = [2,7,11,15], target = 9", "output": "[0,1]" }], + "constraints": "- 2 <= nums.length <= 10^4", + "parameters": "nums: list[int], target: int", + "return_type": "list[int]", + "dummy_return": "[]", + "imports": "", + "test_cases": [{ "args": [[2, 7, 11, 15], 9], "expected": [0, 1] }], + "param_names": "nums, target, expected", + "param_names_with_types": "nums: list[int], target: int, expected: list[int]", + "input_description": "nums={nums}, target={target}", + "input_params": "nums, target", + "expected_param": "expected", + "method_args": "nums, target", + "test_setup": "", + "test_logging": "", + "assertion_code": "assert result == expected", + "test_input_setup": "nums = [2, 7, 11, 15]\ntarget = 9", + "expected_output_setup": "expected = [0, 1]" +} ``` -## Helper Classes +## Naming Conventions + +- **problem_name**: snake_case (e.g., "two_sum", "valid_palindrome") +- **class_name**: PascalCase (e.g., "TwoSum", "ValidPalindrome") +- **method_name**: snake_case (e.g., "two_sum", "is_palindrome") +- **parameters**: Use snake_case for all parameter names + +## Special Problem Types + +### Tree Problems + +- Add `"imports": "from leetcode_py.tree_node import TreeNode"` +- Use `TreeNode | None` for nullable tree parameters +- Test setup: `root = TreeNode.from_list(root_list)` + +### Linked List Problems + +- Add `"imports": "from leetcode_py.list_node import ListNode"` +- Use `ListNode | None` for nullable list parameters +- Test setup: `head = ListNode.from_list(head_list)` + +## Generation Commands + +```bash +# Generate problem +make p-gen PROBLEM={problem_name} + +# Force regenerate (if files exist) +make p-gen PROBLEM={problem_name} FORCE=1 + +# Test specific problem +make p-test PROBLEM={problem_name} + +# Lint check +make lint +``` + +## Tags (Optional) -- TreeNode: `from leetcode_py.tree_node import TreeNode` -- ListNode: `from leetcode_py.list_node import ListNode` -- New helpers: Add to `leetcode_py/` +Common tags: `["grind-75", "blind-75", "neetcode-150", "top-interview"]` diff --git a/.templates/leetcode/examples/basic.json5 b/.templates/leetcode/examples/basic.json5 index 8f0e1ba..4ff8099 100644 --- a/.templates/leetcode/examples/basic.json5 +++ b/.templates/leetcode/examples/basic.json5 @@ -8,7 +8,7 @@ "method_name": "two_sum", // REQUIRED: Problem metadata - "problem_number": "1", + "problem_number": "1", // OPTIONAL: omit if no LeetCode number "problem_title": "Two Sum", "difficulty": "Easy", "topics": "Array, Hash Table", diff --git a/.templates/leetcode/examples/linked_list.json5 b/.templates/leetcode/examples/linked_list.json5 index 06d0b13..44e7ad3 100644 --- a/.templates/leetcode/examples/linked_list.json5 +++ b/.templates/leetcode/examples/linked_list.json5 @@ -1,62 +1,62 @@ { - // Linked list problem template - // Use this for problems involving ListNode structures - - // REQUIRED: Core identifiers - "problem_name": "reverse_linked_list_ii", - "class_name": "ReverseLinkedListII", - "method_name": "reverse_between", - - // REQUIRED: Problem metadata - "problem_number": "92", - "problem_title": "Reverse Linked List II", - "difficulty": "Medium", - "topics": "Linked List", - - // OPTIONAL: Problem categorization - "tags": [], - - // REQUIRED: Problem description - "problem_description": "Given the head of a singly linked list and two integers left and right where left <= right, reverse the nodes of the list from position left to position right, and return the reversed list.", - - // REQUIRED: Examples - "examples": [ - { "input": "head = [1,2,3,4,5], left = 2, right = 4", "output": "[1,4,3,2,5]" }, - { "input": "head = [5], left = 1, right = 1", "output": "[5]" } - ], - - // REQUIRED: Constraints - "constraints": "- The number of nodes in the list is n.\n- 1 <= n <= 500\n- -500 <= Node.val <= 500\n- 1 <= left <= right <= n", - - // REQUIRED: Method signature - "parameters": "head: ListNode | None, left: int, right: int", - "return_type": "ListNode | None", - "dummy_return": "None", - - // REQUIRED: ListNode import for linked list problems - "imports": "from leetcode_py.list_node import ListNode", - - // REQUIRED: Test cases - "test_cases": [ - { "args": [[1, 2, 3, 4, 5], 2, 4], "expected": [1, 4, 3, 2, 5] }, - { "args": [[5], 1, 1], "expected": [5] }, - { "args": [[1, 2, 3], 1, 3], "expected": [3, 2, 1] } - ], - - // REQUIRED: Test parameters (use expected_list for linked list problems) - "param_names": "head_list, left, right, expected_list", - "param_names_with_types": "head_list: list[int], left: int, right: int, expected_list: list[int]", - "input_description": "head_list={head_list}, left={left}, right={right}", - "input_params": "head, left, right", - "expected_param": "expected", - "method_args": "head, left, right", - - // REQUIRED: Linked list-specific test setup - "test_setup": "head = ListNode.from_list(head_list)\nexpected = ListNode.from_list(expected_list)", - "test_logging": "logger.success(f\"Got result: {result.to_list() if result else []}\")", - "assertion_code": "assert result == expected", - - // REQUIRED: Notebook setup for linked list problems - "test_input_setup": "# Example test case\nhead = ListNode.from_list([1, 2, 3, 4, 5])\nleft = 2\nright = 4", - "expected_output_setup": "expected = ListNode.from_list([1, 4, 3, 2, 5])" + // Linked list problem template + // Use this for problems involving ListNode structures + + // REQUIRED: Core identifiers + problem_name: "reverse_linked_list_ii", + class_name: "ReverseLinkedListII", + method_name: "reverse_between", + + // REQUIRED: Problem metadata + problem_number: "92", // OPTIONAL: omit if no LeetCode number + problem_title: "Reverse Linked List II", + difficulty: "Medium", + topics: "Linked List", + + // OPTIONAL: Problem categorization + tags: [], + + // REQUIRED: Problem description + problem_description: "Given the head of a singly linked list and two integers left and right where left <= right, reverse the nodes of the list from position left to position right, and return the reversed list.", + + // REQUIRED: Examples + examples: [ + { input: "head = [1,2,3,4,5], left = 2, right = 4", output: "[1,4,3,2,5]" }, + { input: "head = [5], left = 1, right = 1", output: "[5]" }, + ], + + // REQUIRED: Constraints + constraints: "- The number of nodes in the list is n.\n- 1 <= n <= 500\n- -500 <= Node.val <= 500\n- 1 <= left <= right <= n", + + // REQUIRED: Method signature + parameters: "head: ListNode | None, left: int, right: int", + return_type: "ListNode | None", + dummy_return: "None", + + // REQUIRED: ListNode import for linked list problems + imports: "from leetcode_py import ListNode", + + // REQUIRED: Test cases + test_cases: [ + { args: [[1, 2, 3, 4, 5], 2, 4], expected: [1, 4, 3, 2, 5] }, + { args: [[5], 1, 1], expected: [5] }, + { args: [[1, 2, 3], 1, 3], expected: [3, 2, 1] }, + ], + + // REQUIRED: Test parameters (use expected_list for linked list problems) + param_names: "head_list, left, right, expected_list", + param_names_with_types: "head_list: list[int], left: int, right: int, expected_list: list[int]", + input_description: "head_list={head_list}, left={left}, right={right}", + input_params: "head, left, right", + expected_param: "expected", + method_args: "head, left, right", + + // REQUIRED: Linked list-specific test setup + test_setup: "head = ListNode.from_list(head_list)\nexpected = ListNode.from_list(expected_list)", + test_logging: 'logger.success(f"Got result: {result.to_list() if result else []}")', + assertion_code: "assert result == expected", + + // REQUIRED: Notebook setup for linked list problems + test_input_setup: "# Example test case\nhead = ListNode.from_list([1, 2, 3, 4, 5])\nleft = 2\nright = 4", + expected_output_setup: "expected = ListNode.from_list([1, 4, 3, 2, 5])", } diff --git a/.templates/leetcode/examples/matrix.json5 b/.templates/leetcode/examples/matrix.json5 index e0cb669..968558e 100644 --- a/.templates/leetcode/examples/matrix.json5 +++ b/.templates/leetcode/examples/matrix.json5 @@ -8,7 +8,7 @@ "method_name": "rotate", // REQUIRED: Problem metadata - "problem_number": "48", + "problem_number": "48", // OPTIONAL: omit if no LeetCode number "problem_title": "Rotate Image", "difficulty": "Medium", "topics": "Array, Math, Matrix", diff --git a/.templates/leetcode/examples/string.json5 b/.templates/leetcode/examples/string.json5 index 61411a7..ce3be95 100644 --- a/.templates/leetcode/examples/string.json5 +++ b/.templates/leetcode/examples/string.json5 @@ -8,7 +8,7 @@ "method_name": "is_palindrome", // REQUIRED: Problem metadata - "problem_number": "125", + "problem_number": "125", // OPTIONAL: omit if no LeetCode number "problem_title": "Valid Palindrome", "difficulty": "Easy", "topics": "Two Pointers, String", diff --git a/.templates/leetcode/examples/tree.json5 b/.templates/leetcode/examples/tree.json5 index ae66571..caa6826 100644 --- a/.templates/leetcode/examples/tree.json5 +++ b/.templates/leetcode/examples/tree.json5 @@ -1,63 +1,63 @@ { - // Tree problem template for binary tree problems - // Use this for problems involving TreeNode structures - - // REQUIRED: Core identifiers - "problem_name": "invert_binary_tree", - "class_name": "InvertBinaryTree", - "method_name": "invert_tree", - - // REQUIRED: Problem metadata - "problem_number": "226", - "problem_title": "Invert Binary Tree", - "difficulty": "Easy", - "topics": "Tree, Depth-First Search, Breadth-First Search, Binary Tree", - - // OPTIONAL: Problem categorization - "tags": ["grind-75"], - - // REQUIRED: Problem description - "problem_description": "Given the root of a binary tree, invert the tree, and return its root.", - - // REQUIRED: Examples (tree problems show array representation) - "examples": [ - { "input": "root = [4,2,7,1,3,6,9]", "output": "[4,7,2,9,6,3,1]" }, - { "input": "root = [2,1,3]", "output": "[2,3,1]" }, - { "input": "root = []", "output": "[]" } - ], - - // REQUIRED: Constraints - "constraints": "- The number of nodes in the tree is in the range [0, 100].\n- -100 <= Node.val <= 100", - - // REQUIRED: Method signature (TreeNode | None for nullable tree parameters) - "parameters": "root: TreeNode | None", - "return_type": "TreeNode | None", - "dummy_return": "None", - - // REQUIRED: TreeNode import for tree problems - "imports": "from leetcode_py.tree_node import TreeNode", - - // REQUIRED: Test cases (use array representation for tree inputs/outputs) - "test_cases": [ - { "args": [[4, 2, 7, 1, 3, 6, 9]], "expected": [4, 7, 2, 9, 6, 3, 1] }, - { "args": [[2, 1, 3]], "expected": [2, 3, 1] }, - { "args": [[]], "expected": [] } - ], - - // REQUIRED: Test parameters (use expected_list for tree problems) - "param_names": "root_list, expected_list", - "param_names_with_types": "root_list: list[int | None], expected_list: list[int | None]", - "input_description": "root_list={root_list}", - "input_params": "root", - "expected_param": "expected", - "method_args": "root", - - // REQUIRED: Tree-specific test setup (converts arrays to TreeNode objects) - "test_setup": "root = TreeNode.from_list(root_list)\nexpected = TreeNode.from_list(expected_list)", - "test_logging": "logger.success(f\"Got result: {result.to_list() if result else []}\")", - "assertion_code": "assert result == expected", - - // REQUIRED: Notebook setup for tree problems - "test_input_setup": "# Example test case\nroot = TreeNode.from_list([4, 2, 7, 1, 3, 6, 9])", - "expected_output_setup": "expected = TreeNode.from_list([4, 7, 2, 9, 6, 3, 1])" + // Tree problem template for binary tree problems + // Use this for problems involving TreeNode structures + + // REQUIRED: Core identifiers + problem_name: "invert_binary_tree", + class_name: "InvertBinaryTree", + method_name: "invert_tree", + + // REQUIRED: Problem metadata + problem_number: "226", // OPTIONAL: omit if no LeetCode number + problem_title: "Invert Binary Tree", + difficulty: "Easy", + topics: "Tree, Depth-First Search, Breadth-First Search, Binary Tree", + + // OPTIONAL: Problem categorization + tags: ["grind-75"], + + // REQUIRED: Problem description + problem_description: "Given the root of a binary tree, invert the tree, and return its root.", + + // REQUIRED: Examples (tree problems show array representation) + examples: [ + { input: "root = [4,2,7,1,3,6,9]", output: "[4,7,2,9,6,3,1]" }, + { input: "root = [2,1,3]", output: "[2,3,1]" }, + { input: "root = []", output: "[]" }, + ], + + // REQUIRED: Constraints + constraints: "- The number of nodes in the tree is in the range [0, 100].\n- -100 <= Node.val <= 100", + + // REQUIRED: Method signature (TreeNode | None for nullable tree parameters) + parameters: "root: TreeNode | None", + return_type: "TreeNode | None", + dummy_return: "None", + + // REQUIRED: TreeNode import for tree problems + imports: "from leetcode_py import TreeNode", + + // REQUIRED: Test cases (use array representation for tree inputs/outputs) + test_cases: [ + { args: [[4, 2, 7, 1, 3, 6, 9]], expected: [4, 7, 2, 9, 6, 3, 1] }, + { args: [[2, 1, 3]], expected: [2, 3, 1] }, + { args: [[]], expected: [] }, + ], + + // REQUIRED: Test parameters (use expected_list for tree problems) + param_names: "root_list, expected_list", + param_names_with_types: "root_list: list[int | None], expected_list: list[int | None]", + input_description: "root_list={root_list}", + input_params: "root", + expected_param: "expected", + method_args: "root", + + // REQUIRED: Tree-specific test setup (converts arrays to TreeNode objects) + test_setup: "root = TreeNode.from_list(root_list)\nexpected = TreeNode.from_list(expected_list)", + test_logging: 'logger.success(f"Got result: {result.to_list() if result else []}")', + assertion_code: "assert result == expected", + + // REQUIRED: Notebook setup for tree problems + test_input_setup: "# Example test case\nroot = TreeNode.from_list([4, 2, 7, 1, 3, 6, 9])", + expected_output_setup: "expected = TreeNode.from_list([4, 7, 2, 9, 6, 3, 1])", } diff --git a/.templates/leetcode/gen.py b/.templates/leetcode/gen.py index 4de54a7..585c769 100644 --- a/.templates/leetcode/gen.py +++ b/.templates/leetcode/gen.py @@ -1,151 +1,26 @@ #!/usr/bin/env python3 -"""Generate LeetCode problem from JSON using cookiecutter.""" - -import json +"""Compatibility wrapper for generator.""" +import sys from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) import typer -from cookiecutter.main import cookiecutter -import sys - - -def check_and_prompt_tags(data: dict) -> dict: - import sys - - common_tags = ["grind-75", "blind-75", "neetcode-150", "top-interview"] - - if "tags" in data and (not data["tags"] or data["tags"] == []): - if sys.stdin.isatty(): # Interactive terminal - typer.echo("\nšŸ“‹ No tags specified. Would you like to add any common tags?") - typer.echo("Available options:") - for i, tag in enumerate(common_tags, 1): - typer.echo(f" {i}. {tag}") - typer.echo(" 0. Skip (no tags)") - - choices_input = typer.prompt("Select options (comma-separated, e.g. '1,2' or '0' to skip)") - - try: - choices = [int(x.strip()) for x in choices_input.split(",")] - selected_tags = [] - - for choice in choices: - if choice == 0: - selected_tags = [] - break - elif 1 <= choice <= len(common_tags): - tag = common_tags[choice - 1] - if tag not in selected_tags: - selected_tags.append(tag) - - data["tags"] = selected_tags - if selected_tags: - typer.echo(f"āœ… Added tags: {', '.join(selected_tags)}") - else: - typer.echo("āœ… No tags added") - - except ValueError: - typer.echo("āš ļø Invalid input, skipping tags") - data["tags"] = [] - - return data - - -def auto_set_dummy_return(data: dict) -> dict: - if "dummy_return" not in data and "return_type" in data: - return_type = data["return_type"] - dummy_map = {"bool": "False", "int": "0", "str": '""', "float": "0.0", "None": "None"} - - if return_type in dummy_map: - data["dummy_return"] = dummy_map[return_type] - elif return_type.startswith("list["): - data["dummy_return"] = "[]" - elif return_type.startswith("dict["): - data["dummy_return"] = "{}" - elif return_type.startswith("set["): - data["dummy_return"] = "set()" - elif return_type.startswith("tuple["): - data["dummy_return"] = "()" - else: - data["dummy_return"] = "None" - - return data +from leetcode_py.tools import TemplateGenerator +app = typer.Typer(help="Generate LeetCode problem templates") -def convert_arrays_to_nested(data: dict) -> dict: - extra_context = data.copy() - array_fields = ["examples", "test_cases", "tags"] - for field in array_fields: - if field in data and isinstance(data[field], list): - extra_context[f"_{field}"] = {"list": data[field]} - del extra_context[field] - return extra_context - -def check_overwrite_permission(problem_name: str, force: bool) -> None: - - if force: - return - - output_dir = Path(__file__).parent.parent.parent / "leetcode" - problem_dir = output_dir / problem_name - - if not problem_dir.exists(): - return - - typer.echo(f"āš ļø Warning: Problem '{problem_name}' already exists in leetcode/", err=True) - typer.echo("This will overwrite existing files. Use --force to skip this check.", err=True) - - if sys.stdin.isatty(): # Interactive terminal - confirm = typer.confirm("Continue?") - if not confirm: - typer.echo("Cancelled.") - raise typer.Exit(1) - else: # Non-interactive mode - typer.echo("Non-interactive mode: use --force to overwrite.", err=True) - raise typer.Exit(1) - - -def generate_problem(json_file: str, force: bool = False) -> None: - json_path = Path(json_file) - if not json_path.exists(): - typer.echo(f"Error: {json_file} not found", err=True) - raise typer.Exit(1) - - # Load JSON data - with open(json_path) as f: - data = json.load(f) - - # Check and prompt for tags if empty - data = check_and_prompt_tags(data) - - # Auto-set dummy_return if not provided - data = auto_set_dummy_return(data) - - # Save updated data back to JSON file - with open(json_path, "w") as f: - json.dump(data, f) - - # Convert arrays to cookiecutter-friendly nested format - extra_context = convert_arrays_to_nested(data) - - # Check if problem already exists - problem_name = extra_context.get("problem_name", "unknown") - check_overwrite_permission(problem_name, force) - - # Generate project using cookiecutter +@app.command() +def generate( + json_file: str = typer.Argument(help="Path to JSON problem definition"), + force: bool = typer.Option(False, "--force", help="Force overwrite existing files") +): + """Generate LeetCode problem from JSON using cookiecutter.""" + generator = TemplateGenerator() template_dir = Path(__file__).parent output_dir = template_dir.parent.parent / "leetcode" - - cookiecutter( - str(template_dir), - extra_context=extra_context, - no_input=True, - overwrite_if_exists=True, - output_dir=str(output_dir), - ) - - typer.echo(f"āœ… Generated problem: {problem_name}") + generator.generate_problem(json_file, template_dir, output_dir, force) if __name__ == "__main__": - typer.run(generate_problem) + app() diff --git a/.templates/leetcode/json/invert_binary_tree.json b/.templates/leetcode/json/invert_binary_tree.json index c0a07c5..d9e26dc 100644 --- a/.templates/leetcode/json/invert_binary_tree.json +++ b/.templates/leetcode/json/invert_binary_tree.json @@ -17,7 +17,7 @@ "parameters": "root: TreeNode | None", "return_type": "TreeNode | None", "dummy_return": "None", - "imports": "from leetcode_py.tree_node import TreeNode", + "imports": "from leetcode_py import TreeNode", "test_cases": [ { "args": [[4, 2, 7, 1, 3, 6, 9]], "expected": [4, 7, 2, 9, 6, 3, 1] }, { "args": [[2, 1, 3]], "expected": [2, 3, 1] }, diff --git a/.templates/leetcode/json/lru_cache.json b/.templates/leetcode/json/lru_cache.json new file mode 100644 index 0000000..3792eab --- /dev/null +++ b/.templates/leetcode/json/lru_cache.json @@ -0,0 +1,42 @@ +{ + "problem_name": "lru_cache", + "class_name": "LRUCache", + "method_name": "lru_cache", + "problem_number": "146", + "problem_title": "LRU Cache", + "difficulty": "Medium", + "topics": "Hash Table, Linked List, Design, Doubly-Linked List", + "tags": ["grind-75", "top-interview"], + "problem_description": "Design a data structure that follows the constraints of a Least Recently Used (LRU) cache.\n\nImplement the LRUCache class:\n\n- LRUCache(int capacity) Initialize the LRU cache with positive size capacity.\n- int get(int key) Return the value of the key if the key exists, otherwise return -1.\n- void put(int key, int value) Update the value of the key if the key exists. Otherwise, add the key-value pair to the cache. If the number of keys exceeds the capacity from this operation, evict the least recently used key.\n\nThe functions get and put must each run in O(1) average time complexity.", + "examples": [ + { + "input": "[\"LRUCache\", \"put\", \"put\", \"get\", \"put\", \"get\", \"put\", \"get\", \"get\", \"get\"]\n[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]]", + "output": "[null, null, null, 1, null, -1, null, -1, 3, 4]" + } + ], + "constraints": "- 1 <= capacity <= 3000\n- 0 <= key <= 10^4\n- 0 <= value <= 10^5\n- At most 2 * 10^5 calls will be made to get and put.", + "parameters": "capacity: int", + "return_type": "None", + "dummy_return": "None", + "imports": "", + "test_cases": [ + { + "args": [ + ["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"], + [[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]] + ], + "expected": [null, null, null, 1, null, -1, null, -1, 3, 4] + } + ], + "param_names": "operations, inputs, expected", + "param_names_with_types": "operations: list[str], inputs: list[list[int]], expected: list[int | None]", + "input_description": "operations={operations}, inputs={inputs}", + "input_params": "operations, inputs", + "expected_param": "expected", + "method_args": "operations, inputs", + "test_setup": "cache = None\nresult = []\nfor i, op in enumerate(operations):\n if op == \"LRUCache\":\n cache = LRUCache(inputs[i][0])\n result.append(None)\n elif op == \"get\":\n result.append(cache.get(inputs[i][0]))\n elif op == \"put\":\n cache.put(inputs[i][0], inputs[i][1])\n result.append(None)", + "test_logging": "logger.info(f\"Testing LRU Cache with operations: {operations}\")\nlogger.info(f\"Inputs: {inputs}\")\nlogger.info(f\"Expected: {expected}\")\nlogger.info(f\"Result: {result}\")", + "assertion_code": "assert result == expected", + "test_input_setup": "operations = [\"LRUCache\", \"put\", \"put\", \"get\", \"put\", \"get\", \"put\", \"get\", \"get\", \"get\"]\ninputs = [[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]]", + "expected_output_setup": "expected = [None, None, None, 1, None, -1, None, -1, 3, 4]" +} diff --git a/.templates/leetcode/json/reverse_linked_list_ii.json b/.templates/leetcode/json/reverse_linked_list_ii.json index 73e2d6e..e71f081 100644 --- a/.templates/leetcode/json/reverse_linked_list_ii.json +++ b/.templates/leetcode/json/reverse_linked_list_ii.json @@ -9,7 +9,7 @@ ], "expected_output_setup": "expected = ListNode.from_list([1, 4, 3, 2, 5])", "expected_param": "expected", - "imports": "from leetcode_py.list_node import ListNode", + "imports": "from leetcode_py import ListNode", "input_description": "head_list={head_list}, left={left}, right={right}", "input_params": "head, left, right", "method_args": "head, left, right", diff --git a/.templates/leetcode/scrape.py b/.templates/leetcode/scrape.py new file mode 100644 index 0000000..82fa996 --- /dev/null +++ b/.templates/leetcode/scrape.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Compatibility wrapper for scraper.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +import json +from typing import Optional +import typer +from leetcode_py.tools import LeetCodeScraper + +app = typer.Typer(help="Fetch LeetCode problem information") + + +@app.command() +def fetch( + number: Optional[int] = typer.Option(None, "-n", "--number", help="Problem number (e.g., 1)"), + slug: Optional[str] = typer.Option(None, "-s", "--slug", help="Problem slug (e.g., 'two-sum')"), +): + """Fetch LeetCode problem information and return as JSON.""" + if not number and not slug: + typer.echo("Error: Must provide either --number or --slug", err=True) + raise typer.Exit(1) + + if number and slug: + typer.echo("Error: Cannot provide both --number and --slug", err=True) + raise typer.Exit(1) + + scraper = LeetCodeScraper() + + if number: + problem = scraper.get_problem_by_number(number) + else: + if slug is None: + typer.echo("Error: Slug cannot be None", err=True) + raise typer.Exit(1) + problem = scraper.get_problem_by_slug(slug) + + if not problem: + typer.echo(json.dumps({"error": "Problem not found"})) + raise typer.Exit(1) + + formatted = scraper.format_problem_info(problem) + typer.echo(json.dumps(formatted, indent=2)) + + +if __name__ == "__main__": + app() diff --git a/.templates/leetcode/{{cookiecutter.problem_name}}/README.md b/.templates/leetcode/{{cookiecutter.problem_name}}/README.md index b281830..35ace9b 100644 --- a/.templates/leetcode/{{cookiecutter.problem_name}}/README.md +++ b/.templates/leetcode/{{cookiecutter.problem_name}}/README.md @@ -3,7 +3,9 @@ **Difficulty:** {{cookiecutter.difficulty}} **Topics:** {{cookiecutter.topics}} **Tags:** {% for _, tags in cookiecutter._tags | dictsort %}{{ tags | join(', ') }}{% endfor %} +{%- if cookiecutter.problem_number %} **LeetCode:** [Problem {{cookiecutter.problem_number}}](https://leetcode.com/problems/{{cookiecutter.problem_name.replace('_', "-")}}/description/) +{%- endif %} ## Problem Description diff --git a/Makefile b/Makefile index 3bc7bbb..273c4fa 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ PYTHON_VERSION = 3.13 -PROBLEM ?= spiral_matrix +PROBLEM ?= lru_cache FORCE ?= 0 sync_submodules: diff --git a/README.md b/README.md index ecacdf2..ffd2a86 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,7 @@ Premium LeetCode practice repository with Python solutions, algorithm templates, data structure visualizations, and automated testing. Perfect for coding interview preparation, competitive programming, and mastering algorithms with Blind 75, Grind 75, and NeetCode 150 problems. -## šŸ“‹ Prerequisites - -- Python 3.13+ -- make, git, Graphviz, poetry - -## šŸ› ļø Installation +## šŸš€ Quick Start ```bash # Clone the repository @@ -22,15 +17,24 @@ git clone https://github.com/wisarootl/leetcode-py.git cd leetcode-py # Install dependencies -pip install -r requirements.txt +poetry install -# Generate all problems +# Generate all problems to start practicing (fresh start - wipes all solutions) make gen-all-problems -# Verify setup +# Run existing problems +make p-test PROBLEM=insert_interval +make p-test PROBLEM=invert_binary_tree + +# Run all tests make test ``` +## šŸ“‹ Prerequisites + +- Python 3.13+ +- make, git, Graphviz, poetry + ## šŸ“ Problem Structure Each problem follows a consistent template: @@ -44,7 +48,7 @@ leetcode/two_sum/ └── __init__.py # Package marker ``` -## šŸŽÆ Supported Problem Categories +## šŸŽÆ Supported Problem Categories (ongoing) - **Arrays & Hashing** - Two Sum, Group Anagrams, Top K Elements - **Two Pointers** - Valid Palindrome, Container With Most Water @@ -62,7 +66,7 @@ leetcode/two_sum/ - **Intervals** - Merge Intervals, Meeting Rooms - **Math & Geometry** - Rotate Image, Spiral Matrix -Includes problems from **Blind 75**, **Grind 75**, **NeetCode 150**, and **Top Interview Questions**. This is an ongoing project - contributions are welcome! +Includes problems from **Blind 75**, **Grind 75**, **NeetCode 150**, and **Top Interview Questions**. ## šŸŽØ Visualizations @@ -91,46 +95,29 @@ _Interactive multi-cell playground for each problem_ - **Full linting** - black, isort, ruff, mypy with nbqa for notebooks - **Modern Python** - PEP 585/604 syntax with full type hints -## šŸš€ Quick Start - -```bash -# Generate all problems to start practicing -make gen-all-problems - -# Run existing problems -make p-test PROBLEM=insert_interval -make p-test PROBLEM=invert_binary_tree - -# Run all tests -make test -``` - ## šŸ”„ Workflow Examples **Practice existing problems**: ```bash # Work on a specific problem -make p-test PROBLEM=two_sum -# Edit leetcode/two_sum/solution.py +make p-test PROBLEM=lru_cache +# Edit leetcode/lru_cache/solution.py # Run tests to verify + +# Or use make p-test if default problem is set in Makefile +make p-test ``` **Add new problems**: ```bash -# Copy problem description and solution placeholder from LeetCode -# Then ask your LLM assistant: -# "Create a new LeetCode problem for Valid Anagram" -# -# Behind the scenes, the LLM will: -# 1. Create JSON template following .amazonq/rules/problem-creation.md -# 2. Run `make p-gen PROBLEM=valid_anagram` -# 3. Generate complete problem structure with tests -# 4. You just implement the solution! +# Ask your LLM assistant: +# "Create LeetCode problem 146 (LRU Cache)" +# The assistant handles everything automatically! ``` -_The LLM follows structured rules in `.amazonq/rules/problem-creation.md` to ensure consistent, high-quality problem generation using proven templates._ +_Behind the scenes: Assistant follows `.amazonq/rules/problem-creation.md` to scrape problem data, create JSON template, generate structure with `make p-gen`, and verify with `make lint`._ **Bulk operations**: @@ -143,18 +130,16 @@ make gen-all-problems make lint ``` -## 🧰 Helper Classes +## 🧰 Helper Classes (ongoing) -- **TreeNode**: `from leetcode_py.tree_node import TreeNode` +- **TreeNode**: `from leetcode_py import TreeNode` - Beautiful tree visualization with anytree rendering - Jupyter notebook support with Graphviz diagrams - Easy array ↔ tree conversion for testing -- **ListNode**: `from leetcode_py.list_node import ListNode` +- **ListNode**: `from leetcode_py import ListNode` - Clean arrow visualization (`1 -> 2 -> 3`) - Simple array ↔ list conversion - Perfect for debugging linked list problems - New helpers: Add to `leetcode_py/` -This is an ongoing project - contributions are welcome! - Perfect for interview preparation with professional-grade tooling and beautiful visualizations. diff --git a/leetcode/invert_binary_tree/playground.ipynb b/leetcode/invert_binary_tree/playground.ipynb index 62707c1..1156392 100644 --- a/leetcode/invert_binary_tree/playground.ipynb +++ b/leetcode/invert_binary_tree/playground.ipynb @@ -2,14 +2,14 @@ "cells": [ { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "imports", "metadata": {}, "outputs": [], "source": [ "from solution import Solution\n", "\n", - "from leetcode_py.tree_node import TreeNode" + "from leetcode_py import TreeNode" ] }, { diff --git a/leetcode/invert_binary_tree/solution.py b/leetcode/invert_binary_tree/solution.py index 3a6481e..452457c 100644 --- a/leetcode/invert_binary_tree/solution.py +++ b/leetcode/invert_binary_tree/solution.py @@ -1,6 +1,6 @@ from collections import deque -from leetcode_py.tree_node import TreeNode +from leetcode_py import TreeNode # Note: "Fringe" is the general CS term for the data structure holding nodes to be explored. # Stack (LIFO) → DFS, Queue (FIFO) → BFS, Priority Queue → A*/Best-first search diff --git a/leetcode/invert_binary_tree/tests.py b/leetcode/invert_binary_tree/tests.py index 71c84d6..69cd938 100644 --- a/leetcode/invert_binary_tree/tests.py +++ b/leetcode/invert_binary_tree/tests.py @@ -1,8 +1,8 @@ import pytest from loguru import logger +from leetcode_py import TreeNode from leetcode_py.test_utils import logged_test -from leetcode_py.tree_node import TreeNode from .solution import Solution, SolutionBFS, SolutionDFS diff --git a/leetcode/lru_cache/README.md b/leetcode/lru_cache/README.md new file mode 100644 index 0000000..01c5496 --- /dev/null +++ b/leetcode/lru_cache/README.md @@ -0,0 +1,35 @@ +# 146. LRU Cache + +**Difficulty:** Medium +**Topics:** Hash Table, Linked List, Design, Doubly-Linked List +**Tags:** grind-75, top-interview +**LeetCode:** [Problem 146](https://leetcode.com/problems/lru-cache/description/) + +## Problem Description + +Design a data structure that follows the constraints of a Least Recently Used (LRU) cache. + +Implement the LRUCache class: + +- LRUCache(int capacity) Initialize the LRU cache with positive size capacity. +- int get(int key) Return the value of the key if the key exists, otherwise return -1. +- void put(int key, int value) Update the value of the key if the key exists. Otherwise, add the key-value pair to the cache. If the number of keys exceeds the capacity from this operation, evict the least recently used key. + +The functions get and put must each run in O(1) average time complexity. + +## Examples + +### Example 1: + +``` +Input: ["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"] +[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]] +Output: [null, null, null, 1, null, -1, null, -1, 3, 4] +``` + +## Constraints + +- 1 <= capacity <= 3000 +- 0 <= key <= 10^4 +- 0 <= value <= 10^5 +- At most 2 \* 10^5 calls will be made to get and put. diff --git a/leetcode/lru_cache/__init__.py b/leetcode/lru_cache/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leetcode/lru_cache/playground.ipynb b/leetcode/lru_cache/playground.ipynb new file mode 100644 index 0000000..2e9dae1 --- /dev/null +++ b/leetcode/lru_cache/playground.ipynb @@ -0,0 +1,89 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "imports", + "metadata": {}, + "outputs": [], + "source": [ + "from solution import LRUCache" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "setup", + "metadata": {}, + "outputs": [], + "source": [ + "operations = [\"LRUCache\", \"put\", \"put\", \"get\", \"put\", \"get\", \"put\", \"get\", \"get\", \"get\"]\n", + "inputs = [[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]]\n", + "expected = [None, None, None, 1, None, -1, None, -1, 3, 4]" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "execute", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[None, None, None, 1, None, -1, None, -1, 3, 4]" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cache: LRUCache | None = None\n", + "result: list[int | None] = []\n", + "for i, op in enumerate(operations):\n", + " if op == \"LRUCache\":\n", + " cache = LRUCache(inputs[i][0])\n", + " result.append(None)\n", + " elif op == \"get\" and cache is not None:\n", + " result.append(cache.get(inputs[i][0]))\n", + " elif op == \"put\" and cache is not None:\n", + " cache.put(inputs[i][0], inputs[i][1])\n", + " result.append(None)\n", + "result" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "test", + "metadata": {}, + "outputs": [], + "source": [ + "assert result == expected" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "leetcode-py-py3.13", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/leetcode/lru_cache/solution.py b/leetcode/lru_cache/solution.py new file mode 100644 index 0000000..817b786 --- /dev/null +++ b/leetcode/lru_cache/solution.py @@ -0,0 +1,33 @@ +from collections import OrderedDict + + +class LRUCache: + # Space: O(capacity) + def __init__(self, capacity: int): + self.capacity = capacity + self.cache: OrderedDict[int, int] = OrderedDict() + + # Time: O(1) + # Space: O(1) + def get(self, key: int) -> int: + if key not in self.cache: + return -1 + + # Move to end (most recent) + self.cache.move_to_end(key) + return self.cache[key] + + # Time: O(1) + # Space: O(1) + def put(self, key: int, value: int) -> None: + if key in self.cache: + # Update existing and move to end + self.cache[key] = value + self.cache.move_to_end(key) + else: + # Add new + if len(self.cache) >= self.capacity: + # Remove LRU (first item) + self.cache.popitem(last=False) + + self.cache[key] = value diff --git a/leetcode/lru_cache/tests.py b/leetcode/lru_cache/tests.py new file mode 100644 index 0000000..93b17a6 --- /dev/null +++ b/leetcode/lru_cache/tests.py @@ -0,0 +1,39 @@ +import pytest +from loguru import logger + +from leetcode_py.test_utils import logged_test + +from .solution import LRUCache + + +class TestLRUCache: + @pytest.mark.parametrize( + "operations, inputs, expected", + [ + ( + ["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"], + [[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]], + [None, None, None, 1, None, -1, None, -1, 3, 4], + ), + ], + ) + @logged_test + def test_lru_cache(self, operations: list[str], inputs: list[list[int]], expected: list[int | None]): + logger.info(f"Testing LRU Cache with operations: {operations}") + logger.info(f"Inputs: {inputs}") + logger.info(f"Expected: {expected}") + + cache: LRUCache | None = None + result: list[int | None] = [] + for i, op in enumerate(operations): + if op == "LRUCache": + cache = LRUCache(inputs[i][0]) + result.append(None) + elif op == "get" and cache is not None: + result.append(cache.get(inputs[i][0])) + elif op == "put" and cache is not None: + cache.put(inputs[i][0], inputs[i][1]) + result.append(None) + + logger.info(f"Result: {result}") + assert result == expected diff --git a/leetcode/reverse_linked_list_ii/playground.ipynb b/leetcode/reverse_linked_list_ii/playground.ipynb index 74f09dc..7b68b1f 100644 --- a/leetcode/reverse_linked_list_ii/playground.ipynb +++ b/leetcode/reverse_linked_list_ii/playground.ipynb @@ -2,14 +2,14 @@ "cells": [ { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "imports", "metadata": {}, "outputs": [], "source": [ "from solution import Solution\n", "\n", - "from leetcode_py.list_node import ListNode" + "from leetcode_py import ListNode" ] }, { diff --git a/leetcode/reverse_linked_list_ii/solution.py b/leetcode/reverse_linked_list_ii/solution.py index 0830bd0..7db8da6 100644 --- a/leetcode/reverse_linked_list_ii/solution.py +++ b/leetcode/reverse_linked_list_ii/solution.py @@ -1,4 +1,4 @@ -from leetcode_py.list_node import ListNode +from leetcode_py import ListNode class Solution: diff --git a/leetcode/reverse_linked_list_ii/tests.py b/leetcode/reverse_linked_list_ii/tests.py index 31c2e8b..d363d84 100644 --- a/leetcode/reverse_linked_list_ii/tests.py +++ b/leetcode/reverse_linked_list_ii/tests.py @@ -1,7 +1,7 @@ import pytest from loguru import logger -from leetcode_py.list_node import ListNode +from leetcode_py import ListNode from leetcode_py.test_utils import logged_test from .solution import Solution diff --git a/leetcode_py/__init__.py b/leetcode_py/__init__.py index e69de29..d9be26e 100644 --- a/leetcode_py/__init__.py +++ b/leetcode_py/__init__.py @@ -0,0 +1,4 @@ +from leetcode_py.data_structures.list_node import ListNode +from leetcode_py.data_structures.tree_node import TreeNode + +__all__ = ["ListNode", "TreeNode"] diff --git a/leetcode_py/data_structures/__init__.py b/leetcode_py/data_structures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leetcode_py/list_node.py b/leetcode_py/data_structures/list_node.py similarity index 100% rename from leetcode_py/list_node.py rename to leetcode_py/data_structures/list_node.py diff --git a/leetcode_py/tree_node.py b/leetcode_py/data_structures/tree_node.py similarity index 81% rename from leetcode_py/tree_node.py rename to leetcode_py/data_structures/tree_node.py index bef27f7..796c9f3 100644 --- a/leetcode_py/tree_node.py +++ b/leetcode_py/data_structures/tree_node.py @@ -12,6 +12,25 @@ def build_anytree(node: "TreeNode | None", parent: Node | None = None) -> Node | return current +def add_nodes(dot: graphviz.Digraph, node: "TreeNode | None", node_id: int = 0) -> int: + if not node: + return node_id + + dot.node(str(node_id), str(node.val)) + current_id = node_id + next_id = node_id + 1 + + if node.left: + dot.edge(str(current_id), str(next_id)) + next_id = add_nodes(dot, node.left, next_id) + 1 + + if node.right: + dot.edge(str(current_id), str(next_id)) + next_id = add_nodes(dot, node.right, next_id) + 1 + + return next_id - 1 + + class TreeNode: def __init__(self, val: int = 0, left: "TreeNode | None" = None, right: "TreeNode | None" = None): self.val = val @@ -77,25 +96,7 @@ def _repr_html_(self) -> str: dot = graphviz.Digraph() dot.attr(rankdir="TB") - def add_nodes(node: "TreeNode | None", node_id: int = 0) -> int: - if not node: - return node_id - - dot.node(str(node_id), str(node.val)) - current_id = node_id - next_id = node_id + 1 - - if node.left: - dot.edge(str(current_id), str(next_id)) - next_id = add_nodes(node.left, next_id) + 1 - - if node.right: - dot.edge(str(current_id), str(next_id)) - next_id = add_nodes(node.right, next_id) + 1 - - return next_id - 1 - - add_nodes(self) + add_nodes(dot, self) return dot.pipe(format="svg", encoding="utf-8") def __eq__(self, other: object) -> bool: diff --git a/leetcode_py/tools/__init__.py b/leetcode_py/tools/__init__.py new file mode 100644 index 0000000..51fc3e4 --- /dev/null +++ b/leetcode_py/tools/__init__.py @@ -0,0 +1,7 @@ +"""LeetCode tools package for scraping and template generation.""" + +from .generator import TemplateGenerator +from .parser import HTMLParser +from .scraper import LeetCodeScraper + +__all__ = ["LeetCodeScraper", "HTMLParser", "TemplateGenerator"] diff --git a/leetcode_py/tools/generator.py b/leetcode_py/tools/generator.py new file mode 100644 index 0000000..ebacbff --- /dev/null +++ b/leetcode_py/tools/generator.py @@ -0,0 +1,210 @@ +"""Template generation utilities for LeetCode problems.""" + +import json +import sys +from pathlib import Path +from typing import Any, Dict, Protocol + +import typer +from cookiecutter.main import cookiecutter + + +class FileOperations(Protocol): + """Protocol for file operations to enable testing.""" + + def read_json(self, path: Path) -> Dict[str, Any]: + """Read JSON from file.""" + ... + + def write_json(self, path: Path, data: Dict[str, Any]) -> None: + """Write JSON to file.""" + ... + + def exists(self, path: Path) -> bool: + """Check if path exists.""" + ... + + +class DefaultFileOperations: + """Default file operations implementation.""" + + def read_json(self, path: Path) -> Dict[str, Any]: + """Read JSON from file.""" + try: + with open(path) as f: + return json.load(f) + except (json.JSONDecodeError, OSError) as e: + typer.echo(f"Error reading {path}: {e}", err=True) + raise typer.Exit(1) + + def write_json(self, path: Path, data: Dict[str, Any]) -> None: + """Write JSON to file.""" + try: + with open(path, "w") as f: + json.dump(data, f) + except OSError as e: + typer.echo(f"Error writing {path}: {e}", err=True) + raise typer.Exit(1) + + def exists(self, path: Path) -> bool: + """Check if path exists.""" + return path.exists() + + +class TemplateGenerator: + """Generator for LeetCode problem templates using cookiecutter.""" + + def __init__(self, file_ops: FileOperations | None = None): + self.common_tags = ["grind-75", "blind-75", "neetcode-150", "top-interview"] + self.file_ops = file_ops or DefaultFileOperations() + + def check_and_prompt_tags(self, data: Dict[str, Any]) -> Dict[str, Any]: + """Check and prompt for tags if empty.""" + if self._should_prompt_for_tags(data) and sys.stdin.isatty(): + selected_tags = self._prompt_for_tags() + data["tags"] = selected_tags + self._display_tags_result(selected_tags) + return data + + def _should_prompt_for_tags(self, data: Dict[str, Any]) -> bool: + """Check if we should prompt for tags.""" + return "tags" in data and (not data["tags"] or data["tags"] == []) + + def _prompt_for_tags(self) -> list[str]: + """Prompt user for tag selection.""" + self._display_tag_options() + choices_input = typer.prompt("Select options (comma-separated, e.g. '1,2' or '0' to skip)") + return self._process_tag_choices(choices_input) + + def _display_tag_options(self) -> None: + """Display available tag options.""" + typer.echo("\nšŸ“‹ No tags specified. Would you like to add any common tags?") + typer.echo("Available options:") + for i, tag in enumerate(self.common_tags, 1): + typer.echo(f" {i}. {tag}") + typer.echo(" 0. Skip (no tags)") + + def _process_tag_choices(self, choices_input: str) -> list[str]: + """Process user's tag choices.""" + try: + choices = [int(x.strip()) for x in choices_input.split(",")] + return self._build_selected_tags(choices) + except ValueError: + typer.echo("āš ļø Invalid input, skipping tags") + return [] + + def _build_selected_tags(self, choices: list[int]) -> list[str]: + """Build list of selected tags from choices.""" + selected_tags: list[str] = [] + for choice in choices: + if choice == 0: + return [] + if 1 <= choice <= len(self.common_tags): + tag = self.common_tags[choice - 1] + if tag not in selected_tags: + selected_tags.append(tag) + return selected_tags + + def _display_tags_result(self, selected_tags: list[str]) -> None: + """Display the result of tag selection.""" + if selected_tags: + typer.echo(f"āœ… Added tags: {', '.join(selected_tags)}") + else: + typer.echo("āœ… No tags added") + + def auto_set_dummy_return(self, data: Dict[str, Any]) -> Dict[str, Any]: + """Auto-set dummy_return based on return_type.""" + if "dummy_return" not in data and "return_type" in data: + return_type = data["return_type"] + dummy_map = {"bool": "False", "int": "0", "str": '""', "float": "0.0", "None": "None"} + + if return_type in dummy_map: + data["dummy_return"] = dummy_map[return_type] + elif return_type.startswith("list["): + data["dummy_return"] = "[]" + elif return_type.startswith("dict["): + data["dummy_return"] = "{}" + elif return_type.startswith("set["): + data["dummy_return"] = "set()" + elif return_type.startswith("tuple["): + data["dummy_return"] = "()" + else: + data["dummy_return"] = "None" + + return data + + def convert_arrays_to_nested(self, data: Dict[str, Any]) -> Dict[str, Any]: + """Convert arrays to cookiecutter-friendly nested format.""" + extra_context = data.copy() + array_fields = ["examples", "test_cases", "tags"] + for field in array_fields: + if field in data and isinstance(data[field], list): + extra_context[f"_{field}"] = {"list": data[field]} + del extra_context[field] + return extra_context + + def check_overwrite_permission(self, problem_name: str, force: bool, output_dir: Path) -> None: + """Check if problem exists and get overwrite permission.""" + if force: + return + + problem_dir = output_dir / problem_name + + if not self.file_ops.exists(problem_dir): + return + + typer.echo( + f"āš ļø Warning: Problem '{problem_name}' already exists in {output_dir.name}/", err=True + ) + typer.echo("This will overwrite existing files. Use --force to skip this check.", err=True) + + if sys.stdin.isatty(): # Interactive terminal + confirm = typer.confirm("Continue?") + if not confirm: + typer.echo("Cancelled.") + raise typer.Exit(1) + else: # Non-interactive mode + typer.echo("Non-interactive mode: use --force to overwrite.", err=True) + raise typer.Exit(1) + + def generate_problem( + self, json_file: str, template_dir: Path, output_dir: Path, force: bool = False + ) -> None: + """Generate problem from JSON using cookiecutter.""" + json_path = Path(json_file) + if not self.file_ops.exists(json_path): + typer.echo(f"Error: {json_file} not found", err=True) + raise typer.Exit(1) + + # Load JSON data + data = self.file_ops.read_json(json_path) + + # Process data + data = self.check_and_prompt_tags(data) + data = self.auto_set_dummy_return(data) + + # Save updated data back to JSON file + self.file_ops.write_json(json_path, data) + + # Convert arrays to cookiecutter-friendly nested format + extra_context = self.convert_arrays_to_nested(data) + + # Check if problem already exists + problem_name = extra_context.get("problem_name", "unknown") + + self.check_overwrite_permission(problem_name, force, output_dir) + + # Generate project using cookiecutter + try: + cookiecutter( + str(template_dir), + extra_context=extra_context, + no_input=True, + overwrite_if_exists=True, + output_dir=str(output_dir), + ) + except Exception as e: + typer.echo(f"Error generating template: {e}", err=True) + raise typer.Exit(1) + + typer.echo(f"āœ… Generated problem: {problem_name}") diff --git a/leetcode_py/tools/parser.py b/leetcode_py/tools/parser.py new file mode 100644 index 0000000..0aa8024 --- /dev/null +++ b/leetcode_py/tools/parser.py @@ -0,0 +1,69 @@ +"""HTML parsing utilities for LeetCode problem content.""" + +import re +from typing import Any, Dict, List + + +class HTMLParser: + """Parser for LeetCode HTML content.""" + + @staticmethod + def clean_html(text: str) -> str: + """Remove HTML tags for clean text.""" + return re.sub(r"<[^>]+>", "", text).strip() + + @staticmethod + def parse_content(html_content: str) -> Dict[str, Any]: + """Parse HTML content to extract description, examples, and constraints.""" + # Extract description (everything before first example) + desc_match = re.search( + r'

(.*)(?=

|

Constraints:|$)', html_content, re.DOTALL + ) + description = HTMLParser.clean_html(desc_match.group(1)) if desc_match else "" + + # Extract examples + examples = [] + example_pattern = ( + r'

Example (\d+):

\s*
\s*(.*)\s*
' + ) + for match in re.finditer(example_pattern, html_content, re.DOTALL): + example_num = match.group(1) + example_text = HTMLParser.clean_html(match.group(2)) + examples.append({"number": int(example_num), "text": example_text}) + + # Extract constraints + constraints_match = re.search( + r"

Constraints:

\s*", html_content, re.DOTALL + ) + constraints = [] + if constraints_match: + constraint_items = re.findall(r"
  • (.*?)
  • ", constraints_match.group(1)) + constraints = [HTMLParser.clean_html(item) for item in constraint_items] + + return {"description": description, "examples": examples, "constraints": constraints} + + @staticmethod + def parse_test_cases(test_cases_str: str) -> List[List[str]]: + """Parse test cases from the exampleTestcases string.""" + if not test_cases_str: + return [] + + # Split by newlines and group into test cases + lines = [line.strip() for line in test_cases_str.split("\n") if line.strip()] + + # Group lines into test cases + test_cases = [] + current_case = [] + + for line in lines: + if line.startswith("[") or line.startswith('"') or line.isdigit() or line.startswith("-"): + current_case.append(line) + else: + if current_case: + test_cases.append(current_case) + current_case = [line] + + if current_case: + test_cases.append(current_case) + + return test_cases diff --git a/leetcode_py/tools/scraper.py b/leetcode_py/tools/scraper.py new file mode 100644 index 0000000..8d6cfa5 --- /dev/null +++ b/leetcode_py/tools/scraper.py @@ -0,0 +1,137 @@ +"""LeetCode GraphQL API scraper to fetch problem information.""" + +from typing import Any, Dict, Optional + +import requests + +from .parser import HTMLParser + + +class LeetCodeScraper: + """Scraper for LeetCode problem information using GraphQL API.""" + + def __init__(self): + self.base_url = "https://leetcode.com/graphql" + self.headers = { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + } + + def get_problem_by_slug(self, problem_slug: str) -> Optional[Dict[str, Any]]: + """Get problem info by problem slug (e.g., 'two-sum').""" + query = """ + query questionData($titleSlug: String!) { + question(titleSlug: $titleSlug) { + questionId + questionFrontendId + title + titleSlug + content + difficulty + topicTags { + name + } + codeSnippets { + lang + langSlug + code + } + exampleTestcases + } + } + """ + + variables = {"titleSlug": problem_slug} + response = requests.post( + self.base_url, json={"query": query, "variables": variables}, headers=self.headers + ) + + if response.status_code == 200: + data = response.json() + return data.get("data", {}).get("question") + return None + + def get_problem_by_number(self, problem_number: int) -> Optional[Dict[str, Any]]: + """Get problem info by problem number (e.g., 1 for Two Sum).""" + # First try to get slug from algorithms API + slug = self._get_slug_by_number(problem_number) + if slug: + return self.get_problem_by_slug(slug) + + return self._try_common_slugs(problem_number) + + def _get_slug_by_number(self, problem_number: int) -> Optional[str]: + """Get problem slug by number using the algorithms API.""" + try: + response = requests.get( + "https://leetcode.com/api/problems/algorithms/", headers=self.headers + ) + + if response.status_code == 200: + data = response.json() + for problem in data.get("stat_status_pairs", []): + if problem["stat"]["frontend_question_id"] == problem_number: + return problem["stat"]["question__title_slug"] + except Exception: + pass + + return None + + def _try_common_slugs(self, problem_number: int) -> Optional[Dict[str, Any]]: + """Try common slug patterns for well-known problems.""" + common_slugs = { + 1: "two-sum", + 2: "add-two-numbers", + 3: "longest-substring-without-repeating-characters", + 15: "3sum", + 20: "valid-parentheses", + 21: "merge-two-sorted-lists", + 53: "maximum-subarray", + 121: "best-time-to-buy-and-sell-stock", + 125: "valid-palindrome", + 226: "invert-binary-tree", + } + + if problem_number in common_slugs: + return self.get_problem_by_slug(common_slugs[problem_number]) + + return None + + def get_python_code(self, problem_info: Dict[str, Any]) -> Optional[str]: + """Extract Python code snippet from problem info.""" + if not problem_info or "codeSnippets" not in problem_info: + return None + + for snippet in problem_info["codeSnippets"]: + if snippet.get("langSlug") == "python3": + return snippet.get("code") + return None + + def format_problem_info(self, problem_info: Dict[str, Any]) -> Dict[str, Any]: + """Format problem info into a clean structure.""" + if not problem_info: + return {} + + topics = [tag["name"] for tag in problem_info.get("topicTags", [])] + python_code = self.get_python_code(problem_info) + + # Parse content for description, examples, and constraints + content = problem_info.get("content", "") + parsed_content = HTMLParser.parse_content(content) + + # Parse test cases + test_cases = HTMLParser.parse_test_cases(problem_info.get("exampleTestcases", "")) + + return { + "number": problem_info.get("questionFrontendId"), + "title": problem_info.get("title"), + "slug": problem_info.get("titleSlug"), + "difficulty": problem_info.get("difficulty"), + "topics": topics, + "description": parsed_content["description"], + "examples": parsed_content["examples"], + "constraints": parsed_content["constraints"], + "python_code": python_code, + "test_cases": test_cases, + "raw_content": content, + } diff --git a/poetry.lock b/poetry.lock index f12021e..fb708f9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -142,7 +142,7 @@ version = "2025.8.3" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, @@ -259,7 +259,7 @@ version = "3.4.3" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72"}, {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe"}, @@ -633,7 +633,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -1586,7 +1586,7 @@ version = "2.32.5" description = "Python HTTP for Humans." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, @@ -1846,7 +1846,7 @@ version = "2.5.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, @@ -1910,4 +1910,4 @@ dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "5f928d6ce2e601e55ea877e851e1d9d0109e1554d2d36cf803302a5056eeb5c3" +content-hash = "01523761e407eb4721a6ac25255a75913ec9eb170901cd8ad47a4dc31857fef4" diff --git a/pyproject.toml b/pyproject.toml index 8585d9d..4dfd014 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.13" +requests = "^2.32.5" typer = "^0.17.0" [tool.poetry.group.base.dependencies] diff --git a/tests/data_structures/__init__.py b/tests/data_structures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_list_node.py b/tests/data_structures/test_list_node.py similarity index 98% rename from tests/test_list_node.py rename to tests/data_structures/test_list_node.py index 12cc04e..236f188 100644 --- a/tests/test_list_node.py +++ b/tests/data_structures/test_list_node.py @@ -1,6 +1,6 @@ import pytest -from leetcode_py.list_node import ListNode +from leetcode_py import ListNode class TestListNode: diff --git a/tests/test_tree_node.py b/tests/data_structures/test_tree_node.py similarity index 95% rename from tests/test_tree_node.py rename to tests/data_structures/test_tree_node.py index 3f872f3..4d07f9f 100644 --- a/tests/test_tree_node.py +++ b/tests/data_structures/test_tree_node.py @@ -1,6 +1,7 @@ import pytest -from leetcode_py.tree_node import TreeNode, build_anytree +from leetcode_py import TreeNode +from leetcode_py.data_structures.tree_node import build_anytree class TestTreeNode: @@ -155,7 +156,9 @@ def test_str_with_none_tree(self): # This happens when we have a node but build_anytree fails import unittest.mock - with unittest.mock.patch("leetcode_py.tree_node.build_anytree", return_value=None): + with unittest.mock.patch( + "leetcode_py.data_structures.tree_node.build_anytree", return_value=None + ): node = TreeNode(1) result = str(node) assert result == "None" diff --git a/tests/tools/__init__.py b/tests/tools/__init__.py new file mode 100644 index 0000000..206835c --- /dev/null +++ b/tests/tools/__init__.py @@ -0,0 +1 @@ +"""Tests for leetcode_py.tools package.""" diff --git a/tests/tools/test_generator.py b/tests/tools/test_generator.py new file mode 100644 index 0000000..f8020c0 --- /dev/null +++ b/tests/tools/test_generator.py @@ -0,0 +1,238 @@ +from pathlib import Path +from typing import Any + +from leetcode_py.tools.generator import TemplateGenerator + + +class TestTemplateGenerator: + """Test cases for TemplateGenerator.""" + + def setup_method(self): + """Set up test fixtures.""" + self.generator = TemplateGenerator() + + def test_init(self): + """Test generator initialization.""" + assert "grind-75" in self.generator.common_tags + assert "blind-75" in self.generator.common_tags + + def test_auto_set_dummy_return_bool(self): + """Test auto-setting dummy return for bool type.""" + data: dict[str, Any] = {"return_type": "bool"} + result = self.generator.auto_set_dummy_return(data) + assert result["dummy_return"] == "False" + + def test_auto_set_dummy_return_list(self): + """Test auto-setting dummy return for list type.""" + data: dict[str, Any] = {"return_type": "list[int]"} + result = self.generator.auto_set_dummy_return(data) + assert result["dummy_return"] == "[]" + + def test_auto_set_dummy_return_existing(self): + """Test that existing dummy_return is not overwritten.""" + data: dict[str, Any] = {"return_type": "bool", "dummy_return": "True"} + result = self.generator.auto_set_dummy_return(data) + assert result["dummy_return"] == "True" + + def test_auto_set_dummy_return_all_types(self): + """Test auto_set_dummy_return for all supported types.""" + test_cases = [ + ("int", "0"), + ("str", '""'), + ("float", "0.0"), + ("None", "None"), + ("dict[str, int]", "{}"), + ("set[int]", "set()"), + ("tuple[int, str]", "()"), + ("CustomType", "None"), # Unknown type defaults to None + ] + + for return_type, expected_dummy in test_cases: + data: dict[str, Any] = {"return_type": return_type} + result = self.generator.auto_set_dummy_return(data) + assert result["dummy_return"] == expected_dummy + + def test_auto_set_dummy_return_no_return_type(self): + """Test auto_set_dummy_return when no return_type is provided.""" + data: dict[str, Any] = {"problem_name": "test"} + result = self.generator.auto_set_dummy_return(data) + assert "dummy_return" not in result + + def test_convert_arrays_to_nested(self): + """Test converting arrays to nested format.""" + data: dict[str, Any] = { + "examples": [{"input": "test"}], + "tags": ["grind-75"], + "other_field": "value", + } + + result = self.generator.convert_arrays_to_nested(data) + + assert "_examples" in result + assert result["_examples"] == {"list": [{"input": "test"}]} + assert "_tags" in result + assert result["_tags"] == {"list": ["grind-75"]} + assert "examples" not in result + assert "tags" not in result + assert result["other_field"] == "value" + + def test_convert_arrays_to_nested_partial_arrays(self): + """Test converting only some arrays to nested format.""" + data: dict[str, Any] = { + "examples": [{"input": "test"}], + "test_cases": [[1, 2, 3]], + "other_list": ["not", "converted"], # Not in array_fields + "string_field": "value", + } + + result = self.generator.convert_arrays_to_nested(data) + + assert "_examples" in result + assert "_test_cases" in result + assert "other_list" in result # Should remain unchanged + assert result["other_list"] == ["not", "converted"] + assert result["string_field"] == "value" + + def test_convert_arrays_to_nested_non_list_values(self): + """Test converting arrays when field exists but is not a list.""" + data: dict[str, Any] = {"examples": "not a list", "tags": None, "test_cases": 123} + + result = self.generator.convert_arrays_to_nested(data) + + # Non-list values should remain unchanged + assert result["examples"] == "not a list" + assert result["tags"] is None + assert result["test_cases"] == 123 + + def test_check_overwrite_permission_force(self): + """Test overwrite permission with force flag.""" + output_dir = Path("/fake/output") + # Should not raise exception with force=True + self.generator.check_overwrite_permission("test_problem", True, output_dir) + + def test_check_overwrite_permission_nonexistent_problem(self): + """Test overwrite permission when problem doesn't exist.""" + output_dir = Path("/nonexistent/output") + # Should not raise exception when problem doesn't exist + self.generator.check_overwrite_permission("nonexistent_problem", False, output_dir) + + def test_check_and_prompt_tags_with_existing_tags(self): + """Test check_and_prompt_tags when tags already exist.""" + data: dict[str, Any] = {"tags": ["existing-tag"]} + result = self.generator.check_and_prompt_tags(data) + assert result["tags"] == ["existing-tag"] # Should remain unchanged + + def test_check_and_prompt_tags_no_tags_field(self): + """Test check_and_prompt_tags when no tags field exists.""" + data: dict[str, Any] = {"problem_name": "test"} + result = self.generator.check_and_prompt_tags(data) + assert result == data # Should remain unchanged + + def test_check_and_prompt_tags_non_interactive(self): + """Test check_and_prompt_tags in non-interactive mode.""" + import io + import sys + + # Simulate non-interactive terminal + original_stdin = sys.stdin + sys.stdin = io.StringIO() # Empty stdin + + try: + data: dict[str, Any] = {"tags": []} + result = self.generator.check_and_prompt_tags(data) + assert result["tags"] == [] # Should remain empty + finally: + sys.stdin = original_stdin + + def test_generate_problem_components(self): + """Test individual components of problem generation.""" + # Test data processing + data: dict[str, Any] = {"problem_name": "test", "return_type": "bool", "tags": []} + + # Test auto_set_dummy_return + processed_data = self.generator.auto_set_dummy_return(data) + assert processed_data["dummy_return"] == "False" + + # Test convert_arrays_to_nested + nested_data = self.generator.convert_arrays_to_nested(processed_data) + assert "_tags" in nested_data + assert nested_data["_tags"] == {"list": []} + + def test_file_operations_injection(self): + """Test that file operations can be injected for testing.""" + from unittest.mock import Mock + + from leetcode_py.tools.generator import FileOperations + + mock_file_ops = Mock(spec=FileOperations) + generator = TemplateGenerator(file_ops=mock_file_ops) + assert generator.file_ops is mock_file_ops + + def test_check_and_prompt_tags_interactive_valid_choices(self): + """Test interactive tag selection with valid choices.""" + from unittest.mock import patch + + data: dict[str, Any] = {"tags": []} + + with ( + patch("sys.stdin.isatty", return_value=True), + patch("typer.prompt", return_value="1,2"), + patch("typer.echo"), + ): + result = self.generator.check_and_prompt_tags(data) + assert "grind-75" in result["tags"] + assert "blind-75" in result["tags"] + + def test_check_and_prompt_tags_interactive_skip(self): + """Test interactive tag selection with skip option.""" + from unittest.mock import patch + + data: dict[str, Any] = {"tags": []} + + with ( + patch("sys.stdin.isatty", return_value=True), + patch("typer.prompt", return_value="0"), + patch("typer.echo"), + ): + result = self.generator.check_and_prompt_tags(data) + assert result["tags"] == [] + + def test_check_and_prompt_tags_interactive_invalid_input(self): + """Test interactive tag selection with invalid input.""" + from unittest.mock import patch + + data: dict[str, Any] = {"tags": []} + + with ( + patch("sys.stdin.isatty", return_value=True), + patch("typer.prompt", return_value="invalid"), + patch("typer.echo"), + ): + result = self.generator.check_and_prompt_tags(data) + assert result["tags"] == [] + + def test_generate_problem_success(self): + """Test successful problem generation.""" + from unittest.mock import Mock, patch + + from leetcode_py.tools.generator import FileOperations + + mock_file_ops = Mock(spec=FileOperations) + mock_file_ops.exists.side_effect = lambda path: str(path).endswith("test.json") + mock_file_ops.read_json.return_value = { + "problem_name": "test_problem", + "return_type": "bool", + "tags": [], + } + + generator = TemplateGenerator(file_ops=mock_file_ops) + + template_dir = Path("/test/template") + output_dir = Path("/test/output") + + with patch("leetcode_py.tools.generator.cookiecutter", return_value=None) as mock_cookiecutter: + generator.generate_problem("test.json", template_dir, output_dir, force=True) + + mock_file_ops.read_json.assert_called_once() + mock_file_ops.write_json.assert_called_once() + mock_cookiecutter.assert_called_once() diff --git a/tests/tools/test_generator_file_ops.py b/tests/tools/test_generator_file_ops.py new file mode 100644 index 0000000..5a05fff --- /dev/null +++ b/tests/tools/test_generator_file_ops.py @@ -0,0 +1,137 @@ +"""Tests for TemplateGenerator file operations.""" + +from pathlib import Path +from unittest.mock import Mock + +import pytest +import typer + +from leetcode_py.tools.generator import FileOperations, TemplateGenerator + + +class TestTemplateGeneratorFileOps: + """Test cases for file operations in TemplateGenerator.""" + + def setup_method(self): + """Set up test fixtures.""" + self.generator = TemplateGenerator() + + def test_generate_problem_file_not_found(self): + """Test generate_problem when JSON file doesn't exist.""" + mock_file_ops = Mock(spec=FileOperations) + mock_file_ops.exists.return_value = False + generator = TemplateGenerator(file_ops=mock_file_ops) + + template_dir = Path("/test/template") + output_dir = Path("/test/output") + + with pytest.raises(typer.Exit): + generator.generate_problem("nonexistent.json", template_dir, output_dir, False) + + def test_auto_set_dummy_return_comprehensive(self): + """Test all branches of auto_set_dummy_return.""" + # Test when dummy_return already exists + data_with_dummy = {"return_type": "bool", "dummy_return": "existing"} + result = self.generator.auto_set_dummy_return(data_with_dummy) + assert result["dummy_return"] == "existing" + + # Test when no return_type exists + data_no_return_type = {"problem_name": "test"} + result = self.generator.auto_set_dummy_return(data_no_return_type) + assert "dummy_return" not in result + + # Test all type mappings + type_mappings = {"bool": "False", "int": "0", "str": '""', "float": "0.0", "None": "None"} + + for return_type, expected in type_mappings.items(): + data = {"return_type": return_type} + result = self.generator.auto_set_dummy_return(data) + assert result["dummy_return"] == expected + + # Test container types + container_types = [ + ("list[int]", "[]"), + ("dict[str, int]", "{}"), + ("set[str]", "set()"), + ("tuple[int, str]", "()"), + ] + + for return_type, expected in container_types: + data = {"return_type": return_type} + result = self.generator.auto_set_dummy_return(data) + assert result["dummy_return"] == expected + + # Test unknown type + data_unknown = {"return_type": "UnknownType"} + result = self.generator.auto_set_dummy_return(data_unknown) + assert result["dummy_return"] == "None" + + def test_default_file_operations_read_json_success(self): + """Test DefaultFileOperations read_json success.""" + import json + import tempfile + + from leetcode_py.tools.generator import DefaultFileOperations + + file_ops = DefaultFileOperations() + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + test_data = {"test": "data"} + json.dump(test_data, f) + f.flush() + + result = file_ops.read_json(Path(f.name)) + assert result == test_data + + Path(f.name).unlink() # Clean up + + def test_default_file_operations_read_json_error(self): + """Test DefaultFileOperations read_json with invalid JSON.""" + import tempfile + + from leetcode_py.tools.generator import DefaultFileOperations + + file_ops = DefaultFileOperations() + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write("invalid json") + f.flush() + + with pytest.raises(typer.Exit): + file_ops.read_json(Path(f.name)) + + Path(f.name).unlink() # Clean up + + def test_default_file_operations_write_json_success(self): + """Test DefaultFileOperations write_json success.""" + import json + import tempfile + + from leetcode_py.tools.generator import DefaultFileOperations + + file_ops = DefaultFileOperations() + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + test_data = {"test": "data"} + file_ops.write_json(Path(f.name), test_data) + + # Verify the file was written correctly + with open(f.name) as read_f: + result = json.load(read_f) + assert result == test_data + + Path(f.name).unlink() # Clean up + + def test_default_file_operations_exists(self): + """Test DefaultFileOperations exists method.""" + import tempfile + + from leetcode_py.tools.generator import DefaultFileOperations + + file_ops = DefaultFileOperations() + + with tempfile.NamedTemporaryFile(delete=False) as f: + assert file_ops.exists(Path(f.name)) is True + + Path(f.name).unlink() + assert file_ops.exists(Path(f.name)) is False diff --git a/tests/tools/test_parser.py b/tests/tools/test_parser.py new file mode 100644 index 0000000..8330ebe --- /dev/null +++ b/tests/tools/test_parser.py @@ -0,0 +1,58 @@ +from leetcode_py.tools.parser import HTMLParser + + +class TestHTMLParser: + """Test cases for HTMLParser.""" + + def test_clean_html(self): + """Test HTML tag removal.""" + html = "

    Hello world

    " + result = HTMLParser.clean_html(html) + assert result == "Hello world" + + def test_parse_content_with_examples(self): + """Test parsing content with examples.""" + html_content = """ +

    Given an array of integers nums, return indices.

    + +

    Example 1:

    +
    +        Input: nums = [2,7,11,15], target = 9
    +        Output: [0,1]
    +        
    + +

    Constraints:

    + + """ + + result = HTMLParser.parse_content(html_content) + + assert "Given an array of integers nums, return indices." in result["description"] + assert len(result["examples"]) == 1 + assert result["examples"][0]["number"] == 1 + assert "nums = [2,7,11,15]" in result["examples"][0]["text"] + assert len(result["constraints"]) == 2 + assert "nums.length" in result["constraints"][0] + + def test_parse_test_cases(self): + """Test parsing test cases.""" + test_cases_str = """[2,7,11,15] +9 +[3,2,4] +6""" + + result = HTMLParser.parse_test_cases(test_cases_str) + + assert len(result) == 1 + assert result[0] == ["[2,7,11,15]", "9", "[3,2,4]", "6"] + + def test_parse_empty_content(self): + """Test parsing empty content.""" + result = HTMLParser.parse_content("") + + assert result["description"] == "" + assert result["examples"] == [] + assert result["constraints"] == [] diff --git a/tests/tools/test_scraper.py b/tests/tools/test_scraper.py new file mode 100644 index 0000000..0d80562 --- /dev/null +++ b/tests/tools/test_scraper.py @@ -0,0 +1,87 @@ +from leetcode_py.tools.scraper import LeetCodeScraper + + +class TestLeetCodeScraper: + """Test cases for LeetCodeScraper with real API calls.""" + + def setup_method(self): + """Set up test fixtures.""" + self.scraper = LeetCodeScraper() + + def test_init(self): + """Test scraper initialization.""" + assert self.scraper.base_url == "https://leetcode.com/graphql" + assert "Content-Type" in self.scraper.headers + + def test_get_python_code(self): + """Test Python code extraction.""" + problem_info = { + "codeSnippets": [ + {"langSlug": "java", "code": "class Solution {}"}, + {"langSlug": "python3", "code": "class Solution:\n def test(self):"}, + {"langSlug": "cpp", "code": "class Solution {};"}, + ] + } + + result = self.scraper.get_python_code(problem_info) + assert result == "class Solution:\n def test(self):" + + def test_get_python_code_not_found(self): + """Test Python code extraction when not available.""" + problem_info = {"codeSnippets": [{"langSlug": "java", "code": "class Solution {}"}]} + + result = self.scraper.get_python_code(problem_info) + assert result is None + + def test_try_common_slugs_known(self): + """Test common slug fallback for known problem.""" + result = self.scraper._try_common_slugs(1) + assert result is not None + assert result["title"] == "Two Sum" + assert result["difficulty"] == "Easy" + + def test_try_common_slugs_unknown(self): + """Test common slug fallback for unknown problem.""" + result = self.scraper._try_common_slugs(9999) + assert result is None + + def test_get_problem_by_slug_success(self): + """Test successful problem retrieval by slug.""" + result = self.scraper.get_problem_by_slug("two-sum") + + assert result is not None + assert result["title"] == "Two Sum" + assert result["difficulty"] == "Easy" + assert result["questionFrontendId"] == "1" + + def test_get_problem_by_slug_failure(self): + """Test failed problem retrieval by slug.""" + result = self.scraper.get_problem_by_slug("non-existent-problem-12345") + assert result is None + + def test_get_problem_by_number_success(self): + """Test successful problem retrieval by number.""" + result = self.scraper.get_problem_by_number(1) + + assert result is not None + assert result["title"] == "Two Sum" + assert result["questionFrontendId"] == "1" + + def test_format_problem_info_real_data(self): + """Test problem info formatting with real data.""" + # Get real problem data + problem_info = self.scraper.get_problem_by_slug("two-sum") + assert problem_info is not None + + # Format it + result = self.scraper.format_problem_info(problem_info) + + assert result["number"] == "1" + assert result["title"] == "Two Sum" + assert result["difficulty"] == "Easy" + assert "Array" in result["topics"] + assert "Hash Table" in result["topics"] + assert result["python_code"] is not None + assert "class Solution:" in result["python_code"] + assert len(result["examples"]) > 0 + assert len(result["constraints"]) > 0