diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c130c86d..bbab585c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -31,7 +31,7 @@ jobs: - name: Extract program version id: program_version run: | - echo ::set-output name=version::$(check50 --version | cut --delimiter ' ' --fields 2) + echo "version=$(check50 --version | cut --delimiter ' ' --fields 2)" >> $GITHUB_OUTPUT - name: Create Release if: ${{ github.ref == 'refs/heads/main' }} diff --git a/CONTRIBUTING_LANG.md b/CONTRIBUTING_LANG.md new file mode 100644 index 00000000..394bc4c7 --- /dev/null +++ b/CONTRIBUTING_LANG.md @@ -0,0 +1,48 @@ +# Check50 Language Translations +Thank you for your interest in making CS50's tooling more accessible for all. Before contributing, please read this in full to avoid any complications. + +## Instructions +CS50 uses GitHub to host code, track issues and feature requests, as well as accept pull requests. Please do not email staff members regarding specific issues or features. + +In order to add or edit a language for `check50`, please follow these steps. + +1. Fork the `check50` repository. +2. Once in the `check50` directory, run `pip install babel` and `pip install -e .` +3. If the language you are looking to add already exists in `check50/locale/`, please skip to step 6. + - See all of the 2 letter language codes [here](https://www.loc.gov/standards/iso639-2/php/code_list.php) +4. Generate the template of strings to translate by running `python3 setup.py extract_messages`. This will create a file in `check50/locale/` called `check50.pot` +5. Run `python setup.py init_catalog -l `, where `` is the 2 letter language code (see [here](https://www.loc.gov/standards/iso639-2/php/code_list.php)), to create a file called `check50.po` located at `check50/locale//LC_MESSAGES/`. This file is where the translations will be inputted. +6. The original English strings are found at every `msgid` occurence. Translations should be inputted directly under at every `msgstr` occurence. +7. To test your translations, run `python3 setup.py compile_catalog` to compile the `check50.po` file into `check50.mo`. +8. `pip3 install .` to install the new version of `check50` containing these translations. + +## Design and Formatting +Please follow the formatting of the `msgstr` English strings. For example, if the `msgid` string is + +``` +msgid "" +"check50 is not intended for use in interactive mode. Some behavior may " +"not function as expected." +``` + +The `msgstr` string should replicate the spacing of the English string. + +Example: +``` +msgstr "" +"check50 không thể sử dụng bằng chế độ tương tác, có thể " +"không hoạt động như mong đợi." +``` + +Instead of: +``` +msgstr "" +"check50 không thể sử dụng bằng chế độ tương tác, có thể không hoạt động như mong đợi." +``` + + +## Translation Error Reports + + +## References +This document was adapted from the open-source contribution guidelines for [Meta's Draft](https://github.com/facebookarchive/draft-js/blob/main/CONTRIBUTING.md) diff --git a/check50/__init__.py b/check50/__init__.py index f389d56d..34366923 100644 --- a/check50/__init__.py +++ b/check50/__init__.py @@ -23,6 +23,15 @@ def _setup_translation(): _set_version() _setup_translation() +# Discourage use of check50 in the interactive mode, due to a naming conflict of +# the `_` variable. check50 uses it for translations, but Python stores the +# result of the last expression in a variable called `_`. +import sys +if hasattr(sys, 'ps1') or sys.flags.interactive: + import warnings + warnings.warn(_("check50 is not intended for use in interactive mode. " + "Some behavior may not function as expected.")) + from ._api import ( import_checks, data, _data, @@ -38,7 +47,9 @@ def _setup_translation(): from . import regex from .runner import check +from .config import config from pexpect import EOF __all__ = ["import_checks", "data", "exists", "hash", "include", "regex", - "run", "log", "Failure", "Mismatch", "Missing", "check", "EOF"] + "run", "log", "Failure", "Mismatch", "Missing", "check", "EOF", + "config"] diff --git a/check50/__main__.py b/check50/__main__.py index 7b0e2a62..b95ed94f 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 @@ -20,12 +21,14 @@ 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 LOGGER = logging.getLogger("check50") +gettext.install("check50", str(importlib.resources.files("check50").joinpath("locale"))) + lib50.set_local_path(os.environ.get("CHECK50_PATH", "~/.local/share/check50")) @@ -258,6 +261,20 @@ def process_args(args): if args.ansi_log and "ansi" not in seen_output: LOGGER.warning(_("--ansi-log has no effect when ansi is not among the output formats")) + if args.https or args.ssh: + if args.offline: + LOGGER.warning(_("Using either --https and --ssh will have no effect when running offline")) + args.auth_method = None + elif args.https and args.ssh: + LOGGER.warning(_("--https and --ssh have no effect when used together")) + args.auth_method = None + elif args.https: + args.auth_method = "https" + else: + args.auth_method = "ssh" + else: + args.auth_method = None + class LoggerWriter: def __init__(self, logger, level): @@ -273,10 +290,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) @@ -333,6 +350,18 @@ def main(): parser.add_argument("--no-install-dependencies", action="store_true", help=_("do not install dependencies (only works with --local)")) + parser.add_argument("--assertion-rewrite", + action="store", + nargs="?", + const="enabled", + choices=["true", "enabled", "1", "on", "false", "disabled", "0", "off"], + help=_("enable or disable assertion rewriting; overrides ENABLE_CHECK50_ASSERT flag in the checks file")) + parser.add_argument("--https", + action="store_true", + help=_("force authentication via HTTPS")) + parser.add_argument("--ssh", + action="store_true", + help=_("force authentication via SSH")) parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}") @@ -387,7 +416,27 @@ def main(): if not args.no_install_dependencies: install_dependencies(config["dependencies"]) - checks_file = (internal.check_dir / config["checks"]).resolve() + # Store the original checks file and leave as is + original_checks_file = (internal.check_dir / config["checks"]).resolve() + + # If the user has enabled the rewrite feature + assertion_rewrite_enabled = False + if args.assertion_rewrite is not None: + assertion_rewrite_enabled = args.assertion_rewrite.lower() in ("true", "1", "enabled", "on") + else: + assertion_rewrite_enabled = assertions.rewrite_enabled(str(original_checks_file)) + + if assertion_rewrite_enabled: + # 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/_api.py b/check50/_api.py index e2055aa4..6ed1d4f9 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -11,6 +11,7 @@ from pexpect.exceptions import EOF, TIMEOUT from . import internal, regex +from .config import config _log = [] internal.register.before_every(_log.clear) @@ -238,7 +239,9 @@ def stdout(self, output=None, str_output=None, regex=True, timeout=3, show_timeo :type show_timeout: bool :raises check50.Mismatch: if ``output`` is specified and nothing that the \ process outputs matches it - :raises check50.Failure: if process times out or if it outputs invalid UTF-8 text. + :raises check50.Missing: if the process times out + :raises check50.Failure: if the process outputs invalid UTF-8 text or \ + otherwise fails to verify output Example usage:: @@ -422,6 +425,8 @@ class Missing(Failure): """ def __init__(self, missing_item, collection, help=None): + if isinstance(collection, list): + collection = _process_list(collection, _raw) super().__init__(rationale=_("Did not find {} in {}").format(_raw(missing_item), _raw(collection)), help=help) if missing_item == EOF: @@ -453,15 +458,19 @@ class Mismatch(Failure): """ def __init__(self, expected, actual, help=None): - super().__init__(rationale=_("expected {}, not {}").format(_raw(expected), _raw(actual)), help=help) + def _safe_truncate(x, y): + return _truncate(x, y) if x not in (EOF, TIMEOUT) else x - if expected == EOF: - expected = "EOF" + expected, actual = _safe_truncate(expected, actual), _safe_truncate(actual, expected) - if actual == EOF: - actual = "EOF" + rationale = _("expected: {}\n actual: {}").format( + _raw(expected), + _raw(actual) + ) - self.payload.update({"expected": expected, "actual": actual}) + super().__init__(rationale=rationale, help=help) + + self.payload.update({"expected": _raw(expected), "actual": _raw(actual)}) def hidden(failure_rationale): @@ -493,19 +502,95 @@ def wrapper(*args, **kwargs): return wrapper return decorator +def _process_list(lst, processor, flatten="shallow", joined_by="\n"): + """ + Applies a function `processor` to every element of a list. + + `flatten` has 3 choices: + - `none`: Apply `processor` to every element of a list without flattening (e.g. `['1', '2', '[3]']`). + - `shallow`: Flatten by one level only and apply `processor` (e.g. `'1\\n2\\n[3]'`). + - `deep`: Recursively flatten and apply `processor` (e.g. `'1\\n2\\n3'`). + + Example usage: + if isinstance(obj, list): + return _process_list(obj, _raw, joined_by=" ") + + :param lst: A list to be modified. + :type lst: list + :param processor: The function that processes each item. + :type processor: callable + :param flatten: The level of flattening to apply. One of "none", "shallow", or "deep". + :type flatten: str + :param joined_by: If `flatten` is one of "shallow" or "deep", uses this string to join the elements of the list. + :param joined_by: str + :rtype: list | str + """ + match flatten: + case "shallow": + return joined_by.join(processor(item) for item in lst) + case "deep": + def _flatten_deep(x): + for item in x: + if isinstance(item, list): + yield from _flatten_deep(item) + else: + yield item + + return joined_by.join(processor(item) for item in _flatten_deep(lst)) + case _: + # for "none" and every other case + return [processor(item) for item in lst] + +def _truncate(s, other): + def normalize(obj): + if isinstance(obj, list): + return _process_list(obj, str) + else: + return str(obj) + + s, other = normalize(s), normalize(other) -def _raw(s): - """Get raw representation of s, truncating if too long.""" + if not config.dynamic_truncate: + if len(s) > config.truncate_len: + s = s[:config.truncate_len] + "..." + return s - if isinstance(s, list): - s = "\n".join(_raw(item) for item in s) + # find the index of first difference + limit = min(len(s), len(other)) + i = limit + for index in range(limit): + if s[index] != other[index]: + i = index + break + # If the diff is within the first config.truncate_len characters, + # start from the beginning (no need for "..." at the start) + if i < config.truncate_len: + start = 0 + end = min(config.truncate_len, len(s)) + else: + # center around diff for differences further into the string + start = max(i - (config.truncate_len // 2), 0) + end = min(start + config.truncate_len, len(s)) + + snippet = s[start:end] + + if start > 0: + snippet = "..." + snippet + if end < len(s): + snippet = snippet + "..." + + return snippet + + +def _raw(s): + """Get raw representation of s.""" if s == EOF: return "EOF" + elif s == TIMEOUT: + return "TIMEOUT" s = f'"{repr(str(s))[1:-1]}"' - if len(s) > 15: - s = s[:15] + "...\"" # Truncate if too long return s diff --git a/check50/assertions/__init__.py b/check50/assertions/__init__.py new file mode 100644 index 00000000..3b77f65c --- /dev/null +++ b/check50/assertions/__init__.py @@ -0,0 +1 @@ +from .rewrite import rewrite, rewrite_enabled diff --git a/check50/assertions/rewrite.py b/check50/assertions/rewrite.py new file mode 100644 index 00000000..1b3bee67 --- /dev/null +++ b/check50/assertions/rewrite.py @@ -0,0 +1,260 @@ +import ast +import re + +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 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) + +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 and case. + + :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 + 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: The `assert` statement node being visited and transformed. + :type node: ast.Assert + """ + 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, ...} + 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 + )) + + # 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) + + # 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 string form of the condition + ast.Constant(value=ast.unparse(node.test)), + # The additional msg or exception that the user provided + node.msg or ast.Constant(value=None) + ], + # And these named parameters: + keywords=keywords + ) + ) + + + 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" + + def _extract_names(self, expr): + """ + 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 + """ + 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 previous 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) + + 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) + 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] + """ + 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 new file mode 100644 index 00000000..83c86a98 --- /dev/null +++ b/check50/assertions/runtime.py @@ -0,0 +1,183 @@ +from check50 import Failure, Missing, Mismatch +import inspect +import tokenize +import types, builtins +from io import StringIO + + +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 + follows the condition (seperated by a comma). If the msg_or_exc is not + a string, an exception, or it was not provided, it is silently ignored. + + 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 user's end if the assertion fails. + + Example usage: + ``` + assert x in y + ``` + will be converted to + ``` + check50_assert(x in y, "x in y", None, "in", x, y) + ``` + + :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 | 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: str | None + :param right: The right side of the conditional, if applicable + :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. + :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 context is None: + context = {} + + # Grab the global and local variables as of now + caller_frame = inspect.currentframe().f_back + caller_globals = caller_frame.f_globals + caller_locals = caller_frame.f_locals + + # 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): + for expr_str in context: + try: + context[expr_str] = eval(expr_str, caller_globals, caller_locals) + except Exception as e: + context[expr_str] = f"[error evaluating: {e}]" + + # filter out modules, functions, and built-ins, which is needed to avoid + # overwriting function definitions in evaluaton and avoid useless string + # output + def is_irrelevant_value(v): + return isinstance(v, (types.ModuleType, types.FunctionType, types.BuiltinFunctionType)) + + def is_builtin_name(name): + return name in dir(builtins) + + filtered_context = { + k: v for k, v in context.items() + if not is_irrelevant_value(v) and not is_builtin_name(k.split("(")[0]) + } + + # produces a string like "var1 = ..., var2 = ..., foo() = ..." + context_str = ", ".join(f"{k} = {repr(v)}" for k, v in filtered_context.items()) + else: + filtered_context = {} + + # 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, filtered_context) + + # Merge globals with expression context for evaluation + eval_globals = caller_globals.copy() + eval_globals.update(eval_context) + + # Merge locals with expression context for evaluation + eval_locals = caller_locals.copy() + eval_locals.update(eval_context) + + cond = eval(eval_src, eval_globals, eval_locals) + + # 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): + raise msg_or_exc + 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) + 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) + else: + help_msg = f"\n where {context_str}" if context_str else "" + raise Failure(f"check did not pass: {src}" + 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'] + } + ``` + """ + # Parse the src into a stream of tokens + tokens = tokenize.generate_tokens(StringIO(src).readline) + + new_tokens = [] + new_context = {} + placeholder_map = {} # used for duplicates in src (i.e. x == x => __expr0 == __expr0) + counter = 0 + + for tok_type, tok_string, start, end, line in tokens: + if tok_string in context: + if tok_string not in placeholder_map: + placeholder = f"__expr{counter}" + placeholder_map[tok_string] = placeholder + new_context[placeholder] = context[tok_string] + counter += 1 + else: + # Avoid creating a new __expr{i} variable if it has already been seen + placeholder = placeholder_map[tok_string] + new_tokens.append((tok_type, placeholder)) + else: + # Anything not found in the context dictionary is placed here, + # including keywords, whitespace, operators, etc. + new_tokens.append((tok_type, tok_string)) + + eval_src = tokenize.untokenize(new_tokens) + return eval_src, new_context diff --git a/check50/config.py b/check50/config.py new file mode 100644 index 00000000..a7c00d18 --- /dev/null +++ b/check50/config.py @@ -0,0 +1,69 @@ +class Config: + """ + Configuration for `check50` behavior. + + This class stores user-defined configuration options that influence + check50's output formatting. + + For developers of `check50`, you can extend the `Config` class by adding new + variables to the `__init__`, which will automatically generate new "setter" + functions to modify the default values. Additionally, if the new + configuration needs to be validated before the user can modify it, add your + validation into the `_validators` dictionary. + """ + + def __init__(self): + self.truncate_len = 30 + self.dynamic_truncate = True + + # Create boolean validators for your variables here (if needed): + # A help message is not required. + self._validators = { + "truncate_len": (lambda val: isinstance(val, int) and val >= 1, + "truncate_len must be a positive integer"), + "dynamic_truncate": (lambda val: isinstance(val, bool) or val in (0, 1), + "dynamic_truncate must be a boolean") + } + + # Dynamically generates setter functions based on variable names and + # the type of the default values + self._generate_setters() + + def _generate_setters(self): + def make_setter(attr): + """Factory for making functions like `set_(arg)`""" + + def setter(self, value): + # Get the entry in the dict of validators. + # Check to see if the value passes the validator, and if it + # didn't, display the help message, if any. + validator_entry = self._validators.get(attr) + + if validator_entry: + if isinstance(validator_entry, tuple): + validator, help = validator_entry + else: + validator, help = validator_entry, None + + if not validator(value): + error_msg = f"invalid value for {attr}: {value}" + if help: + error_msg += f", {help}" + raise ValueError(error_msg) + + setattr(self, attr, value) + return setter + + # Iterate through the names of every instantiated variable + for attribute_name in self.__dict__: + if attribute_name.startswith('_'): + continue # skip "private" attributes (denoted with a prefix `_`) + value = getattr(self, attribute_name) + if callable(value): + continue # skip functions/methods + + # Create a class method with the given name and function + setattr(self.__class__, f"set_{attribute_name}", make_setter(attribute_name)) + + +config = Config() diff --git a/check50/locale/vi/LC_MESSAGES/check50.mo b/check50/locale/vi/LC_MESSAGES/check50.mo new file mode 100644 index 00000000..110d8bee Binary files /dev/null and b/check50/locale/vi/LC_MESSAGES/check50.mo differ diff --git a/check50/locale/vi/LC_MESSAGES/check50.po b/check50/locale/vi/LC_MESSAGES/check50.po new file mode 100644 index 00000000..f0319272 --- /dev/null +++ b/check50/locale/vi/LC_MESSAGES/check50.po @@ -0,0 +1,431 @@ +# Vietnamese translations for check50. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the check50 project. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: check50 4.0.0.dev0\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2025-08-05 15:34-0400\n" +"PO-Revision-Date: 2025-08-05 15:34-0400\n" +"Last-Translator: FULL NAME \n" +"Language: vi\n" +"Language-Team: vi \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#: check50/__init__.py:32 +msgid "" +"check50 is not intended for use in interactive mode. Some behavior may " +"not function as expected." +msgstr "" +"check50 không thể sử dụng bằng chế độ tương tác, có thể " +"không hoạt động như mong đợi." + + +#: check50/__main__.py:82 +msgid "failed to install dependencies" +msgstr "không cài đặt dependencies được" + +#: check50/__main__.py:158 +#, python-brace-format +msgid "" +"check50 is taking longer than normal!\n" +"See https://submit.cs50.io/check50/{} for more detail" +msgstr "" +"check50 đang chạy lâu hơn bình thường.\n" +"Xem https://submit.cs50.io/check50/{} để biết thêm thông tin" + +#: check50/__main__.py:164 +msgid "" +"Sorry, something's wrong, please try again.\n" +"If the problem persists, please visit our status page " +"https://cs50.statuspage.io for more information." +msgstr "" +"Rất tiếc, có gì xảy ra, vui lòng thử lại. Nếu vấn đề này còn tiếp nữa, " +"vui lòng xem https://cs50.statuspage.io để biết thêm thông tin." + +#: check50/__main__.py:185 +msgid "logout of check50" +msgstr "đăng xuất khỏi check50" + +#: check50/__main__.py:192 +msgid "failed to logout" +msgstr "không đăng xuất được" + +#: check50/__main__.py:194 +msgid "logged out successfully" +msgstr "đăng xuất thành công" + +#: check50/__main__.py:200 +#, python-brace-format +msgid "Could not find checks for {}." +msgstr "Không tìm thấy checks cho {}." + +#: check50/__main__.py:204 +msgid " Did you mean:" +msgstr " Ý bạn là:" + +#: check50/__main__.py:207 +msgid "" +"\n" +"Do refer back to the problem specification if unsure." +msgstr "" +"\n" +"Vui lòng xem lại hướng dẫn của bài tập này nếu không chắc chắn." + +#: check50/__main__.py:210 +msgid "" +"\n" +"If you are confident the slug is correct and you have an internet " +"connection, try running without --offline." +msgstr "" +"\n" +"Nếu bạn chắc chắn slug này đúng và bạn đã kết nối internet, " +"hãy thử chạy mà không dùng --offline." + +#: check50/__main__.py:249 +#, python-brace-format +msgid "You should always use --local when using: {}" +msgstr "Bạn nên luôn sử dụng --local khi dùng: {}" + +#: check50/__main__.py:255 +#, python-brace-format +msgid "Duplicate output format specified: {}" +msgstr "Định dạng đầu ra bị trùng lập: {}" + +#: check50/__main__.py:262 +msgid "--ansi-log has no effect when ansi is not among the output formats" +msgstr "--ansi-log không có tác dụng nếu ansi không nằm trong các định dạng đầu ra" + +#: check50/__main__.py:298 +msgid "prescribed identifier of work to check" +msgstr "" + +#: check50/__main__.py:301 +msgid "" +"run check50 in development mode (implies --offline, and --log-level " +"info).\n" +"causes slug to be interpreted as a literal path to a checks package." +msgstr "" +"chạy check50 ở chế độ phát triển (nghĩa là dùng --offline và --log-level info).\n" +"slug sẽ được hiểu như đường dẫn tới checks package." + +#: check50/__main__.py:305 +msgid "" +"run checks completely offline (implies --local, --no-download-checks and " +"--no-install-dependencies)" +msgstr "" +"chạy kiểm tra hoàn toàn offline (có cả --local, --no-download-checks và --no-install-dependencies)" + +#: check50/__main__.py:308 +msgid "run checks locally instead of uploading to cs50" +msgstr "chạy kiểm tra ở máy cục bộ thay vì tải lên cs50" + +#: check50/__main__.py:314 +msgid "format of check results" +msgstr "định dạng kết quả kiểm tra" + +#: check50/__main__.py:318 +msgid "target specific checks to run" +msgstr "chỉ định kiểm tra cụ thể để chạy" + +#: check50/__main__.py:322 +msgid "file to write output to" +msgstr "tập tin để ghi kết quả đầu ra" + +#: check50/__main__.py:327 +msgid "" +"warning: displays usage warnings.\n" +"info: adds all commands run, any locally installed dependencies and print" +" messages.\n" +"debug: adds the output of all commands run." +msgstr "" +"warning: hiển thị cảnh báo sử dụng.\n" +"info: thêm tất cả lệnh đã chạy, các dependency cài cục bộ và các thông báo print.\n" +"debug: thêm toàn bộ đầu ra của mọi lệnh đã chạy." + +#: check50/__main__.py:332 +msgid "display log in ansi output mode" +msgstr "hiển thị log ở chế độ đầu ra ansi" + +#: check50/__main__.py:335 +msgid "" +"do not download checks, but use previously downloaded checks instead " +"(only works with --local)" +msgstr "không tải checks mới, sử dụng gói đã tải trước đó (chỉ hoạt động với --local)" + +#: check50/__main__.py:338 +msgid "do not install dependencies (only works with --local)" +msgstr "không cài đặt dependencies (chỉ hoạt động với --local)" + +#: check50/__main__.py:371 +#, python-brace-format +msgid "{} is not a directory" +msgstr "{} không phải là thư mục" + +#: check50/__main__.py:377 +msgid "" +"check50 could not retrieve checks from GitHub. Try running check50 again " +"with --offline." +msgstr "check50 không thể lấy checks từ GitHub. Vui lòng thử lại với --offline." + +#: check50/__main__.py:455 +#, python-brace-format +msgid "To see more detailed results go to {}" +msgstr "Để xem kết quả chi tiết hơn, hãy truy cập {}" + +#: check50/_api.py:81 +#, python-brace-format +msgid "hashing {}..." +msgstr "đang hash {}..." + +#: check50/_api.py:104 +#, python-brace-format +msgid "checking that {} exists..." +msgstr "đang kiểm tra {} tồn tại..." + +#: check50/_api.py:106 check50/_exceptions.py:42 +#, python-brace-format +msgid "{} not found" +msgstr "không tìm thấy {}" + +#: check50/_api.py:158 +#, python-brace-format +msgid "running {}..." +msgstr "đang chạy {}..." + +#: check50/_api.py:192 +#, python-brace-format +msgid "sending input {}..." +msgstr "đang gửi đầu vào {}..." + +#: check50/_api.py:198 +msgid "expected prompt for input, found none" +msgstr "dự kiến chương trình sẽ hỏi nhập dữ liệu, nhưng không có gì hiển thị" + +#: check50/_api.py:200 check50/_api.py:293 check50/_api.py:370 +msgid "output not valid ASCII text" +msgstr "đầu ra không hợp lệ (không phải văn bản ASCII)" + +#: check50/_api.py:275 +msgid "checking for EOF..." +msgstr "đang kiểm tra EOF..." + +#: check50/_api.py:278 +#, python-brace-format +msgid "checking for output \"{}\"..." +msgstr "đang kiểm tra đầu ra \"{}\"..." + +#: check50/_api.py:290 +#, python-brace-format +msgid "check50 waited {} seconds for the output of the program" +msgstr "check50 đã đợi {} giây để nhận đầu ra từ chương trình" + +#: check50/_api.py:295 +msgid "check50 could not verify output" +msgstr "check50 không thể xác thực đầu ra" + +#: check50/_api.py:312 +msgid "checking that input was rejected..." +msgstr "đang kiểm tra dữ liệu nhập bị từ chối..." + +#: check50/_api.py:319 +msgid "expected program to reject input, but it did not" +msgstr "chương trình cần phải từ chối đầu vào này, nhưng lại không từ chối" + +#: check50/_api.py:351 +#, python-brace-format +msgid "checking that program exited with status {}..." +msgstr "đang kiểm tra chương trình thoát với mã trạng thái {}..." + +#: check50/_api.py:353 +#, python-brace-format +msgid "expected exit code {}, not {}" +msgstr "dự kiến mã thoát là {}, không phải {}" + +#: check50/_api.py:368 +msgid "timed out while waiting for program to exit" +msgstr "hết thời gian chờ chương trình thoát" + +#: check50/_api.py:375 +msgid "failed to execute program due to segmentation fault" +msgstr "không thể chạy chương trình do lỗi segmentation fault" + +#: check50/_api.py:428 +#, python-brace-format +msgid "Did not find {} in {}" +msgstr "Không tìm thấy {} trong {}" + +#: check50/_api.py:464 +#, python-brace-format +msgid "" +"expected: {}\n" +" actual: {}" +msgstr "" +"dự kiến: {}\n" +" thực tế: {}" + +#: check50/_exceptions.py:50 +msgid "" +"Sorry, something is wrong! check50 ran into an error, please try again.\n" +"If the problem persists, please visit our status page " +"https://cs50.statuspage.io for more information." +msgstr "" +"Rất tiếc, có gì xảy ra, vui lòng thử nộp lại. Nếu vấn đề này còn tiếp nữa, " +"vui lòng xem https://cs50.statuspage.io để biết thêm thông tin." + +#: check50/_simple.py:64 +#, python-brace-format +msgid "" +"{} is not a valid name for a check; check names should consist only of " +"alphanumeric characters, underscores, and spaces" +msgstr "" +"{} không phải là check hợp lệ; tên của check chỉ nên bao gồm " +"ký tự chữ, số, dấu gạch dưới (_) và khoảng trắng" + +#: check50/_simple.py:89 +msgid "You forgot a - in front of run" +msgstr "Bạn quên thêm dấu - phía trước run" + +#: check50/_simple.py:94 +#, python-brace-format +msgid "{} is not a valid command in check {}, use only: {}" +msgstr "{} không phải là lệnh hợp lệ trong kiểm tra {}, chỉ sử dụng: {}" + +#: check50/_simple.py:98 +#, python-brace-format +msgid "Missing {} in check {}" +msgstr "Thiếu {} trong kiểm tra {}" + +#: check50/c.py:43 +msgid "compile requires at least one file" +msgstr "biên dịch yêu cầu ít nhất một tập tin" + +#: check50/c.py:106 +msgid "checking for valgrind errors..." +msgstr "đang kiểm tra lỗi valgrind..." + +#: check50/c.py:129 +msgid "file" +msgstr "tập tin" + +#: check50/c.py:129 +msgid "line" +msgstr "dòng" + +#: check50/c.py:139 +msgid "valgrind tests failed; see log for more information." +msgstr "kiểm tra valgrind thất bại; xem log để biết thêm chi tiết." + +#: check50/flask.py:36 +#, python-brace-format +msgid "could not find {}" +msgstr "không tìm thấy {}" + +#: check50/flask.py:44 +#, python-brace-format +msgid "{} does not contain an app" +msgstr "{} không chứa ứng dụng (app)" + +#: check50/flask.py:110 +#, python-brace-format +msgid "checking that status code {} is returned..." +msgstr "đang kiểm tra trả về mã trạng thái {}..." + +#: check50/flask.py:112 +#, python-brace-format +msgid "expected status code {}, but got {}" +msgstr "dự kiến mã trạng thái {}, nhưng nhận được {}" + +#: check50/flask.py:123 +#, python-brace-format +msgid "expected request to return HTML, but it returned {}" +msgstr "yêu cầu dự kiến trả về HTML, nhưng lại trả về {}" + +#: check50/flask.py:140 +#, python-brace-format +msgid "sending {} request to {}" +msgstr "đang gửi yêu cầu {} đến {}" + +#: check50/flask.py:144 +#, python-brace-format +msgid "exception raised in application: {}: {}" +msgstr "ứng dụng phát sinh ngoại lệ: {}: {}" + +#: check50/flask.py:145 +msgid "application raised an exception (see the log for more details)" +msgstr "ứng dụng phát sinh ngoại lệ (xem log để biết thêm chi tiết)" + +#: check50/flask.py:155 +#, python-brace-format +msgid "checking that \"{}\" is in page" +msgstr "đang kiểm tra \"{}\" có trong trang hay không" + +#: check50/flask.py:161 +#, python-brace-format +msgid "expected to find \"{}\" in page, but it wasn't found" +msgstr "dự kiến sẽ tìm thấy \"{}\" trong trang, nhưng không tìm thấy" + +#: check50/internal.py:121 check50/internal.py:128 +msgid "Invalid slug for check50. Did you mean something else?" +msgstr "Slug không hợp lệ cho check50. Có phải bạn định nhập nội dung khác?" + +#: check50/internal.py:190 +msgid "yes" +msgstr "có" + +#: check50/internal.py:190 +#, python-brace-format +msgid "{} [Y/n] " +msgstr "{} [Y/n] " + +#: check50/py.py:43 +#, python-brace-format +msgid "importing {}..." +msgstr "đang import {}..." + +#: check50/py.py:57 +#, python-brace-format +msgid "compiling {} into byte code..." +msgstr "đang biên dịch {} thành mã byte..." + +#: check50/py.py:62 +msgid "Exception raised: " +msgstr "Phát sinh ngoại lệ: " + +#: check50/py.py:66 +#, python-brace-format +msgid "{} raised while compiling {} (see the log for more details)" +msgstr "{} phát sinh khi biên dịch {} (xem log để biết thêm chi tiết)" + +#: check50/runner.py:57 +#, python-brace-format +msgid "check timed out after {} seconds" +msgstr "kiểm tra bị quá thời gian sau {} giây" + +#: check50/runner.py:152 +msgid "check50 ran into an error while running checks!" +msgstr "check50 gặp lỗi khi thực hiện kiểm tra!" + +#: check50/runner.py:241 +#, python-brace-format +msgid "Unknown check: {}" +msgstr "Kiểm tra chưa biết: {}" + +#: check50/runner.py:268 +msgid "can't check until a frown turns upside down" +msgstr "chưa thể kiểm tra cho đến khi bạn mỉm cười" + +#: check50/renderer/_renderers.py:74 +#, python-brace-format +msgid "Results for {} generated by check50 v{}" +msgstr "Kết quả cho {} được tạo bởi check50 v{}" + +#: check50/renderer/_renderers.py:82 +msgid "check skipped" +msgstr "bỏ qua kiểm tra" diff --git a/check50/renderer/_renderers.py b/check50/renderer/_renderers.py index 0ddc252a..2752d867 100644 --- a/check50/renderer/_renderers.py +++ b/check50/renderer/_renderers.py @@ -1,5 +1,6 @@ import json import pathlib +import random import jinja2 import termcolor @@ -14,11 +15,56 @@ def to_html(slug, results, version): content = f.read() template = jinja2.Template( - content, autoescape=jinja2.select_autoescape(enabled_extensions=("html",))) - html = template.render(slug=slug, results=results, version=version) + content, autoescape=jinja2.select_autoescape(enabled_extensions=("html",)) + ) + + html = template.render( + slug=slug, + results=results, + version=version, + fmt_special_chars=_fmt_special_chars, + color="#808080" # RGB (128, 128, 128) + ) return html +def _fmt_special_chars(txt, color): + """Converts a plaintext string into a string of HTML elements that highlights special chars.""" + def highlight_char(char, color): + """Highlights and escapes a char.""" + return f"{repr(char)[1:-1]}" + + # We'd like to interpret whitespace (ws) as HTML in only these specific cases: + ws_to_html = { + "\n": "
", + " ": " ", + } + fmtted_txt = [] + + for i, char in enumerate(txt): + is_last = i == len(txt) - 1 + + if not char.isprintable() and char not in ws_to_html: + # Most special invisible characters, excluding those in ws_to_html, are highlighted + fmtted_txt.append(highlight_char(char, color)) + elif char in ws_to_html: + # If there's a trailing whitespace character, we highlight it + if is_last: + # Spaces aren't normally highlightable, so we convert to nbsp. + if char == ' ': + char = ws_to_html[char] + + fmtted_txt.append(highlight_char(char, color)) + else: + # Certain special chars are interpreted in HTML, without escaping or highlighting + fmtted_txt.append(ws_to_html[char]) + else: + # Non-special characters are unchanged + fmtted_txt.append(char) + + # Return the text as a string of plaintext + html elements + return ''.join(fmtted_txt) + def to_json(slug, results, version): return json.dumps({"slug": slug, "results": results, "version": version}, indent=4) @@ -26,8 +72,10 @@ def to_json(slug, results, version): def to_ansi(slug, results, version, _log=False): lines = [termcolor.colored(_("Results for {} generated by check50 v{}").format(slug, version), "white", attrs=["bold"])] + num_passed = 0 for result in results: if result["passed"]: + num_passed += 1 lines.append(termcolor.colored(f":) {result['description']}", "green")) elif result["passed"] is None: lines.append(termcolor.colored(f":| {result['description']}", "yellow")) @@ -44,5 +92,15 @@ def to_ansi(slug, results, version, _log=False): if _log: lines += (f" {line}" for line in result["log"]) + + if not all(result["passed"] for result in results) and num_passed > len(results) // 2: + if random.random() < 0.20: + message = random.choice([ + "~~~~~ You can do it! ~~~~~", + "~~~~~ Keep it up! ~~~~~", + "~~~~~ You're getting there! ~~~~~" + ]) + lines.append(termcolor.colored(message, "magenta")) + return "\n".join(lines) diff --git a/check50/renderer/templates/results.html b/check50/renderer/templates/results.html index 6a51efa8..40b58d7f 100644 --- a/check50/renderer/templates/results.html +++ b/check50/renderer/templates/results.html @@ -86,7 +86,7 @@

