diff --git a/.DS_Store b/.DS_Store index e8ef403..f1e2967 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/CHANGELOG.md b/CHANGELOG.md index bc8bbba..5ea8fed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ # Changelog +## 1.3.5 +- Add Jinja templates in favor over standard library for string formatting +- Add problems pulled from Neetcode solutions GitHub ## 1.3.4 - Add Neetcode 250 diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..0897aa9 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +recursive-include leetcode_study_tool/templates * diff --git a/Makefile b/Makefile index af64655..0eb64d1 100644 --- a/Makefile +++ b/Makefile @@ -10,10 +10,11 @@ all: format: python -m ruff format ./leetcode_study_tool + python -m ruff check ./leetcode_study_tool --fix format-check: - python -m ruff check ./leetcode_study_tool python -m ruff format ./leetcode_study_tool --check + python -m ruff check ./leetcode_study_tool test: pytest tests/ --cov --cov-fail-under=85 diff --git a/README.md b/README.md index d9459df..01c48b2 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ $ pip install leetcode-study-tool ## 💻 Usage ```shell usage: leetcode-study-tool [-h] (--url URL | --file FILE | --preset {blind_75,grind_75,grind_169,neetcode_150,neetcode_250,neetcode_all}) [--format {anki,excel}] - [--csrf CSRF] [--output OUTPUT] [--language LANGUAGE] + [--template TEMPLATE] [--csrf CSRF] [--output OUTPUT] [--language LANGUAGE] [--include-code] Generates problems from LeetCode questions in a desired format. @@ -32,11 +32,14 @@ options: The preset to use to generate problem(s) for. (default: None) --format {anki,excel}, -F {anki,excel} The format to save the Leetcode problem(s) in. (default: anki) + --template TEMPLATE, -t TEMPLATE + Path to a custom Jinja template file for rendering problems. (default: None) --csrf CSRF, -c CSRF The CSRF token to use for LeetCode authentication. (default: None) --output OUTPUT, -o OUTPUT The output file to write the problem(s) to. (default: output.txt) --language LANGUAGE, -l LANGUAGE The language to generate problem(s) for. (default: None) + --include-code, -ic Include solution code from NeetCode GitHub repository using the specified language. (default: False) ``` ## 💡 Example @@ -52,10 +55,22 @@ which will generate the file `output.txt`. We can then open Anki to import these ![anki demo](static/anki-demo.gif) +### Including Solution Code + +You can include solution code from the NeetCode GitHub repository by using the `--include-code` flag along with specifying a programming language: + +```shell +$ leetcode-study-tool -p grind_75 --language python --include-code +``` + +This will fetch solution code in the specified language (when available) and include it in your Anki cards or Excel output. + +Supported languages include: c, cpp, csharp, dart, go, java, javascript, kotlin, python, ruby, rust, scala, swift, and typescript. + ## 📒 Anki When generating an Anki output, the resulting "cards" are saved as a `.txt` file. These cards include three fields: 1. The front of the study card, containing the question ID, Title, URL, and problem description -2. The publicly available solutions (and NeetCode solution, if available) +2. The publicly available solutions (and NeetCode solution or code, if available) 3. The tags associated with the problem (i.e., if the problem involves a hash map, arrays, etc...) ## 📊 Excel @@ -69,12 +84,83 @@ When generating an Excel output, the resulting questions are saved in an `.xlsx` 7. Solution links for the problem (if they are reachable) 8. Companies that have asked this question recently in interviews (if they are reachable) +## Custom Templates + +LeetCode Study Tool supports custom Jinja2 templates for generating Anki cards or other outputs. You can specify your own template file using the `--template` flag: + +```bash +leetcode-study-tool --url "https://leetcode.com/problems/two-sum/" --template "path/to/my_template.jinja" +``` + +### Template Variables + +When creating your custom template, the following variables are available: + +| Variable | Description | +|----------|-------------| +| `url` | The URL to the LeetCode problem | +| `slug` | The problem slug | +| `data` | Object containing all problem data | +| `data.id` | Problem ID | +| `data.title` | Problem title | +| `data.content` | Problem description (HTML) | +| `data.difficulty` | Problem difficulty (Easy, Medium, Hard) | +| `data.tags` | List of topic tags for the problem | +| `data.companies` | List of companies that ask this problem | +| `data.solutions` | List of available solutions on LeetCode | +| `data.neetcode_solution` | NeetCode solution code (if --include-code is used) | +| `data.language` | Language of the solution code | +| `neetcode` | NeetCode video information (when available) | + +### Solution Code Example + +Here's an example template that highlights the solution code: + +```jinja +

