Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions challenges/advanced-forward/question.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ def copy(self) -> MyClass:

inst = MyClass(x=1)
assert_type(inst.copy(), MyClass)

## End of test code ##
# pyright: analyzeUnannotatedFunctions=false
1 change: 1 addition & 0 deletions docs/Contribute.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Once you come up with an idea, go to the next steps.
- Describe the challenge, make sure people understand what they need to accomplish (i.e. the `TODO:` part)
- A comment `## End of your code ##`. This is mandatory, just copy and paste it.
- Several test cases. Add a comment `# expect-type-error` after the lines where type errors should be thrown.
- (Optional) Add a comment `## End of test code ##`. Several [pyright-config](https://github.com/microsoft/pyright/blob/main/docs/configuration.md#type-check-diagnostics-settings) with the format `# pyright: <config_name>=<value>`
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor grammatical issue in the documentation.

The sentence structure is incomplete. After describing the comment marker, it reads "Several [pyright-config]..." which doesn't form a complete sentence.

📝 Suggested fix
-   - (Optional) Add a comment `## End of test code ##`. Several [pyright-config](https://github.com/microsoft/pyright/blob/main/docs/configuration.md#type-check-diagnostics-settings) with the format `# pyright: <config_name>=<value>`
+   - (Optional) Add a comment `## End of test code ##`, followed by [pyright-config](https://github.com/microsoft/pyright/blob/main/docs/configuration.md#type-check-diagnostics-settings) directives with the format `# pyright: <config_name>=<value>`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- (Optional) Add a comment `## End of test code ##`. Several [pyright-config](https://github.com/microsoft/pyright/blob/main/docs/configuration.md#type-check-diagnostics-settings) with the format `# pyright: <config_name>=<value>`
- (Optional) Add a comment `## End of test code ##`, followed by [pyright-config](https://github.com/microsoft/pyright/blob/main/docs/configuration.md#type-check-diagnostics-settings) directives with the format `# pyright: <config_name>=<value>`
🤖 Prompt for AI Agents
In @docs/Contribute.md at line 45, The sentence fragment should be rewritten so
it reads as a complete instruction: change the line that currently reads about
the optional comment marker and pyright config to something like: "Optionally
add a comment `## End of test code ##`. You may also include pyright
configuration options using the format `# pyright: <config_name>=<value>`."
Update the wording around the `## End of test code ##` marker and the `#
pyright: <config_name>=<value>` example so the two clauses form complete,
grammatical sentences.


`solution.py` contains the right solution, with everything else unchanged.

Expand Down
14 changes: 14 additions & 0 deletions tests/assets/challenges/basic-foo-pyright-config/question.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""A simple question, only for running tests.
"""


def foo():
pass


## End of your code ##
foo(1)
foo(1, 2) # expect-type-error

## End of test code ##
# pyright: reportGeneralTypeIssues=error
6 changes: 6 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from flask.testing import FlaskClient

from app import app
from views.challenge import ChallengeManager

CHALLENGES_DIR = Path(__file__).parent.parent / "challenges"
ALL_QUESTIONS = list(CHALLENGES_DIR.glob("**/question.py"))
Expand All @@ -22,6 +23,11 @@ def assets_dir() -> Path:
return Path(__file__).parent / "assets"


@pytest.fixture()
def mgr(assets_dir: Path):
return ChallengeManager(assets_dir / "challenges")


@pytest.fixture()
def test_client() -> FlaskClient:
return app.test_client()
Expand Down
24 changes: 12 additions & 12 deletions tests/test_challenge.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from pathlib import Path

import pytest
from views.challenge import ChallengeKey, ChallengeManager


Expand All @@ -11,23 +10,24 @@ def test_load_empty_dir(self, tmpdir):
def test_defaults(self):
assert ChallengeManager().challenge_count > 0

def test_load_tests_assets(self, assets_dir):
mgr = ChallengeManager(assets_dir / "challenges")
def test_load_tests_assets(self, mgr: ChallengeManager):
assert mgr.challenge_count > 0

def test_partition_test_code(self, mgr: ChallengeManager):
pyright_config_test = mgr.get_challenge(
ChallengeKey.from_str("basic-foo-pyright-config")
).test_code

_, pyright_basic_config = mgr._partition_test_code(pyright_config_test)
assert pyright_basic_config.endswith("pyright: reportGeneralTypeIssues=error\n")

class TestChallengeWithHints:
@pytest.fixture()
def challenge_mgr(self, assets_dir):
return ChallengeManager(assets_dir / "challenges")

def test_misc(self, challenge_mgr):
c_foo = challenge_mgr.get_challenge(ChallengeKey.from_str("basic-foo"))
class TestChallengeWithHints:
def test_misc(self, mgr: ChallengeManager):
c_foo = mgr.get_challenge(ChallengeKey.from_str("basic-foo"))
assert c_foo.hints is None

# Get the challenge with hints
c_foo_hints = challenge_mgr.get_challenge(
ChallengeKey.from_str("basic-foo-hints")
)
c_foo_hints = mgr.get_challenge(ChallengeKey.from_str("basic-foo-hints"))
assert c_foo_hints.hints
assert isinstance(c_foo_hints.hints, str)
28 changes: 15 additions & 13 deletions tests/test_identical.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@


def test_identical(solution_file: Path):
level, challenge_name = solution_file.parent.name.split("-", maxsplit=1)
with solution_file.open() as f:
solution_code = f.read()
solution_test = Challenge(
name=challenge_name, level=Level(level), code=solution_code
).test_code

question_file = solution_file.parent / "question.py"
with question_file.open() as f:
question_code = f.read()
question_test = Challenge(
name=challenge_name, level=Level(level), code=question_code
).test_code
def get_test_code(path: Path):
TEST_SPLITTER = "\n## End of test code ##\n"
level, challenge_name = path.parent.name.split("-", maxsplit=1)

with solution_file.open() as f:
challenge_code = f.read()
challenge = Challenge(
name=challenge_name, level=Level(level), code=challenge_code
)

return challenge.test_code.partition(TEST_SPLITTER)[0]

solution_test = get_test_code(solution_file)
question_test = get_test_code(solution_file.parent / "question.py")
Comment on lines +11 to +24
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical bug: get_test_code always reads from solution_file regardless of the path argument.

Line 15 opens solution_file instead of the path parameter. This means both solution_test and question_test are derived from the same file, causing the test to always pass trivially without actually verifying that solution and question test codes are identical.

🐛 Proposed fix
     def get_test_code(path: Path):
         TEST_SPLITTER = "\n## End of test code ##\n"
         level, challenge_name = path.parent.name.split("-", maxsplit=1)

-        with solution_file.open() as f:
+        with path.open() as f:
             challenge_code = f.read()
         challenge = Challenge(
             name=challenge_name, level=Level(level), code=challenge_code
         )

         return challenge.test_code.partition(TEST_SPLITTER)[0]
🤖 Prompt for AI Agents
In @tests/test_identical.py around lines 11 - 24, get_test_code currently
ignores its path arg and always reads solution_file, so both solution_test and
question_test come from the same file; open and read the provided path parameter
instead of solution_file inside get_test_code (fix the file open call used when
building challenge_code and test extraction), keeping the rest of the function
logic the same so solution_test and question_test are derived from their
respective paths.


assert solution_test.strip() == question_test.strip()
2 changes: 0 additions & 2 deletions tests/test_questions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@

from pathlib import Path

import pytest

from views.challenge import ChallengeManager


Expand Down
76 changes: 62 additions & 14 deletions views/challenge.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,29 @@
from typing import ClassVar, Optional, TypeAlias

ROOT_DIR = Path(__file__).parent.parent
PYRIGHT_BASIC_CONFIG = """
# pyright: analyzeUnannotatedFunctions=true
# pyright: strictParameterNoneValue=true
# pyright: disableBytesTypePromotions=false
# pyright: strictListInference=false
# pyright: strictDictionaryInference=false
# pyright: strictSetInference=false
# pyright: deprecateTypingAliases=false
# pyright: enableExperimentalFeatures=false
# pyright: reportMissingImports=error
# pyright: reportUndefinedVariable=error
# pyright: reportGeneralTypeIssues=error
# pyright: reportOptionalSubscript=error
# pyright: reportOptionalMemberAccess=error
# pyright: reportOptionalCall=error
# pyright: reportOptionalIterable=error
# pyright: reportOptionalContextManager=error
# pyright: reportOptionalOperand=error
# pyright: reportTypedDictNotRequiredAccess=error
# pyright: reportPrivateImportUsage=error
# pyright: reportUnboundVariable=error
# pyright: reportUnusedCoroutine=error
"""


class Level(StrEnum):
Expand Down Expand Up @@ -145,13 +168,30 @@ def _get_challenges_groupby_level(self) -> dict[Level, list[ChallengeName]]:
# Pyright error messages look like:
# `<filename>:<line_no>:<col_no> - <error|warning|information>: <message>`
# Here we only capture the error messages and line numbers
PYRIGHT_MESSAGE_REGEX = r"^(?:.+?):(\d+):[\s\-\d]+(error:.+)$"
PYRIGHT_MESSAGE_REGEX = (
r"^(?:.+?):(?P<line_number>\d+):[\s\-\d]+(?P<message>error:.+)$"
)

@staticmethod
def _partition_test_code(test_code: str):
TEST_SPLITTER = "\n## End of test code ##\n"

# PYRIGHT_BASIC_CONFIG aim to limit user to modify the config
test_code, end_test_comment, pyright_config = test_code.partition(TEST_SPLITTER)
pyright_basic_config = PYRIGHT_BASIC_CONFIG

# Replace `## End of test code ##` with PYRIGHT_BASIC_CONFIG
if end_test_comment:
pyright_basic_config += pyright_config
return test_code, pyright_basic_config

@classmethod
def _type_check_with_pyright(
cls, user_code: str, test_code: str
) -> TypeCheckResult:
code = f"{user_code}{test_code}"
test_code, pyright_basic_config = cls._partition_test_code(test_code)

code = f"{user_code}{test_code}{pyright_basic_config}"
buffer = io.StringIO(code)

# This produces a stream of TokenInfos, example:
Expand Down Expand Up @@ -187,37 +227,45 @@ def _type_check_with_pyright(
return TypeCheckResult(message=stderr, passed=False)
error_lines: list[str] = []

# Substract lineno in merged code by lineno_delta, so that the lineno in
# Substract lineno in merged code by user_code_line_len, so that the lineno in
# error message matches those in the test code editor. Fixed #20.
lineno_delta = len(user_code.splitlines())
user_code_lines_len = len(user_code.splitlines())
for line in stdout.splitlines():
m = re.match(cls.PYRIGHT_MESSAGE_REGEX, line)
if m is None:
continue
line_number, message = int(m.group(1)), m.group(2)
line_number, message = int(m["line_number"]), m["message"]
if line_number in error_line_seen_in_err_msg:
# Each reported error should be attached to a specific line,
# If it is commented with # expect-type-error, let it pass.
error_line_seen_in_err_msg[line_number] = True
continue
# Error could be thrown from user code too, in which case delta shouldn't be applied.
error_lines.append(
f"{line_number if line_number <= lineno_delta else line_number - lineno_delta}:{message}"
)
error_line = f"%s:{message}"

if line_number <= user_code_lines_len:
error_lines.append(error_line % line_number)
elif line_number <= user_code_lines_len + len(test_code.splitlines()):
error_lines.append(error_line % (line_number - user_code_lines_len))
else:
error_lines.append(error_line % "[pyright-config]")

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

passed = len(error_lines) == 0
if passed:
error_lines.append("\nAll tests passed")
else:
error_lines.append(f"\nFound {len(error_lines)} errors")
# Error for pyright-config will not fail the challenge
passed = True
for error_line in error_lines:
if error_line.startswith("[pyright-config]"):
continue
passed = False

error_lines.append(f"\nFound {len(error_lines)} errors")
Comment on lines +261 to +268
Copy link

@coderabbitai coderabbitai bot Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor: Error count includes non-failing pyright-config errors, which may confuse users.

A user might see "Found 1 errors" but still pass the challenge if the only error is from pyright-config. Consider either:

  1. Excluding pyright-config errors from the count, or
  2. Clarifying in the message (e.g., "Found 1 errors (0 blocking)")

Also, minor grammar nit: "Found 1 errors" should be "Found 1 error" for singular.

💡 Suggested improvement
-        error_lines.append(f"\nFound {len(error_lines)} errors")
+        blocking_count = sum(1 for e in error_lines if not e.startswith("[pyright-config]"))
+        error_lines.append(f"\nFound {blocking_count} error{'s' if blocking_count != 1 else ''}")
🤖 Prompt for AI Agents
In @views/challenge.py around lines 261 - 268, The error count currently
includes non-blocking pyright-config lines and the singular/plural grammar is
wrong; filter out lines starting with "[pyright-config]" into a blocking_errors
list and use that to determine passed (set passed = True when
len(blocking_errors) == 0) and to build the summary message, e.g., append
f"\nFound {n} error" vs "errors" based on n where n = len(blocking_errors), or
include both totals like "Found X errors (Y blocking)"; update references to
error_lines and the append call to use the new counts.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well I don't agree

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@laike9m Understood! I'll defer to your judgment on this. Thanks for the feedback.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suggested solution seems complicated. Can you work on a better solution?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@laike9m Sure! Here's a simpler approach that adds a counter to the existing loop where you're already checking for pyright-config errors:

# Error for pyright-config will not fail the challenge
passed = True
blocking_errors = 0
for error_line in error_lines:
    if error_line.startswith("[pyright-config]"):
        continue
    blocking_errors += 1
    passed = False

error_word = "error" if blocking_errors == 1 else "errors"
error_lines.append(f"\nFound {blocking_errors} {error_word}")

This reuses the loop you already have, so there's no extra iteration. It counts only the blocking errors and fixes the singular/plural grammar issue. Does this work better for you?


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

Expand Down
2 changes: 1 addition & 1 deletion views/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def get_challenge(level: str, name: str):
"level": challenge.level,
"challenges_groupby_level": challenge_manager.challenges_groupby_level,
"code_under_test": challenge.user_code,
"test_code": challenge.test_code,
"test_code": challenge.test_code.partition("\n## End of test code ##\n")[0],
"hints_for_display": render_hints(challenge.hints) if challenge.hints else None,
"python_info": platform.python_version(),
}
Expand Down