diff --git a/.amazonq/rules/development-rules.md b/.amazonq/rules/development-rules.md index d7f1080..8d11859 100644 --- a/.amazonq/rules/development-rules.md +++ b/.amazonq/rules/development-rules.md @@ -11,6 +11,7 @@ - Use snake_case for Python methods - Include type hints: `list[str]`, `dict[str, int]`, `Type | None` - Follow linting rules (black, isort, ruff, mypy) +- **NO noise docstrings**: Avoid docstrings that merely restate the function name (e.g., `"""Test CLI help command."""` for `test_cli_help()`). Only add docstrings when they provide meaningful context beyond what the code itself conveys ## Testing diff --git a/.amazonq/rules/problem-creation.md b/.amazonq/rules/problem-creation.md index 4cafbf6..a262244 100644 --- a/.amazonq/rules/problem-creation.md +++ b/.amazonq/rules/problem-creation.md @@ -4,7 +4,7 @@ When user requests a problem by **number** or **name/slug**, the assistant will: -1. **Scrape** problem data using `.templates/leetcode/scrape.py` +1. **Scrape** problem data using `lcpy scrape` 2. **Transform** data into proper JSON template format 3. **CRITICAL: Include images** - Extract image URLs from scraped data and add to readme_examples with format: `![Example N](image_url)\n\n` before code blocks - Check scraped data for image URLs in the `raw_content` field @@ -12,7 +12,7 @@ When user requests a problem by **number** or **name/slug**, the assistant will: - Common patterns: `kthtree1.jpg`, `kthtree2.jpg`, `clone_graph.png`, `container.jpg` - Images provide crucial visual context, especially for tree and graph problems - Always verify images are included in `readme_examples` and accessible -4. **Create** JSON file in `.templates/leetcode/json/{problem_name}.json` +4. **Create** JSON file in `leetcode_py/cli/resources/leetcode/json/problems/{problem_name}.json` 5. **Update** Makefile with `PROBLEM ?= {problem_name}` 6. **Generate** problem structure using `make p-gen` 7. **Verify** with `make p-lint` - fix template issues in JSON if possible, or manually fix generated files if template limitations @@ -22,15 +22,15 @@ When user requests a problem by **number** or **name/slug**, the assistant will: ```bash # Fetch by number -poetry run python .templates/leetcode/scrape.py -n 1 +lcpy scrape -n 1 # Fetch by slug -poetry run python .templates/leetcode/scrape.py -s "two-sum" +lcpy scrape -s "two-sum" ``` ## JSON Template Format -Required fields for `.templates/leetcode/json/{problem_name}.json`: +Required fields for `leetcode_py/cli/resources/leetcode/json/problems/{problem_name}.json`: **CRITICAL: Use single quotes for Python strings in playground fields to avoid JSON escaping issues with Jupyter notebooks.** @@ -41,61 +41,15 @@ Required fields for `.templates/leetcode/json/{problem_name}.json`: - `playground_assertion`: Use single quotes for string literals - Double quotes in JSON + cookiecutter + Jupyter notebook = triple escaping issues -**Reference examples in `.templates/leetcode/examples/` for complete templates:** - -- `basic.json5` - All standard problems (array, string, tree, linked list, etc.) -- `design.json5` - Data structure design problems (LRU Cache, etc.) - -````json -{ - "problem_name": "two_sum", - "solution_class_name": "Solution", - "problem_number": "1", - "problem_title": "Two Sum", - "difficulty": "Easy", - "topics": "Array, Hash Table", - "tags": ["grind-75"], - "readme_description": "Given an array of integers `nums` and an integer `target`, return indices of the two numbers such that they add up to `target`.", - "readme_examples": [ - { - "content": "![Example 1](https://example.com/image1.jpg)\n\n```\nInput: nums = [2,7,11,15], target = 9\nOutput: [0,1]\n```\n**Explanation:** Because nums[0] + nums[1] == 9, we return [0, 1]." - } - ], - "readme_constraints": "- 2 <= nums.length <= 10^4\n- -10^9 <= nums[i] <= 10^9\n- -10^9 <= target <= 10^9\n- Only one valid answer exists.", - "readme_additional": "", - "solution_imports": "", - "solution_methods": [ - { - "name": "two_sum", - "parameters": "nums: list[int], target: int", - "return_type": "list[int]", - "dummy_return": "[]" - } - ], - "test_imports": "import pytest\nfrom leetcode_py.test_utils import logged_test\nfrom .solution import Solution", - "test_class_name": "TwoSum", - "test_helper_methods": [ - { - "name": "setup_method", - "parameters": "", - "body": "self.solution = Solution()" - } - ], - "test_methods": [ - { - "name": "test_two_sum", - "parametrize": "nums, target, expected", - "parametrize_typed": "nums: list[int], target: int, expected: list[int]", - "test_cases": "[([2, 7, 11, 15], 9, [0, 1]), ([3, 2, 4], 6, [1, 2])]", - "body": "result = self.solution.two_sum(nums, target)\nassert result == expected" - } - ], - "playground_imports": "from solution import Solution", - "playground_test_case": "# Example test case\nnums = [2, 7, 11, 15]\ntarget = 9\nexpected = [0, 1]", - "playground_execution": "result = Solution().two_sum(nums, target)\nresult", - "playground_assertion": "assert result == expected" -} -```` +**Reference the complete template example:** + +See `leetcode_py/cli/resources/leetcode/examples/example.json5` for a comprehensive template with: + +- All field definitions and variations +- Comments explaining each field +- Examples for different problem types (basic, tree, linked list, design, trie) +- Proper JSON escaping rules for playground fields +- Multiple solution class patterns ## Naming Conventions diff --git a/.amazonq/rules/test-case-enhancement.md b/.amazonq/rules/test-quality-assurance.md similarity index 79% rename from .amazonq/rules/test-case-enhancement.md rename to .amazonq/rules/test-quality-assurance.md index 8b341d7..dd68f27 100644 --- a/.amazonq/rules/test-case-enhancement.md +++ b/.amazonq/rules/test-quality-assurance.md @@ -1,4 +1,4 @@ -# Test Case Enhancement Rules +# Test Quality Assurance Rules ## Simple Enhancement Workflow @@ -7,7 +7,7 @@ When user requests test case enhancement or **test reproducibility verification* ### 1. Problem Resolution - Use active file context or user-provided problem name -- If unclear, run: `poetry run python .templates/check_test_cases.py --threshold=10 --max=1` +- If unclear, run: `poetry run python -m leetcode_py.tools.check_test_cases --threshold=10 --max=1` ### 2. Enhancement Process @@ -55,20 +55,35 @@ mv .cache/leetcode/{problem_name} leetcode/{problem_name} ## Quick Commands +### CLI Commands (Recommended) + ```bash -# Find problems needing enhancement -poetry run python .templates/check_test_cases.py --threshold=10 +# Generate enhanced problem +lcpy gen -s {problem_name} -o leetcode --force # Test specific problem make p-test PROBLEM={problem_name} -# Generate from JSON template -make p-gen PROBLEM={problem_name} FORCE=1 - # Lint check make p-lint PROBLEM={problem_name} ``` +### Development Commands + +```bash +# Find problems needing enhancement +poetry run python -m leetcode_py.tools.check_test_cases --threshold=10 + +# Check all problems (no limit) +poetry run python -m leetcode_py.tools.check_test_cases --threshold=10 --max=none + +# Check with custom threshold +poetry run python -m leetcode_py.tools.check_test_cases --threshold=12 + +# Generate from JSON template (uses lcpy internally) +make p-gen PROBLEM={problem_name} FORCE=1 +``` + ## Test Reproducibility Verification Use this same workflow when CI tests fail due to reproducibility issues: diff --git a/.github/workflows/ci-test-reproducibility.yml b/.github/workflows/ci-test-reproducibility.yml index 1be9cab..4e1a841 100644 --- a/.github/workflows/ci-test-reproducibility.yml +++ b/.github/workflows/ci-test-reproducibility.yml @@ -62,7 +62,7 @@ jobs: fi - name: Check test case count - run: poetry run python .templates/check_test_cases.py --threshold=10 --max=100 + run: poetry run python -m leetcode_py.tools.check_test_cases --threshold=10 --max=none - name: Backup existing problems run: | diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 7526068..6365946 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -33,7 +33,9 @@ jobs: key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} - name: Install dependencies - run: poetry install --no-interaction --no-ansi + run: | + poetry install --no-interaction --no-ansi + poetry run pip install -e . - name: Cache Graphviz installation id: cache-graphviz diff --git a/.templates/check_test_cases.py b/.templates/check_test_cases.py deleted file mode 100644 index a99a310..0000000 --- a/.templates/check_test_cases.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env python3 - -import json -from pathlib import Path -from typing import Optional -import typer - - -def count_test_cases(json_data): - """Count total test cases across all test methods.""" - total = 0 - - # Handle both direct test_methods and nested _test_methods.list - test_methods = json_data.get("test_methods", []) - if not test_methods and "_test_methods" in json_data: - test_methods = json_data["_test_methods"].get("list", []) - - for method in test_methods: - test_cases = method.get("test_cases", "") - if test_cases.strip(): - # Parse the test_cases string to count actual test cases - try: - # Remove outer brackets and split by top-level commas - cases_str = test_cases.strip() - if cases_str.startswith("[") and cases_str.endswith("]"): - cases_str = cases_str[1:-1] # Remove outer brackets - - # Count test cases by counting commas at parenthesis depth 0 - depth = 0 - case_count = 1 if cases_str.strip() else 0 - - for char in cases_str: - if char in "([{": - depth += 1 - elif char in ")]}": - depth -= 1 - elif char == "," and depth == 0: - case_count += 1 - - total += case_count - except Exception: - # Fallback to old method if parsing fails - total += test_cases.count("(") - test_cases.count("([") + test_cases.count("[(") - return total - - -def main( - threshold: int = typer.Option( - 10, "--threshold", "-t", help="Show files with test cases <= threshold" - ), - max_results: str = typer.Option( - 1, "--max", "-m", help="Maximum number of results to show ('none' for no limit)" - ), -): - """Check test case counts in LeetCode JSON templates.""" - json_dir = Path(".templates/leetcode/json") - all_files = [] - - for json_file in json_dir.glob("*.json"): - try: - with open(json_file) as f: - data = json.load(f) - - test_count = count_test_cases(data) - all_files.append((json_file.name, test_count)) - except Exception as e: - typer.echo(f"Error reading {json_file.name}: {e}", err=True) - - # Sort by test count - all_files.sort(key=lambda x: x[1]) - - # Filter by threshold - filtered_files = [f for f in all_files if f[1] <= threshold] - - # Apply max results limit - if max_results.lower() not in ["none", "null", "-1"]: - try: - max_count = int(max_results) - if max_count > 0: - filtered_files = filtered_files[:max_count] - except ValueError: - typer.echo(f"Invalid max_results value: {max_results}", err=True) - raise typer.Exit(1) - - typer.echo(f"Problems with ≀{threshold} test cases ({len(filtered_files)} total):") - for filename, count in filtered_files: - typer.echo(f"{filename}: {count} test cases") - - # Exit with non-zero code if any files found - if filtered_files: - raise typer.Exit(1) - - -if __name__ == "__main__": - typer.run(main) diff --git a/.templates/leetcode/gen.py b/.templates/leetcode/gen.py deleted file mode 100644 index 585c769..0000000 --- a/.templates/leetcode/gen.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python3 -"""Compatibility wrapper for generator.""" -import sys -from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) - -import typer -from leetcode_py.tools import TemplateGenerator - -app = typer.Typer(help="Generate LeetCode problem templates") - - -@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" - generator.generate_problem(json_file, template_dir, output_dir, force) - - -if __name__ == "__main__": - app() diff --git a/.templates/leetcode/scrape.py b/.templates/leetcode/scrape.py deleted file mode 100644 index 82fa996..0000000 --- a/.templates/leetcode/scrape.py +++ /dev/null @@ -1,48 +0,0 @@ -#!/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/CONTRIBUTING.md b/CONTRIBUTING.md index 60ef591..f1d27ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ Use an LLM assistant (Cursor, GitHub Copilot Chat, Amazon Q) with the rule files ### 2. Enhance Test Cases -- Include `.amazonq/rules/test-case-enhancement.md` in your LLM context +- Include `.amazonq/rules/test-quality-assurance.md` in your LLM context - Ask: "Enhance test cases for [problem_name] problem" ### 3. Improve Helper Classes diff --git a/Makefile b/Makefile index c6eeb6a..1849e62 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ define lint_target poetry run black $(1) poetry run isort $(1) $(if $(filter .,$(1)), \ - poetry run nbqa ruff . --nbqa-exclude=".templates" --ignore=F401$(COMMA)F821, \ + poetry run nbqa ruff . --nbqa-exclude="leetcode_py/cli/resources/" --ignore=F401$(COMMA)F821, \ poetry run nbqa ruff $(1) --ignore=F401$(COMMA)F821) poetry run ruff check $(1) --exclude="**/*.ipynb" poetry run mypy $(1) \ @@ -33,10 +33,10 @@ define lint_target --non-interactive \ --check-untyped-defs $(if $(filter .,$(1)), \ - poetry run nbqa isort . --nbqa-exclude=".templates", \ + poetry run nbqa isort . --nbqa-exclude="leetcode_py/cli/resources/", \ poetry run nbqa isort $(1)) $(if $(filter .,$(1)), \ - poetry run nbqa mypy . --nbqa-exclude=".templates" \ + poetry run nbqa mypy . --nbqa-exclude="leetcode_py/cli/resources/" \ --ignore-missing-imports --disable-error-code=name-defined, \ poetry run nbqa mypy $(1) --ignore-missing-imports --disable-error-code=name-defined) endef @@ -72,7 +72,7 @@ p-lint: p-gen: @echo "Generating problem: $(PROBLEM)" - poetry run python .templates/leetcode/gen.py .templates/leetcode/json/$(PROBLEM).json $(if $(filter 1,$(FORCE)),--force) + poetry run lcpy gen -s $(PROBLEM) -o leetcode $(if $(filter 1,$(FORCE)),--force) p-del: rm -rf leetcode/$(PROBLEM) @@ -84,6 +84,11 @@ nb-to-py: @find leetcode -name "*.ipynb" -delete @echo "Conversion complete. All .ipynb files converted to .py and deleted." +# Find problems with few test cases +check-test-cases: + @echo "Checking test case coverage..." + poetry run python leetcode_py/tools/check_test_cases.py --threshold=$(or $(THRESHOLD),10) --max=$(or $(MAX),1) + # Generate All Problems - useful for people who fork this repo gen-all-problems: @echo "This will DELETE all existing problems and regenerate from JSON templates." @@ -93,8 +98,4 @@ gen-all-problems: @echo "Deleting existing problems..." @rm -rf leetcode/*/ @echo "Generating all problems..." - @for json_file in .templates/leetcode/json/*.json; do \ - problem=$$(basename "$$json_file" .json); \ - echo "Generating: $$problem"; \ - poetry run python .templates/leetcode/gen.py "$$json_file" $(if $(filter 1,$(FORCE)),--force); \ - done + poetry run lcpy gen -t grind-75 -o leetcode $(if $(filter 1,$(FORCE)),--force) diff --git a/README.md b/README.md index ba2846d..bf5c4a9 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,23 @@ A modern Python LeetCode practice environment that goes beyond basic problem sol ## πŸš€ Quick Start +### CLI Installation (Recommended) + +```bash +# Install globally via pip +pip install leetcode-py + +# Generate problems anywhere +lcpy gen -n 1 # Generate Two Sum +lcpy gen -t grind-75 # Generate all Grind 75 problems +lcpy list -t grind-75 # List available problems +lcpy scrape -n 1 # Fetch problem data +``` + +### Development Setup + ```bash -# Clone and setup +# Clone and setup for development git clone https://github.com/wisarootl/leetcode-py.git cd leetcode-py poetry install @@ -77,10 +92,10 @@ leetcode/two_sum/ - **ListNode**: Clean arrow-based visualization (`1 -> 2 -> 3`) - **Interactive Debugging**: Multi-cell playground environment -![Tree Visualization Placeholder](docs/images/tree-viz.png) +![Tree Visualization](https://raw.githubusercontent.com/wisarootl/leetcode-py/main/docs/images/tree-viz.png) _Beautiful tree rendering with anytree and Graphviz_ -![LinkedList Visualization Placeholder](docs/images/linkedlist-viz.png) +![LinkedList Visualization](https://raw.githubusercontent.com/wisarootl/leetcode-py/main/docs/images/linkedlist-viz.png) _Clean arrow-based list visualization_ ### Flexible Notebook Support @@ -89,14 +104,36 @@ _Clean arrow-based list visualization_ - **Repository State**: This repo uses Python files (`.py`) for better version control - **User Choice**: Use `make nb-to-py` to convert notebooks to Python files, or keep as `.ipynb` for interactive development -![Notebook Placeholder](docs/images/notebook-example.png) +![Notebook Example](https://raw.githubusercontent.com/wisarootl/leetcode-py/main/docs/images/notebook-example.png) _Interactive multi-cell playground for each problem_ ## πŸ”„ Usage Patterns -### For Current Grind 75 Problems +### CLI Usage (Global Installation) + +Perfect for quick problem generation anywhere: + +```bash +# Generate specific problems +lcpy gen -n 1 -n 125 -n 206 # Multiple problems by number +lcpy gen -s two-sum -s valid-palindrome # Multiple problems by slug + +# Bulk generation +lcpy gen -t grind-75 # All Grind 75 problems +lcpy gen -t grind-75 -d Easy # Only Easy problems from Grind 75 -Perfect if you want to focus on the most essential interview problems: +# Explore available problems +lcpy list # All problems +lcpy list -t grind-75 # Filter by tag +lcpy list -d Medium # Filter by difficulty + +# Fetch problem data +lcpy scrape -n 1 > two_sum.json # Save problem data +``` + +### Development Workflow + +For repository development and customization: ```bash # Regenerate all 75 problems with fresh TODO placeholders @@ -108,7 +145,7 @@ make p-test PROBLEM=valid_palindrome make p-test PROBLEM=merge_two_sorted_lists ``` -### For Additional Problems (LLM-Assisted) +### LLM-Assisted Problem Creation If you need more problems beyond Grind 75, use an LLM assistant in your IDE (Cursor, GitHub Copilot Chat, Amazon Q, etc.): @@ -126,12 +163,12 @@ If you need more problems beyond Grind 75, use an LLM assistant in your IDE (Cur **Required LLM Context**: Include these rule files in your LLM context for automated problem generation and test enhancement: - [`.amazonq/rules/problem-creation.md`](.amazonq/rules/problem-creation.md) - Complete problem generation workflow -- [`.amazonq/rules/test-case-enhancement.md`](.amazonq/rules/test-case-enhancement.md) - Test enhancement and reproducibility verification +- [`.amazonq/rules/test-quality-assurance.md`](.amazonq/rules/test-quality-assurance.md) - Test enhancement and reproducibility verification **Manual Check**: Find problems needing more test cases: ```bash -poetry run python .templates/check_test_cases.py --threshold=10 +poetry run python -m leetcode_py.tools.check_test_cases --threshold=10 ``` ## 🧰 Helper Classes @@ -160,7 +197,30 @@ poetry run python .templates/check_test_cases.py --threshold=10 - Graphviz visualization for Jupyter notebooks - Generic key type support -## πŸ› οΈ Development Commands +## πŸ› οΈ Commands + +### CLI Commands (Global) + +```bash +# Generate problems +lcpy gen -n 1 # Single problem by number +lcpy gen -s two-sum # Single problem by slug +lcpy gen -t grind-75 # Bulk generation by tag +lcpy gen -n 1 -n 2 -n 3 # Multiple problems +lcpy gen -t grind-75 -d Easy # Filter by difficulty +lcpy gen -n 1 -o my-problems # Custom output directory + +# List problems +lcpy list # All available problems +lcpy list -t grind-75 # Filter by tag +lcpy list -d Medium # Filter by difficulty + +# Scrape problem data +lcpy scrape -n 1 # Fetch by number +lcpy scrape -s two-sum # Fetch by slug +``` + +### Development Commands (Repository) ```bash # Problem-specific operations diff --git a/docs/cli-usage.md b/docs/cli-usage.md new file mode 100644 index 0000000..811beb5 --- /dev/null +++ b/docs/cli-usage.md @@ -0,0 +1,210 @@ +# CLI Usage Guide + +## Installation + +```bash +pip install leetcode-py +``` + +## Quick Start + +```bash +# Generate a single problem +lcpy gen -n 1 + +# Generate multiple problems +lcpy gen -n 1 -n 125 -n 206 + +# Generate by slug +lcpy gen -s two-sum -s valid-palindrome + +# Generate all problems with a tag +lcpy gen -t grind-75 + +# List available problems +lcpy list + +# Scrape problem data +lcpy scrape -n 1 +``` + +## Commands + +### `lcpy gen` - Generate Problems + +Generate LeetCode problem templates from existing JSON definitions. + +**Basic Usage:** + +```bash +lcpy gen [OPTIONS] +``` + +**Options:** + +- `-n, --problem-num` - Problem number(s) (use multiple -n flags) +- `-s, --problem-slug` - Problem slug(s) (use multiple -s flags) +- `-t, --problem-tag` - Problem tag for bulk generation +- `-d, --difficulty` - Filter by difficulty (Easy/Medium/Hard) +- `--all` - Generate all problems +- `-o, --output` - Output directory (default: current directory) +- `--force` - Force overwrite existing files + +**Examples:** + +```bash +# Single problem by number +lcpy gen -n 1 + +# Multiple problems by number +lcpy gen -n 1 -n 2 -n 3 + +# Single problem by slug +lcpy gen -s two-sum + +# Multiple problems by slug +lcpy gen -s two-sum -s valid-palindrome + +# All problems with specific tag +lcpy gen -t grind-75 + +# Filter by difficulty +lcpy gen -t grind-75 -d Easy + +# Custom output directory +lcpy gen -n 1 -o my-problems + +# Force overwrite existing files +lcpy gen -n 1 --force +``` + +### `lcpy scrape` - Scrape Problem Data + +Fetch problem data from LeetCode and output as JSON. + +**Basic Usage:** + +```bash +lcpy scrape [OPTIONS] +``` + +**Options:** + +- `-n, --problem-num` - Problem number +- `-s, --problem-slug` - Problem slug + +**Examples:** + +```bash +# Scrape by problem number +lcpy scrape -n 1 + +# Scrape by problem slug +lcpy scrape -s two-sum + +# Save to file +lcpy scrape -n 1 > two_sum.json +``` + +### `lcpy list` - List Problems + +Display available problems in a formatted table. + +**Basic Usage:** + +```bash +lcpy list [OPTIONS] +``` + +**Options:** + +- `-t, --tag` - Filter by tag +- `-d, --difficulty` - Filter by difficulty + +**Examples:** + +```bash +# List all problems +lcpy list + +# Filter by tag +lcpy list -t grind-75 + +# Filter by difficulty +lcpy list -d Easy + +# Combine filters +lcpy list -t grind-75 -d Medium +``` + +## Problem Structure + +Each generated problem creates the following structure: + +``` +problem_name/ +β”œβ”€β”€ README.md # Problem description +β”œβ”€β”€ solution.py # Implementation template +β”œβ”€β”€ test_solution.py # Test cases +β”œβ”€β”€ helpers.py # Test helpers +β”œβ”€β”€ playground.py # Interactive debugging +└── __init__.py # Package marker +``` + +## Tags + +Available tags for bulk operations: + +- `grind-75` - Essential 75 coding interview problems +- `blind-75` - Original Blind 75 problems +- `neetcode-150` - NeetCode 150 problems +- `top-interview` - Top interview questions +- `easy`, `medium`, `hard` - Difficulty-based tags + +## Output Directory + +By default, problems are generated in the current directory. Use `-o` to specify a different location: + +```bash +# Generate in current directory +lcpy gen -n 1 + +# Generate in specific directory +lcpy gen -n 1 -o leetcode + +# Generate in absolute path +lcpy gen -n 1 -o /path/to/problems +``` + +## Error Handling + +The CLI provides clear error messages and exit codes: + +- **Exit code 0**: Success +- **Exit code 1**: Error occurred + +**Common errors:** + +- Problem number not found +- Invalid tag name +- File already exists (use `--force` to overwrite) +- Network errors during scraping + +## Integration with Existing Workflow + +The CLI is designed to work alongside the existing repository structure: + +```bash +# In existing leetcode-py repository +lcpy gen -n 1 -o leetcode + +# This creates: leetcode/two_sum/ +``` + +## Tips + +1. **Bulk Generation**: Use tags for efficient bulk operations +2. **Force Overwrite**: Use `--force` when regenerating existing problems +3. **Output Organization**: Use `-o` to organize problems in subdirectories +4. **Filtering**: Combine tags and difficulty filters for precise selection +5. **Scripting**: CLI commands work well in shell scripts and automation diff --git a/.templates/leetcode/{{cookiecutter.problem_name}}/__init__.py b/leetcode_py/cli/__init__.py similarity index 100% rename from .templates/leetcode/{{cookiecutter.problem_name}}/__init__.py rename to leetcode_py/cli/__init__.py diff --git a/leetcode_py/cli/commands/__init__.py b/leetcode_py/cli/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leetcode_py/cli/commands/gen.py b/leetcode_py/cli/commands/gen.py new file mode 100644 index 0000000..067a97d --- /dev/null +++ b/leetcode_py/cli/commands/gen.py @@ -0,0 +1,153 @@ +import json +from pathlib import Path + +import typer + +from leetcode_py.tools.generator import generate_problem + +from ..utils.problem_finder import ( + find_problem_by_number, + find_problems_by_tag, + get_all_problems, + get_problem_json_path, +) +from ..utils.resources import get_template_path + + +def _get_problem_difficulty(problem_name: str) -> str | None: + json_path = get_problem_json_path(problem_name) + if not json_path.exists(): + return None + + try: + with open(json_path) as f: + data = json.load(f) + return data.get("difficulty") + except Exception: + return None + + +def _validate_single_option( + problem_nums: list[int], + problem_slugs: list[str], + problem_tag: str | None, + all_problems: bool, +) -> None: + options_count = sum( + [ + len(problem_nums) > 0, + len(problem_slugs) > 0, + problem_tag is not None, + all_problems, + ] + ) + + if options_count != 1: + typer.echo( + "Error: Exactly one of --problem-num, --problem-slug, --problem-tag, or --all is required", + err=True, + ) + raise typer.Exit(1) + + +def _resolve_by_numbers(problem_nums: list[int]) -> list[str]: + problems = [] + for num in problem_nums: + problem_name = find_problem_by_number(num) + if not problem_name: + typer.echo(f"Error: Problem number {num} not found", err=True) + raise typer.Exit(1) + problems.append(problem_name) + return problems + + +def _resolve_by_tag(problem_tag: str) -> list[str]: + problems = find_problems_by_tag(problem_tag) + if not problems: + typer.echo(f"Error: No problems found with tag '{problem_tag}'", err=True) + raise typer.Exit(1) + typer.echo(f"Found {len(problems)} problems with tag '{problem_tag}'") + return problems + + +def _filter_by_difficulty(problems: list[str], difficulty: str) -> list[str]: + filtered_problems = [] + for problem_name in problems: + problem_difficulty = _get_problem_difficulty(problem_name) + if problem_difficulty and problem_difficulty.lower() == difficulty.lower(): + filtered_problems.append(problem_name) + typer.echo(f"Filtered to {len(filtered_problems)} problems with difficulty '{difficulty}'") + return filtered_problems + + +def resolve_problems( + problem_nums: list[int], + problem_slugs: list[str], + problem_tag: str | None, + difficulty: str | None, + all_problems: bool, +) -> list[str]: + _validate_single_option(problem_nums, problem_slugs, problem_tag, all_problems) + + if problem_nums: + problems = _resolve_by_numbers(problem_nums) + elif problem_slugs: + problems = problem_slugs + elif problem_tag: + problems = _resolve_by_tag(problem_tag) + else: # all_problems + problems = get_all_problems() + typer.echo(f"Found {len(problems)} problems") + + if difficulty: + problems = _filter_by_difficulty(problems, difficulty) + + return problems + + +def generate( + problem_nums: list[int] = typer.Option( + [], "-n", "--problem-num", help="Problem number(s) (use multiple -n flags)" + ), + problem_slugs: list[str] = typer.Option( + [], "-s", "--problem-slug", help="Problem slug(s) (use multiple -s flags)" + ), + problem_tag: str | None = typer.Option(None, "-t", "--problem-tag", help="Problem tag (bulk)"), + difficulty: str | None = typer.Option( + None, "-d", "--difficulty", help="Filter by difficulty (Easy/Medium/Hard)" + ), + all_problems: bool = typer.Option(False, "--all", help="Generate all problems"), + output: str = typer.Option(".", "-o", "--output", help="Output directory"), + force: bool = typer.Option(False, "--force", help="Force overwrite existing files"), +): + template_dir = get_template_path() + output_dir = Path(output) + + # Determine which problems to generate + problems = resolve_problems(problem_nums, problem_slugs, problem_tag, difficulty, all_problems) + + # Generate each problem + success_count = 0 + failed_count = 0 + + for problem_name in problems: + json_path = get_problem_json_path(problem_name) + if not json_path.exists(): + typer.echo(f"Warning: JSON file not found for problem '{problem_name}', skipping", err=True) + failed_count += 1 + continue + + try: + generate_problem(json_path, template_dir, output_dir, force) + success_count += 1 + except typer.Exit: + # typer.Exit was already handled with proper error message + failed_count += 1 + except Exception as e: + typer.echo(f"Error generating problem '{problem_name}': {e}", err=True) + failed_count += 1 + + typer.echo(f"Completed: {success_count} successful, {failed_count} failed") + + if failed_count > 0: + raise typer.Exit(1) diff --git a/leetcode_py/cli/commands/list.py b/leetcode_py/cli/commands/list.py new file mode 100644 index 0000000..6b8c3e4 --- /dev/null +++ b/leetcode_py/cli/commands/list.py @@ -0,0 +1,94 @@ +"""List command for displaying available LeetCode problems.""" + +import typer +from rich.console import Console +from rich.table import Table + +from ..utils.problem_finder import find_problems_by_tag, get_all_problems, get_tags_for_problem + +console = Console() + + +def list_problems( + tag: str | None = typer.Option(None, "-t", "--tag", help="Filter by tag (e.g., 'grind-75')"), + difficulty: str | None = typer.Option( + None, "-d", "--difficulty", help="Filter by difficulty (Easy/Medium/Hard)" + ), +) -> None: + + # Get problems based on filters + if tag: + problems = find_problems_by_tag(tag) + if not problems: + typer.echo(f"Error: No problems found with tag '{tag}'", err=True) + raise typer.Exit(1) + else: + problems = get_all_problems() + + if not problems: + typer.echo("No problems found", err=True) + raise typer.Exit(1) + + # Create table + table = Table(title="LeetCode Problems") + table.add_column("Number", style="cyan", no_wrap=True) + table.add_column("Title", style="magenta") + table.add_column("Difficulty", style="green") + table.add_column("Tags", style="blue") + + # Get problem data and sort by number + problem_list = [] + for problem_name in problems: + try: + problem_data = _get_problem_data(problem_name) + + # Apply difficulty filter + if difficulty and problem_data.get("difficulty", "").lower() != difficulty.lower(): + continue + + problem_list.append((problem_data, problem_name)) + except Exception: + # Skip problems that can't be loaded + continue + + # Sort by problem number (convert to int for proper numerical sorting) + problem_list.sort( + key=lambda x: int(x[0].get("number", "999999")) if x[0].get("number", "?").isdigit() else 999999 + ) + + # Update table title with count + table.title = f"LeetCode Problems ({len(problem_list)} problems)" + + # Add sorted problems to table + for problem_data, problem_name in problem_list: + table.add_row( + problem_data.get("number", "?"), + problem_data.get("title", problem_name), + problem_data.get("difficulty", "Unknown"), + ", ".join(problem_data.get("tags", [])), + ) + + console.print(table) + + +def _get_problem_data(problem_name: str) -> dict: + import json + + from ..utils.problem_finder import get_problem_json_path + + json_path = get_problem_json_path(problem_name) + if not json_path.exists(): + return {"title": problem_name, "tags": get_tags_for_problem(problem_name)} + + try: + with open(json_path) as f: + data = json.load(f) + + return { + "number": data.get("problem_number", "?"), + "title": data.get("problem_title", problem_name), + "difficulty": data.get("difficulty", "Unknown"), + "tags": get_tags_for_problem(problem_name), + } + except Exception: + return {"title": problem_name, "tags": get_tags_for_problem(problem_name)} diff --git a/leetcode_py/cli/commands/scrape.py b/leetcode_py/cli/commands/scrape.py new file mode 100644 index 0000000..7a55c1b --- /dev/null +++ b/leetcode_py/cli/commands/scrape.py @@ -0,0 +1,49 @@ +import json + +import typer + +from leetcode_py.tools.scraper import LeetCodeScraper + +ERROR_EXACTLY_ONE_OPTION = "Error: Exactly one of --problem-num or --problem-slug is required" + + +def fetch_and_format_problem( + scraper: LeetCodeScraper, problem_num: int | None, problem_slug: str | None +) -> dict: + if problem_num is not None: + problem = scraper.get_problem_by_number(problem_num) + if not problem: + typer.echo(f"Error: Problem number {problem_num} not found", err=True) + raise typer.Exit(1) + elif problem_slug is not None: + problem = scraper.get_problem_by_slug(problem_slug) + if not problem: + typer.echo(f"Error: Problem slug '{problem_slug}' not found", err=True) + raise typer.Exit(1) + else: + typer.echo(ERROR_EXACTLY_ONE_OPTION, err=True) + raise typer.Exit(1) + + return scraper.format_problem_info(problem) + + +def scrape( + problem_num: int | None = typer.Option(None, "-n", "--problem-num", help="Problem number (e.g., 1)"), + problem_slug: str | None = typer.Option( + None, "-s", "--problem-slug", help="Problem slug (e.g., 'two-sum')" + ), +) -> None: + options_provided = sum(x is not None for x in [problem_num, problem_slug]) + if options_provided != 1: + typer.echo(ERROR_EXACTLY_ONE_OPTION, err=True) + raise typer.Exit(1) + + scraper = LeetCodeScraper() + + try: + formatted = fetch_and_format_problem(scraper, problem_num, problem_slug) + typer.echo(json.dumps(formatted, indent=2)) + + except Exception as e: + typer.echo(f"Error fetching problem: {e}", err=True) + raise typer.Exit(1) diff --git a/leetcode_py/cli/main.py b/leetcode_py/cli/main.py new file mode 100644 index 0000000..7f5f695 --- /dev/null +++ b/leetcode_py/cli/main.py @@ -0,0 +1,32 @@ +from importlib.metadata import version + +import typer + +from .commands.gen import generate +from .commands.list import list_problems +from .commands.scrape import scrape + +app = typer.Typer(help="LeetCode problem generator - Generate and list LeetCode problems") + + +def show_version(): + typer.echo(f"lcpy version {version('leetcode-py-sdk')}") + raise typer.Exit() + + +@app.callback(invoke_without_command=True) +def main_callback( + ctx: typer.Context, + version: bool = typer.Option(False, "--version", "-V", help="Show version and exit"), +): + if version: + show_version() + + if ctx.invoked_subcommand is None: + typer.echo(ctx.get_help()) + raise typer.Exit() + + +app.command(name="gen")(generate) +app.command(name="scrape")(scrape) +app.command(name="list")(list_problems) diff --git a/.templates/leetcode/cookiecutter.json b/leetcode_py/cli/resources/leetcode/cookiecutter.json similarity index 100% rename from .templates/leetcode/cookiecutter.json rename to leetcode_py/cli/resources/leetcode/cookiecutter.json diff --git a/.templates/leetcode/examples/example.json5 b/leetcode_py/cli/resources/leetcode/examples/example.json5 similarity index 100% rename from .templates/leetcode/examples/example.json5 rename to leetcode_py/cli/resources/leetcode/examples/example.json5 diff --git a/.templates/leetcode/json/accounts_merge.json b/leetcode_py/cli/resources/leetcode/json/problems/accounts_merge.json similarity index 100% rename from .templates/leetcode/json/accounts_merge.json rename to leetcode_py/cli/resources/leetcode/json/problems/accounts_merge.json diff --git a/.templates/leetcode/json/add_binary.json b/leetcode_py/cli/resources/leetcode/json/problems/add_binary.json similarity index 100% rename from .templates/leetcode/json/add_binary.json rename to leetcode_py/cli/resources/leetcode/json/problems/add_binary.json diff --git a/.templates/leetcode/json/balanced_binary_tree.json b/leetcode_py/cli/resources/leetcode/json/problems/balanced_binary_tree.json similarity index 100% rename from .templates/leetcode/json/balanced_binary_tree.json rename to leetcode_py/cli/resources/leetcode/json/problems/balanced_binary_tree.json diff --git a/.templates/leetcode/json/basic_calculator.json b/leetcode_py/cli/resources/leetcode/json/problems/basic_calculator.json similarity index 100% rename from .templates/leetcode/json/basic_calculator.json rename to leetcode_py/cli/resources/leetcode/json/problems/basic_calculator.json diff --git a/.templates/leetcode/json/best_time_to_buy_and_sell_stock.json b/leetcode_py/cli/resources/leetcode/json/problems/best_time_to_buy_and_sell_stock.json similarity index 100% rename from .templates/leetcode/json/best_time_to_buy_and_sell_stock.json rename to leetcode_py/cli/resources/leetcode/json/problems/best_time_to_buy_and_sell_stock.json diff --git a/.templates/leetcode/json/binary_search.json b/leetcode_py/cli/resources/leetcode/json/problems/binary_search.json similarity index 100% rename from .templates/leetcode/json/binary_search.json rename to leetcode_py/cli/resources/leetcode/json/problems/binary_search.json diff --git a/.templates/leetcode/json/binary_tree_level_order_traversal.json b/leetcode_py/cli/resources/leetcode/json/problems/binary_tree_level_order_traversal.json similarity index 100% rename from .templates/leetcode/json/binary_tree_level_order_traversal.json rename to leetcode_py/cli/resources/leetcode/json/problems/binary_tree_level_order_traversal.json diff --git a/.templates/leetcode/json/binary_tree_right_side_view.json b/leetcode_py/cli/resources/leetcode/json/problems/binary_tree_right_side_view.json similarity index 100% rename from .templates/leetcode/json/binary_tree_right_side_view.json rename to leetcode_py/cli/resources/leetcode/json/problems/binary_tree_right_side_view.json diff --git a/.templates/leetcode/json/climbing_stairs.json b/leetcode_py/cli/resources/leetcode/json/problems/climbing_stairs.json similarity index 100% rename from .templates/leetcode/json/climbing_stairs.json rename to leetcode_py/cli/resources/leetcode/json/problems/climbing_stairs.json diff --git a/.templates/leetcode/json/clone_graph.json b/leetcode_py/cli/resources/leetcode/json/problems/clone_graph.json similarity index 100% rename from .templates/leetcode/json/clone_graph.json rename to leetcode_py/cli/resources/leetcode/json/problems/clone_graph.json diff --git a/.templates/leetcode/json/coin_change.json b/leetcode_py/cli/resources/leetcode/json/problems/coin_change.json similarity index 100% rename from .templates/leetcode/json/coin_change.json rename to leetcode_py/cli/resources/leetcode/json/problems/coin_change.json diff --git a/.templates/leetcode/json/combination_sum.json b/leetcode_py/cli/resources/leetcode/json/problems/combination_sum.json similarity index 100% rename from .templates/leetcode/json/combination_sum.json rename to leetcode_py/cli/resources/leetcode/json/problems/combination_sum.json diff --git a/.templates/leetcode/json/construct_binary_tree_from_preorder_and_inorder_traversal.json b/leetcode_py/cli/resources/leetcode/json/problems/construct_binary_tree_from_preorder_and_inorder_traversal.json similarity index 100% rename from .templates/leetcode/json/construct_binary_tree_from_preorder_and_inorder_traversal.json rename to leetcode_py/cli/resources/leetcode/json/problems/construct_binary_tree_from_preorder_and_inorder_traversal.json diff --git a/.templates/leetcode/json/container_with_most_water.json b/leetcode_py/cli/resources/leetcode/json/problems/container_with_most_water.json similarity index 100% rename from .templates/leetcode/json/container_with_most_water.json rename to leetcode_py/cli/resources/leetcode/json/problems/container_with_most_water.json diff --git a/.templates/leetcode/json/contains_duplicate.json b/leetcode_py/cli/resources/leetcode/json/problems/contains_duplicate.json similarity index 100% rename from .templates/leetcode/json/contains_duplicate.json rename to leetcode_py/cli/resources/leetcode/json/problems/contains_duplicate.json diff --git a/.templates/leetcode/json/course_schedule.json b/leetcode_py/cli/resources/leetcode/json/problems/course_schedule.json similarity index 100% rename from .templates/leetcode/json/course_schedule.json rename to leetcode_py/cli/resources/leetcode/json/problems/course_schedule.json diff --git a/.templates/leetcode/json/diagonal_traverse.json b/leetcode_py/cli/resources/leetcode/json/problems/diagonal_traverse.json similarity index 100% rename from .templates/leetcode/json/diagonal_traverse.json rename to leetcode_py/cli/resources/leetcode/json/problems/diagonal_traverse.json diff --git a/.templates/leetcode/json/diameter_of_binary_tree.json b/leetcode_py/cli/resources/leetcode/json/problems/diameter_of_binary_tree.json similarity index 100% rename from .templates/leetcode/json/diameter_of_binary_tree.json rename to leetcode_py/cli/resources/leetcode/json/problems/diameter_of_binary_tree.json diff --git a/.templates/leetcode/json/evaluate_reverse_polish_notation.json b/leetcode_py/cli/resources/leetcode/json/problems/evaluate_reverse_polish_notation.json similarity index 100% rename from .templates/leetcode/json/evaluate_reverse_polish_notation.json rename to leetcode_py/cli/resources/leetcode/json/problems/evaluate_reverse_polish_notation.json diff --git a/.templates/leetcode/json/find_all_anagrams_in_a_string.json b/leetcode_py/cli/resources/leetcode/json/problems/find_all_anagrams_in_a_string.json similarity index 100% rename from .templates/leetcode/json/find_all_anagrams_in_a_string.json rename to leetcode_py/cli/resources/leetcode/json/problems/find_all_anagrams_in_a_string.json diff --git a/.templates/leetcode/json/find_median_from_data_stream.json b/leetcode_py/cli/resources/leetcode/json/problems/find_median_from_data_stream.json similarity index 100% rename from .templates/leetcode/json/find_median_from_data_stream.json rename to leetcode_py/cli/resources/leetcode/json/problems/find_median_from_data_stream.json diff --git a/.templates/leetcode/json/first_bad_version.json b/leetcode_py/cli/resources/leetcode/json/problems/first_bad_version.json similarity index 100% rename from .templates/leetcode/json/first_bad_version.json rename to leetcode_py/cli/resources/leetcode/json/problems/first_bad_version.json diff --git a/.templates/leetcode/json/flood_fill.json b/leetcode_py/cli/resources/leetcode/json/problems/flood_fill.json similarity index 100% rename from .templates/leetcode/json/flood_fill.json rename to leetcode_py/cli/resources/leetcode/json/problems/flood_fill.json diff --git a/.templates/leetcode/json/implement_queue_using_stacks.json b/leetcode_py/cli/resources/leetcode/json/problems/implement_queue_using_stacks.json similarity index 100% rename from .templates/leetcode/json/implement_queue_using_stacks.json rename to leetcode_py/cli/resources/leetcode/json/problems/implement_queue_using_stacks.json diff --git a/.templates/leetcode/json/implement_trie_prefix_tree.json b/leetcode_py/cli/resources/leetcode/json/problems/implement_trie_prefix_tree.json similarity index 100% rename from .templates/leetcode/json/implement_trie_prefix_tree.json rename to leetcode_py/cli/resources/leetcode/json/problems/implement_trie_prefix_tree.json diff --git a/.templates/leetcode/json/insert_interval.json b/leetcode_py/cli/resources/leetcode/json/problems/insert_interval.json similarity index 100% rename from .templates/leetcode/json/insert_interval.json rename to leetcode_py/cli/resources/leetcode/json/problems/insert_interval.json diff --git a/.templates/leetcode/json/invert_binary_tree.json b/leetcode_py/cli/resources/leetcode/json/problems/invert_binary_tree.json similarity index 100% rename from .templates/leetcode/json/invert_binary_tree.json rename to leetcode_py/cli/resources/leetcode/json/problems/invert_binary_tree.json diff --git a/.templates/leetcode/json/k_closest_points_to_origin.json b/leetcode_py/cli/resources/leetcode/json/problems/k_closest_points_to_origin.json similarity index 100% rename from .templates/leetcode/json/k_closest_points_to_origin.json rename to leetcode_py/cli/resources/leetcode/json/problems/k_closest_points_to_origin.json diff --git a/.templates/leetcode/json/kth_smallest_element_in_a_bst.json b/leetcode_py/cli/resources/leetcode/json/problems/kth_smallest_element_in_a_bst.json similarity index 100% rename from .templates/leetcode/json/kth_smallest_element_in_a_bst.json rename to leetcode_py/cli/resources/leetcode/json/problems/kth_smallest_element_in_a_bst.json diff --git a/.templates/leetcode/json/largest_rectangle_in_histogram.json b/leetcode_py/cli/resources/leetcode/json/problems/largest_rectangle_in_histogram.json similarity index 100% rename from .templates/leetcode/json/largest_rectangle_in_histogram.json rename to leetcode_py/cli/resources/leetcode/json/problems/largest_rectangle_in_histogram.json diff --git a/.templates/leetcode/json/letter_combinations_of_a_phone_number.json b/leetcode_py/cli/resources/leetcode/json/problems/letter_combinations_of_a_phone_number.json similarity index 100% rename from .templates/leetcode/json/letter_combinations_of_a_phone_number.json rename to leetcode_py/cli/resources/leetcode/json/problems/letter_combinations_of_a_phone_number.json diff --git a/.templates/leetcode/json/linked_list_cycle.json b/leetcode_py/cli/resources/leetcode/json/problems/linked_list_cycle.json similarity index 100% rename from .templates/leetcode/json/linked_list_cycle.json rename to leetcode_py/cli/resources/leetcode/json/problems/linked_list_cycle.json diff --git a/.templates/leetcode/json/longest_palindrome.json b/leetcode_py/cli/resources/leetcode/json/problems/longest_palindrome.json similarity index 100% rename from .templates/leetcode/json/longest_palindrome.json rename to leetcode_py/cli/resources/leetcode/json/problems/longest_palindrome.json diff --git a/.templates/leetcode/json/longest_palindromic_substring.json b/leetcode_py/cli/resources/leetcode/json/problems/longest_palindromic_substring.json similarity index 100% rename from .templates/leetcode/json/longest_palindromic_substring.json rename to leetcode_py/cli/resources/leetcode/json/problems/longest_palindromic_substring.json diff --git a/.templates/leetcode/json/longest_substring_without_repeating_characters.json b/leetcode_py/cli/resources/leetcode/json/problems/longest_substring_without_repeating_characters.json similarity index 100% rename from .templates/leetcode/json/longest_substring_without_repeating_characters.json rename to leetcode_py/cli/resources/leetcode/json/problems/longest_substring_without_repeating_characters.json diff --git a/.templates/leetcode/json/lowest_common_ancestor_of_a_binary_search_tree.json b/leetcode_py/cli/resources/leetcode/json/problems/lowest_common_ancestor_of_a_binary_search_tree.json similarity index 100% rename from .templates/leetcode/json/lowest_common_ancestor_of_a_binary_search_tree.json rename to leetcode_py/cli/resources/leetcode/json/problems/lowest_common_ancestor_of_a_binary_search_tree.json diff --git a/.templates/leetcode/json/lowest_common_ancestor_of_a_binary_tree.json b/leetcode_py/cli/resources/leetcode/json/problems/lowest_common_ancestor_of_a_binary_tree.json similarity index 100% rename from .templates/leetcode/json/lowest_common_ancestor_of_a_binary_tree.json rename to leetcode_py/cli/resources/leetcode/json/problems/lowest_common_ancestor_of_a_binary_tree.json diff --git a/.templates/leetcode/json/lru_cache.json b/leetcode_py/cli/resources/leetcode/json/problems/lru_cache.json similarity index 100% rename from .templates/leetcode/json/lru_cache.json rename to leetcode_py/cli/resources/leetcode/json/problems/lru_cache.json diff --git a/.templates/leetcode/json/majority_element.json b/leetcode_py/cli/resources/leetcode/json/problems/majority_element.json similarity index 100% rename from .templates/leetcode/json/majority_element.json rename to leetcode_py/cli/resources/leetcode/json/problems/majority_element.json diff --git a/.templates/leetcode/json/maximum_depth_of_binary_tree.json b/leetcode_py/cli/resources/leetcode/json/problems/maximum_depth_of_binary_tree.json similarity index 100% rename from .templates/leetcode/json/maximum_depth_of_binary_tree.json rename to leetcode_py/cli/resources/leetcode/json/problems/maximum_depth_of_binary_tree.json diff --git a/.templates/leetcode/json/maximum_profit_in_job_scheduling.json b/leetcode_py/cli/resources/leetcode/json/problems/maximum_profit_in_job_scheduling.json similarity index 100% rename from .templates/leetcode/json/maximum_profit_in_job_scheduling.json rename to leetcode_py/cli/resources/leetcode/json/problems/maximum_profit_in_job_scheduling.json diff --git a/.templates/leetcode/json/maximum_subarray.json b/leetcode_py/cli/resources/leetcode/json/problems/maximum_subarray.json similarity index 100% rename from .templates/leetcode/json/maximum_subarray.json rename to leetcode_py/cli/resources/leetcode/json/problems/maximum_subarray.json diff --git a/.templates/leetcode/json/merge_intervals.json b/leetcode_py/cli/resources/leetcode/json/problems/merge_intervals.json similarity index 100% rename from .templates/leetcode/json/merge_intervals.json rename to leetcode_py/cli/resources/leetcode/json/problems/merge_intervals.json diff --git a/.templates/leetcode/json/merge_k_sorted_lists.json b/leetcode_py/cli/resources/leetcode/json/problems/merge_k_sorted_lists.json similarity index 100% rename from .templates/leetcode/json/merge_k_sorted_lists.json rename to leetcode_py/cli/resources/leetcode/json/problems/merge_k_sorted_lists.json diff --git a/.templates/leetcode/json/merge_two_sorted_lists.json b/leetcode_py/cli/resources/leetcode/json/problems/merge_two_sorted_lists.json similarity index 100% rename from .templates/leetcode/json/merge_two_sorted_lists.json rename to leetcode_py/cli/resources/leetcode/json/problems/merge_two_sorted_lists.json diff --git a/.templates/leetcode/json/middle_of_the_linked_list.json b/leetcode_py/cli/resources/leetcode/json/problems/middle_of_the_linked_list.json similarity index 100% rename from .templates/leetcode/json/middle_of_the_linked_list.json rename to leetcode_py/cli/resources/leetcode/json/problems/middle_of_the_linked_list.json diff --git a/.templates/leetcode/json/min_stack.json b/leetcode_py/cli/resources/leetcode/json/problems/min_stack.json similarity index 100% rename from .templates/leetcode/json/min_stack.json rename to leetcode_py/cli/resources/leetcode/json/problems/min_stack.json diff --git a/.templates/leetcode/json/minimum_height_trees.json b/leetcode_py/cli/resources/leetcode/json/problems/minimum_height_trees.json similarity index 100% rename from .templates/leetcode/json/minimum_height_trees.json rename to leetcode_py/cli/resources/leetcode/json/problems/minimum_height_trees.json diff --git a/.templates/leetcode/json/minimum_window_substring.json b/leetcode_py/cli/resources/leetcode/json/problems/minimum_window_substring.json similarity index 100% rename from .templates/leetcode/json/minimum_window_substring.json rename to leetcode_py/cli/resources/leetcode/json/problems/minimum_window_substring.json diff --git a/.templates/leetcode/json/number_of_islands.json b/leetcode_py/cli/resources/leetcode/json/problems/number_of_islands.json similarity index 100% rename from .templates/leetcode/json/number_of_islands.json rename to leetcode_py/cli/resources/leetcode/json/problems/number_of_islands.json diff --git a/.templates/leetcode/json/partition_equal_subset_sum.json b/leetcode_py/cli/resources/leetcode/json/problems/partition_equal_subset_sum.json similarity index 100% rename from .templates/leetcode/json/partition_equal_subset_sum.json rename to leetcode_py/cli/resources/leetcode/json/problems/partition_equal_subset_sum.json diff --git a/.templates/leetcode/json/permutations.json b/leetcode_py/cli/resources/leetcode/json/problems/permutations.json similarity index 100% rename from .templates/leetcode/json/permutations.json rename to leetcode_py/cli/resources/leetcode/json/problems/permutations.json diff --git a/.templates/leetcode/json/product_of_array_except_self.json b/leetcode_py/cli/resources/leetcode/json/problems/product_of_array_except_self.json similarity index 100% rename from .templates/leetcode/json/product_of_array_except_self.json rename to leetcode_py/cli/resources/leetcode/json/problems/product_of_array_except_self.json diff --git a/.templates/leetcode/json/ransom_note.json b/leetcode_py/cli/resources/leetcode/json/problems/ransom_note.json similarity index 100% rename from .templates/leetcode/json/ransom_note.json rename to leetcode_py/cli/resources/leetcode/json/problems/ransom_note.json diff --git a/.templates/leetcode/json/reverse_linked_list.json b/leetcode_py/cli/resources/leetcode/json/problems/reverse_linked_list.json similarity index 100% rename from .templates/leetcode/json/reverse_linked_list.json rename to leetcode_py/cli/resources/leetcode/json/problems/reverse_linked_list.json diff --git a/.templates/leetcode/json/reverse_linked_list_ii.json b/leetcode_py/cli/resources/leetcode/json/problems/reverse_linked_list_ii.json similarity index 100% rename from .templates/leetcode/json/reverse_linked_list_ii.json rename to leetcode_py/cli/resources/leetcode/json/problems/reverse_linked_list_ii.json diff --git a/.templates/leetcode/json/rotting_oranges.json b/leetcode_py/cli/resources/leetcode/json/problems/rotting_oranges.json similarity index 100% rename from .templates/leetcode/json/rotting_oranges.json rename to leetcode_py/cli/resources/leetcode/json/problems/rotting_oranges.json diff --git a/.templates/leetcode/json/search_in_rotated_sorted_array.json b/leetcode_py/cli/resources/leetcode/json/problems/search_in_rotated_sorted_array.json similarity index 100% rename from .templates/leetcode/json/search_in_rotated_sorted_array.json rename to leetcode_py/cli/resources/leetcode/json/problems/search_in_rotated_sorted_array.json diff --git a/.templates/leetcode/json/serialize_and_deserialize_binary_tree.json b/leetcode_py/cli/resources/leetcode/json/problems/serialize_and_deserialize_binary_tree.json similarity index 100% rename from .templates/leetcode/json/serialize_and_deserialize_binary_tree.json rename to leetcode_py/cli/resources/leetcode/json/problems/serialize_and_deserialize_binary_tree.json diff --git a/.templates/leetcode/json/sort_colors.json b/leetcode_py/cli/resources/leetcode/json/problems/sort_colors.json similarity index 100% rename from .templates/leetcode/json/sort_colors.json rename to leetcode_py/cli/resources/leetcode/json/problems/sort_colors.json diff --git a/.templates/leetcode/json/spiral_matrix.json b/leetcode_py/cli/resources/leetcode/json/problems/spiral_matrix.json similarity index 100% rename from .templates/leetcode/json/spiral_matrix.json rename to leetcode_py/cli/resources/leetcode/json/problems/spiral_matrix.json diff --git a/.templates/leetcode/json/string_to_integer_atoi.json b/leetcode_py/cli/resources/leetcode/json/problems/string_to_integer_atoi.json similarity index 100% rename from .templates/leetcode/json/string_to_integer_atoi.json rename to leetcode_py/cli/resources/leetcode/json/problems/string_to_integer_atoi.json diff --git a/.templates/leetcode/json/subsets.json b/leetcode_py/cli/resources/leetcode/json/problems/subsets.json similarity index 100% rename from .templates/leetcode/json/subsets.json rename to leetcode_py/cli/resources/leetcode/json/problems/subsets.json diff --git a/.templates/leetcode/json/task_scheduler.json b/leetcode_py/cli/resources/leetcode/json/problems/task_scheduler.json similarity index 100% rename from .templates/leetcode/json/task_scheduler.json rename to leetcode_py/cli/resources/leetcode/json/problems/task_scheduler.json diff --git a/.templates/leetcode/json/three_sum.json b/leetcode_py/cli/resources/leetcode/json/problems/three_sum.json similarity index 100% rename from .templates/leetcode/json/three_sum.json rename to leetcode_py/cli/resources/leetcode/json/problems/three_sum.json diff --git a/.templates/leetcode/json/time_based_key_value_store.json b/leetcode_py/cli/resources/leetcode/json/problems/time_based_key_value_store.json similarity index 100% rename from .templates/leetcode/json/time_based_key_value_store.json rename to leetcode_py/cli/resources/leetcode/json/problems/time_based_key_value_store.json diff --git a/.templates/leetcode/json/trapping_rain_water.json b/leetcode_py/cli/resources/leetcode/json/problems/trapping_rain_water.json similarity index 100% rename from .templates/leetcode/json/trapping_rain_water.json rename to leetcode_py/cli/resources/leetcode/json/problems/trapping_rain_water.json diff --git a/.templates/leetcode/json/two_sum.json b/leetcode_py/cli/resources/leetcode/json/problems/two_sum.json similarity index 100% rename from .templates/leetcode/json/two_sum.json rename to leetcode_py/cli/resources/leetcode/json/problems/two_sum.json diff --git a/.templates/leetcode/json/unique_paths.json b/leetcode_py/cli/resources/leetcode/json/problems/unique_paths.json similarity index 100% rename from .templates/leetcode/json/unique_paths.json rename to leetcode_py/cli/resources/leetcode/json/problems/unique_paths.json diff --git a/.templates/leetcode/json/valid_anagram.json b/leetcode_py/cli/resources/leetcode/json/problems/valid_anagram.json similarity index 100% rename from .templates/leetcode/json/valid_anagram.json rename to leetcode_py/cli/resources/leetcode/json/problems/valid_anagram.json diff --git a/.templates/leetcode/json/valid_palindrome.json b/leetcode_py/cli/resources/leetcode/json/problems/valid_palindrome.json similarity index 100% rename from .templates/leetcode/json/valid_palindrome.json rename to leetcode_py/cli/resources/leetcode/json/problems/valid_palindrome.json diff --git a/.templates/leetcode/json/valid_parentheses.json b/leetcode_py/cli/resources/leetcode/json/problems/valid_parentheses.json similarity index 100% rename from .templates/leetcode/json/valid_parentheses.json rename to leetcode_py/cli/resources/leetcode/json/problems/valid_parentheses.json diff --git a/.templates/leetcode/json/validate_binary_search_tree.json b/leetcode_py/cli/resources/leetcode/json/problems/validate_binary_search_tree.json similarity index 100% rename from .templates/leetcode/json/validate_binary_search_tree.json rename to leetcode_py/cli/resources/leetcode/json/problems/validate_binary_search_tree.json diff --git a/.templates/leetcode/json/word_break.json b/leetcode_py/cli/resources/leetcode/json/problems/word_break.json similarity index 100% rename from .templates/leetcode/json/word_break.json rename to leetcode_py/cli/resources/leetcode/json/problems/word_break.json diff --git a/.templates/leetcode/json/word_ladder.json b/leetcode_py/cli/resources/leetcode/json/problems/word_ladder.json similarity index 100% rename from .templates/leetcode/json/word_ladder.json rename to leetcode_py/cli/resources/leetcode/json/problems/word_ladder.json diff --git a/.templates/leetcode/json/word_search.json b/leetcode_py/cli/resources/leetcode/json/problems/word_search.json similarity index 100% rename from .templates/leetcode/json/word_search.json rename to leetcode_py/cli/resources/leetcode/json/problems/word_search.json diff --git a/.templates/leetcode/json/zero_one_matrix.json b/leetcode_py/cli/resources/leetcode/json/problems/zero_one_matrix.json similarity index 100% rename from .templates/leetcode/json/zero_one_matrix.json rename to leetcode_py/cli/resources/leetcode/json/problems/zero_one_matrix.json diff --git a/leetcode_py/cli/resources/leetcode/json/tags.json5 b/leetcode_py/cli/resources/leetcode/json/tags.json5 new file mode 100644 index 0000000..ac0d614 --- /dev/null +++ b/leetcode_py/cli/resources/leetcode/json/tags.json5 @@ -0,0 +1,87 @@ +{ + // Grind 75 - extracted from actual problem JSON files + "grind-75": [ + "accounts_merge", + "add_binary", + "balanced_binary_tree", + "basic_calculator", + "best_time_to_buy_and_sell_stock", + "binary_search", + "binary_tree_level_order_traversal", + "binary_tree_right_side_view", + "climbing_stairs", + "clone_graph", + "coin_change", + "combination_sum", + "construct_binary_tree_from_preorder_and_inorder_traversal", + "container_with_most_water", + "contains_duplicate", + "course_schedule", + "diameter_of_binary_tree", + "evaluate_reverse_polish_notation", + "find_all_anagrams_in_a_string", + "find_median_from_data_stream", + "first_bad_version", + "flood_fill", + "implement_queue_using_stacks", + "implement_trie_prefix_tree", + "insert_interval", + "invert_binary_tree", + "k_closest_points_to_origin", + "kth_smallest_element_in_a_bst", + "largest_rectangle_in_histogram", + "letter_combinations_of_a_phone_number", + "linked_list_cycle", + "longest_palindrome", + "longest_palindromic_substring", + "longest_substring_without_repeating_characters", + "lowest_common_ancestor_of_a_binary_search_tree", + "lowest_common_ancestor_of_a_binary_tree", + "lru_cache", + "majority_element", + "maximum_depth_of_binary_tree", + "maximum_profit_in_job_scheduling", + "maximum_subarray", + "merge_intervals", + "merge_k_sorted_lists", + "merge_two_sorted_lists", + "middle_of_the_linked_list", + "min_stack", + "minimum_height_trees", + "minimum_window_substring", + "number_of_islands", + "partition_equal_subset_sum", + "permutations", + "product_of_array_except_self", + "ransom_note", + "reverse_linked_list", + "rotting_oranges", + "search_in_rotated_sorted_array", + "serialize_and_deserialize_binary_tree", + "sort_colors", + "spiral_matrix", + "string_to_integer_atoi", + "subsets", + "task_scheduler", + "three_sum", + "time_based_key_value_store", + "trapping_rain_water", + "two_sum", + "unique_paths", + "valid_anagram", + "valid_palindrome", + "valid_parentheses", + "validate_binary_search_tree", + "word_break", + "word_ladder", + "word_search", + "zero_one_matrix" + ], + + // Test tag for development and testing + "test": [ + "two_sum", + "valid_palindrome", + "binary_search" + ] +} diff --git a/.templates/leetcode/{{cookiecutter.problem_name}}/README.md b/leetcode_py/cli/resources/leetcode/{{cookiecutter.problem_name}}/README.md similarity index 100% rename from .templates/leetcode/{{cookiecutter.problem_name}}/README.md rename to leetcode_py/cli/resources/leetcode/{{cookiecutter.problem_name}}/README.md diff --git a/leetcode_py/cli/resources/leetcode/{{cookiecutter.problem_name}}/__init__.py b/leetcode_py/cli/resources/leetcode/{{cookiecutter.problem_name}}/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.templates/leetcode/{{cookiecutter.problem_name}}/helpers.py b/leetcode_py/cli/resources/leetcode/{{cookiecutter.problem_name}}/helpers.py similarity index 100% rename from .templates/leetcode/{{cookiecutter.problem_name}}/helpers.py rename to leetcode_py/cli/resources/leetcode/{{cookiecutter.problem_name}}/helpers.py diff --git a/.templates/leetcode/{{cookiecutter.problem_name}}/playground.ipynb b/leetcode_py/cli/resources/leetcode/{{cookiecutter.problem_name}}/playground.ipynb similarity index 100% rename from .templates/leetcode/{{cookiecutter.problem_name}}/playground.ipynb rename to leetcode_py/cli/resources/leetcode/{{cookiecutter.problem_name}}/playground.ipynb diff --git a/.templates/leetcode/{{cookiecutter.problem_name}}/solution.py b/leetcode_py/cli/resources/leetcode/{{cookiecutter.problem_name}}/solution.py similarity index 100% rename from .templates/leetcode/{{cookiecutter.problem_name}}/solution.py rename to leetcode_py/cli/resources/leetcode/{{cookiecutter.problem_name}}/solution.py diff --git a/.templates/leetcode/{{cookiecutter.problem_name}}/test_solution.py b/leetcode_py/cli/resources/leetcode/{{cookiecutter.problem_name}}/test_solution.py similarity index 100% rename from .templates/leetcode/{{cookiecutter.problem_name}}/test_solution.py rename to leetcode_py/cli/resources/leetcode/{{cookiecutter.problem_name}}/test_solution.py diff --git a/leetcode_py/cli/utils/__init__.py b/leetcode_py/cli/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leetcode_py/cli/utils/problem_finder.py b/leetcode_py/cli/utils/problem_finder.py new file mode 100644 index 0000000..5943b5c --- /dev/null +++ b/leetcode_py/cli/utils/problem_finder.py @@ -0,0 +1,78 @@ +import json +from functools import lru_cache +from pathlib import Path + +import json5 + +from .resources import get_problems_json_path, get_tags_path + + +def find_problems_by_tag(tag: str) -> list[str]: + tags_file = get_tags_path() + + try: + with open(tags_file) as f: + tags_data = json5.load(f) + return tags_data.get(tag, []) + except (ValueError, OSError, KeyError): + return [] + + +def get_problem_json_path(problem_name: str) -> Path: + json_path = get_problems_json_path() + return json_path / f"{problem_name}.json" + + +@lru_cache(maxsize=1) +def _build_problem_number_cache() -> dict[int, str]: + json_path = get_problems_json_path() + number_to_name_map: dict[int, str] = {} + + for json_file in json_path.glob("*.json"): + try: + with open(json_file) as f: + data = json.load(f) + problem_number = data.get("problem_number") + if problem_number and problem_number.isdigit(): + number_to_name_map[int(problem_number)] = data.get("problem_name", json_file.stem) + except (json.JSONDecodeError, KeyError, OSError): + continue + + return number_to_name_map + + +def find_problem_by_number(number: int) -> str | None: + cache = _build_problem_number_cache() + return cache.get(number) + + +def get_all_problems() -> list[str]: + json_path = get_problems_json_path() + return [json_file.stem for json_file in json_path.glob("*.json")] + + +@lru_cache(maxsize=1) +def _build_problem_tags_cache() -> dict[str, list[str]]: + tags_file = get_tags_path() + problem_tags_map: dict[str, list[str]] = {} + + try: + with open(tags_file) as f: + tags_data = json5.load(f) + + # Build reverse mapping: problem -> list of tags + for tag_name, problems in tags_data.items(): + if isinstance(problems, list): + for problem_name in problems: + if problem_name not in problem_tags_map: + problem_tags_map[problem_name] = [] + problem_tags_map[problem_name].append(tag_name) + + return problem_tags_map + except (ValueError, OSError, KeyError): + return {} + + +def get_tags_for_problem(problem_name: str) -> list[str]: + cache = _build_problem_tags_cache() + return cache.get(problem_name, []) diff --git a/leetcode_py/cli/utils/resources.py b/leetcode_py/cli/utils/resources.py new file mode 100644 index 0000000..2c61086 --- /dev/null +++ b/leetcode_py/cli/utils/resources.py @@ -0,0 +1,19 @@ +from pathlib import Path + + +def get_template_path() -> Path: + current_file = Path(__file__) + resources_path = current_file.parent.parent / "resources" / "leetcode" + + if resources_path.exists(): + return resources_path + + raise FileNotFoundError(f"Template resources not found at {resources_path}.") + + +def get_problems_json_path() -> Path: + return get_template_path() / "json" / "problems" + + +def get_tags_path() -> Path: + return get_template_path() / "json" / "tags.json5" diff --git a/leetcode_py/tools/__init__.py b/leetcode_py/tools/__init__.py index 51fc3e4..2a590ba 100644 --- a/leetcode_py/tools/__init__.py +++ b/leetcode_py/tools/__init__.py @@ -1,7 +1,7 @@ """LeetCode tools package for scraping and template generation.""" -from .generator import TemplateGenerator +from .generator import generate_problem from .parser import HTMLParser from .scraper import LeetCodeScraper -__all__ = ["LeetCodeScraper", "HTMLParser", "TemplateGenerator"] +__all__ = ["LeetCodeScraper", "HTMLParser", "generate_problem"] diff --git a/leetcode_py/tools/check_test_cases.py b/leetcode_py/tools/check_test_cases.py new file mode 100644 index 0000000..fbbad79 --- /dev/null +++ b/leetcode_py/tools/check_test_cases.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 + +import ast +import json +from typing import Any + +import typer + +from leetcode_py.cli.utils.resources import get_problems_json_path + + +def count_test_cases_for_problem(json_data: dict[str, Any]) -> int: + """Count total test cases across all test methods for a problem.""" + total = 0 + + # Handle both direct test_methods and nested _test_methods.list + test_methods = json_data.get("test_methods", []) + if not test_methods and "_test_methods" in json_data: + test_methods = json_data["_test_methods"].get("list", []) + + for method in test_methods: + test_cases = method.get("test_cases", "") + if test_cases.strip(): + # Parse Python list literal using ast.literal_eval + cases_list = ast.literal_eval(test_cases) + total += len(cases_list) + return total + + +def check_test_cases( + threshold: int = typer.Option( + 10, "--threshold", "-t", help="Show problems with test cases <= threshold" + ), + max_results: str = typer.Option( + "10", "--max", "-m", help="Maximum number of results to show ('none' for no limit)" + ), +) -> None: + """Check test case counts in LeetCode problems.""" + problems_dir = get_problems_json_path() + all_problems: list[tuple[str, int]] = [] + + for problem_file in problems_dir.glob("*.json"): + try: + with open(problem_file) as f: + data = json.load(f) + + test_count = count_test_cases_for_problem(data) + all_problems.append((problem_file.name, test_count)) + except Exception as e: + typer.echo(f"Error reading problem {problem_file.name}: {e}", err=True) + + # Sort by test count + all_problems.sort(key=lambda x: x[1]) + + # Filter by threshold + filtered_problems = [p for p in all_problems if p[1] <= threshold] + + # Apply max results limit + if max_results.lower() not in ["none", "null", "-1"]: + try: + max_count = int(max_results) + if max_count > 0: + filtered_problems = filtered_problems[:max_count] + except ValueError: + typer.echo(f"Invalid max_results value: {max_results}", err=True) + raise typer.Exit(1) + + typer.echo(f"Problems with ≀{threshold} test cases ({len(filtered_problems)} total):") + for problem_name, count in filtered_problems: + typer.echo(f"{problem_name}: {count} test cases") + + # Exit with non-zero code if any problems found + if filtered_problems: + raise typer.Exit(1) + + +app = typer.Typer() +app.command()(check_test_cases) + +if __name__ == "__main__": + app() diff --git a/leetcode_py/tools/generator.py b/leetcode_py/tools/generator.py index b18c4d1..4474cb9 100644 --- a/leetcode_py/tools/generator.py +++ b/leetcode_py/tools/generator.py @@ -1,216 +1,69 @@ -"""Template generation utilities for LeetCode problems.""" - import json -import sys from pathlib import Path -from typing import Any, Dict, Protocol +import black 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 load_json_data(json_path: Path) -> dict: + if not json_path.exists(): + typer.echo(f"Error: {json_path} not found", err=True) + raise typer.Exit(1) - def exists(self, path: Path) -> bool: - """Check if path exists.""" - ... + try: + with open(json_path) as f: + return json.load(f) + except (json.JSONDecodeError, OSError) as e: + typer.echo(f"Error reading {json_path}: {e}", err=True) + raise typer.Exit(1) -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) +def check_problem_exists(problem_name: str, output_dir: Path, force: bool) -> None: + if not force: + problem_dir = output_dir / problem_name + if problem_dir.exists(): + typer.echo( + f"Error: Problem '{problem_name}' already exists. Use --force to overwrite.", 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 format_python_files(problem_dir: Path) -> None: + if not problem_dir.exists(): + return - 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.""" + py_files = list(problem_dir.glob("*.py")) + for py_file in py_files: 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 = [ - "tags", - "readme_examples", - "solution_methods", - "test_helper_methods", - "test_methods", - ] - 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 + content = py_file.read_text() + formatted = black.format_str(content, mode=black.FileMode()) + py_file.write_text(formatted) + except Exception: + # Silently continue if formatting fails + pass + + +def generate_from_template(data: dict, template_dir: Path, output_dir: Path) -> None: + """Generate problem files using cookiecutter template.""" + try: + cookiecutter( + str(template_dir), + extra_context=data, + no_input=True, + overwrite_if_exists=True, + output_dir=str(output_dir), ) - 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) + problem_name = data.get("problem_name", "unknown") + format_python_files(output_dir / problem_name) + typer.echo(f"βœ… Generated problem: {problem_name}") + except Exception as e: + typer.echo(f"Error generating template: {e}", err=True) + raise typer.Exit(1) - # 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}") +def generate_problem(json_path: Path, template_dir: Path, output_dir: Path, force: bool = False) -> None: + data = load_json_data(json_path) + problem_name = data.get("problem_name", "unknown") + check_problem_exists(problem_name, output_dir, force) + generate_from_template(data, template_dir, output_dir) diff --git a/leetcode_py/tools/parser.py b/leetcode_py/tools/parser.py index 0aa8024..8fb8c2c 100644 --- a/leetcode_py/tools/parser.py +++ b/leetcode_py/tools/parser.py @@ -1,7 +1,7 @@ """HTML parsing utilities for LeetCode problem content.""" import re -from typing import Any, Dict, List +from typing import Any class HTMLParser: @@ -13,7 +13,7 @@ def clean_html(text: str) -> str: return re.sub(r"<[^>]+>", "", text).strip() @staticmethod - def parse_content(html_content: str) -> Dict[str, Any]: + 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( @@ -43,7 +43,7 @@ def parse_content(html_content: str) -> Dict[str, Any]: return {"description": description, "examples": examples, "constraints": constraints} @staticmethod - def parse_test_cases(test_cases_str: str) -> List[List[str]]: + def parse_test_cases(test_cases_str: str) -> list[list[str]]: """Parse test cases from the exampleTestcases string.""" if not test_cases_str: return [] diff --git a/leetcode_py/tools/scraper.py b/leetcode_py/tools/scraper.py index 8d6cfa5..05418bc 100644 --- a/leetcode_py/tools/scraper.py +++ b/leetcode_py/tools/scraper.py @@ -1,24 +1,38 @@ """LeetCode GraphQL API scraper to fetch problem information.""" -from typing import Any, Dict, Optional +from typing import Any import requests from .parser import HTMLParser +GRAPHQL_URL = "https://leetcode.com/graphql" +ALGORITHMS_API_URL = "https://leetcode.com/api/problems/algorithms/" +USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" + +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", +} -class LeetCodeScraper: - """Scraper for LeetCode problem information using GraphQL API.""" +class LeetCodeScraper: def __init__(self): - self.base_url = "https://leetcode.com/graphql" + self.base_url = GRAPHQL_URL self.headers = { "Content-Type": "application/json", - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + "User-Agent": USER_AGENT, } - def get_problem_by_slug(self, problem_slug: str) -> Optional[Dict[str, Any]]: - """Get problem info by problem slug (e.g., 'two-sum').""" + def get_problem_by_slug(self, problem_slug: str) -> dict[str, Any] | None: query = """ query questionData($titleSlug: String!) { question(titleSlug: $titleSlug) { @@ -51,8 +65,7 @@ def get_problem_by_slug(self, problem_slug: str) -> Optional[Dict[str, Any]]: 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).""" + def get_problem_by_number(self, problem_number: int) -> dict[str, Any] | None: # First try to get slug from algorithms API slug = self._get_slug_by_number(problem_number) if slug: @@ -60,13 +73,9 @@ def get_problem_by_number(self, problem_number: int) -> Optional[Dict[str, Any]] 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.""" + def _get_slug_by_number(self, problem_number: int) -> str | None: try: - response = requests.get( - "https://leetcode.com/api/problems/algorithms/", headers=self.headers - ) - + response = requests.get(ALGORITHMS_API_URL, headers=self.headers) if response.status_code == 200: data = response.json() for problem in data.get("stat_status_pairs", []): @@ -74,31 +83,14 @@ def _get_slug_by_number(self, problem_number: int) -> Optional[str]: 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]) - + def _try_common_slugs(self, problem_number: int) -> dict[str, Any] | None: + 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.""" + def get_python_code(self, problem_info: dict[str, Any]) -> str | None: if not problem_info or "codeSnippets" not in problem_info: return None @@ -107,8 +99,7 @@ def get_python_code(self, problem_info: Dict[str, Any]) -> Optional[str]: 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.""" + def format_problem_info(self, problem_info: dict[str, Any]) -> dict[str, Any]: if not problem_info: return {} diff --git a/poetry.lock b/poetry.lock index 2598c1b..c88ef01 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. [[package]] name = "anytree" @@ -6,7 +6,7 @@ version = "2.13.0" description = "Powerful and Lightweight Python Tree Data Structure with various plugins" optional = false python-versions = "<4.0,>=3.9.2" -groups = ["base"] +groups = ["main"] files = [ {file = "anytree-2.13.0-py3-none-any.whl", hash = "sha256:4cbcf10df36b1f1cba131b7e487ff3edafc9d6e932a3c70071b5b768bab901ff"}, {file = "anytree-2.13.0.tar.gz", hash = "sha256:c9d3aa6825fdd06af7ebb05b4ef291d2db63e62bb1f9b7d9b71354be9d362714"}, @@ -31,7 +31,7 @@ version = "1.3.0" description = "Better dates & times for Python" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main"] files = [ {file = "arrow-1.3.0-py3-none-any.whl", hash = "sha256:c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80"}, {file = "arrow-1.3.0.tar.gz", hash = "sha256:d4540617648cb5f895730f1ad8c82a65f2dad0166f57b75f3ca54759c4d67a85"}, @@ -102,7 +102,7 @@ version = "0.4.4" description = "Ultra-lightweight pure Python package to check if a file is binary or text." optional = false python-versions = "*" -groups = ["dev"] +groups = ["main"] files = [ {file = "binaryornot-0.4.4-py2.py3-none-any.whl", hash = "sha256:b8b71173c917bddcd2c16070412e369c3ed7f0528926f70cac18a6c97fd563e4"}, {file = "binaryornot-0.4.4.tar.gz", hash = "sha256:359501dfc9d40632edc9fac890e19542db1a287bbcfa58175b66658392018061"}, @@ -117,7 +117,7 @@ version = "25.1.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main"] files = [ {file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"}, {file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"}, @@ -162,7 +162,7 @@ version = "2025.8.3" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, @@ -284,7 +284,7 @@ version = "5.2.0" description = "Universal encoding detector for Python 3" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main"] files = [ {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, @@ -296,7 +296,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 = ["main", "dev"] +groups = ["main"] 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"}, @@ -385,7 +385,7 @@ version = "8.2.1" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, @@ -405,7 +405,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\"", base = "sys_platform == \"win32\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} +markers = {main = "platform_system == \"Windows\"", base = "sys_platform == \"win32\"", dev = "sys_platform == \"win32\""} [[package]] name = "comm" @@ -428,7 +428,7 @@ version = "2.6.0" description = "A command-line utility that creates projects from project templates, e.g. creating a Python package project from a Python package project template." optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main"] files = [ {file = "cookiecutter-2.6.0-py3-none-any.whl", hash = "sha256:a54a8e37995e4ed963b3e82831072d1ad4b005af736bb17b99c2cbd9d41b6e2d"}, {file = "cookiecutter-2.6.0.tar.gz", hash = "sha256:db21f8169ea4f4fdc2408d48ca44859349de2647fbe494a9d6c3edfc0542c21c"}, @@ -685,7 +685,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -829,7 +829,7 @@ version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, @@ -841,6 +841,21 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "json5" +version = "0.12.1" +description = "A Python implementation of the JSON5 data format." +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "json5-0.12.1-py3-none-any.whl", hash = "sha256:d9c9b3bc34a5f54d43c35e11ef7cb87d8bdd098c6ace87117a7b7e83e705c1d5"}, + {file = "json5-0.12.1.tar.gz", hash = "sha256:b2743e77b3242f8d03c143dd975a6ec7c52e2f2afe76ed934e53503dd4ad4990"}, +] + +[package.extras] +dev = ["build (==1.2.2.post1)", "coverage (==7.5.4) ; python_version < \"3.9\"", "coverage (==7.8.0) ; python_version >= \"3.9\"", "mypy (==1.14.1) ; python_version < \"3.9\"", "mypy (==1.15.0) ; python_version >= \"3.9\"", "pip (==25.0.1)", "pylint (==3.2.7) ; python_version < \"3.9\"", "pylint (==3.3.6) ; python_version >= \"3.9\"", "ruff (==0.11.2)", "twine (==6.1.0)", "uv (==0.6.11)"] + [[package]] name = "jsonschema" version = "4.25.1" @@ -1000,7 +1015,7 @@ version = "3.0.2" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main"] files = [ {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, @@ -1178,7 +1193,7 @@ version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, @@ -1257,7 +1272,7 @@ version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, @@ -1285,7 +1300,7 @@ version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, @@ -1313,7 +1328,7 @@ version = "4.4.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, @@ -1490,23 +1505,23 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests [[package]] name = "pytest-cov" -version = "6.3.0" +version = "7.0.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749"}, - {file = "pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2"}, + {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, + {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, ] [package.dependencies] -coverage = {version = ">=7.5", extras = ["toml"]} +coverage = {version = ">=7.10.6", extras = ["toml"]} pluggy = ">=1.2" -pytest = ">=6.2.5" +pytest = ">=7" [package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] +testing = ["process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "python-dateutil" @@ -1514,7 +1529,7 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -1529,7 +1544,7 @@ version = "8.0.4" description = "A Python slugify application that also handles Unicode" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main"] files = [ {file = "python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856"}, {file = "python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8"}, @@ -1578,7 +1593,7 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -1762,7 +1777,7 @@ version = "2.32.5" description = "Python HTTP for Humans." optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, @@ -1784,7 +1799,7 @@ version = "14.1.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f"}, {file = "rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8"}, @@ -2009,7 +2024,7 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -2041,7 +2056,7 @@ version = "1.3" description = "The most basic Text::Unidecode port" optional = false python-versions = "*" -groups = ["dev"] +groups = ["main"] files = [ {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, @@ -2163,7 +2178,7 @@ version = "2.9.0.20250822" description = "Typing stubs for python-dateutil" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main"] files = [ {file = "types_python_dateutil-2.9.0.20250822-py3-none-any.whl", hash = "sha256:849d52b737e10a6dc6621d2bd7940ec7c65fcb69e6aa2882acf4e56b2b508ddc"}, {file = "types_python_dateutil-2.9.0.20250822.tar.gz", hash = "sha256:84c92c34bd8e68b117bff742bc00b692a1e8531262d4507b33afcc9f7716cd53"}, @@ -2187,7 +2202,7 @@ version = "2.5.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, @@ -2251,4 +2266,4 @@ dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "e8769741fb13e346d2f8b7a6bc97b1a3042f1b2a0cfafbaa2125279ab454c9af" +content-hash = "11af98f38dd5768ebe54804d96677bc209cd0927a64f14c2110dde92d9496301" diff --git a/pyproject.toml b/pyproject.toml index 68c5bab..28663d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,20 +19,26 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] packages = [{include = "leetcode_py"}] +include = ["leetcode_py/cli/resources/**/*"] + +[tool.poetry.scripts] +lcpy = "leetcode_py.cli.main:app" [tool.poetry.dependencies] python = "^3.13" +anytree = "^2.13.0" +black = "^25.1.0" +cookiecutter = "^2.6.0" graphviz = "^0.21" +json5 = "^0.12.1" requests = "^2.32.5" +rich = "^14.1.0" typer = "^0.17.0" [tool.poetry.group.base.dependencies] -anytree = "^2.13.0" loguru = "^0.7.3" [tool.poetry.group.dev.dependencies] -black = "^25.1.0" -cookiecutter = "^2.6.0" ipykernel = "^6.30.1" isort = "^6.0.1" jupytext = "^1.16.6" @@ -40,7 +46,7 @@ mypy = "^1.17.1" nbqa = "^1.9.1" pre-commit = "^4.3.0" pytest = "^8.4.1" -pytest-cov = "^6.2.1" +pytest-cov = "^7.0.0" ruff = "^0.13.0" [build-system] @@ -56,7 +62,7 @@ target-version = ['py312'] include = '.*\.(py|ipynb)$' # All .py and .ipynb files extend-exclude = ''' /( - .templates + leetcode_py/cli/resources )/ ''' @@ -68,12 +74,12 @@ force_grid_wrap = 0 combine_as_imports = true use_parentheses = true ensure_newline_before_comments = true -extend_skip_glob = [".templates/*"] +extend_skip_glob = ["leetcode_py/cli/resources/*"] [tool.ruff] line-length = 105 target-version = 'py312' -extend-exclude = [".templates"] +extend-exclude = ["leetcode_py/cli/resources"] [tool.ruff.lint.pydocstyle] convention = "numpy" @@ -83,7 +89,7 @@ files = '**/*.py' warn_unused_configs = true ignore_missing_imports = true disable_error_code = ["return", "no-redef"] -exclude = [".templates"] +exclude = ["leetcode_py/cli/resources"] [tool.pytest.ini_options] testpaths = ["leetcode", "tests"] @@ -91,6 +97,7 @@ python_files = ["tests.py", "test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] addopts = "-v --tb=short" +norecursedirs = ["leetcode_py/cli/resources"] [tool.coverage.run] omit = ["**/playground.py"] diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/test_gen.py b/tests/cli/test_gen.py new file mode 100644 index 0000000..6fe2728 --- /dev/null +++ b/tests/cli/test_gen.py @@ -0,0 +1,161 @@ +import re +import tempfile +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from leetcode_py.cli.main import app + +runner = CliRunner() + + +def test_gen_help(): + result = runner.invoke(app, ["gen", "--help"]) + assert result.exit_code == 0 + # Remove ANSI color codes for reliable string matching + clean_output = re.sub(r"\x1b\[[0-9;]*m", "", result.stdout) + assert "--problem-num" in clean_output + assert "--problem-slug" in clean_output + assert "--problem-tag" in clean_output + assert "--difficulty" in clean_output + assert "--all" in clean_output + + +def test_gen_no_options(): + result = runner.invoke(app, ["gen"]) + assert result.exit_code == 1 + assert ( + "Exactly one of --problem-num, --problem-slug, --problem-tag, or --all is required" + in result.stderr + ) + + +@pytest.mark.parametrize( + "args", + [ + ["-n", "1", "-s", "two-sum"], + ["-n", "1", "-t", "test"], + ["-s", "two-sum", "-t", "test"], + ["-n", "1", "--all"], + ], +) +def test_gen_multiple_options(args): + result = runner.invoke(app, ["gen"] + args) + assert result.exit_code == 1 + assert ( + "Exactly one of --problem-num, --problem-slug, --problem-tag, or --all is required" + in result.stderr + ) + + +@pytest.mark.parametrize( + "args,expected_problems,expected_count", + [ + (["-n", "1"], ["two_sum"], "1 successful, 0 failed"), + (["-n", "1", "-n", "125"], ["two_sum", "valid_palindrome"], "2 successful, 0 failed"), + ], +) +def test_gen_by_numbers(args, expected_problems, expected_count): + with tempfile.TemporaryDirectory() as temp_dir: + result = runner.invoke(app, ["gen"] + args + ["-o", temp_dir, "--force"]) + assert result.exit_code == 0 + + for problem in expected_problems: + assert f"Generated problem: {problem}" in result.stdout + problem_dir = Path(temp_dir) / problem + assert problem_dir.exists() + assert (problem_dir / "solution.py").exists() + + assert f"Completed: {expected_count}" in result.stdout + + +@pytest.mark.parametrize( + "args,expected_problems,expected_count", + [ + (["-s", "valid_palindrome"], ["valid_palindrome"], "1 successful, 0 failed"), + ( + ["-s", "two_sum", "-s", "valid_palindrome"], + ["two_sum", "valid_palindrome"], + "2 successful, 0 failed", + ), + ], +) +def test_gen_by_slugs(args, expected_problems, expected_count): + with tempfile.TemporaryDirectory() as temp_dir: + result = runner.invoke(app, ["gen"] + args + ["-o", temp_dir, "--force"]) + assert result.exit_code == 0 + + for problem in expected_problems: + assert f"Generated problem: {problem}" in result.stdout + problem_dir = Path(temp_dir) / problem + assert problem_dir.exists() + assert (problem_dir / "solution.py").exists() + + assert f"Completed: {expected_count}" in result.stdout + + +def test_gen_by_tag(): + with tempfile.TemporaryDirectory() as temp_dir: + result = runner.invoke(app, ["gen", "-t", "test", "-o", temp_dir, "--force"]) + assert result.exit_code == 0 + assert "Found" in result.stdout + assert "problems with tag 'test'" in result.stdout + assert "Generated problem:" in result.stdout + assert "successful" in result.stdout + + +def test_gen_with_difficulty_filter(): + with tempfile.TemporaryDirectory() as temp_dir: + result = runner.invoke(app, ["gen", "-t", "test", "-d", "Easy", "-o", temp_dir, "--force"]) + assert result.exit_code == 0 + assert "Found" in result.stdout + assert "Filtered to" in result.stdout + assert "problems with difficulty 'Easy'" in result.stdout + + +@pytest.mark.parametrize( + "args,expected_error", + [ + (["-n", "99999"], "Problem number 99999 not found"), + (["-t", "nonexistent"], "No problems found with tag 'nonexistent'"), + ], +) +def test_gen_invalid_inputs(args, expected_error): + result = runner.invoke(app, ["gen"] + args) + assert result.exit_code == 1 + assert expected_error in result.stderr + + +def test_gen_existing_problem_without_force(): + with tempfile.TemporaryDirectory() as temp_dir: + # First generation should succeed + result1 = runner.invoke(app, ["gen", "-n", "1", "-o", temp_dir, "--force"]) + assert result1.exit_code == 0 + + # Second generation without --force should fail + result2 = runner.invoke(app, ["gen", "-n", "1", "-o", temp_dir]) + assert result2.exit_code == 1 + assert "already exists" in result2.stderr + assert "Completed: 0 successful, 1 failed" in result2.stdout + + +def test_gen_mixed_success_failure(): + with tempfile.TemporaryDirectory() as temp_dir: + # Create one problem first + runner.invoke(app, ["gen", "-n", "1", "-o", temp_dir, "--force"]) + + # Try to generate existing + new problem without force + result = runner.invoke(app, ["gen", "-n", "1", "-n", "125", "-o", temp_dir]) + assert result.exit_code == 1 + assert "already exists" in result.stderr + assert "Generated problem: valid_palindrome" in result.stdout + assert "Completed: 1 successful, 1 failed" in result.stdout + + +def test_gen_default_output_directory(): + # Test that default output is current directory + result = runner.invoke(app, ["gen", "--help"]) + assert result.exit_code == 0 + # The help should show the default value + assert "Output directory" in result.stdout diff --git a/tests/cli/test_list.py b/tests/cli/test_list.py new file mode 100644 index 0000000..74a8bcd --- /dev/null +++ b/tests/cli/test_list.py @@ -0,0 +1,53 @@ +import re + +from typer.testing import CliRunner + +from leetcode_py.cli.main import app + +runner = CliRunner() + + +def test_list_help(): + result = runner.invoke(app, ["list", "--help"]) + assert result.exit_code == 0 + # Remove ANSI color codes for reliable string matching + clean_output = re.sub(r"\x1b\[[0-9;]*m", "", result.stdout) + assert "--tag" in clean_output + assert "--difficulty" in clean_output + + +def test_list_all_problems(): + result = runner.invoke(app, ["list"]) + assert result.exit_code == 0 + assert "LeetCode Problems" in result.stdout + assert "Number" in result.stdout + assert "Title" in result.stdout + assert "Difficulty" in result.stdout + + +def test_list_by_tag(): + result = runner.invoke(app, ["list", "-t", "grind-75"]) + assert result.exit_code == 0 + assert "LeetCode Problems" in result.stdout + assert "Two Sum" in result.stdout + + +def test_list_by_difficulty(): + result = runner.invoke(app, ["list", "-d", "Easy"]) + assert result.exit_code == 0 + assert "LeetCode Problems" in result.stdout + assert "Easy" in result.stdout + assert "Two Sum" in result.stdout + + +def test_list_invalid_tag(): + result = runner.invoke(app, ["list", "-t", "nonexistent-tag"]) + assert result.exit_code == 1 + assert "No problems found with tag 'nonexistent-tag'" in result.stderr + + +def test_list_combined_filters(): + result = runner.invoke(app, ["list", "-t", "grind-75", "-d", "Easy"]) + assert result.exit_code == 0 + assert "LeetCode Problems" in result.stdout + assert "Two Sum" in result.stdout diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py new file mode 100644 index 0000000..e581e4e --- /dev/null +++ b/tests/cli/test_main.py @@ -0,0 +1,68 @@ +import pytest +from typer.testing import CliRunner + +from leetcode_py.cli.main import app + +runner = CliRunner() + + +def test_cli_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "LeetCode problem generator" in result.stdout + assert "gen" in result.stdout + assert "scrape" in result.stdout + assert "list" in result.stdout + + +def test_cli_version(): + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert "lcpy version" in result.stdout + + +def test_cli_version_short(): + result = runner.invoke(app, ["-V"]) + assert result.exit_code == 0 + assert "lcpy version" in result.stdout + + +@pytest.mark.parametrize("command", ["gen", "scrape", "list"]) +def test_command_help(command): + result = runner.invoke(app, [command, "--help"]) + assert result.exit_code == 0 + assert "Usage:" in result.stdout + + +def test_invalid_command(): + result = runner.invoke(app, ["invalid"]) + assert result.exit_code != 0 + + +def test_no_args_shows_help(): + result = runner.invoke(app, []) + assert result.exit_code == 0 + assert "LeetCode problem generator" in result.stdout + assert "Commands" in result.stdout + + +def test_cli_structure(): + """Test that all expected commands are available.""" + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + + # Check all main commands are listed + commands = ["gen", "scrape", "list"] + for cmd in commands: + assert cmd in result.stdout + + +def test_version_format(): + """Test version output format.""" + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + + # Should contain version info + output = result.stdout.strip() + assert output.startswith("lcpy version") + assert len(output.split()) >= 3 # "lcpy version X.Y.Z" diff --git a/tests/cli/test_problem_finder.py b/tests/cli/test_problem_finder.py new file mode 100644 index 0000000..d84dcfc --- /dev/null +++ b/tests/cli/test_problem_finder.py @@ -0,0 +1,106 @@ +import pytest + +from leetcode_py.cli.utils.problem_finder import ( + find_problem_by_number, + find_problems_by_tag, + get_all_problems, + get_tags_for_problem, +) + + +def test_find_problem_by_number(): + # Test existing problem + result = find_problem_by_number(1) + assert result == "two_sum" + + # Test another existing problem + result = find_problem_by_number(125) + assert result == "valid_palindrome" + + +def test_find_problem_by_number_not_found(): + result = find_problem_by_number(99999) + assert result is None + + +def test_find_problems_by_tag(): + # Test existing tag + result = find_problems_by_tag("test") + assert isinstance(result, list) + assert len(result) > 0 + + # Test grind-75 tag (should have many problems) + result = find_problems_by_tag("grind-75") + assert isinstance(result, list) + assert len(result) > 10 # Should have many problems + + +def test_find_problems_by_tag_not_found(): + result = find_problems_by_tag("nonexistent") + assert result == [] + + +def test_get_all_problems(): + result = get_all_problems() + assert isinstance(result, list) + assert len(result) > 0 + + # Should contain known problems + assert "two_sum" in result + assert "valid_palindrome" in result + + +def test_get_tags_for_problem(): + # Test problem with known tags + result = get_tags_for_problem("two_sum") + assert isinstance(result, list) + assert "grind-75" in result + + # Test problem that might not have tags + result = get_tags_for_problem("nonexistent_problem") + assert result == [] + + +@pytest.mark.parametrize( + "number,expected", + [ + (1, "two_sum"), + (125, "valid_palindrome"), + (99999, None), + ], +) +def test_find_problem_by_number_parametrized(number, expected): + result = find_problem_by_number(number) + assert result == expected + + +@pytest.mark.parametrize( + "tag,should_exist", + [ + ("grind-75", True), + ("test", True), + ("nonexistent", False), + ], +) +def test_find_problems_by_tag_parametrized(tag, should_exist): + result = find_problems_by_tag(tag) + if should_exist: + assert len(result) > 0 + else: + assert len(result) == 0 + + +def test_problem_finder_consistency(): + """Test that problem finder functions are consistent with each other.""" + all_problems = get_all_problems() + + # Test that problems found by number are in all_problems + for number in [1, 125]: + problem = find_problem_by_number(number) + if problem: + assert problem in all_problems + + # Test that problems found by tag are in all_problems + test_problems = find_problems_by_tag("test") + for problem in test_problems: + assert problem in all_problems diff --git a/tests/cli/test_resources.py b/tests/cli/test_resources.py new file mode 100644 index 0000000..f75344e --- /dev/null +++ b/tests/cli/test_resources.py @@ -0,0 +1,92 @@ +import inspect +from pathlib import Path + +from leetcode_py.cli.utils.resources import get_problems_json_path, get_tags_path, get_template_path + + +def test_get_template_path_development(): + result = get_template_path() + assert isinstance(result, Path) + assert result.name == "leetcode" + assert result.exists() + assert result.is_absolute() + + +def test_get_template_path_structure(): + template_path = get_template_path() + + # Check expected subdirectories exist + assert (template_path / "json").exists() + assert (template_path / "json" / "problems").exists() + assert (template_path / "json" / "tags.json5").exists() + assert (template_path / "{{cookiecutter.problem_name}}").exists() + + +def test_get_problems_json_path(): + result = get_problems_json_path() + assert isinstance(result, Path) + assert result.parts[-2:] == ("json", "problems") + assert result.exists() + + # Check it contains JSON files + json_files = list(result.glob("*.json")) + assert len(json_files) > 0 + assert any(f.name == "two_sum.json" for f in json_files) + + +def test_get_tags_path(): + result = get_tags_path() + assert isinstance(result, Path) + assert result.name == "tags.json5" + assert result.exists() + assert result.suffix == ".json5" + + +def test_get_template_path_relative_resolution(): + """Test that template path is resolved relative to __file__.""" + template_path = get_template_path() + + # Should be: leetcode_py/cli/resources/leetcode + expected_parts = ("leetcode_py", "cli", "resources", "leetcode") + assert template_path.parts[-4:] == expected_parts + + +def test_get_template_path_uses_relative_path(): + source = inspect.getsource(get_template_path) + assert "__file__" in source + assert "parent.parent" in source + assert "resources" in source + + +def test_get_template_path_error_handling(): + source = inspect.getsource(get_template_path) + assert "FileNotFoundError" in source + assert "not found" in source.lower() + + +def test_path_functions_consistency(): + template_path = get_template_path() + json_path = get_problems_json_path() + tags_path = get_tags_path() + + # JSON path should be under template path + assert str(json_path).startswith(str(template_path)) + + # Tags path should be under template path + assert str(tags_path).startswith(str(template_path)) + + # Tags should be sibling to problems directory + assert tags_path.parent == json_path.parent + + +def test_cookiecutter_template_exists(): + template_path = get_template_path() + cookiecutter_dir = template_path / "{{cookiecutter.problem_name}}" + + assert cookiecutter_dir.exists() + assert cookiecutter_dir.is_dir() + + # Check for expected template files + expected_files = ["solution.py", "test_solution.py", "README.md", "helpers.py"] + for filename in expected_files: + assert (cookiecutter_dir / filename).exists(), f"Missing template file: {filename}" diff --git a/tests/cli/test_scrape.py b/tests/cli/test_scrape.py new file mode 100644 index 0000000..bd61638 --- /dev/null +++ b/tests/cli/test_scrape.py @@ -0,0 +1,76 @@ +import json +import re + +import pytest +from typer.testing import CliRunner + +from leetcode_py.cli.main import app + +runner = CliRunner() + + +def test_scrape_help(): + result = runner.invoke(app, ["scrape", "--help"]) + assert result.exit_code == 0 + # Remove ANSI color codes for reliable string matching + clean_output = re.sub(r"\x1b\[[0-9;]*m", "", result.stdout) + assert "--problem-num" in clean_output + assert "--problem-slug" in clean_output + + +def test_scrape_no_options(): + result = runner.invoke(app, ["scrape"]) + assert result.exit_code == 1 + assert "Exactly one of --problem-num or --problem-slug is required" in result.stderr + + +def test_scrape_multiple_options(): + result = runner.invoke(app, ["scrape", "-n", "1", "-s", "two-sum"]) + assert result.exit_code == 1 + assert "Exactly one of --problem-num or --problem-slug is required" in result.stderr + + +@pytest.mark.parametrize( + "args, expected_number, expected_title, expected_slug, expected_difficulty", + [ + (["-n", "1"], "1", "Two Sum", "two-sum", "Easy"), + (["-s", "two-sum"], "1", "Two Sum", "two-sum", "Easy"), + ], +) +def test_scrape_success_real_api( + args, expected_number, expected_title, expected_slug, expected_difficulty +): + result = runner.invoke(app, ["scrape"] + args) + + assert result.exit_code == 0 + + # Parse JSON output + data = json.loads(result.stdout) + + # Verify problem data + assert data["number"] == expected_number + assert data["title"] == expected_title + assert data["slug"] == expected_slug + assert data["difficulty"] == expected_difficulty + + # Verify structure for number-based test only + if "-n" in args: + assert "Array" in data["topics"] + assert "Hash Table" in data["topics"] + assert data["description"] # Should have description + assert data["examples"] # Should have examples + assert data["constraints"] # Should have constraints + + +@pytest.mark.parametrize( + "args, expected_error", + [ + (["-n", "999999"], "Problem number 999999 not found"), + (["-s", "nonexistent-problem"], "Problem slug 'nonexistent-problem' not found"), + ], +) +def test_scrape_not_found_real_api(args, expected_error): + result = runner.invoke(app, ["scrape"] + args) + + assert result.exit_code == 1 + assert expected_error in result.stderr diff --git a/tests/test_dict_tree.py b/tests/data_structures/test_dict_tree.py similarity index 100% rename from tests/test_dict_tree.py rename to tests/data_structures/test_dict_tree.py diff --git a/tests/tools/test_check_test_cases.py b/tests/tools/test_check_test_cases.py new file mode 100644 index 0000000..6034fe4 --- /dev/null +++ b/tests/tools/test_check_test_cases.py @@ -0,0 +1,149 @@ +from unittest.mock import Mock, mock_open, patch + +import pytest +from typer.testing import CliRunner + +from leetcode_py.tools.check_test_cases import app, count_test_cases_for_problem + + +class TestCountTestCasesForProblem: + @pytest.mark.parametrize( + "json_data, expected", + [ + ( + {"test_methods": [{"test_cases": "[(1, 2, 3), (4, 5, 6)]"}, {"test_cases": "[(7, 8)]"}]}, + 3, + ), + ( + { + "_test_methods": { + "list": [ + {"test_cases": "[(1, 2), (3, 4), (5, 6)]"}, + {"test_cases": "[(7, 8, 9)]"}, + ] + } + }, + 4, + ), + ({"test_methods": [{"test_cases": "[]"}, {"test_cases": ""}, {"test_cases": " "}]}, 0), + ({}, 0), + ( + { + "test_methods": [ + {"test_cases": "[([1, 2], 'hello', True), ([3, 4], 'world', False)]"}, + {"test_cases": "[([], '', None)]"}, + ] + }, + 3, + ), + ], + ) + def test_count_test_cases(self, json_data, expected): + assert count_test_cases_for_problem(json_data) == expected + + def test_invalid_test_cases_raises_error(self): + json_data = {"test_methods": [{"test_cases": "invalid python literal"}]} + with pytest.raises((ValueError, SyntaxError)): + count_test_cases_for_problem(json_data) + + +class TestCheckTestCases: + def setup_method(self): + self.runner = CliRunner() + + @patch("leetcode_py.tools.check_test_cases.get_problems_json_path") + def test_check_with_no_problems_found(self, mock_get_path): + mock_path = Mock() + mock_path.glob.return_value = [] + mock_get_path.return_value = mock_path + + result = self.runner.invoke(app, ["--threshold", "10"]) + + assert result.exit_code == 0 + assert "Problems with ≀10 test cases (0 total):" in result.stdout + + @pytest.mark.parametrize( + "filename, json_data, expected_exit_code, expected_output", + [ + ( + "test_problem.json", + { + "test_methods": [ + {"test_cases": "[(1, 2), (3, 4), (5, 6), (7, 8), (9, 10)]"}, + {"test_cases": "[(11, 12), (13, 14), (15, 16), (17, 18), (19, 20)]"}, + {"test_cases": "[(21, 22), (23, 24), (25, 26), (27, 28), (29, 30)]"}, + ] + }, + 0, + "Problems with ≀10 test cases (0 total):", + ), + ( + "small_problem.json", + {"test_methods": [{"test_cases": "[(1, 2), (3, 4), (5, 6), (7, 8), (9, 10)]"}]}, + 1, + "Problems with ≀10 test cases (1 total):", + ), + ], + ) + @patch("leetcode_py.tools.check_test_cases.get_problems_json_path") + @patch("builtins.open", new_callable=mock_open) + def test_check_with_threshold( + self, mock_file, mock_get_path, filename, json_data, expected_exit_code, expected_output + ): + mock_problem_file = Mock() + mock_problem_file.name = filename + mock_path = Mock() + mock_path.glob.return_value = [mock_problem_file] + mock_get_path.return_value = mock_path + mock_file.return_value.read.return_value = "" + + with patch("json.load", return_value=json_data): + result = self.runner.invoke(app, ["--threshold", "10"]) + + assert result.exit_code == expected_exit_code + assert expected_output in result.stdout + + @patch("leetcode_py.tools.check_test_cases.get_problems_json_path") + @patch("builtins.open", new_callable=mock_open) + def test_check_with_max_results_limit(self, mock_file, mock_get_path): + mock_files = [Mock(name=f"problem_{i}.json") for i in range(5)] + mock_path = Mock() + mock_path.glob.return_value = mock_files + mock_get_path.return_value = mock_path + json_data = {"test_methods": [{"test_cases": "[(1, 2), (3, 4)]"}]} + mock_file.return_value.read.return_value = "" + + with patch("json.load", return_value=json_data): + result = self.runner.invoke(app, ["--threshold", "10", "--max", "2"]) + + assert result.exit_code == 1 + assert "Problems with ≀10 test cases (2 total):" in result.stdout + + @pytest.mark.parametrize( + "args, expected_exit_code, expected_output, output_stream", + [ + (["--max", "invalid"], 1, "Invalid max_results value: invalid", "stderr"), + ], + ) + def test_invalid_inputs(self, args, expected_exit_code, expected_output, output_stream): + result = self.runner.invoke(app, args) + assert result.exit_code == expected_exit_code + output = getattr(result, output_stream) + assert expected_output in output + + def test_real_json_integration(self): + """Integration test with real JSON files - should fail if any problems have ≀threshold test cases.""" + threshold = 10 + result = self.runner.invoke(app, ["--threshold", str(threshold), "--max", "-1"]) + + # Extract count from output like "Problems with ≀10 test cases (X total):" + import re + + match = re.search(rf"Problems with ≀{threshold} test cases \((\d+) total\):", result.stdout) + assert match, f"Could not parse output: {result.stdout}" + + count = int(match.group(1)) + if count > 0: + pytest.fail( + f"Found {count} problems with ≀{threshold} test cases. All problems should have >{threshold} test cases." + ) diff --git a/tests/tools/test_generator.py b/tests/tools/test_generator.py index 9dd62ec..b132bf8 100644 --- a/tests/tools/test_generator.py +++ b/tests/tools/test_generator.py @@ -1,238 +1,108 @@ +import json +import shutil +import tempfile 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] = { - "readme_examples": [{"content": "test"}], - "tags": ["grind-75"], - "other_field": "value", - } - - result = self.generator.convert_arrays_to_nested(data) - - assert "_readme_examples" in result - assert result["_readme_examples"] == {"list": [{"content": "test"}]} - assert "_tags" in result - assert result["_tags"] == {"list": ["grind-75"]} - assert "readme_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] = { - "solution_methods": [{"name": "test"}], - "test_methods": [[1, 2, 3]], - "other_list": ["not", "converted"], # Not in array_fields - "string_field": "value", - } - - result = self.generator.convert_arrays_to_nested(data) - - assert "_solution_methods" in result - assert "_test_methods" 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] = {"readme_examples": "not a list", "tags": None, "solution_methods": 123} - - result = self.generator.convert_arrays_to_nested(data) - - # Non-list values should remain unchanged - assert result["readme_examples"] == "not a list" - assert result["tags"] is None - assert result["solution_methods"] == 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() + +import pytest +import typer + +from leetcode_py.cli.utils.resources import get_problems_json_path, get_template_path +from leetcode_py.tools.generator import check_problem_exists, generate_problem, load_json_data + + +class TestGenerator: + + def test_load_json_data_success(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=True) as f: + json.dump({"problem_name": "test"}, f) + f.flush() + json_path = Path(f.name) + + data = load_json_data(json_path) + assert data == {"problem_name": "test"} + + def test_load_json_data_real_file(self): + real_json_path = get_problems_json_path() / "two_sum.json" + + data = load_json_data(real_json_path) + + assert data["problem_name"] == "two_sum" + assert data["solution_class_name"] == "Solution" + assert data["problem_number"] == "1" + assert data["problem_title"] == "Two Sum" + + def test_load_json_data_file_not_found(self): + with pytest.raises(typer.Exit): + load_json_data(Path("/nonexistent/file.json")) + + def test_load_json_data_invalid_json(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=True) as f: + f.write("invalid json") + f.flush() + json_path = Path(f.name) + + with pytest.raises(typer.Exit): + load_json_data(json_path) + + def test_check_problem_exists_force_true(self): + with tempfile.TemporaryDirectory() as temp_dir: + output_dir = Path(temp_dir) + problem_dir = output_dir / "test_problem" + problem_dir.mkdir() + + # Should not raise with force=True + check_problem_exists("test_problem", output_dir, force=True) + + def test_check_problem_exists_no_conflict(self): + with tempfile.TemporaryDirectory() as temp_dir: + output_dir = Path(temp_dir) + + # Should not raise when problem doesn't exist + check_problem_exists("nonexistent_problem", output_dir, force=False) + + def test_check_problem_exists_conflict(self): + with tempfile.TemporaryDirectory() as temp_dir: + output_dir = Path(temp_dir) + problem_dir = output_dir / "test_problem" + problem_dir.mkdir() + + with pytest.raises(typer.Exit): + check_problem_exists("test_problem", output_dir, force=False) + + def test_generate_problem_real_integration(self): + # Use real paths from the project + real_json_path = get_problems_json_path() / "two_sum.json" + real_template_path = get_template_path() + + with tempfile.TemporaryDirectory() as temp_dir: + # Copy real JSON to temp location + temp_json = Path(temp_dir) / "two_sum.json" + shutil.copy2(real_json_path, temp_json) + + # Copy entire template directory (including cookiecutter.json) + temp_template = Path(temp_dir) / "template" + shutil.copytree(real_template_path, temp_template) + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + # Generate problem + generate_problem(temp_json, temp_template, output_dir, force=True) + + # Assert files were created + problem_dir = output_dir / "two_sum" + assert problem_dir.exists() + assert (problem_dir / "README.md").exists() + assert (problem_dir / "solution.py").exists() + assert (problem_dir / "test_solution.py").exists() + assert (problem_dir / "helpers.py").exists() + assert (problem_dir / "__init__.py").exists() + + # Assert content is correct + solution_content = (problem_dir / "solution.py").read_text() + assert "class Solution:" in solution_content + assert "def two_sum(self, nums: list[int], target: int) -> list[int]:" in solution_content + + readme_content = (problem_dir / "README.md").read_text() + assert "# Two Sum" in readme_content + assert "Given an array of integers" in readme_content diff --git a/tests/tools/test_generator_file_ops.py b/tests/tools/test_generator_file_ops.py deleted file mode 100644 index 5a05fff..0000000 --- a/tests/tools/test_generator_file_ops.py +++ /dev/null @@ -1,137 +0,0 @@ -"""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