Skip to content

Commit 61f9cc2

Browse files
📝 Add docstrings to feat_patch
Docstrings generation was requested by @laike9m. * #98 (comment) The following files were modified: * `tests/assets/challenges/basic-foo-pyright-config/question.py` * `tests/conftest.py` * `tests/test_identical.py` * `views/challenge.py` * `views/views.py`
1 parent 6cab7ca commit 61f9cc2

5 files changed

Lines changed: 164 additions & 39 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""A simple question, only for running tests.
2+
"""
3+
4+
5+
def foo():
6+
"""
7+
No-op placeholder function used by tests.
8+
9+
Performs no operation.
10+
"""
11+
pass
12+
13+
14+
## End of your code ##
15+
foo(1)
16+
foo(1, 2) # expect-type-error
17+
18+
## End of test code ##
19+
# pyright: reportGeneralTypeIssues=error

tests/conftest.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,46 @@
1010
from flask.testing import FlaskClient
1111

1212
from app import app
13+
from views.challenge import ChallengeManager
1314

1415
CHALLENGES_DIR = Path(__file__).parent.parent / "challenges"
1516
ALL_QUESTIONS = list(CHALLENGES_DIR.glob("**/question.py"))
1617
ALL_SOLUTIONS = list(CHALLENGES_DIR.glob("**/solution*.py"))
17-
ALL_HINTS = list(CHALLENGES_DIR.glob("**/hints.md"))
1818

1919

2020
@pytest.fixture()
2121
def assets_dir() -> Path:
22-
"""The directory contains test assets."""
22+
"""
23+
Path to the test assets directory located alongside this file.
24+
25+
Returns:
26+
Path: Path to the "assets" directory adjacent to this conftest.py file.
27+
"""
2328
return Path(__file__).parent / "assets"
2429

2530

31+
@pytest.fixture()
32+
def mgr(assets_dir: Path):
33+
"""
34+
Create a ChallengeManager for the "challenges" subdirectory of the provided assets directory.
35+
36+
Parameters:
37+
assets_dir (Path): Path to the test assets directory containing challenge data.
38+
39+
Returns:
40+
ChallengeManager: Instance initialized with `assets_dir / "challenges"`.
41+
"""
42+
return ChallengeManager(assets_dir / "challenges")
43+
44+
2645
@pytest.fixture()
2746
def test_client() -> FlaskClient:
47+
"""
48+
Create a Flask test client for the application.
49+
50+
Returns:
51+
test_client (FlaskClient): A test client bound to the application for issuing HTTP requests in tests.
52+
"""
2853
return app.test_client()
2954

3055

@@ -35,9 +60,4 @@ def question_file(request):
3560

3661
@pytest.fixture(params=ALL_SOLUTIONS, ids=lambda x: x.parent.name)
3762
def solution_file(request):
38-
return request.param
39-
40-
41-
@pytest.fixture(params=ALL_HINTS, ids=lambda x: x.parent.name)
42-
def hint_file(request) -> Path:
43-
return request.param
63+
return request.param

tests/test_identical.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,27 @@
88

99

1010
def test_identical(solution_file: Path):
11-
level, challenge_name = solution_file.parent.name.split("-", maxsplit=1)
12-
with solution_file.open() as f:
13-
solution_code = f.read()
14-
solution_test = Challenge(
15-
name=challenge_name, level=Level(level), code=solution_code
16-
).test_code
17-
18-
question_file = solution_file.parent / "question.py"
19-
with question_file.open() as f:
20-
question_code = f.read()
21-
question_test = Challenge(
22-
name=challenge_name, level=Level(level), code=question_code
23-
).test_code
24-
assert solution_test.strip() == question_test.strip()
11+
"""
12+
Checks that the test code embedded in the given solution file matches the test code in the corresponding question file.
13+
14+
Reads the solution file to construct a Challenge object (using the solution file's parent directory name to derive level and challenge name), extracts each challenge's test code up to the marker "\n## End of test code ##\n", strips surrounding whitespace, and asserts the two extracted test code segments are equal.
15+
16+
Parameters:
17+
solution_file (Path): Path to the solution file whose test code will be compared to the question file located at the same directory with name "question.py".
18+
"""
19+
def get_test_code(path: Path):
20+
TEST_SPLITTER = "\n## End of test code ##\n"
21+
level, challenge_name = path.parent.name.split("-", maxsplit=1)
22+
23+
with solution_file.open() as f:
24+
challenge_code = f.read()
25+
challenge = Challenge(
26+
name=challenge_name, level=Level(level), code=challenge_code
27+
)
28+
29+
return challenge.test_code.partition(TEST_SPLITTER)[0]
30+
31+
solution_test = get_test_code(solution_file)
32+
question_test = get_test_code(solution_file.parent / "question.py")
33+
34+
assert solution_test.strip() == question_test.strip()

views/challenge.py

Lines changed: 83 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,29 @@
1111
from typing import ClassVar, Optional, TypeAlias
1212

1313
ROOT_DIR = Path(__file__).parent.parent
14+
PYRIGHT_BASIC_CONFIG = """
15+
# pyright: analyzeUnannotatedFunctions=true
16+
# pyright: strictParameterNoneValue=true
17+
# pyright: disableBytesTypePromotions=false
18+
# pyright: strictListInference=false
19+
# pyright: strictDictionaryInference=false
20+
# pyright: strictSetInference=false
21+
# pyright: deprecateTypingAliases=false
22+
# pyright: enableExperimentalFeatures=false
23+
# pyright: reportMissingImports=error
24+
# pyright: reportUndefinedVariable=error
25+
# pyright: reportGeneralTypeIssues=error
26+
# pyright: reportOptionalSubscript=error
27+
# pyright: reportOptionalMemberAccess=error
28+
# pyright: reportOptionalCall=error
29+
# pyright: reportOptionalIterable=error
30+
# pyright: reportOptionalContextManager=error
31+
# pyright: reportOptionalOperand=error
32+
# pyright: reportTypedDictNotRequiredAccess=error
33+
# pyright: reportPrivateImportUsage=error
34+
# pyright: reportUnboundVariable=error
35+
# pyright: reportUnusedCoroutine=error
36+
"""
1437

1538

1639
class Level(StrEnum):
@@ -145,13 +168,50 @@ def _get_challenges_groupby_level(self) -> dict[Level, list[ChallengeName]]:
145168
# Pyright error messages look like:
146169
# `<filename>:<line_no>:<col_no> - <error|warning|information>: <message>`
147170
# Here we only capture the error messages and line numbers
148-
PYRIGHT_MESSAGE_REGEX = r"^(?:.+?):(\d+):[\s\-\d]+(error:.+)$"
171+
PYRIGHT_MESSAGE_REGEX = (
172+
r"^(?:.+?):(?P<line_number>\d+):[\s\-\d]+(?P<message>error:.+)$"
173+
)
174+
175+
@staticmethod
176+
def _partition_test_code(test_code: str):
177+
"""
178+
Split test code from an optional Pyright configuration block and return the test portion plus the effective Pyright configuration.
179+
180+
Parameters:
181+
test_code (str): Combined test code that may include a separator line "\n## End of test code ##\n" followed by additional Pyright configuration.
182+
183+
Returns:
184+
tuple[str, str]: A tuple (test_code, pyright_basic_config) where `test_code` is the portion before the splitter and `pyright_basic_config` is the base PYRIGHT_BASIC_CONFIG optionally extended with any config found after the splitter.
185+
"""
186+
TEST_SPLITTER = "\n## End of test code ##\n"
187+
188+
# PYRIGHT_BASIC_CONFIG aim to limit user to modify the config
189+
test_code, end_test_comment, pyright_config = test_code.partition(TEST_SPLITTER)
190+
pyright_basic_config = PYRIGHT_BASIC_CONFIG
191+
192+
# Replace `## End of test code ##` with PYRIGHT_BASIC_CONFIG
193+
if end_test_comment:
194+
pyright_basic_config += pyright_config
195+
return test_code, pyright_basic_config
149196

150197
@classmethod
151198
def _type_check_with_pyright(
152199
cls, user_code: str, test_code: str
153200
) -> TypeCheckResult:
154-
code = f"{user_code}{test_code}"
201+
"""
202+
Run Pyright on combined user and test code (including any embedded Pyright config) and report type-check results.
203+
204+
Parameters:
205+
user_code (str): The user's source code to be type-checked.
206+
test_code (str): The test suite code (may include an embedded Pyright config region) appended to the user code.
207+
208+
Returns:
209+
TypeCheckResult: An object containing `message`, a newline-separated report of Pyright errors and a summary line,
210+
and `passed`, which is `true` if no reported errors (other than those originating from Pyright config) remain.
211+
"""
212+
test_code, pyright_basic_config = cls._partition_test_code(test_code)
213+
214+
code = f"{user_code}{test_code}{pyright_basic_config}"
155215
buffer = io.StringIO(code)
156216

157217
# This produces a stream of TokenInfos, example:
@@ -187,39 +247,47 @@ def _type_check_with_pyright(
187247
return TypeCheckResult(message=stderr, passed=False)
188248
error_lines: list[str] = []
189249

190-
# Substract lineno in merged code by lineno_delta, so that the lineno in
250+
# Substract lineno in merged code by user_code_line_len, so that the lineno in
191251
# error message matches those in the test code editor. Fixed #20.
192-
lineno_delta = len(user_code.splitlines())
252+
user_code_lines_len = len(user_code.splitlines())
193253
for line in stdout.splitlines():
194254
m = re.match(cls.PYRIGHT_MESSAGE_REGEX, line)
195255
if m is None:
196256
continue
197-
line_number, message = int(m.group(1)), m.group(2)
257+
line_number, message = int(m["line_number"]), m["message"]
198258
if line_number in error_line_seen_in_err_msg:
199259
# Each reported error should be attached to a specific line,
200260
# If it is commented with # expect-type-error, let it pass.
201261
error_line_seen_in_err_msg[line_number] = True
202262
continue
203263
# Error could be thrown from user code too, in which case delta shouldn't be applied.
204-
error_lines.append(
205-
f"{line_number if line_number <= lineno_delta else line_number - lineno_delta}:{message}"
206-
)
264+
error_line = f"%s:{message}"
265+
266+
if line_number <= user_code_lines_len:
267+
error_lines.append(error_line % line_number)
268+
elif line_number <= user_code_lines_len + len(test_code.splitlines()):
269+
error_lines.append(error_line % (line_number - user_code_lines_len))
270+
else:
271+
error_lines.append(error_line % "[pyright-config]")
207272

208273
# If there are any lines that are expected to fail but not reported by pyright,
209274
# they should be considered as errors.
210275
for line_number, seen in error_line_seen_in_err_msg.items():
211276
if not seen:
212277
error_lines.append(
213-
f"{line_number - lineno_delta}: error: Expected type error but instead passed"
278+
f"{line_number - user_code_lines_len}: error: Expected type error but instead passed"
214279
)
215280

216-
passed = len(error_lines) == 0
217-
if passed:
218-
error_lines.append("\nAll tests passed")
219-
else:
220-
error_lines.append(f"\nFound {len(error_lines)} errors")
281+
# Error for pyright-config will not fail the challenge
282+
passed = True
283+
for error_line in error_lines:
284+
if error_line.startswith("[pyright-config]"):
285+
continue
286+
passed = False
287+
288+
error_lines.append(f"\nFound {len(error_lines)} errors")
221289

222290
return TypeCheckResult(message="\n".join(error_lines), passed=passed)
223291

224292

225-
challenge_manager = ChallengeManager()
293+
challenge_manager = ChallengeManager()

views/views.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,21 @@ def index():
5353
@app_views.route("/<level>/<name>", methods=["GET"])
5454
@validate_challenge
5555
def get_challenge(level: str, name: str):
56+
"""
57+
Render the challenge page or HTMX component for a given challenge.
58+
59+
Builds the template context for the challenge identified by level and name, including the user's code under test, the test code truncated at the "\n## End of test code ##\n" marker, rendered hints (when present), challenges grouped by level, and Python runtime information. Returns the HTMX component template when HTMX is active; otherwise returns the full challenge page template.
60+
61+
Returns:
62+
Flask response: Rendered HTML response for the requested challenge page or HTMX component.
63+
"""
5664
challenge = challenge_manager.get_challenge(ChallengeKey(Level(level), name))
5765
params = {
5866
"name": name,
5967
"level": challenge.level,
6068
"challenges_groupby_level": challenge_manager.challenges_groupby_level,
6169
"code_under_test": challenge.user_code,
62-
"test_code": challenge.test_code,
70+
"test_code": challenge.test_code.partition("\n## End of test code ##\n")[0],
6371
"hints_for_display": render_hints(challenge.hints) if challenge.hints else None,
6472
"python_info": platform.python_version(),
6573
}
@@ -100,4 +108,4 @@ def run_challenge(level: str, name: str):
100108
@app_views.route("/random", methods=["GET"])
101109
def run_random_challenge():
102110
challenge = challenge_manager.get_random_challenge()
103-
return redirect(f"/{challenge['level']}/{challenge['name']}")
111+
return redirect(f"/{challenge['level']}/{challenge['name']}")

0 commit comments

Comments
 (0)