{{ data.id }}. {{ data.title }} ({{ data.difficulty }})

+ +
+ {{ data.content }} +
+ +
+ {% for tag in data.tags %} + {{ tag.name }} + {% endfor %} +
+ +; + +{% if data.neetcode_solution %} +

Solution Code ({{ data.language }})

+

+{{ data.neetcode_solution }}
+
+{% endif %} + +{% if data.solutions %} +
+

Community Solutions:

+ +
+{% endif %} + +; + +{{ data.tags|map(attribute='slug')|join(' ') }} +``` + ## 🛣 Roadmap - [X] Use TQDM to show card generation progress - [X] Add support for exporting to an excel sheet - [X] Add support for showing neetcode solutions on the back of the card as a - [X] Add support for getting the difficulty of questions -- [ ] Add support for Jinja templating formatters +- [X] Add support for Jinja templating formatters +- [X] Add support for including NeetCode solution code - [ ] Add NeetCode shorts - [ ] Add support for fetching premium questions via authentification - [ ] Add support for importing cards into Quizlet diff --git a/leetcode_study_tool/cli.py b/leetcode_study_tool/cli.py index dc3624e..81711cb 100644 --- a/leetcode_study_tool/cli.py +++ b/leetcode_study_tool/cli.py @@ -57,6 +57,13 @@ def generate_parser() -> argparse.ArgumentParser: help="The format to save the Leetcode problem(s) in.", ) + parser.add_argument( + "--template", + "-t", + type=str, + help="Path to a custom Jinja template file for rendering problems.", + ) + parser.add_argument( "--csrf", "-c", @@ -77,6 +84,14 @@ def generate_parser() -> argparse.ArgumentParser: "-l", type=str, help="The language to generate problem(s) for.", + default="python", + ) + + parser.add_argument( + "--include-code", + "-ic", + action="http://github.com/store_true", + help="Include solution code from NeetCode GitHub repository using the specified language.", ) return parser diff --git a/leetcode_study_tool/creator.py b/leetcode_study_tool/creator.py index ccbaa2b..4aac49b 100644 --- a/leetcode_study_tool/creator.py +++ b/leetcode_study_tool/creator.py @@ -10,7 +10,12 @@ from leetcode_study_tool.formatters import FORMAT_MAP from leetcode_study_tool.outputs import SAVE_MAP from leetcode_study_tool.presets import PRESET_MAP -from leetcode_study_tool.queries import generate_session, get_data, get_slug +from leetcode_study_tool.queries import ( + generate_session, + get_data, + get_neetcode_solution, + get_slug, +) class ProblemsCreator: @@ -19,9 +24,10 @@ class ProblemsCreator: """ def __init__(self, args: Union[argparse.Namespace, dict]) -> None: - # Explicitly define for linting self.format = "anki" self.output = "output" + self.template = None + self.include_code = False args = vars(args) for key in args: @@ -53,12 +59,6 @@ def create_problems(self) -> None: """ problems = p_map(self._generate_problem, self.urls) - # with Pool() as pool: - # problems = pool.map( - # self._generate_problem, - # self.urls, - # ) - self._save_output(problems, self.output) def _sanitize(self, input: Union[str, list, None]) -> Union[str, list]: @@ -82,8 +82,6 @@ def _sanitize(self, input: Union[str, list, None]) -> Union[str, list]: if isinstance(input, list): return input input = html.unescape(input) - # input = input.replace(";", " ") - # input = input.replace("\n", " ") input = re.sub(r"[;\n\r]", " ", input) input = input.replace("", "
") input = re.sub(r"(
){2,}", "
", input) @@ -104,8 +102,6 @@ def _generate_problem(self, url: str) -> Union[str, None]: --------- url : str The URL of the question to generate a problem for. - language : str - The coding language to generate a problem for. Returns ------- @@ -118,10 +114,25 @@ def _generate_problem(self, url: str) -> Union[str, None]: slug = get_slug(url) try: data = get_data(slug, self.language, self.session) + + if ( + self.include_code + and self.language + and not data.get("neetcode_solution") + ): + github_solution = get_neetcode_solution( + data["id"], data["title"], self.language + ) + if github_solution: + data["neetcode_solution"] = github_solution + except Exception as e: print(f"Failed to generate problem for {url}: {e}") return None data = {k: self._sanitize(v) for k, v in data.items()} - return FORMAT_MAP[self.format](url, slug, data) # type: ignore + if self.format == "anki": + return FORMAT_MAP[self.format](url, slug, data, self.template) # type: ignore + else: + return FORMAT_MAP[self.format](url, slug, data) # type: ignore diff --git a/leetcode_study_tool/formatters.py b/leetcode_study_tool/formatters.py index 91d4eb7..6ca2a70 100644 --- a/leetcode_study_tool/formatters.py +++ b/leetcode_study_tool/formatters.py @@ -1,64 +1,80 @@ from datetime import date -from textwrap import dedent -from typing import List, Union +from pathlib import Path +from typing import List, Optional, Union -from leetcode_study_tool.leetcode_to_neetcode import LEETCODE_TO_NEETCODE # fmt: skip +from jinja2 import Environment, FileSystemLoader, select_autoescape + +from leetcode_study_tool.leetcode_to_neetcode import LEETCODE_TO_NEETCODE from leetcode_study_tool.queries import get_url +template_dir = Path(__file__).parent / "templates" +env = Environment( + loader=FileSystemLoader(template_dir), autoescape=select_autoescape(["html", "xml"]) +) + -def format_list_element(title: str, elements: List[str], is_link: bool = False) -> str: +def format_solution_link(slug: str, solution_id: str) -> str: """ - formats an HTML list element for the given title and elements + format a link to the LeetCode solution with the given ID. Arguments --------- - title : str - The title of the list element to format. - elements : List[str] - The elements of the list element to format. + slug : str + The slug of the question to format a link for. + solution_id : str + The ID of the solution to format a link for. Returns ------- str - The formatted list element for the given title and data. - """ - if is_link: - return f""" - {title}:
- - """ - return f""" - {title}:
- + The link to the LeetCode solution with the given ID. """ + return f"https://leetcode.com/problems/%7Bslug%7D/solutions/%7Bsolution_id%7D/1/" -def format_solution_link(slug: str, solution_id: str) -> str: +def render_template( + template_path: Optional[str], template_name: Optional[str], **kwargs +) -> str: """ - format a link to the LeetCode solution with the given ID. + Render a template with the given context Arguments --------- - slug : str - The slug of the question to format a link for. - solution_id : str - The ID of the solution to format a link for. + template_path : Optional[str] + Path to a custom template file. If None, use built-in templates. + template_name : str + Name of the built-in template to use if template_path is None. + kwargs : dict + Template context variables. Returns ------- str - The link to the LeetCode solution with the given ID. + Rendered template content. """ - return f"https://leetcode.com/problems/%7Bslug%7D/solutions/%7Bsolution_id%7D/1/" + if not template_path and not template_name: + raise ValueError("Either template_path or template_name must be provided") + if template_path: + custom_dir = Path(template_path).parent + custom_file = Path(template_path).name + custom_env = Environment( + loader=FileSystemLoader(custom_dir), + autoescape=select_autoescape(["html", "xml"]), + ) + template = custom_env.get_template(custom_file) + else: + assert template_name is not None + template = env.get_template(template_name) + + kwargs["solution_url"] = format_solution_link + return template.render(**kwargs) -def format_anki(url: str, slug: str, data: dict): +def format_anki( + url: str, slug: str, data: dict, template_path: Optional[str] = None +) -> str: """ - formats an Anki problem for the given URL and data + formats an Anki problem using Jinja template Arguments --------- @@ -68,56 +84,28 @@ def format_anki(url: str, slug: str, data: dict): The slug of the question to format a problem for. data : dict The data of the question to format a problem for. + template_path : Optional[str] + Path to a custom template file. If None, use built-in templates. Returns ------- str - The Anki problem for the given URL and data. - """ - problem = f""" -

- {data['id']}. {data['title']} -

-

- {data['content']} -

+ The formatted Anki problem. """ - if data["companies"]: - problem += format_list_element( - "Companies", [company["name"] for company in data["companies"]] - ) - - if data["tags"]: - problem += format_list_element("Tags", [tag["name"] for tag in data["tags"]]) - - if data["difficulty"]: - problem += "Difficulty:
" - problem += f"

{data['difficulty']}

" - - problem += ";" - - if str(data["id"]) in LEETCODE_TO_NEETCODE: - neetcode = LEETCODE_TO_NEETCODE[str(data["id"])] - problem += "NeetCode Solution:
" - problem += f"{neetcode['title']}

" - - if data["solutions"]: - problem += format_list_element( - "LeetCode User Solutions", - [ - format_solution_link(slug, solution["id"]) - for solution in data["solutions"] - ], - is_link=True, - ) - - problem += ";" - - problem += " ".join([tag["slug"].lower() for tag in data["tags"]]) - - # Makes code easier to read to remove at the end - problem = dedent(problem).replace("\n", "") - return problem + neetcode = LEETCODE_TO_NEETCODE.get(str(data["id"])) + + rendered = render_template( + template_path, + "anki.html.j2", + url=get_http://github.com/url(url), + slug=slug, + data=data, + neetcode=neetcode, + ) + rendered = rendered.replace("\n", "
") + rendered = rendered.replace("\t", "\u00a0\u00a0\u00a0\u00a0") + rendered = " ".join(rendered.split()) + return rendered def format_quizlet(url: str, slug: str, data: dict): @@ -174,6 +162,7 @@ def format_excel(url: str, slug: str, data: dict) -> List[Union[str, date]]: row.append(neetcode["url"]) else: row.append("") + row.append(data.get("neetcode_solution", "")) row.append( "\n".join( [ @@ -194,3 +183,12 @@ def format_excel(url: str, slug: str, data: dict) -> List[Union[str, date]]: "quizlet": format_quizlet, "excel": format_excel, } + +__all__ = [ + "format_solution_link", + "format_anki", + "format_quizlet", + "format_excel", + "FORMAT_MAP", + "render_template", +] diff --git a/leetcode_study_tool/queries.py b/leetcode_study_tool/queries.py index 573a1b2..5628935 100644 --- a/leetcode_study_tool/queries.py +++ b/leetcode_study_tool/queries.py @@ -1,7 +1,7 @@ import json import re from functools import lru_cache -from typing import Union +from typing import Optional, Union from urllib.parse import urlparse import requests @@ -23,6 +23,26 @@ "solutions": COMMUNITY_SOLUTIONS, } +NEETCODE_LANGUAGES = [ + "c", + "cpp", + "csharp", + "dart", + "go", + "java", + "javascript", + "kotlin", + "python", + "ruby", + "rust", + "scala", + "swift", + "typescript", +] +NEETCODE_BASE_URL = ( + "https://raw.githubusercontent.com/neetcode-gh/leetcode/refs/heads/main" +) + def get_slug(input: str) -> str: """ @@ -148,9 +168,71 @@ def query( if response.status_code == 200: return dict(json.loads(response.content.decode("utf-8")).get("data")) else: - raise requests.exceptions.HTTPError( + response = requests.Response() + response.status_code = 500 + error = requests.exceptions.HTTPError( f"LeetCode GraphQL API returned {response.status_code}" ) + error.response = response + raise error + + +def get_neetcode_solution( + problem_id: str, title: str, language: str = "python" +) -> Optional[str]: + """ + Get the solution for a given problem from the NeetCode GitHub repository. + + Arguments + --------- + problem_id : str + The ID of the problem to get the solution for. + title : str + The title of the problem to get the solution for. + language : str + The language to get the solution for. Must be one of the supported languages. + Defaults to 'python'. + + Returns + ------- + Optional[str] + The solution code if found, None otherwise. + """ + if language not in NEETCODE_LANGUAGES: + return None + + padded_id = problem_id.zfill(4) + + kebab_title = re.sub(r"[^a-zA-Z0-9\s]", "", title).lower().replace(" ", "-") + + file_name = f"{padded_id}-{kebab_title}" + + url = f"{NEETCODE_BASE_URL}/{language}/{file_name}" + + extensions = { + "c": ".c", + "cpp": ".cpp", + "csharp": ".cs", + "dart": ".dart", + "go": ".go", + "java": ".java", + "javascript": ".js", + "kotlin": ".kt", + "python": ".py", + "ruby": ".rb", + "rust": ".rs", + "scala": ".scala", + "swift": ".swift", + "typescript": ".ts", + } + url += extensions.get(language, "") + + try: + response = requests.get(url) + response.raise_for_status() + return response.text + except requests.exceptions.RequestException: + return None def get_data( @@ -186,6 +268,10 @@ def get_data( "solutions", slug, session, skip=0, first=10, languageTags=(language) )["questionSolutions"]["solutions"] + neetcode_solution = None + if language: + neetcode_solution = get_neetcode_solution(id, title, language) + results = { "title": title, "content": content, @@ -194,6 +280,7 @@ def get_data( "tags": tags, "companies": companies, "solutions": solutions, + "neetcode_solution": neetcode_solution, } return results diff --git a/leetcode_study_tool/templates/anki.html.j2 b/leetcode_study_tool/templates/anki.html.j2 new file mode 100644 index 0000000..059cf81 --- /dev/null +++ b/leetcode_study_tool/templates/anki.html.j2 @@ -0,0 +1,43 @@ +

+ {{ data.id }}. {{ data.title }} +

+

{{ data.content }}

+ +{% if data.companies %} +Companies:
+ +{% endif %} + +{% if data.difficulty %} +Difficulty:
+

{{ data.difficulty }}

+{% endif %} + +; + +{% if neetcode %} +NeetCode Solution:
+{{ neetcode.title }}

+{% endif %} + +{% if data.neetcode_solution %} +Code Solution ({{ data.language|default('') }}):
+
{{ data.neetcode_solution }}
+{% endif %} + +{% if data.solutions %} +LeetCode User Solutions:
+ +{% endif %} + +; + +{% if data.tags %}{{ data.tags|map(attribute='slug')|map('lower')|join(' ') }}{% endif %} diff --git a/pyproject.toml b/pyproject.toml index 55fd591..e0d929a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "leetcode-study-tool" -version = "1.3.4" +version = "1.3.5" description = "A tool for studying Leetcode with Python" authors = [{name="John Sutor", email="johnsutor3@gmail.com" }] license = {file = "LICENSE.txt"} @@ -12,7 +12,7 @@ readme = "README.md" keywords = ["leetcode", "leet", "study", "Anki"] classifiers=[ # Development status - 'Development Status :: 4 - Beta', + 'Development Status :: 5 - Production/Stable', # Supported OS 'Operating System :: Microsoft :: Windows', @@ -37,7 +37,8 @@ classifiers=[ dependencies = [ "requests==2.32.3", "XlsxWriter==3.2.0", - "p-tqdm==1.4.2" + "p-tqdm==1.4.2", + "Jinja2==3.1.5" ] [project.optional-dependencies] diff --git a/tests/test_creator.py b/tests/test_creator.py index dc0c95c..5c6ad6c 100644 --- a/tests/test_creator.py +++ b/tests/test_creator.py @@ -18,6 +18,7 @@ def setUp(self) -> None: output="fake-output", format="anki", language=None, + include_code=False, ) self.fake_parsed_args_url = argparse.Namespace( file=None, @@ -25,6 +26,15 @@ def setUp(self) -> None: output="fake-output", format="anki", language=None, + include_code=False, + ) + self.fake_parsed_args_with_code = argparse.Namespace( + file=None, + url="fake-url", + output="fake-output", + format="anki", + language="python", + include_code=True, ) @patch("builtins.open") @@ -60,3 +70,32 @@ def test_generate_problem(self, mock_open): mock_problem_creator = creator.ProblemsCreator(self.fake_parsed_args_file) mock_problem_creator._generate_problem("two-sum") mock_format_anki.assert_called_once() + + @patch("leetcode_study_tool.creator.get_data") + @patch("leetcode_study_tool.creator.get_neetcode_solution") + @patch("leetcode_study_tool.creator.get_slug") + def test_generate_problem_with_code(self, mock_get_slug, mock_get_neetcode_solution, mock_get_data): + mock_get_slug.return_value = "two-sum" + mock_get_data.return_value = { + "id": "1", + "title": "Two Sum", + "content": "test content", + "difficulty": "Easy", + "tags": [], + "companies": [], + "solutions": [] + } + mock_get_neetcode_solution.return_value = "def two_sum(nums, target):\n # Solution code" + + mock_format_anki = MagicMock() + with patch.dict( + "leetcode_study_tool.formatters.FORMAT_MAP", + {"anki": mock_format_anki}, + ): + mock_problem_creator = creator.ProblemsCreator(self.fake_parsed_args_with_code) + mock_problem_creator._generate_problem("two-sum") + + mock_get_neetcode_solution.assert_called_once_with("1", "Two Sum", "python") + + called_args = mock_format_anki.call_args[0] + self.assertIsNotNone(called_args[2].get("neetcode_solution")) diff --git a/tests/test_formatters.py b/tests/test_formatters.py index d8e7949..359fc86 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -3,6 +3,9 @@ from textwrap import dedent from typing import Any, Dict import re +import tempfile +import os +from unittest.mock import patch import leetcode_study_tool.formatters as formatters from leetcode_study_tool.queries import get_data, get_url @@ -17,42 +20,28 @@ def assertAnkiCardStructure( self, anki_html: str, problem_slug: str, problem_data: Dict[Any, Any] ): """ - Instead of comparing exact strings, verify the structure and key components - of the Anki card HTML. + Verify the structure and key components of the Anki card HTML. + For tags, ensure they appear after the second semicolon. """ self.assertTrue(f"https://leetcode.com/problems/%7Bproblem_slug%7D" in anki_html) - self.assertTrue(f'

{problem_data["difficulty"]}

' in anki_html) - - for tag in problem_data["tags"]: - self.assertTrue(tag["name"] in anki_html) + + semicolon_index = anki_html.rfind(";") + for tag in problem_data.get("tags", []): + self.assertIn( + tag["slug"], anki_html[semicolon_index:], + f"Tag {tag['name']} should appear after the second semicolon" + ) self.assertRegex(anki_html, r"LeetCode User Solutions:") - solution_links = re.findall( r"https://leetcode/.com/problems/[^/]+/solutions//d+/1/", anki_html ) self.assertGreater( len(solution_links), 0, "Should have at least one solution link" ) - if problem_data.get("neetcode_video_id"): - self.assertTrue("youtube.com/watch?" in anki_html) - - def test_format_list_element(self): - self.assertEqual( - dedent( - formatters.format_list_element("fake-title", ["fake-el-1", "fake-el-2"]) - ), - dedent( - """ - fake-title:
- - """ - ), - ) + self.assertIn("youtube.com/watch?", anki_html) def test_format_solution_link(self): self.assertEqual( @@ -72,7 +61,7 @@ def test_format_anki(self): self.assertAnkiCardStructure(formatted_anki, problem_slug, data) - self.assertTrue(formatted_anki.startswith("

")) + self.assertTrue(formatted_anki.startswith("

")) self.assertTrue("" in formatted_anki) self.assertEqual( @@ -105,3 +94,124 @@ def test_format_excel(self): "https://youtube.com/watch?v=KLlXCFG5TnA", ], ) + + def test_render_template(self): + """Test the template rendering functionality""" + test_data = { + "id": "1", + "title": "Test Problem", + "content": "Test content", + "difficulty": "Medium", + "tags": [{"name": "Array", "slug": "array"}], + "solutions": [{"id": "12345"}] + } + + rendered = formatters.render_template( + None, + "anki.html.j2", + url="https://example.com/", + slug="test-problem", + data=test_data, + neetcode=None + ) + + self.assertIn("Test Problem", rendered) + self.assertIn("Medium", rendered) + self.assertIn("solutions/12345/1/", rendered) + + semicolon_index = rendered.rfind(";") + self.assertIn("array", rendered[semicolon_index:], + "Tag 'Array' should appear only after the second semicolon") + + def test_render_custom_template(self): + """Test rendering with a custom template file""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.html.j2', delete=False) as tmp: + tmp.write("{{ data.title }} - {{ data.difficulty }} - {{ url }}") + tmp_path = tmp.name + + try: + test_data = { + "id": "1", + "title": "Test Problem", + "content": "Test content", + "difficulty": "Medium", + "tags": [{"name": "Array", "slug": "array"}], + "solutions": [{"id": "12345"}] + } + + rendered = formatters.render_template( + tmp_path, + None, + url="https://example.com/", + data=test_data + ) + + self.assertEqual("Test Problem - Medium - https://example.com", rendered) + finally: + os.unlink(tmp_path) + + def test_render_template_error(self): + """Test error handling when no template is provided""" + with self.assertRaises(ValueError): + formatters.render_template(None, None) + + def test_format_anki_custom_template(self): + """Test the Anki card formatter with custom template""" + problem_slug = "two-sum" + data = get_data(problem_slug) + + with tempfile.NamedTemporaryFile(mode='w', suffix='.html.j2', delete=False) as tmp: + tmp.write("CUSTOM: {{ data.title }} - {{ data.difficulty }} - {{ url }}") + tmp_path = tmp.name + + try: + formatted_anki = formatters.format_anki( + get_url(http://github.com/problem_slug), problem_slug, data, template_path=tmp_path + ) + + self.assertIn("CUSTOM:", formatted_anki) + self.assertIn("Two Sum", formatted_anki) + self.assertIn("Easy", formatted_anki) + finally: + os.unlink(tmp_path) + + def test_format_anki_with_template(self): + """Test the Anki card formatter with templates""" + problem_slug = "two-sum" + data = get_data(problem_slug) + formatted_anki = formatters.format_anki( + get_url(http://github.com/problem_slug), problem_slug, data + ) + + self.assertIn("

", formatted_anki) + self.assertIn("

", formatted_anki) + + if data.get("companies"): + self.assertIn("Companies:", formatted_anki) + + if str(data["id"]) in formatters.LEETCODE_TO_NEETCODE: + self.assertIn("NeetCode Solution:", formatted_anki) + + @patch("leetcode_study_tool.queries.get_data") + def test_format_anki_with_github_solution(self, mock_get_data): + """Test the Anki card formatter with a GitHub solution""" + problem_slug = "two-sum" + + mock_data = { + "id": "1", + "title": "Two Sum", + "difficulty": "Easy", + "content": "

Test content

", + "tags": [{"name": "Array", "slug": "array"}], + "companies": [{"name": "Amazon", "slug": "amazon"}], + "solutions": [{"id": "12345"}], + "neetcode_solution": "def two_sum(nums, target):\n # GitHub solution code\n pass" + } + mock_get_data.return_value = mock_data + + formatted_anki = formatters.format_anki( + get_url(http://github.com/problem_slug), problem_slug, mock_data + ) + + self.assertIn("GitHub solution code", formatted_anki) + self.assertIn("def two_sum", formatted_anki) diff --git a/tests/test_queries.py b/tests/test_queries.py index 86f466a..b705055 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -1,5 +1,6 @@ import unittest -from unittest.mock import call, patch +from unittest.mock import call, patch, MagicMock +import requests import leetcode_study_tool.queries as queries @@ -62,27 +63,136 @@ def test_query_raises(self, mock_get): fake_kwarg="fake-value", ) mock_get.return_value.status_code = 500 - # self.assertRaises(requests.exceptions.HTTPError, queries.query, "content", "fake-slug") - @patch("leetcode_study_tool.queries.query") - def test_get_data(self, mock_query): - session = queries.generate_session() - queries.get_data(self.fake_slug, self.fake_language, session) - calls = [ - call("title", self.fake_slug, session), - call("content", self.fake_slug, session), - call("tags", self.fake_slug, session), - call("companies", self.fake_slug, session), - call( - "solutions", - self.fake_slug, - session, - skip=0, - first=10, - languageTags=(self.fake_language), - ), + @patch("requests.Session.get") + def test_query_raises_http_error(self, mock_get): + """Test that query raises HTTPError when status code is not 200""" + queries.query.cache_clear() + mock_get.return_value.status_code = 500 + mock_get.return_value.content = b'{"error": "Internal Server Error"}' + + with self.assertRaisesRegex(requests.exceptions.HTTPError, "LeetCode GraphQL API returned 500"): + queries.query("content", "fake-slug") + + @patch("requests.Session.get") + def test_query_handles_non_200_responses(self, mock_get): + """Test that query handles non-200 responses appropriately""" + mock_get.return_value.status_code = 500 + mock_get.return_value.content = b'{"error": "Internal Server Error"}' + + try: + result = queries.query("content", "fake-slug") + self.assertFalse(isinstance(result, dict) and result.get("success")) + except Exception as e: + pass + + @patch("requests.get") + def test_get_neetcode_solution_success(self, mock_get): + """Test that get_neetcode_solution returns the solution when successful""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "def solution():\n pass" + mock_get.return_value = mock_response + + result = queries.get_neetcode_solution("1", "Two Sum", "python") + + self.assertEqual(result, "def solution():\n pass") + mock_get.assert_called_once() + call_args = mock_get.call_args[0][0] + self.assertTrue("0001-two-sum.py" in call_args) + self.assertTrue("python" in call_args) + + @patch("requests.get") + def test_get_neetcode_solution_unsupported_language(self, mock_get): + """Test that get_neetcode_solution returns None for unsupported languages""" + result = queries.get_neetcode_solution("1", "Two Sum", "unsupported-lang") + + self.assertIsNone(result) + mock_get.assert_not_called() + + @patch("requests.get") + def test_get_neetcode_solution_http_error(self, mock_get): + """Test that get_neetcode_solution handles HTTP errors gracefully""" + mock_get.side_effect = requests.exceptions.RequestException("404 Not Found") + + result = queries.get_neetcode_solution("1", "Two Sum", "python") + self.assertIsNone(result) + mock_get.assert_called_once() + + @patch("requests.get") + def test_get_neetcode_solution_special_chars(self, mock_get): + """Test that get_neetcode_solution handles titles with special characters""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "function solution() {}" + mock_get.return_value = mock_response + + result = queries.get_neetcode_solution("42", "Trapping Rain Water!", "javascript") + + self.assertEqual(result, "function solution() {}") + call_args = mock_get.call_args[0][0] + self.assertTrue("0042-trapping-rain-water.js" in call_args) + + def test_get_neetcode_solution_file_extensions(self): + """Test that get_neetcode_solution uses the correct file extensions""" + test_cases = [ + ("python", ".py"), + ("javascript", ".js"), + ("java", ".java"), + ("cpp", ".cpp"), + ("ruby", ".rb"), + ("rust", ".rs") ] - mock_query.assert_has_calls(calls, any_order=True) + + for language, extension in test_cases: + with patch("requests.get") as mock_get: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = f"Code in {language}" + mock_get.return_value = mock_response + + queries.get_neetcode_solution("1", "Two Sum", language) + + call_args = mock_get.call_args[0][0] + self.assertTrue(call_args.endswith(extension), + f"Expected URL to end with {extension} for {language}") + + @patch("leetcode_study_tool.queries.query") + @patch("leetcode_study_tool.queries.get_neetcode_solution") + def test_get_data_includes_neetcode_solution(self, mock_get_neetcode, mock_query): + """Test that get_data includes neetcode solution when language is specified""" + mock_query.side_effect = lambda content, *args, **kwargs: { + "title": {"question": {"title": "Test", "difficulty": "Easy", "questionId": "1"}}, + "content": {"question": {"content": "test content"}}, + "tags": {"question": {"topicTags": []}}, + "companies": {"question": {"companyTags": []}}, + "solutions": {"questionSolutions": {"solutions": []}} + }[content] + + mock_get_neetcode.return_value = "def solution():\n pass" + + data = queries.get_data("test-slug", "python") + + mock_get_neetcode.assert_called_once_with("1", "Test", "python") + self.assertEqual(data["neetcode_solution"], "def solution():\n pass") + + @patch("leetcode_study_tool.queries.query") + @patch("leetcode_study_tool.queries.get_neetcode_solution") + def test_get_data_no_neetcode_solution_when_language_missing(self, mock_get_neetcode, mock_query): + """Test that get_data skips neetcode solution when no language is specified""" + mock_query.side_effect = lambda content, *args, **kwargs: { + "title": {"question": {"title": "Test", "difficulty": "Easy", "questionId": "1"}}, + + "content": {"question": {"content": "test content"}}, + "tags": {"question": {"topicTags": []}}, + "companies": {"question": {"companyTags": []}}, + "solutions": {"questionSolutions": {"solutions": []}} + }[content] + + data = queries.get_data("test-slug") + + mock_get_neetcode.assert_not_called() + self.assertIsNone(data["neetcode_solution"]) if __name__ == "__main__":