:| {{ check.description }}

{% else %} {% set expected = check.cause.expected | e %} {% endif %} - {{ expected | replace(" ", " ") | replace("\n", "
") }} + {{ fmt_special_chars(expected, color) }} {% endautoescape %} @@ -102,12 +102,13 @@

:| {{ check.description }}

{% else %} {% set actual = check.cause.actual | e %} {% endif %} - {{ actual | replace(" ", " ") | replace("\n", "
") }} + {{ fmt_special_chars(actual, color) }} {% endautoescape %} +
{% endif %} {# Missing if there was one #} diff --git a/setup.py b/setup.py index 85fefaf4..7c3d64cf 100644 --- a/setup.py +++ b/setup.py @@ -24,12 +24,12 @@ }, keywords=["check", "check50"], name="check50", - packages=["check50", "check50.renderer"], + packages=["check50", "check50.renderer", "check50.assertions"], python_requires=">= 3.8", entry_points={ "console_scripts": ["check50=check50.__main__:main"] }, url="https://github.com/cs50/check50", - version="3.4.0", + version="4.0.0-dev", include_package_data=True ) diff --git a/tests/api_tests.py b/tests/api_tests.py index b3b86a96..f75beb01 100644 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -294,5 +294,109 @@ def test_no_reject(self): with self.assertRaises(check50.Failure): self.process.reject() +class TestMismatch(unittest.TestCase): + """Test Mismatch exception class for proper JSON serialization.""" + + def test_json_serialization_with_strings(self): + """Test that regular strings are properly escaped for JSON.""" + import json + + test_cases = [ + # Regular strings + ("hello", "world"), + # Strings with quotes + ('Hello "World"', 'Goodbye "World"'), + # Strings with newlines + ("First\nSecond", "First\nDifferent"), + # Strings with backslashes + ("Path\\to\\file", "Path\\to\\other"), + # JSON-like strings + ('{"key": "value"}', '{"key": "different"}'), + # Mixed special characters + ('Line with \\ and " and \n', 'Another \\ line " with \n'), + ] + + for expected, actual in test_cases: + with self.subTest(expected=expected, actual=actual): + mismatch = check50.Mismatch(expected, actual) + + # Ensure payload can be serialized to JSON + json_str = json.dumps(mismatch.payload) + + # Ensure it can be parsed back + parsed = json.loads(json_str) + + # Verify expected fields are present + self.assertIn('rationale', parsed) + self.assertIn('expected', parsed) + self.assertIn('actual', parsed) + self.assertIsNone(parsed.get('help')) + + def test_json_serialization_with_special_values(self): + """Test that special values like EOF and class types are handled.""" + import json + from pexpect.exceptions import EOF, TIMEOUT + + test_cases = [ + # EOF and TIMEOUT constants + (check50.EOF, "some output"), + ("some input", check50.EOF), + (check50.EOF, check50.EOF), + # Class types (simulating the error case) + (EOF, "output"), + ("input", EOF), + (EOF, TIMEOUT), + ] + + for expected, actual in test_cases: + with self.subTest(expected=expected, actual=actual): + mismatch = check50.Mismatch(expected, actual) + + # Ensure payload can be serialized to JSON + json_str = json.dumps(mismatch.payload) + + # Ensure it can be parsed back + parsed = json.loads(json_str) + + # Verify expected fields are present and are strings + self.assertIn('rationale', parsed) + self.assertIn('expected', parsed) + self.assertIn('actual', parsed) + + # Ensure values in payload are strings, not class types + self.assertIsInstance(parsed['expected'], str) + self.assertIsInstance(parsed['actual'], str) + + def test_mismatch_with_help(self): + """Test that help messages are included in the payload.""" + import json + + mismatch = check50.Mismatch("expected", "actual", help="Did you forget something?") + + # Ensure payload can be serialized to JSON + json_str = json.dumps(mismatch.payload) + parsed = json.loads(json_str) + + # Verify help is in the payload + self.assertEqual(parsed['help'], "Did you forget something?") + + def test_mismatch_with_truncation(self): + """Test that long strings are truncated properly.""" + import json + + # Create very long strings that will be truncated + long_expected = "a" * 1000 + long_actual = "b" * 1000 + + mismatch = check50.Mismatch(long_expected, long_actual) + + # Ensure payload can be serialized to JSON + json_str = json.dumps(mismatch.payload) + parsed = json.loads(json_str) + + # Verify truncation occurred (should have ellipsis) + self.assertIn("...", parsed['expected']) + self.assertIn("...", parsed['actual']) + if __name__ == '__main__': unittest.main() diff --git a/tests/check50_tests.py b/tests/check50_tests.py index d917704d..4c3dc1e6 100644 --- a/tests/check50_tests.py +++ b/tests/check50_tests.py @@ -99,7 +99,8 @@ def test_with_empty_file(self): process.expect_exact("foo.py exists") process.expect_exact(":(") process.expect_exact("prints hello") - process.expect_exact("expected \"hello\", not \"\"") + process.expect_exact("expected: \"hello\"") + process.expect_exact("actual: \"\"") process.close(force=True) @@ -145,7 +146,8 @@ def test_with_empty_file(self): process.expect_exact("foo.py exists") process.expect_exact(":(") process.expect_exact("prints hello name") - process.expect_exact("expected \"hello bar\", not \"\"") + process.expect_exact("expected: \"hello bar\"") + process.expect_exact("actual: \"\"") process.close(force=True) def test_with_correct_file(self): @@ -491,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/assertions_rewrite_disabled/__init__.py b/tests/checks/assertions_rewrite_disabled/__init__.py new file mode 100644 index 00000000..9c44a497 --- /dev/null +++ b/tests/checks/assertions_rewrite_disabled/__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 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_enabled/__init__.py b/tests/checks/assertions_rewrite_enabled/__init__.py new file mode 100644 index 00000000..bc105553 --- /dev/null +++ b/tests/checks/assertions_rewrite_enabled/__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