From dd2e27d631be27f9ac2dea8a7c94b2774d8cead7 Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Wed, 16 Jul 2025 21:42:34 +0000 Subject: [PATCH 01/14] Jul 16, 2025, 5:42 PM --- .vscode/settings.json | 3 ++ check50/__init__.py | 1 - check50/__main__.py | 5 ++- check50/assertions/__init__.py | 1 + check50/assertions/rewrite.py | 64 ++++++++++++++++++++++++++++++++++ check50/assertions/runtime.py | 51 +++++++++++++++++++++++++++ check50/py.py | 2 +- check50/runner.py | 2 +- setup.py | 2 +- 9 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 check50/assertions/__init__.py create mode 100644 check50/assertions/rewrite.py create mode 100644 check50/assertions/runtime.py diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..96efefc7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "gitdoc.enabled": true +} \ No newline at end of file diff --git a/check50/__init__.py b/check50/__init__.py index 5f052914..a49e8a20 100644 --- a/check50/__init__.py +++ b/check50/__init__.py @@ -36,7 +36,6 @@ def _setup_translation(): Failure, Mismatch, Missing ) - from . import regex from .runner import check from pexpect import EOF diff --git a/check50/__main__.py b/check50/__main__.py index 2a4eaa9e..49f386fb 100644 --- a/check50/__main__.py +++ b/check50/__main__.py @@ -23,7 +23,7 @@ import requests import termcolor -from . import _exceptions, internal, renderer, __version__ +from . import _exceptions, internal, renderer, assertions, __version__ from .contextmanagers import nullcontext from .runner import CheckRunner @@ -371,6 +371,9 @@ def main(): checks_file = (internal.check_dir / config["checks"]).resolve() + # Rewrite all assert statements to check50_assert + assertions.rewrite(str(checks_file)) + # Have lib50 decide which files to include included_files = lib50.files(config.get("files"))[0] diff --git a/check50/assertions/__init__.py b/check50/assertions/__init__.py new file mode 100644 index 00000000..63216e2a --- /dev/null +++ b/check50/assertions/__init__.py @@ -0,0 +1 @@ +from .rewrite import rewrite \ No newline at end of file diff --git a/check50/assertions/rewrite.py b/check50/assertions/rewrite.py new file mode 100644 index 00000000..7a71cbfd --- /dev/null +++ b/check50/assertions/rewrite.py @@ -0,0 +1,64 @@ +import ast + +def rewrite(path: str): + """ + A function that rewrites all instances of `assert` in a file to our own + `check50_assert` function that raises our own exceptions. + + :param path: The path to the file you wish to rewrite. + :type path: str + """ + with open(path) as f: + source = f.read() + + # Parse the tree and replace all instance of `assert`. + tree = ast.parse(source, filename=path) + transformer = _AssertionRewriter() + new_tree = transformer.visit(tree) + ast.fix_missing_locations(new_tree) + + # Insert `from check50.assertions.runtime import check50_assert` only if not already present + if not any( + isinstance(stmt, ast.ImportFrom) and stmt.module == "check50.assertions.runtime" + for stmt in new_tree.body + ): + # Create an import statement for the check50_assert + import_stmt = ast.ImportFrom( + module="check50.assertions.runtime", + names=[ast.alias(name="check50_assert", asname=None)], + level=0 + ) + + # Prepend to the beginning of the file + new_tree.body.insert(0, import_stmt) + + modified_source = ast.unparse(new_tree) + + # Write to the file + with open(path, 'w') as f: + f.write(modified_source) + +class _AssertionRewriter(ast.NodeTransformer): + """ + Helper class to to wrap the conditions being tested by assert with a + function called `check50_assert`. + """ + def _visit_Assert(self, node): + """ + An overwrite of the AST module's _visit_Assert to inject our code in + place of the default assertion logic. + + :param node: An AST node. + :type node: ast.node + """ + self.generic_visit(node) + return ast.Expr( + value=ast.Call( + func=ast.Name(id="check50_assert", ctx=ast.Load()), + args=[ + node.test, + ast.Constant(value=ast.unparse(node.test)) + ], + keywords=[] + ) + ) \ No newline at end of file diff --git a/check50/assertions/runtime.py b/check50/assertions/runtime.py new file mode 100644 index 00000000..c8e49235 --- /dev/null +++ b/check50/assertions/runtime.py @@ -0,0 +1,51 @@ +from check50 import Failure, Missing, Mismatch +import ast + +def check50_assert(cond: bool, src: str): + """ + Asserts a conditional statement. If the condition evaluates to True, + nothing happens. Otherwise, the condition will raise a check50 exception. + Used in rewriting check files. Evaluates subconditions in order and raises + the first exception it sees. The specific exception raised depends on the + type of conditional statement (see also `classify_ast`.) + + :param cond: The conditional statement. + :type cond: bool + :param src: The source code string of the conditional expression \ + (e.g., 'x in y'), extracted from the AST. + :type src: str + + :raises check50.Missing, check50.Mismatch, or check50.Failure: if the condition fails + """ + if cond: + return + + expr = ast.parse(src, mode="eval").body + exc = classify_ast(expr) # the exception that should be raised + raise exc(f"Assertion failed: {src}") + +def classify_ast(expr): + """ + Classifies an AST expression to return an exception based on the operator. + + For instance, if the expression was read as "x not in [1,2,3]", the + function would return a check50.Missing error. + + :param expr: The AST expression. + :type expr: ast.expr + + :raises check50.Missing: if the comparison operator is one of: \ + (ast.In, ast.NotIn) + :raises check50.Mismatch: if the comparison operator is one of: \ + (ast.Eq, ast.NotEq, ast.Gt, ast.Lt, ast.GtE, \ + ast.LtE) + :raises check50.Failure: if not a comparison, or otherwise + """ + if isinstance(expr, ast.Compare): + for op in expr.ops: + if isinstance(op, (ast.In, ast.NotIn)): + return Missing + elif isinstance(op, (ast.Eq, ast.NotEq, ast.Gt, ast.Lt, ast.GtE, ast.LtE)): + return Mismatch + + return Failure \ No newline at end of file diff --git a/check50/py.py b/check50/py.py index 167de3f8..372c5300 100644 --- a/check50/py.py +++ b/check50/py.py @@ -64,4 +64,4 @@ def compile(file): for line in e.msg.splitlines(): log(line) - raise Failure(_("{} raised while compiling {} (see the log for more details)").format(e.exc_type_name, file)) + raise Failure(_("{} raised while compiling {} (see the log for more details)").format(e.exc_type_name, file)) \ No newline at end of file diff --git a/check50/runner.py b/check50/runner.py index 0ccf0c84..9d7db714 100644 --- a/check50/runner.py +++ b/check50/runner.py @@ -15,6 +15,7 @@ import sys import tempfile import traceback +import ast import attr import lib50 @@ -122,7 +123,6 @@ def prints_hello(): """ def decorator(check): - # Modules are evaluated from the top of the file down, so _check_names will # contain the names of the checks in the order in which they are declared _check_names.append(check.__name__) diff --git a/setup.py b/setup.py index b94ff83b..b8ad2170 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ }, keywords=["check", "check50"], name="check50", - packages=["check50", "check50.renderer"], + packages=["check50", "check50.renderer", "check50.assertions"], python_requires=">= 3.6", entry_points={ "console_scripts": ["check50=check50.__main__:main"] From 2c1aaf0ce6a5848f991db8da862ffb43d4df8a8a Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Mon, 21 Jul 2025 15:01:22 -0400 Subject: [PATCH 02/14] added assertion rewrites --- check50/__main__.py | 12 +++++-- check50/assertions/rewrite.py | 10 +++--- check50/assertions/runtime.py | 68 ++++++++++++++++------------------- 3 files changed, 46 insertions(+), 44 deletions(-) diff --git a/check50/__main__.py b/check50/__main__.py index 49f386fb..74f75790 100644 --- a/check50/__main__.py +++ b/check50/__main__.py @@ -369,9 +369,15 @@ def main(): if not args.no_install_dependencies: install_dependencies(config["dependencies"]) - checks_file = (internal.check_dir / config["checks"]).resolve() - - # Rewrite all assert statements to check50_assert + # Store the original checks file and leave as is + original_checks_file = (internal.check_dir / config["checks"]).resolve() + + # Create a temporary copy of the checks file + with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as tmp: + checks_file = Path(tmp.name) + shutil.copyfile(original_checks_file, checks_file) + + # Rewrite all assert statements in the copied checks file to check50_assert assertions.rewrite(str(checks_file)) # Have lib50 decide which files to include diff --git a/check50/assertions/rewrite.py b/check50/assertions/rewrite.py index 7a71cbfd..6058f3b2 100644 --- a/check50/assertions/rewrite.py +++ b/check50/assertions/rewrite.py @@ -43,9 +43,10 @@ class _AssertionRewriter(ast.NodeTransformer): Helper class to to wrap the conditions being tested by assert with a function called `check50_assert`. """ - def _visit_Assert(self, node): + + def visit_Assert(self, node): """ - An overwrite of the AST module's _visit_Assert to inject our code in + An overwrite of the AST module's visit_Assert to inject our code in place of the default assertion logic. :param node: An AST node. @@ -56,8 +57,9 @@ def _visit_Assert(self, node): value=ast.Call( func=ast.Name(id="check50_assert", ctx=ast.Load()), args=[ - node.test, - ast.Constant(value=ast.unparse(node.test)) + node.test, + ast.Constant(value=ast.unparse(node.test)), + node.msg if node.msg is not None else ast.Constant(value=None) ], keywords=[] ) diff --git a/check50/assertions/runtime.py b/check50/assertions/runtime.py index c8e49235..28208310 100644 --- a/check50/assertions/runtime.py +++ b/check50/assertions/runtime.py @@ -1,51 +1,45 @@ from check50 import Failure, Missing, Mismatch import ast -def check50_assert(cond: bool, src: str): +def check50_assert(cond, src, msg_or_exc=None): """ Asserts a conditional statement. If the condition evaluates to True, - nothing happens. Otherwise, the condition will raise a check50 exception. - Used in rewriting check files. Evaluates subconditions in order and raises - the first exception it sees. The specific exception raised depends on the - type of conditional statement (see also `classify_ast`.) + nothing happens. Otherwise, it will look for a message or exception that + follows the condition (seperated by a comma). If the msg_or_exc is not + a string, an exception, or not provided, then the additional argument is + silently ignored, raising a check50.Failure. + + Used for rewriting check files. + + Example usage: + ``` + assert x in y, check50.Missing(x, y) + ``` + will be converted to + ``` + check50_assert(x in y, "x in y", check50.Missing(x, y)) + ``` :param cond: The conditional statement. :type cond: bool :param src: The source code string of the conditional expression \ (e.g., 'x in y'), extracted from the AST. :type src: str - - :raises check50.Missing, check50.Mismatch, or check50.Failure: if the condition fails + :param msg_or_exc: The message or exception following the conditional in \ + the assertion statement. + :type msg_or_exc: str, BaseException, optional + + :raises check50.Failure: if msg_or_exc is a string, if msg_or_exc is not + included, or if both msg_or_exc is not a string and + not an exception + :raises msg_or_exc: if msg_or_exc is an exception """ if cond: return - - expr = ast.parse(src, mode="eval").body - exc = classify_ast(expr) # the exception that should be raised - raise exc(f"Assertion failed: {src}") - -def classify_ast(expr): - """ - Classifies an AST expression to return an exception based on the operator. - - For instance, if the expression was read as "x not in [1,2,3]", the - function would return a check50.Missing error. - - :param expr: The AST expression. - :type expr: ast.expr - - :raises check50.Missing: if the comparison operator is one of: \ - (ast.In, ast.NotIn) - :raises check50.Mismatch: if the comparison operator is one of: \ - (ast.Eq, ast.NotEq, ast.Gt, ast.Lt, ast.GtE, \ - ast.LtE) - :raises check50.Failure: if not a comparison, or otherwise - """ - if isinstance(expr, ast.Compare): - for op in expr.ops: - if isinstance(op, (ast.In, ast.NotIn)): - return Missing - elif isinstance(op, (ast.Eq, ast.NotEq, ast.Gt, ast.Lt, ast.GtE, ast.LtE)): - return Mismatch - - return Failure \ No newline at end of file + + if isinstance(msg_or_exc, str): + raise Failure(msg_or_exc) + elif isinstance(msg_or_exc, BaseException): + raise msg_or_exc + else: + raise Failure(f"Assertion failure: {src}") \ No newline at end of file From 9280ef001e0471810547a8065989f80b19ae0d4a Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Tue, 22 Jul 2025 13:13:02 -0400 Subject: [PATCH 03/14] added conditional inferencing --- check50/assertions/rewrite.py | 53 ++++++++++++++++++++++++---- check50/assertions/runtime.py | 66 +++++++++++++++++++++++------------ 2 files changed, 89 insertions(+), 30 deletions(-) diff --git a/check50/assertions/rewrite.py b/check50/assertions/rewrite.py index 6058f3b2..c1a86c99 100644 --- a/check50/assertions/rewrite.py +++ b/check50/assertions/rewrite.py @@ -22,7 +22,7 @@ def rewrite(path: str): isinstance(stmt, ast.ImportFrom) and stmt.module == "check50.assertions.runtime" for stmt in new_tree.body ): - # Create an import statement for the check50_assert + # Create an import statement for check50_assert import_stmt = ast.ImportFrom( module="check50.assertions.runtime", names=[ast.alias(name="check50_assert", asname=None)], @@ -40,27 +40,66 @@ def rewrite(path: str): class _AssertionRewriter(ast.NodeTransformer): """ - Helper class to to wrap the conditions being tested by assert with a + Helper class to to wrap the conditions being tested by `assert` with a function called `check50_assert`. """ - def visit_Assert(self, node): """ An overwrite of the AST module's visit_Assert to inject our code in place of the default assertion logic. - :param node: An AST node. - :type node: ast.node + :param node: The `assert` statement node being visited and transformed. + :type node: ast.Assert """ self.generic_visit(node) + cond_type = self._identify_comparison_type(node.test) + + keywords = [ast.keyword(arg="cond_type", value=ast.Constant(value=cond_type))] + + # Grab the values from the left and right side of the conditional + # (used in check50.Missing and check50.Mismatch) + if isinstance(node.test, ast.Compare) and node.test.comparators: + left = node.test.left + right = node.test.comparators[0] + keywords.extend([ + ast.keyword(arg="left", value=left), + ast.keyword(arg="right", value=right) + ]) + return ast.Expr( value=ast.Call( + # Create a function called check50_assert func=ast.Name(id="check50_assert", ctx=ast.Load()), + # Give it these postional arguments: args=[ + # The condition node.test, + # The string form of the condition ast.Constant(value=ast.unparse(node.test)), + # The additional msg or exception that the user provided node.msg if node.msg is not None else ast.Constant(value=None) ], - keywords=[] + # And these named parameters: + keywords=keywords ) - ) \ No newline at end of file + ) + + def _identify_comparison_type(self, test_node): + """ + Checks if a conditional is a comparison between two expressions. If so, + attempts to identify the comparison operator (e.g., `==`, `in`). Falls + back to "unknown" if the conditional is not a comparison or if the + operator is not recognized. + + :param test_node: The AST conditional node that is being identified. + :type test_node: ast.expr + """ + if isinstance(test_node, ast.Compare) and test_node.ops: + op = test_node.ops[0] # the operator in between the comparators + if isinstance(op, ast.Eq): + return "eq" + elif isinstance(op, ast.In): + return "in" + + return "unknown" + diff --git a/check50/assertions/runtime.py b/check50/assertions/runtime.py index 28208310..5307f8de 100644 --- a/check50/assertions/runtime.py +++ b/check50/assertions/runtime.py @@ -1,45 +1,65 @@ from check50 import Failure, Missing, Mismatch -import ast -def check50_assert(cond, src, msg_or_exc=None): +def check50_assert(cond, src, msg_or_exc=None, cond_type="unknown", left=None, right=None): """ - Asserts a conditional statement. If the condition evaluates to True, + Asserts a conditional statement. If the condition evaluates to True, nothing happens. Otherwise, it will look for a message or exception that follows the condition (seperated by a comma). If the msg_or_exc is not - a string, an exception, or not provided, then the additional argument is - silently ignored, raising a check50.Failure. + a string, an exception, or it was not provided, it is silently ignored. - Used for rewriting check files. + In such cases, we attempt to determine which exception should be raised + based on the type of the conditional. If recognized, it raises either + check50.Mismatch or check50.Missing. If the conditional type is unknown or + unhandled, check50.Failure is raised with a default message. + + Used for rewriting assertion statements in check files. + + Note: + Exceptions from the check50 library are preferred, since they will be + handled gracefully and integrated into the check output. Native Python + exceptions are technically supported, but check50 will immediately + terminate on the users's end if the assertion fails. Example usage: - ``` - assert x in y, check50.Missing(x, y) - ``` - will be converted to - ``` - check50_assert(x in y, "x in y", check50.Missing(x, y)) - ``` - - :param cond: The conditional statement. + ``` + assert x in y + ``` + will be converted to + ``` + check50_assert(x in y, "x in y", None, "in", x, y) + ``` + + :param cond: The evaluated conditional statement. :type cond: bool :param src: The source code string of the conditional expression \ (e.g., 'x in y'), extracted from the AST. :type src: str :param msg_or_exc: The message or exception following the conditional in \ - the assertion statement. - :type msg_or_exc: str, BaseException, optional + the assertion statement. + :type msg_or_exc: str | BaseException | None + :param cond_type: The type of conditional, one of {"eq", "in", "unknown"} + :type cond_type: str + :param left: The left side of the conditional, if applicable + :type left: Any + :param right: The right side of the conditional, if applicable + :type right: Any - :raises check50.Failure: if msg_or_exc is a string, if msg_or_exc is not - included, or if both msg_or_exc is not a string and - not an exception - :raises msg_or_exc: if msg_or_exc is an exception + :raises msg_or_exc: If msg_or_exc is an exception. + :raises check50.Mismatch: If no exception is provided and cond_type is "eq". + :raises check50.Missing: If no exception is provided and cond_type is "in". + :raises check50.Failure: If msg_or_exc is a string, or if cond_type is \ + unrecognized. """ if cond: return - + if isinstance(msg_or_exc, str): raise Failure(msg_or_exc) elif isinstance(msg_or_exc, BaseException): raise msg_or_exc + elif cond_type == 'eq' and left and right: + raise Mismatch(left, right) + elif cond_type == 'in' and left and right: + raise Missing(left, right) else: - raise Failure(f"Assertion failure: {src}") \ No newline at end of file + raise Failure(f"assertion failed: {src}") From 5c05c72d757bf5ed498239c2fbbbeccf4f527308 Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Tue, 22 Jul 2025 14:18:33 -0400 Subject: [PATCH 04/14] comment fix --- check50/assertions/runtime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/check50/assertions/runtime.py b/check50/assertions/runtime.py index 5307f8de..bc2efd5a 100644 --- a/check50/assertions/runtime.py +++ b/check50/assertions/runtime.py @@ -18,7 +18,7 @@ def check50_assert(cond, src, msg_or_exc=None, cond_type="unknown", left=None, r Exceptions from the check50 library are preferred, since they will be handled gracefully and integrated into the check output. Native Python exceptions are technically supported, but check50 will immediately - terminate on the users's end if the assertion fails. + terminate on the user's end if the assertion fails. Example usage: ``` From 777e7d82f709cc141d94a894ad87cf25e26ba25d Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Wed, 23 Jul 2025 10:44:06 -0400 Subject: [PATCH 05/14] import shutil --- check50/__main__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/check50/__main__.py b/check50/__main__.py index f80aa879..0fc21d48 100644 --- a/check50/__main__.py +++ b/check50/__main__.py @@ -7,6 +7,7 @@ import logging import os import platform +import shutil import site from pathlib import Path import subprocess From 3b5bd4d0a7f984249e76cb3037e372a12407e5a2 Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Wed, 23 Jul 2025 17:57:22 -0400 Subject: [PATCH 06/14] added context --- check50/assertions/rewrite.py | 41 +++++++++++++++++++++++++++++++++++ check50/assertions/runtime.py | 19 ++++++++++++---- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/check50/assertions/rewrite.py b/check50/assertions/rewrite.py index c1a86c99..67ac9860 100644 --- a/check50/assertions/rewrite.py +++ b/check50/assertions/rewrite.py @@ -66,6 +66,16 @@ def visit_Assert(self, node): ast.keyword(arg="right", value=right) ]) + # Extract variable names and build context={"var": var, ...} + var_names = self._extract_names(node.test) + context_dict = self._make_context_dict(var_names) + + if var_names and context_dict.keys: + keywords.append(ast.keyword( + arg="context", + value=context_dict + )) + return ast.Expr( value=ast.Call( # Create a function called check50_assert @@ -84,6 +94,7 @@ def visit_Assert(self, node): ) ) + def _identify_comparison_type(self, test_node): """ Checks if a conditional is a comparison between two expressions. If so, @@ -103,3 +114,33 @@ def _identify_comparison_type(self, test_node): return "unknown" + def _extract_names(self, expr): + """ + Returns a set of the names of every variable in a given AST expression. + + :param expr: An AST expression. + :type expr: ast.AST + """ + class NameExtractor(ast.NodeVisitor): + def __init__(self): + self.names = set() + + def visit_Name(self, node): + self.names.add(node.id) + + extractor = NameExtractor() + extractor.visit(expr) + return extractor.names + + def _make_context_dict(self, name_set): + """ + Returns an AST dictionary in which the keys are the names of variables + and the values are the value from each respective variable. + + :param name_set: A set of known names of variables. + :type name_set: set[str] + """ + return ast.Dict( + keys=[ast.Constant(value=name) for name in name_set], + values=[ast.Name(id=name, ctx=ast.Load()) for name in name_set] + ) diff --git a/check50/assertions/runtime.py b/check50/assertions/runtime.py index bc2efd5a..02a71042 100644 --- a/check50/assertions/runtime.py +++ b/check50/assertions/runtime.py @@ -1,6 +1,6 @@ from check50 import Failure, Missing, Mismatch -def check50_assert(cond, src, msg_or_exc=None, cond_type="unknown", left=None, right=None): +def check50_assert(cond, src, msg_or_exc=None, cond_type="unknown", left=None, right=None, context=None): """ Asserts a conditional statement. If the condition evaluates to True, nothing happens. Otherwise, it will look for a message or exception that @@ -43,6 +43,8 @@ def check50_assert(cond, src, msg_or_exc=None, cond_type="unknown", left=None, r :type left: Any :param right: The right side of the conditional, if applicable :type right: Any + :param context: A collection of the conditional's variable names and values. + :type context: dict :raises msg_or_exc: If msg_or_exc is an exception. :raises check50.Mismatch: If no exception is provided and cond_type is "eq". @@ -53,13 +55,22 @@ def check50_assert(cond, src, msg_or_exc=None, cond_type="unknown", left=None, r if cond: return + context_str = None + if context and isinstance(context, dict): + context_str = ", ".join(f"{k} = {repr(v)}" for k, v in (context or {}).items()) + if isinstance(msg_or_exc, str): raise Failure(msg_or_exc) elif isinstance(msg_or_exc, BaseException): raise msg_or_exc elif cond_type == 'eq' and left and right: - raise Mismatch(left, right) + help_msg = f"checked: {src}" + help_msg += f"\n where {context_str}" if context_str else "" + raise Mismatch(right, left, help=help_msg) elif cond_type == 'in' and left and right: - raise Missing(left, right) + help_msg = f"checked: {src}" + help_msg += f"\n where {context_str}" if context_str else "" + raise Missing(left, right, help=help_msg) else: - raise Failure(f"assertion failed: {src}") + help_msg = f"\n where {context_str}" if context_str else "" + raise Failure(f"check did not pass: {src}" + help_msg) From 88d4cfbae3a9eaba2e6f1ea0e12b81566f4d4631 Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Thu, 24 Jul 2025 17:40:33 -0400 Subject: [PATCH 07/14] wip function evaluation in help msg --- check50/assertions/rewrite.py | 118 +++++++++++++++++++++++++++++----- check50/assertions/runtime.py | 26 ++++++-- 2 files changed, 121 insertions(+), 23 deletions(-) diff --git a/check50/assertions/rewrite.py b/check50/assertions/rewrite.py index 67ac9860..87ec2414 100644 --- a/check50/assertions/rewrite.py +++ b/check50/assertions/rewrite.py @@ -56,16 +56,6 @@ def visit_Assert(self, node): keywords = [ast.keyword(arg="cond_type", value=ast.Constant(value=cond_type))] - # Grab the values from the left and right side of the conditional - # (used in check50.Missing and check50.Mismatch) - if isinstance(node.test, ast.Compare) and node.test.comparators: - left = node.test.left - right = node.test.comparators[0] - keywords.extend([ - ast.keyword(arg="left", value=left), - ast.keyword(arg="right", value=right) - ]) - # Extract variable names and build context={"var": var, ...} var_names = self._extract_names(node.test) context_dict = self._make_context_dict(var_names) @@ -76,6 +66,21 @@ def visit_Assert(self, node): value=context_dict )) + # Set the left and right side of the conditional as strings for later + # evaluation (used when raising check50.Missing and check50.Mismatch) + if isinstance(node.test, ast.Compare) and node.test.comparators: + left_node = node.test.left + right_node = node.test.comparators[0] + + left_str = ast.unparse(left_node) + right_str = ast.unparse(right_node) + + keywords.extend([ + ast.keyword(arg="left", value=ast.Constant(value=left_str)), + ast.keyword(arg="right", value=ast.Constant(value=right_str)) + ]) + + return ast.Expr( value=ast.Call( # Create a function called check50_assert @@ -87,7 +92,7 @@ def visit_Assert(self, node): # The string form of the condition ast.Constant(value=ast.unparse(node.test)), # The additional msg or exception that the user provided - node.msg if node.msg is not None else ast.Constant(value=None) + node.msg or ast.Constant(value=None) ], # And these named parameters: keywords=keywords @@ -116,7 +121,9 @@ def _identify_comparison_type(self, test_node): def _extract_names(self, expr): """ - Returns a set of the names of every variable in a given AST expression. + Returns a set of the names of every variable, function + (including the modules or classes they're located under), and function + argument in a given AST expression. :param expr: An AST expression. :type expr: ast.AST @@ -124,9 +131,80 @@ def _extract_names(self, expr): class NameExtractor(ast.NodeVisitor): def __init__(self): self.names = set() + self._in_func_chain = False # flag to track nested Calls and Names + + def visit_Call(self, node): + # Temporarily store whether we're already in a chain + already_in_chain = self._in_func_chain + + # If already_in_chain is False, we're at the top-most level of + # the Call node. Without this guard, callable classes/modules + # will also be included in the output. For instance, + # check50.run('./test') AND check50.run('./test').stdout() will + # be included. + if not already_in_chain: + # Grab the entire dotted function name + full_name = self._get_full_func_name(node) + self.names.add(full_name) + + # As we travel down the function's subtree, denote this flag as True + self._in_func_chain = True + self.visit(node.func) + self._in_func_chain = already_in_chain # Restore state + + # Now visit the arguments of this function + for arg in node.args: + self.visit(arg) + for kw in node.keywords: + self.visit(kw) def visit_Name(self, node): - self.names.add(node.id) + if not self._in_func_chain: # ignore Names of modules + self.names.add(node.id) + + def _get_full_func_name(self, node): + """ + Grab the entire function name, including the module or class + in which the function was located, as well as the function + arguments. + + For instance, this function would return + ``` + "check50.run('./test').stdout()" + ``` + as opposed to + ``` + "stdout" + ``` + """ + def format_args(call_node): + # Positional arguments + args = [ast.unparse(arg) for arg in call_node.args] + # Keyword arguments + kwargs = [f"{kw.arg}={ast.unparse(kw.value)}" for kw in call_node.keywords] + all_args = args + kwargs + return f"({', '.join(all_args)})" + + parts = [] + # Apply the same operations for even nested function calls. + while isinstance(node, ast.Call): + func = node.func + arg_string = format_args(node) + + # Attributes inside of Calls signify a `.` attribute was used + if isinstance(func, ast.Attribute): + parts.append(func.attr + arg_string) + node = func.value # step into next node in chain + elif isinstance(func, ast.Name): + parts.append(func.id + arg_string) + return ".".join(reversed(parts)) + else: + return f"[DEBUG] failed to grab func name: {ast.unparse(func)}" + + if isinstance(node, ast.Name): + parts.append(node.id) + + return ".".join(reversed(parts)) extractor = NameExtractor() extractor.visit(expr) @@ -140,7 +218,13 @@ def _make_context_dict(self, name_set): :param name_set: A set of known names of variables. :type name_set: set[str] """ - return ast.Dict( - keys=[ast.Constant(value=name) for name in name_set], - values=[ast.Name(id=name, ctx=ast.Load()) for name in name_set] - ) + keys, values = [], [] + for name in name_set: + keys.append(ast.Constant(value=name)) + # Defer evaluation of the values until later, since we don't have + # access to function results at this point + values.append(ast.Constant(value=None)) + + return ast.Dict(keys=keys, values=values) + + diff --git a/check50/assertions/runtime.py b/check50/assertions/runtime.py index 02a71042..317a914a 100644 --- a/check50/assertions/runtime.py +++ b/check50/assertions/runtime.py @@ -40,10 +40,10 @@ def check50_assert(cond, src, msg_or_exc=None, cond_type="unknown", left=None, r :param cond_type: The type of conditional, one of {"eq", "in", "unknown"} :type cond_type: str :param left: The left side of the conditional, if applicable - :type left: Any + :type left: str | None :param right: The right side of the conditional, if applicable - :type right: Any - :param context: A collection of the conditional's variable names and values. + :type right: str | None + :param context: A collection of the conditional's variable names as keys. :type context: dict :raises msg_or_exc: If msg_or_exc is an exception. @@ -56,7 +56,21 @@ def check50_assert(cond, src, msg_or_exc=None, cond_type="unknown", left=None, r return context_str = None - if context and isinstance(context, dict): + if context or (left and right): + # Add `left` and `right` to `context` so that they can be evaluated in + # the same pass as the other variables + if left and right: + context[left] = None + context[right] = None + # Evaluate context + import inspect + for expr_str in context: + try: + caller_frame = inspect.currentframe().f_back + context[expr_str] = eval(expr_str, caller_frame.f_globals, caller_frame.f_locals) + except Exception as e: + context[expr_str] = f"[error evaluating: {e}]" + context_str = ", ".join(f"{k} = {repr(v)}" for k, v in (context or {}).items()) if isinstance(msg_or_exc, str): @@ -66,11 +80,11 @@ def check50_assert(cond, src, msg_or_exc=None, cond_type="unknown", left=None, r elif cond_type == 'eq' and left and right: help_msg = f"checked: {src}" help_msg += f"\n where {context_str}" if context_str else "" - raise Mismatch(right, left, help=help_msg) + raise Mismatch(context[right], context[left], help=help_msg) elif cond_type == 'in' and left and right: help_msg = f"checked: {src}" help_msg += f"\n where {context_str}" if context_str else "" - raise Missing(left, right, help=help_msg) + raise Missing(context[left], context[right], help=help_msg) else: help_msg = f"\n where {context_str}" if context_str else "" raise Failure(f"check did not pass: {src}" + help_msg) From 3e5b8cc00bbb413bbcea80f3ec5e3c0e29baf9e2 Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Fri, 25 Jul 2025 15:09:16 -0400 Subject: [PATCH 08/14] added memoization of evaluatable objects --- check50/assertions/rewrite.py | 18 ++++++--- check50/assertions/runtime.py | 71 +++++++++++++++++++++++++++-------- 2 files changed, 69 insertions(+), 20 deletions(-) diff --git a/check50/assertions/rewrite.py b/check50/assertions/rewrite.py index 87ec2414..8566cab6 100644 --- a/check50/assertions/rewrite.py +++ b/check50/assertions/rewrite.py @@ -54,6 +54,7 @@ def visit_Assert(self, node): self.generic_visit(node) cond_type = self._identify_comparison_type(node.test) + # Begin adding a named parameter that determines the type of condition keywords = [ast.keyword(arg="cond_type", value=ast.Constant(value=cond_type))] # Extract variable names and build context={"var": var, ...} @@ -75,20 +76,26 @@ def visit_Assert(self, node): left_str = ast.unparse(left_node) right_str = ast.unparse(right_node) + # Only add to context if not literal constants + if not isinstance(left_node, ast.Constant): + context_dict.keys.append(ast.Constant(value=left_str)) + context_dict.values.append(ast.Constant(value=None)) + if not isinstance(right_node, ast.Constant): + context_dict.keys.append(ast.Constant(value=right_str)) + context_dict.values.append(ast.Constant(value=None)) + + keywords.extend([ ast.keyword(arg="left", value=ast.Constant(value=left_str)), ast.keyword(arg="right", value=ast.Constant(value=right_str)) ]) - return ast.Expr( value=ast.Call( # Create a function called check50_assert func=ast.Name(id="check50_assert", ctx=ast.Load()), # Give it these postional arguments: args=[ - # The condition - node.test, # The string form of the condition ast.Constant(value=ast.unparse(node.test)), # The additional msg or exception that the user provided @@ -150,7 +157,7 @@ def visit_Call(self, node): # As we travel down the function's subtree, denote this flag as True self._in_func_chain = True self.visit(node.func) - self._in_func_chain = already_in_chain # Restore state + self._in_func_chain = already_in_chain # Restore previous state # Now visit the arguments of this function for arg in node.args: @@ -159,8 +166,9 @@ def visit_Call(self, node): self.visit(kw) def visit_Name(self, node): - if not self._in_func_chain: # ignore Names of modules + if not self._in_func_chain: # ignore Names of modules/libraries self.names.add(node.id) + # self.names.add(node.id) def _get_full_func_name(self, node): """ diff --git a/check50/assertions/runtime.py b/check50/assertions/runtime.py index 317a914a..cda329b1 100644 --- a/check50/assertions/runtime.py +++ b/check50/assertions/runtime.py @@ -1,6 +1,6 @@ from check50 import Failure, Missing, Mismatch -def check50_assert(cond, src, msg_or_exc=None, cond_type="unknown", left=None, right=None, context=None): +def check50_assert(src, msg_or_exc=None, cond_type="unknown", left=None, right=None, context=None): """ Asserts a conditional statement. If the condition evaluates to True, nothing happens. Otherwise, it will look for a message or exception that @@ -29,8 +29,6 @@ def check50_assert(cond, src, msg_or_exc=None, cond_type="unknown", left=None, r check50_assert(x in y, "x in y", None, "in", x, y) ``` - :param cond: The evaluated conditional statement. - :type cond: bool :param src: The source code string of the conditional expression \ (e.g., 'x in y'), extracted from the AST. :type src: str @@ -52,27 +50,39 @@ def check50_assert(cond, src, msg_or_exc=None, cond_type="unknown", left=None, r :raises check50.Failure: If msg_or_exc is a string, or if cond_type is \ unrecognized. """ - if cond: - return - + # Evaluate all variables and functions within the context dict and generate + # a string of these values context_str = None if context or (left and right): - # Add `left` and `right` to `context` so that they can be evaluated in - # the same pass as the other variables - if left and right: - context[left] = None - context[right] = None - # Evaluate context import inspect for expr_str in context: try: + # Grab the global and local variables as of now caller_frame = inspect.currentframe().f_back context[expr_str] = eval(expr_str, caller_frame.f_globals, caller_frame.f_locals) except Exception as e: context[expr_str] = f"[error evaluating: {e}]" + # produces a string like "var1 = ..., var2 = ..., foo() = ..." context_str = ", ".join(f"{k} = {repr(v)}" for k, v in (context or {}).items()) + # Since we've memoized the functions and variables once, now try and + # evaluate the conditional by substituting the function calls/vars with + # their results + eval_src, eval_context = substitute_expressions(src, context) + cond = eval(eval_src, {}, eval_context) + + # Finally, quit if the condition evaluated to True. + if cond: + return + + # If `right` or `left` were evaluatable objects, their actual value will be stored in `context`. + # Otherwise, they're still just literals. + right = context.get(right) or right + left = context.get(left) or left + + # Since the condition didn't evaluate to True, now, we can raise special + # exceptions. if isinstance(msg_or_exc, str): raise Failure(msg_or_exc) elif isinstance(msg_or_exc, BaseException): @@ -80,11 +90,42 @@ def check50_assert(cond, src, msg_or_exc=None, cond_type="unknown", left=None, r elif cond_type == 'eq' and left and right: help_msg = f"checked: {src}" help_msg += f"\n where {context_str}" if context_str else "" - raise Mismatch(context[right], context[left], help=help_msg) + raise Mismatch(right, left, help=help_msg) elif cond_type == 'in' and left and right: help_msg = f"checked: {src}" help_msg += f"\n where {context_str}" if context_str else "" - raise Missing(context[left], context[right], help=help_msg) + raise Missing(left, right, help=help_msg) else: help_msg = f"\n where {context_str}" if context_str else "" - raise Failure(f"check did not pass: {src}" + help_msg) + raise Failure(f"check did not pass: {src} {context}" + help_msg) + +def substitute_expressions(src: str, context: dict) -> tuple[str, dict]: + """ + Rewrites `src` by replacing each key in `context` with a placeholder variable name, + and builds a new context dict where those names map to pre-evaluated values. + + For instance, given a `src`: + ``` + check50.run('pwd').stdout() == actual + ``` + it will create a new `eval_src` as + ``` + __expr0 == __expr1 + ``` + and use the given context to define these variables: + ``` + eval_context = { + '__expr0': context['check50.run('pwd').stdout()'], + '__expr1': context['actual'] + } + ``` + """ + new_src = src + new_context = {} + + for i, expr in enumerate(sorted(context.keys(), key=len, reverse=True)): + placeholder = f"__expr{i}" + new_src = new_src.replace(expr, placeholder) + new_context[placeholder] = context[expr] + + return new_src, new_context From 2dd33c7bac502a4f05a13811a9e941e792614dc9 Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Fri, 25 Jul 2025 15:26:27 -0400 Subject: [PATCH 09/14] fixed leftover debug message where context was passed with the error message --- check50/assertions/runtime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/check50/assertions/runtime.py b/check50/assertions/runtime.py index cda329b1..fdf1a568 100644 --- a/check50/assertions/runtime.py +++ b/check50/assertions/runtime.py @@ -97,7 +97,7 @@ def check50_assert(src, msg_or_exc=None, cond_type="unknown", left=None, right=N raise Missing(left, right, help=help_msg) else: help_msg = f"\n where {context_str}" if context_str else "" - raise Failure(f"check did not pass: {src} {context}" + help_msg) + raise Failure(f"check did not pass: {src}" + help_msg) def substitute_expressions(src: str, context: dict) -> tuple[str, dict]: """ From 83eb248f4dd6792447a37e6723b0e85abb60dd99 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Thu, 31 Jul 2025 17:37:19 -0400 Subject: [PATCH 10/14] Clean up --- .vscode/settings.json | 3 --- check50/__init__.py | 1 + check50/py.py | 2 +- check50/runner.py | 1 + 4 files changed, 3 insertions(+), 4 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 96efefc7..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "gitdoc.enabled": true -} \ No newline at end of file diff --git a/check50/__init__.py b/check50/__init__.py index f916ff23..34366923 100644 --- a/check50/__init__.py +++ b/check50/__init__.py @@ -44,6 +44,7 @@ def _setup_translation(): Failure, Mismatch, Missing ) + from . import regex from .runner import check from .config import config diff --git a/check50/py.py b/check50/py.py index 2ed96289..3e4d0321 100644 --- a/check50/py.py +++ b/check50/py.py @@ -63,4 +63,4 @@ def compile(file): for line in e.msg.splitlines(): log(line) - raise Failure(_("{} raised while compiling {} (see the log for more details)").format(e.exc_type_name, file)) \ No newline at end of file + raise Failure(_("{} raised while compiling {} (see the log for more details)").format(e.exc_type_name, file)) diff --git a/check50/runner.py b/check50/runner.py index ef54c6bc..2c992446 100644 --- a/check50/runner.py +++ b/check50/runner.py @@ -121,6 +121,7 @@ def prints_hello(): """ def decorator(check): + # Modules are evaluated from the top of the file down, so _check_names will # contain the names of the checks in the order in which they are declared _check_names.append(check.__name__) From e2e7b0fe1e558470cc9ca44080d4713b8693f9fa Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Fri, 1 Aug 2025 14:10:58 -0400 Subject: [PATCH 11/14] added flag support to enable or disable rewrites --- check50/__main__.py | 25 +++++++++++++++---------- check50/assertions/__init__.py | 2 +- check50/assertions/rewrite.py | 24 ++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/check50/__main__.py b/check50/__main__.py index 0fc21d48..a675d6e4 100644 --- a/check50/__main__.py +++ b/check50/__main__.py @@ -274,10 +274,10 @@ def flush(self): def check_version(package_name=__package__, timeout=1): - """Check for newer version of the package on PyPI""" + """Check for newer version of the package on PyPI""" if not __version__: return - + try: current = packaging.version.parse(__version__) latest = max(requests.get(f"https://pypi.org/pypi/{package_name}/json", timeout=timeout).json()["releases"], key=packaging.version.parse) @@ -390,14 +390,19 @@ def main(): # Store the original checks file and leave as is original_checks_file = (internal.check_dir / config["checks"]).resolve() - - # Create a temporary copy of the checks file - with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as tmp: - checks_file = Path(tmp.name) - shutil.copyfile(original_checks_file, checks_file) - - # Rewrite all assert statements in the copied checks file to check50_assert - assertions.rewrite(str(checks_file)) + + # If the user has enabled the rewrite feature + if assertions.rewrite_enabled(str(original_checks_file)): + # Create a temporary copy of the checks file + with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as tmp: + checks_file = Path(tmp.name) + shutil.copyfile(original_checks_file, checks_file) + + # Rewrite all assert statements in the copied checks file to check50_assert + assertions.rewrite(str(checks_file)) + else: + # Don't rewrite any assert statements and continue + checks_file = original_checks_file # Have lib50 decide which files to include included_files = lib50.files(config.get("files"))[0] diff --git a/check50/assertions/__init__.py b/check50/assertions/__init__.py index 63216e2a..3b77f65c 100644 --- a/check50/assertions/__init__.py +++ b/check50/assertions/__init__.py @@ -1 +1 @@ -from .rewrite import rewrite \ No newline at end of file +from .rewrite import rewrite, rewrite_enabled diff --git a/check50/assertions/rewrite.py b/check50/assertions/rewrite.py index 8566cab6..a4f92da4 100644 --- a/check50/assertions/rewrite.py +++ b/check50/assertions/rewrite.py @@ -1,4 +1,5 @@ import ast +import re def rewrite(path: str): """ @@ -38,6 +39,29 @@ def rewrite(path: str): with open(path, 'w') as f: f.write(modified_source) +def rewrite_enabled(path: str): + """ + Checks if the first line of the file contains a comment of the form: + + ``` + # ENABLE_CHECK50_ASSERT = 1 + ``` + + Ignores whitespace. + + :param path: The path to the file you wish to check. + :type path: str + """ + pattern = re.compile( + r"^#\s*ENABLE_CHECK50_ASSERT\s*=\s*(1|True)$", + re.IGNORECASE + ) + + with open(path, 'r') as f: + first_line = f.readline().strip() + return bool(pattern.match(first_line)) + + class _AssertionRewriter(ast.NodeTransformer): """ Helper class to to wrap the conditions being tested by `assert` with a From 076d4746b44b3149218a3ec7258a1281a8f8e3b5 Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Fri, 1 Aug 2025 14:36:21 -0400 Subject: [PATCH 12/14] added tests for assertion rewrite --- tests/checks/assertions_rewrite/__init__.py | 42 +++++++++++++++++++ .../disabled_assertions_rewrite/__init__.py | 36 ++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 tests/checks/assertions_rewrite/__init__.py create mode 100644 tests/checks/disabled_assertions_rewrite/__init__.py diff --git a/tests/checks/assertions_rewrite/__init__.py b/tests/checks/assertions_rewrite/__init__.py new file mode 100644 index 00000000..21960fbd --- /dev/null +++ b/tests/checks/assertions_rewrite/__init__.py @@ -0,0 +1,42 @@ +# ENABLE_CHECK50_ASSERT = 1 +import check50 + +@check50.check() +def foo(): + stdout = "Hello, world!" + try: + assert stdout is "Beautiful is better than ugly." + except check50.Failure: + pass + + try: + assert stdout is "Explicit is better than implicit.", "help msg goes here" + except check50.Failure: + pass + + try: + assert stdout == "Simple is better than complex." + except check50.Mismatch: + pass + + try: + assert stdout in "Complex is better than complicated." + except check50.Missing: + pass + + try: + assert stdout in "Flat is better than nested." check50.Mismatch("Flat is better than nested.", stdout) + except check50.Mismatch: + pass + + try: + assert bar(qux()) in "Readability counts." + except check50.Missing: + pass + + +def bar(baz): + return "Hello, world!" + +def qux(): + return diff --git a/tests/checks/disabled_assertions_rewrite/__init__.py b/tests/checks/disabled_assertions_rewrite/__init__.py new file mode 100644 index 00000000..9c44a497 --- /dev/null +++ b/tests/checks/disabled_assertions_rewrite/__init__.py @@ -0,0 +1,36 @@ +# ENABLE_CHECK50_ASSERT = 0 +import check50 + +@check50.check() +def foo(): + stdout = "Hello, world!" + try: + assert stdout is "Special cases aren't special enough to break the rules." + except AssertionError: + pass + + try: + assert stdout is "Although practicality beats purity.", "help msg goes here" + except AssertionError: + pass + + try: + assert stdout == "Errors should never pass silently." + except AssertionError: + pass + + try: + assert stdout in "Unless explicitly silenced." + except AssertionError: + pass + + try: + assert bar(qux()) in "In the face of ambiguity, refuse the temptation to guess." + except AssertionError: + pass + +def bar(baz): + return "Hello, world!" + +def qux(): + return From c3adca04feeb852147c1cd438c89be5e67b08f74 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Fri, 1 Aug 2025 14:53:33 -0400 Subject: [PATCH 13/14] add tests for assertions rewrite functionality --- tests/check50_tests.py | 10 ++++++++++ tests/checks/assertions_rewrite_disabled/.cs50.yaml | 3 +++ .../__init__.py | 0 tests/checks/assertions_rewrite_enabled/.cs50.yaml | 3 +++ .../__init__.py | 2 +- 5 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 tests/checks/assertions_rewrite_disabled/.cs50.yaml rename tests/checks/{disabled_assertions_rewrite => assertions_rewrite_disabled}/__init__.py (100%) create mode 100644 tests/checks/assertions_rewrite_enabled/.cs50.yaml rename tests/checks/{assertions_rewrite => assertions_rewrite_enabled}/__init__.py (87%) diff --git a/tests/check50_tests.py b/tests/check50_tests.py index 12b25849..4c3dc1e6 100644 --- a/tests/check50_tests.py +++ b/tests/check50_tests.py @@ -493,5 +493,15 @@ def test_successful_exit(self): self.assertEqual(process.returncode, 0) +class TestAssertionsRewrite(Base): + def test_assertions_rewrite_enabled(self): + process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/assertions_rewrite_enabled") + process.expect_exact(":)") + + def test_assertions_rewrite_disabled(self): + process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/assertions_rewrite_disabled") + process.expect_exact(":)") + + if __name__ == "__main__": unittest.main() diff --git a/tests/checks/assertions_rewrite_disabled/.cs50.yaml b/tests/checks/assertions_rewrite_disabled/.cs50.yaml new file mode 100644 index 00000000..be5ecce0 --- /dev/null +++ b/tests/checks/assertions_rewrite_disabled/.cs50.yaml @@ -0,0 +1,3 @@ +check50: + files: + - !exclude "*" diff --git a/tests/checks/disabled_assertions_rewrite/__init__.py b/tests/checks/assertions_rewrite_disabled/__init__.py similarity index 100% rename from tests/checks/disabled_assertions_rewrite/__init__.py rename to tests/checks/assertions_rewrite_disabled/__init__.py diff --git a/tests/checks/assertions_rewrite_enabled/.cs50.yaml b/tests/checks/assertions_rewrite_enabled/.cs50.yaml new file mode 100644 index 00000000..be5ecce0 --- /dev/null +++ b/tests/checks/assertions_rewrite_enabled/.cs50.yaml @@ -0,0 +1,3 @@ +check50: + files: + - !exclude "*" diff --git a/tests/checks/assertions_rewrite/__init__.py b/tests/checks/assertions_rewrite_enabled/__init__.py similarity index 87% rename from tests/checks/assertions_rewrite/__init__.py rename to tests/checks/assertions_rewrite_enabled/__init__.py index 21960fbd..bc105553 100644 --- a/tests/checks/assertions_rewrite/__init__.py +++ b/tests/checks/assertions_rewrite_enabled/__init__.py @@ -25,7 +25,7 @@ def foo(): pass try: - assert stdout in "Flat is better than nested." check50.Mismatch("Flat is better than nested.", stdout) + assert stdout in "Flat is better than nested.", check50.Mismatch("Flat is better than nested.", stdout) except check50.Mismatch: pass From 212c894579d9d963b9b12288a1b95e7a87cb9027 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Fri, 1 Aug 2025 14:59:18 -0400 Subject: [PATCH 14/14] remove unused import of ast --- check50/runner.py | 1 - 1 file changed, 1 deletion(-) diff --git a/check50/runner.py b/check50/runner.py index 2c992446..ab321c6d 100644 --- a/check50/runner.py +++ b/check50/runner.py @@ -13,7 +13,6 @@ import sys import tempfile import traceback -import ast import attr import lib50