Skip to content

Commit 8f5bc2d

Browse files
committed
add interface unit test
1 parent d85ecc2 commit 8f5bc2d

File tree

9 files changed

+226
-25
lines changed

9 files changed

+226
-25
lines changed

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,21 @@ Planned available languages:
9090
- [ ] erlang
9191
- [ ] elixir
9292

93+
## Contributing
94+
95+
Know any of the above langauges and want to help out? Your contributions will be greatly appreciated!
96+
97+
For more information on how to implement the language interface, refer to [this issue](https://github.com/kguzek/leetcode-project-generator/issues/1).
98+
Then, fork this repository, make your changes and [submit them as a pull request](https://github.com/kguzek/leetcode-project-generator/compare).
99+
100+
### Testing
101+
102+
You can test all registered language interfaces (including your implementation) by running the test file found at [src/lpg/interfaces/lang_test.py](src/lpg/interfaces/lang_test.py):
103+
104+
```sh
105+
python -m unittest src.lpg.interfaces.lang_test
106+
```
107+
108+
This uses the builtin `unittest` module and executes project generation for each language against an arbitrary list of LeetCode problems in a temporary directory, which is then cleaned up.
109+
93110
Thanks for reading!

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
66

77
[project]
88
name = "leetcode-project-generator"
9-
version = "1.2.1"
9+
version = "1.3.0"
1010
authors = [
1111
{ name = "Konrad Guzek", email = "[email protected]" },
1212
]

src/lpg/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Constants used throughout modules."""
2+
3+
OUTPUT_RESULT_PREFIX = "result:"

src/lpg/interfaces/file.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
"""API for accessing various OS and filesystem functions."""
22

33
import os
4+
45
from click import ClickException
56

7+
from .lang.base import BaseLanguageInterface
68
from .lang.c import CLanguageInterface
79
from .lang.python3 import Python3LanguageInterface
810

9-
LANGUAGE_INTERFACES = {
11+
LANGUAGE_INTERFACES: dict[str, BaseLanguageInterface] = {
1012
"c": CLanguageInterface(),
1113
"python3": Python3LanguageInterface(),
1214
}
@@ -27,7 +29,10 @@ def create_project_directory(project_path: str, force: bool):
2729

2830

2931
def create_project(
30-
project_name: str, project_directory: str, template_data: dict, force: bool
32+
project_name: str,
33+
project_directory: str,
34+
template_data: dict[str, str],
35+
force: bool,
3136
):
3237
"""Creates the entire project. Returns the path that it was created in."""
3338

src/lpg/interfaces/lang/base.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
"""This file contains the base class for language interfaces."""
22

3-
from abc import abstractmethod
4-
from typing import Any, Pattern, Match
3+
import re
4+
from abc import ABCMeta, abstractmethod
5+
from typing import Any, Match, Pattern
56

7+
from ...constants import OUTPUT_RESULT_PREFIX
68

7-
class BaseLanguageInterface:
9+
SUPPLEMENTAL_CODE_PATTERN = re.compile(
10+
r"Definition for .+\n(?:(?:# | \* |// ).+\n)+", re.MULTILINE
11+
)
12+
COMMENT_PATTERN = re.compile(r"^(?:# | \* |// )(.+)$")
13+
14+
15+
class BaseLanguageInterface(metaclass=ABCMeta):
816
"""Base class for language interfaces."""
917

1018
def __init__(self):
@@ -14,7 +22,22 @@ def __init__(self):
1422
@property
1523
@abstractmethod
1624
def function_signature_pattern(self) -> Pattern[str]:
17-
"""Returns the function signature regular expression pattern."""
25+
"""The regular expression pattern which extracts data from the function definition."""
26+
27+
@property
28+
@abstractmethod
29+
def compile_command(self) -> list[str] | None:
30+
"""The command to compile the project, for testing. Can be set to None if not needed."""
31+
32+
@property
33+
@abstractmethod
34+
def test_command(self) -> list[str]:
35+
"""The command to run the project test."""
36+
37+
@property
38+
@abstractmethod
39+
def default_output(self) -> str:
40+
"""The default output when running the barebones project test."""
1841

1942
@abstractmethod
2043
def prepare_project_files(self, template: str) -> dict[str, str]:
@@ -28,7 +51,22 @@ def create_project(self, template: str) -> None:
2851
f"Fatal error: project template doesn't match regex:\n\n{template}"
2952
)
3053
self.groups = self.match.groupdict()
54+
self.groups["OUTPUT_RESULT_PREFIX"] = OUTPUT_RESULT_PREFIX
3155

3256
for filename, content in self.prepare_project_files(template).items():
3357
with open(filename, "w", encoding="utf-8") as file:
3458
file.write(content)
59+
60+
def get_supplemental_code(self, template: str) -> str | None:
61+
"""Returns the implicit template code, such as a linked list node implementation."""
62+
match = SUPPLEMENTAL_CODE_PATTERN.search(template)
63+
if match is None:
64+
return None
65+
commented_code = match.group(0).split("\n")
66+
# the first line does not contain code
67+
commented_code.pop(0)
68+
return "\n".join(
69+
match.group(1)
70+
for match in (COMMENT_PATTERN.match(line) for line in commented_code)
71+
if match is not None
72+
)

src/lpg/interfaces/lang/c.py

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""Project generator for the C language."""
22

33
import re
4+
45
from .base import BaseLanguageInterface
56

7+
STDBOOL_HEADER = "#include <stdbool.h>\n"
68
HEADER_FILE_TEMPLATE = "{returnType} {name}({params});"
79

810
TEST_FILE_TEMPLATE = """\
@@ -11,22 +13,31 @@
1113
1214
int main() {{
1315
{param_declarations};
14-
{returnType} result = {name}({params_call});
15-
printf("result: %d\\n", result);
16+
{result_var_declaration}{name}({params_call});
17+
printf("{OUTPUT_RESULT_PREFIX} %d\\n", {result_var});
1618
return 0;
1719
}}
1820
"""
1921

2022
FUNCTION_SIGNATURE_PATTERN = re.compile(
21-
r"^(?P<returnType>(?:struct )?\w+(?:\[\]|\*\*?)?) (?P<name>\w+)\((?P<params>(?:(?:struct )?\w+(?:\[\]|\*\*?)? \w+(?:, )?)+)\)\s?{$",
22-
flags=re.MULTILINE,
23+
r"""
24+
^(?P<returnType>(?:struct\s)?\w+(?:\[\]|\s?\*+)?)\s(?P<name>\w+)
25+
\((?P<params>(?:(?:struct\s)?\w+(?:\[\]|\s?\*+)?\s\w+(?:,\s)?)+)\)\s?{$
26+
""",
27+
flags=re.MULTILINE | re.VERBOSE,
2328
)
2429

30+
SOLUTION_REPLACEMENT_PATTERN = re.compile(r"\n}")
31+
SOLUTION_REPLACEMENT_TEMPLATE = "return 0;\n}"
32+
2533

2634
class CLanguageInterface(BaseLanguageInterface):
2735
"""Implementation of the C language project template interface."""
2836

2937
function_signature_pattern = FUNCTION_SIGNATURE_PATTERN
38+
compile_command = ["gcc", "solution.c", "test.c", "-o", "test"]
39+
test_command = ["./test"]
40+
default_output = "0"
3041

3142
def prepare_project_files(self, template: str):
3243

@@ -35,10 +46,29 @@ def prepare_project_files(self, template: str):
3546
", ", ";\n "
3647
)
3748
self.groups["params_call"] = ", ".join(param.split()[-1] for param in params)
38-
formatted = TEST_FILE_TEMPLATE.format(**self.groups)
49+
50+
headers = ""
51+
if "bool" in template:
52+
headers += STDBOOL_HEADER
53+
# ... additional header checks can be added here
54+
if headers != "":
55+
headers += "\n"
56+
57+
if self.groups["returnType"] == "void":
58+
self.groups["result_var_declaration"] = ""
59+
self.groups["result_var"] = "0"
60+
formatted_template = template
61+
else:
62+
self.groups["result_var_declaration"] = (
63+
f"{self.groups['returnType']} result = "
64+
)
65+
self.groups["result_var"] = "result"
66+
formatted_template = re.sub(
67+
SOLUTION_REPLACEMENT_PATTERN, SOLUTION_REPLACEMENT_TEMPLATE, template
68+
)
3969

4070
return {
41-
"solution.c": f"{template}\n",
42-
"solution.h": HEADER_FILE_TEMPLATE.format(**self.groups),
43-
"test.c": formatted,
71+
"solution.c": f"{headers}{formatted_template}\n",
72+
"solution.h": f"{headers}{HEADER_FILE_TEMPLATE.format(**self.groups)}",
73+
"test.c": TEST_FILE_TEMPLATE.format(**self.groups),
4474
}

src/lpg/interfaces/lang/python3.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Project generator for the Python 3 language."""
22

33
import re
4+
45
from .base import BaseLanguageInterface
56

67
FUNCTION_SIGNATURE_PATTERN = re.compile(
@@ -15,17 +16,20 @@
1516
from solution import Solution
1617
1718
18-
if __name__ == "__main__":
19+
{supplemental_code}if __name__ == "__main__":
1920
{params_setup}
2021
result = Solution().{name}({params_call})
21-
print("result:", result)
22+
print("{OUTPUT_RESULT_PREFIX}", result)
2223
"""
2324

2425

2526
class Python3LanguageInterface(BaseLanguageInterface):
2627
"""Implementation of the Python 3 language project template interface."""
2728

2829
function_signature_pattern = FUNCTION_SIGNATURE_PATTERN
30+
compile_command = None
31+
test_command = ["python", "test.py"]
32+
default_output = "None"
2933

3034
def prepare_project_files(self, template: str):
3135
params = (
@@ -44,7 +48,12 @@ def prepare_project_files(self, template: str):
4448
param.split("=")[0].split(":")[0].strip() for param in params
4549
)
4650

51+
supplemental_code = self.get_supplemental_code(template)
52+
supplemental_code = (
53+
"" if supplemental_code is None else f"{supplemental_code}\n\n\n"
54+
)
55+
self.groups["supplemental_code"] = supplemental_code
4756
return {
48-
"solution.py": f"{TYPING_IMPORT_TEMPLATE}\n{template}pass\n",
57+
"solution.py": f"{TYPING_IMPORT_TEMPLATE}\n{supplemental_code}{template}pass\n",
4958
"test.py": f"{TYPING_IMPORT_TEMPLATE}{TEST_FILE_TEMPLATE.format(**self.groups)}",
5059
}

src/lpg/interfaces/lang_test.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Unit test for all language interfaces."""
2+
3+
import os
4+
import subprocess
5+
import tempfile
6+
import unittest
7+
8+
from ..constants import OUTPUT_RESULT_PREFIX
9+
from .file import LANGUAGE_INTERFACES
10+
from .web import get_code_snippets
11+
12+
TEST_CASES = [
13+
"add-binary",
14+
"add-two-numbers",
15+
"add-two-promises",
16+
"build-array-from-permutation",
17+
"climbing-stairs",
18+
"concatenation-of-array",
19+
"defanging-an-ip-address",
20+
"display-the-first-three-rows",
21+
"divide-two-integers",
22+
"final-value-of-variable-after-performing-operations",
23+
"find-the-index-of-the-first-occurrence-in-a-string",
24+
"hand-of-straights",
25+
"integer-to-roman",
26+
"length-of-last-word",
27+
"longest-palindromic-substring",
28+
"longest-substring-without-repeating-characters",
29+
"merge-sorted-array",
30+
"merge-two-sorted-lists",
31+
"n-queens",
32+
"minimize-maximum-pair-sum-in-array",
33+
"modify-columns",
34+
"number-of-good-pairs",
35+
"plus-one",
36+
"remove-duplicates-from-sorted-list",
37+
"return-length-of-arguments-passed",
38+
"reverse-string",
39+
"score-of-a-string",
40+
"search-insert-position",
41+
"string-to-integer-atoi",
42+
"two-sum",
43+
]
44+
45+
46+
class TestProjectGeneration(unittest.TestCase):
47+
"""Test case for the project generation process."""
48+
49+
def conduct_tests(self, code_snippets: list[dict[str, str]]):
50+
"""Generates, compiles and runs the solution code for each supported language."""
51+
for template_data in code_snippets:
52+
language = template_data["langSlug"]
53+
interface = LANGUAGE_INTERFACES.get(language)
54+
if interface is None:
55+
continue
56+
57+
# print(f"Testing {language} interface...")
58+
interface.create_project(template_data["code"])
59+
if interface.compile_command is not None:
60+
subprocess.run(interface.compile_command, check=True)
61+
result = subprocess.run(
62+
interface.test_command, check=True, capture_output=True
63+
)
64+
self.assertMultiLineEqual(
65+
result.stdout.decode().strip(),
66+
f"{OUTPUT_RESULT_PREFIX} {interface.default_output}",
67+
)
68+
69+
70+
def generate_test(test_case: str):
71+
"""Generates a test function for a specific LeetCode problem."""
72+
73+
def test(self: TestProjectGeneration):
74+
with tempfile.TemporaryDirectory() as temp_dir:
75+
# print(f"\nGenerating {test_case} in directory {temp_dir}...")
76+
os.chdir(temp_dir)
77+
self.conduct_tests(get_code_snippets(test_case))
78+
79+
return test
80+
81+
82+
def register_tests():
83+
"""Registers all LeetCode problems as separate test cases."""
84+
for test_case in TEST_CASES:
85+
test_name = f"test_{test_case.replace('-', '_')}"
86+
setattr(TestProjectGeneration, test_name, generate_test(test_case))
87+
88+
89+
register_tests()
90+
91+
if __name__ == "__main__":
92+
unittest.main()

src/lpg/interfaces/web.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,29 +76,36 @@ def _get_body_from_request(request: urllib.request.Request) -> dict:
7676
return data
7777

7878

79-
def _get_template_data_from_body(body: dict, lang: str) -> dict[str, any]:
79+
def get_code_snippets(title_slug: str) -> list[dict[str, str]]:
80+
"""Fetches all code snippets for the given LeetCode problem."""
81+
request = _create_graphql_request(title_slug)
82+
body = _get_body_from_request(request)
8083
question = body["data"]["question"]
8184
try:
8285
code_snippets = question["codeSnippets"]
8386
except TypeError as type_error:
8487
raise ClickException("Invalid title slug.") from type_error
88+
return code_snippets
89+
90+
91+
def _get_template_data(title_slug, language: str):
92+
code_snippets = get_code_snippets(title_slug)
93+
8594
for lang_data in code_snippets:
86-
if lang_data["langSlug"] != lang:
95+
if lang_data["langSlug"] != language:
8796
continue
8897
return lang_data
89-
raise ClickException(f"Invalid code language '{lang}'.")
98+
raise ClickException(f"Invalid programming language '{language}'.")
9099

91100

92101
def get_leetcode_template(
93-
lang: str, title_slug: str | None = None, url: str | None = None
102+
language: str, title_slug: str | None = None, url: str | None = None
94103
):
95104
"""Fetches the LeetCode problem code template for the given language."""
96105
if url is None:
97106
if title_slug is None:
98107
raise ClickException("Either url or title slug must be specified.")
99108
else:
100109
title_slug = _get_title_slug(url)
101-
request = _create_graphql_request(title_slug)
102-
body = _get_body_from_request(request)
103-
template_data = _get_template_data_from_body(body, lang)
110+
template_data = _get_template_data(title_slug, language)
104111
return title_slug, template_data

0 commit comments

Comments
 (0)