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

+### 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 %}
+
+{% 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}:
-
- {"".join([ "- " + item + "
" for item in elements])}
-
+ 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['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.content }}
+
+{% if data.companies %}
+Companies:
+
+ {% for company in data.companies %}
+ - {{ company.name }}
+ {% endfor %}
+
+{% 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__":