From e62a35a7cf9e113e033bb24e4b59e8dec9cd3842 Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Fri, 11 Jul 2025 12:03:58 -0400 Subject: [PATCH 01/79] Update _api.py --- check50/_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/check50/_api.py b/check50/_api.py index bb7f2e0..fdbd2d1 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -463,7 +463,7 @@ def __init__(self, expected, actual, help=None): if actual == EOF: actual = "EOF" - self.payload.update({"expected": expected, "actual": actual}) + self.payload.update({"expected": repr(expected), "actual": repr(actual)}) def hidden(failure_rationale): From 038dbb728f018f5e149f984a7d3eb8e7ca4daeb4 Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Fri, 11 Jul 2025 12:10:26 -0400 Subject: [PATCH 02/79] Update results.html --- check50/renderer/templates/results.html | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/check50/renderer/templates/results.html b/check50/renderer/templates/results.html index 6a51efa..1871583 100644 --- a/check50/renderer/templates/results.html +++ b/check50/renderer/templates/results.html @@ -27,12 +27,12 @@

:| {{ check.description }}

Cause
{% autoescape false %} - {{ check.cause.rationale | e | replace(" ", " ") }} + {{ check.cause.rationale | replace(" ", " ") }} {% endautoescape %}
{% autoescape false %} {% if check.cause.help %} - {{ check.cause.help | e | replace(" ", " ") }} + {{ check.cause.help | replace(" ", " ") }}
{% endif %} {% endautoescape %} @@ -82,9 +82,9 @@

:| {{ check.description }}

{% autoescape false %} {% if check.cause.expected is not string %} - {% set expected = check.cause.expected | join('\n') | e %} + {% set expected = check.cause.expected | join('\n') %} {% else %} - {% set expected = check.cause.expected | e %} + {% set expected = check.cause.expected %} {% endif %} {{ expected | replace(" ", " ") | replace("\n", "
") }} {% endautoescape %} @@ -98,9 +98,9 @@

:| {{ check.description }}

{% autoescape false %} {% if check.cause.actual is not string %} - {% set actual = check.cause.actual | join('\n') | e %} + {% set actual = check.cause.actual | join('\n') %} {% else %} - {% set actual = check.cause.actual | e %} + {% set actual = check.cause.actual %} {% endif %} {{ actual | replace(" ", " ") | replace("\n", "
") }} {% endautoescape %} @@ -121,7 +121,7 @@

:| {{ check.description }}

{% autoescape false %} - {% set item = check.cause.missing_item | e %} + {% set item = check.cause.missing_item %} {{ item | replace(" ", " ") | replace("\n", "
") }} {% endautoescape %}
@@ -133,7 +133,7 @@

:| {{ check.description }}

{% autoescape false %} - {% set collection = check.cause.collection | e %} + {% set collection = check.cause.collection %} {{ collection | replace(" ", " ") | replace("\n", "
") }} {% endautoescape %}
From 24c8d2450a780e6a7766443dc2b83763d8df29ea Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Fri, 11 Jul 2025 12:30:22 -0400 Subject: [PATCH 03/79] undo commit --- check50/renderer/templates/results.html | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/check50/renderer/templates/results.html b/check50/renderer/templates/results.html index 1871583..6a51efa 100644 --- a/check50/renderer/templates/results.html +++ b/check50/renderer/templates/results.html @@ -27,12 +27,12 @@

:| {{ check.description }}

Cause
{% autoescape false %} - {{ check.cause.rationale | replace(" ", " ") }} + {{ check.cause.rationale | e | replace(" ", " ") }} {% endautoescape %}
{% autoescape false %} {% if check.cause.help %} - {{ check.cause.help | replace(" ", " ") }} + {{ check.cause.help | e | replace(" ", " ") }}
{% endif %} {% endautoescape %} @@ -82,9 +82,9 @@

:| {{ check.description }}

{% autoescape false %} {% if check.cause.expected is not string %} - {% set expected = check.cause.expected | join('\n') %} + {% set expected = check.cause.expected | join('\n') | e %} {% else %} - {% set expected = check.cause.expected %} + {% set expected = check.cause.expected | e %} {% endif %} {{ expected | replace(" ", " ") | replace("\n", "
") }} {% endautoescape %} @@ -98,9 +98,9 @@

:| {{ check.description }}

{% autoescape false %} {% if check.cause.actual is not string %} - {% set actual = check.cause.actual | join('\n') %} + {% set actual = check.cause.actual | join('\n') | e %} {% else %} - {% set actual = check.cause.actual %} + {% set actual = check.cause.actual | e %} {% endif %} {{ actual | replace(" ", " ") | replace("\n", "
") }} {% endautoescape %} @@ -121,7 +121,7 @@

:| {{ check.description }}

{% autoescape false %} - {% set item = check.cause.missing_item %} + {% set item = check.cause.missing_item | e %} {{ item | replace(" ", " ") | replace("\n", "
") }} {% endautoescape %}
@@ -133,7 +133,7 @@

:| {{ check.description }}

{% autoescape false %} - {% set collection = check.cause.collection %} + {% set collection = check.cause.collection | e %} {{ collection | replace(" ", " ") | replace("\n", "
") }} {% endautoescape %}
From d504890c9ef9ed76fa6c97fb2c10bc389dae951e Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Fri, 11 Jul 2025 12:54:40 -0400 Subject: [PATCH 04/79] Update _api.py --- check50/_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/check50/_api.py b/check50/_api.py index fdbd2d1..0efb4fd 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -505,7 +505,7 @@ def _raw(s): if s == EOF: return "EOF" - s = f'"{repr(str(s))[1:-1]}"' + s = repr(str(s)) if len(s) > 15: s = s[:15] + "...\"" # Truncate if too long return s From d5aaed5fc9e93932df444e0d50cd57485e77a83d Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Fri, 11 Jul 2025 13:01:14 -0400 Subject: [PATCH 05/79] Update _api.py --- check50/_api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/check50/_api.py b/check50/_api.py index 0efb4fd..5eb0ac5 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -506,6 +506,8 @@ def _raw(s): return "EOF" s = repr(str(s)) + if s.startswith("'") and s.endswith("'"): + s = f'"{s[1:-1]}"' if len(s) > 15: s = s[:15] + "...\"" # Truncate if too long return s From 2bdd070717c7531351486acec2043d1185fe3f8d Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Fri, 11 Jul 2025 15:23:48 -0400 Subject: [PATCH 06/79] Update results.html --- check50/renderer/templates/results.html | 35 +++++++------------------ 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/check50/renderer/templates/results.html b/check50/renderer/templates/results.html index 6a51efa..50e0e26 100644 --- a/check50/renderer/templates/results.html +++ b/check50/renderer/templates/results.html @@ -75,37 +75,20 @@

:| {{ check.description }}

{% if check.cause and "actual" in check.cause and "expected" in check.cause %}
+ {% set expected = check.cause.expected | join('\n') if check.cause.expected is not string else check.cause.expected %}
Expected Output: -
-
- - {% autoescape false %} - {% if check.cause.expected is not string %} - {% set expected = check.cause.expected | join('\n') | e %} - {% else %} - {% set expected = check.cause.expected | e %} - {% endif %} - {{ expected | replace(" ", " ") | replace("\n", "
") }} - {% endautoescape %} -
-
+
+                                          {{ expected }}
+                                      
+ + {% set actual = check.cause.actual | join('\n') if check.cause.actual is not string else check.cause.actual %}
Actual Output: -
-
- - {% autoescape false %} - {% if check.cause.actual is not string %} - {% set actual = check.cause.actual | join('\n') | e %} - {% else %} - {% set actual = check.cause.actual | e %} - {% endif %} - {{ actual | replace(" ", " ") | replace("\n", "
") }} - {% endautoescape %} -
-
+
+                                          {{ actual }}
+                                      
{% endif %} From 236479b7827bc7c42540b354c2045ff583b6d879 Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Fri, 11 Jul 2025 15:30:58 -0400 Subject: [PATCH 07/79] Update _api.py --- check50/_api.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/check50/_api.py b/check50/_api.py index 5eb0ac5..ec6faad 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -505,11 +505,9 @@ def _raw(s): if s == EOF: return "EOF" - s = repr(str(s)) - if s.startswith("'") and s.endswith("'"): - s = f'"{s[1:-1]}"' - if len(s) > 15: - s = s[:15] + "...\"" # Truncate if too long + s = f'"{repr(str(s))[1:-1]}"' + # if len(s) > 15: + # s = s[:15] + "...\"" # Truncate if too long return s From 39dc8d59ac70bcdc118ec95b386609cf59421422 Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Fri, 11 Jul 2025 15:35:39 -0400 Subject: [PATCH 08/79] Update _api.py --- check50/_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/check50/_api.py b/check50/_api.py index ec6faad..c971602 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -463,7 +463,7 @@ def __init__(self, expected, actual, help=None): if actual == EOF: actual = "EOF" - self.payload.update({"expected": repr(expected), "actual": repr(actual)}) + self.payload.update({"expected": expected, "actual": actual}) def hidden(failure_rationale): From 399ee45da094c7a924159a920895b9ede9b912fe Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Mon, 14 Jul 2025 13:11:17 -0400 Subject: [PATCH 09/79] update truncation and display --- check50/_api.py | 40 +++++++++++++++++++++++-- check50/renderer/_renderers.py | 1 - check50/renderer/templates/results.html | 35 ++++++++++++++++------ 3 files changed, 63 insertions(+), 13 deletions(-) diff --git a/check50/_api.py b/check50/_api.py index c971602..89d1b0f 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -455,7 +455,18 @@ class Mismatch(Failure): """ def __init__(self, expected, actual, help=None): - super().__init__(rationale=_("expected {}, not {}").format(_raw(expected), _raw(actual)), help=help) + expected, actual = _truncate(expected, actual), _truncate(actual, expected) + + # rationale = _("expected {}, not {}").format( + # _raw(expected), + # _raw(actual) + # ) + rationale = _("expected: {}\n actual: {}").format( + _raw(expected), + _raw(actual) + ) + + super().__init__(rationale=rationale, help=help) if expected == EOF: expected = "EOF" @@ -495,6 +506,30 @@ def wrapper(*args, **kwargs): return wrapper return decorator +def _truncate(s, other, max_len=10): + """Truncate string s around its first difference with other""" + + # 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 + + # center around diff + start = max(i - (max_len // 2), 0) + end = min(start + max_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, truncating if too long.""" @@ -506,8 +541,7 @@ def _raw(s): return "EOF" s = f'"{repr(str(s))[1:-1]}"' - # if len(s) > 15: - # s = s[:15] + "...\"" # Truncate if too long + return s diff --git a/check50/renderer/_renderers.py b/check50/renderer/_renderers.py index 0ddc252..9581524 100644 --- a/check50/renderer/_renderers.py +++ b/check50/renderer/_renderers.py @@ -8,7 +8,6 @@ TEMPLATES = pathlib.Path(files("check50.renderer").joinpath("templates")) - def to_html(slug, results, version): with open(TEMPLATES / "results.html") as f: content = f.read() diff --git a/check50/renderer/templates/results.html b/check50/renderer/templates/results.html index 50e0e26..6a51efa 100644 --- a/check50/renderer/templates/results.html +++ b/check50/renderer/templates/results.html @@ -75,20 +75,37 @@

:| {{ check.description }}

{% if check.cause and "actual" in check.cause and "expected" in check.cause %}
- {% set expected = check.cause.expected | join('\n') if check.cause.expected is not string else check.cause.expected %}
Expected Output: -
-                                          {{ expected }}
-                                      
+
+
+ + {% autoescape false %} + {% if check.cause.expected is not string %} + {% set expected = check.cause.expected | join('\n') | e %} + {% else %} + {% set expected = check.cause.expected | e %} + {% endif %} + {{ expected | replace(" ", " ") | replace("\n", "
") }} + {% endautoescape %} +
+
- - {% set actual = check.cause.actual | join('\n') if check.cause.actual is not string else check.cause.actual %}
Actual Output: -
-                                          {{ actual }}
-                                      
+
+
+ + {% autoescape false %} + {% if check.cause.actual is not string %} + {% set actual = check.cause.actual | join('\n') | e %} + {% else %} + {% set actual = check.cause.actual | e %} + {% endif %} + {{ actual | replace(" ", " ") | replace("\n", "
") }} + {% endautoescape %} +
+
{% endif %} From 836fdc1529e975aa7bf5a0cedd1fae47c96904ce Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Mon, 14 Jul 2025 14:34:34 -0400 Subject: [PATCH 10/79] test workflow with rationale --- check50/_api.py | 10 +++++----- check50/renderer/_renderers.py | 5 ++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/check50/_api.py b/check50/_api.py index 89d1b0f..80d26a4 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -457,14 +457,14 @@ class Mismatch(Failure): def __init__(self, expected, actual, help=None): expected, actual = _truncate(expected, actual), _truncate(actual, expected) - # rationale = _("expected {}, not {}").format( - # _raw(expected), - # _raw(actual) - # ) - rationale = _("expected: {}\n actual: {}").format( + rationale = _("expected {}, not {}").format( _raw(expected), _raw(actual) ) + # rationale = _("expected: {}\n actual: {}").format( + # _raw(expected), + # _raw(actual) + # ) super().__init__(rationale=rationale, help=help) diff --git a/check50/renderer/_renderers.py b/check50/renderer/_renderers.py index 9581524..0bdb779 100644 --- a/check50/renderer/_renderers.py +++ b/check50/renderer/_renderers.py @@ -1,5 +1,6 @@ import json import pathlib +import re import jinja2 import termcolor @@ -37,7 +38,9 @@ def to_ansi(slug, results, version, _log=False): else: lines.append(termcolor.colored(f":( {result['description']}", "red")) if result["cause"].get("rationale") is not None: - lines.append(termcolor.colored(f" {result['cause']['rationale']}", "red")) + match = re.match(r'expected (".+?"), not (".+?")', result["cause"].get("rationale")) + lines.append(termcolor.colored(f" expected: {match.group(1)}", "red")) + lines.append(termcolor.colored(f" actual: {match.group(2)}", "red")) if result["cause"].get("help") is not None: lines.append(termcolor.colored(f" {result['cause']['help']}", "red")) From 9ec5408cd4f69a9323386a8aced0ecc2dca796b8 Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Mon, 14 Jul 2025 14:56:48 -0400 Subject: [PATCH 11/79] test workflow --- check50/renderer/_renderers.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/check50/renderer/_renderers.py b/check50/renderer/_renderers.py index 0bdb779..80eb827 100644 --- a/check50/renderer/_renderers.py +++ b/check50/renderer/_renderers.py @@ -1,6 +1,6 @@ import json import pathlib -import re +# import re import jinja2 import termcolor @@ -37,10 +37,12 @@ def to_ansi(slug, results, version, _log=False): lines += (f" {line.rstrip()}" for line in result["cause"]["error"]["traceback"]) else: lines.append(termcolor.colored(f":( {result['description']}", "red")) + # if result["cause"].get("rationale") is not None: + # match = re.match(r'expected (".+?"), not (".+?")', result["cause"].get("rationale")) + # lines.append(termcolor.colored(f" expected: {match.group(1)}", "red")) + # lines.append(termcolor.colored(f" actual: {match.group(2)}", "red")) if result["cause"].get("rationale") is not None: - match = re.match(r'expected (".+?"), not (".+?")', result["cause"].get("rationale")) - lines.append(termcolor.colored(f" expected: {match.group(1)}", "red")) - lines.append(termcolor.colored(f" actual: {match.group(2)}", "red")) + lines.append(termcolor.colored(f" {result['cause']['rationale']}", "red")) if result["cause"].get("help") is not None: lines.append(termcolor.colored(f" {result['cause']['help']}", "red")) From ccda3bb65ab10967643a9eb11c19ed5160811dbf Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Mon, 14 Jul 2025 15:56:06 -0400 Subject: [PATCH 12/79] dynamic truncation --- check50/_api.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/check50/_api.py b/check50/_api.py index 80d26a4..7f93274 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -457,14 +457,14 @@ class Mismatch(Failure): def __init__(self, expected, actual, help=None): expected, actual = _truncate(expected, actual), _truncate(actual, expected) - rationale = _("expected {}, not {}").format( - _raw(expected), - _raw(actual) - ) - # rationale = _("expected: {}\n actual: {}").format( + # rationale = _("expected {}, not {}").format( # _raw(expected), # _raw(actual) # ) + rationale = _("expected: {}\n actual: {}").format( + _raw(expected), + _raw(actual) + ) super().__init__(rationale=rationale, help=help) @@ -507,7 +507,11 @@ def wrapper(*args, **kwargs): return decorator def _truncate(s, other, max_len=10): - """Truncate string s around its first difference with other""" + + if isinstance(s, list): + s = "\n".join(s) + if isinstance(other, list): + other = "\n".join(other) # find the index of first difference limit = min(len(s), len(other)) From 2febd90cca3ba884e77ba858082cbf24cdc3b89b Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Mon, 14 Jul 2025 16:15:13 -0400 Subject: [PATCH 13/79] Added feature where (trailing) invisible chars are highlighted --- check50/renderer/_renderers.py | 52 ++++++++++++++++++++++++- check50/renderer/templates/results.html | 5 ++- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/check50/renderer/_renderers.py b/check50/renderer/_renderers.py index 0ddc252..f026ba4 100644 --- a/check50/renderer/_renderers.py +++ b/check50/renderer/_renderers.py @@ -1,5 +1,6 @@ import json import pathlib +import html 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="rgba(161, 161, 161, 0.5)" + ) 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 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) @@ -46,3 +92,5 @@ def to_ansi(slug, results, version, _log=False): lines += (f" {line}" for line in result["log"]) return "\n".join(lines) + + diff --git a/check50/renderer/templates/results.html b/check50/renderer/templates/results.html index 6a51efa..40b58d7 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 #} From 2d86fb989bcc2b62b26b33387cc06bc1f20596c9 Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Mon, 14 Jul 2025 16:16:11 -0400 Subject: [PATCH 14/79] Comment fix --- check50/renderer/_renderers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/check50/renderer/_renderers.py b/check50/renderer/_renderers.py index f026ba4..dbf5709 100644 --- a/check50/renderer/_renderers.py +++ b/check50/renderer/_renderers.py @@ -45,7 +45,7 @@ def highlight_char(char, color): is_last = i == len(txt) - 1 if not char.isprintable() and char not in ws_to_html: - # Most special characters, excluding those in ws_to_html, are highlighted + # 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 From 3b1bfd793d89f0834b1a80d440add4b61bb5ed3b Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Mon, 14 Jul 2025 16:16:53 -0400 Subject: [PATCH 15/79] Removed unused import --- check50/renderer/_renderers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/check50/renderer/_renderers.py b/check50/renderer/_renderers.py index dbf5709..46fa524 100644 --- a/check50/renderer/_renderers.py +++ b/check50/renderer/_renderers.py @@ -1,6 +1,5 @@ import json import pathlib -import html import jinja2 import termcolor From f93d1ce560ddfd20f20d9ddaa14382757025ac31 Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Mon, 14 Jul 2025 16:56:30 -0400 Subject: [PATCH 16/79] update check50_tests.py --- tests/check50_tests.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/check50_tests.py b/tests/check50_tests.py index 514279c..68278f0 100644 --- a/tests/check50_tests.py +++ b/tests/check50_tests.py @@ -100,7 +100,9 @@ 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\", not \"\"") + process.expect_exact("expected: \"hello\"") + process.expect_exact("actual: \"\"") process.close(force=True) @@ -146,7 +148,10 @@ 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\", not \"\"") + # process.expect_exact(r"expected: \"hello bar\"\n actual: \"\"") + process.expect_exact("expected: \"hello bar\"") + process.expect_exact("actual: \"\"") process.close(force=True) def test_with_correct_file(self): From fffa2e531b46210a3093b589f14cb801319e5589 Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Tue, 15 Jul 2025 10:58:42 -0400 Subject: [PATCH 17/79] remove comments --- check50/_api.py | 4 ---- check50/renderer/_renderers.py | 5 ----- tests/check50_tests.py | 3 --- 3 files changed, 12 deletions(-) diff --git a/check50/_api.py b/check50/_api.py index 7f93274..4059bfa 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -457,10 +457,6 @@ class Mismatch(Failure): def __init__(self, expected, actual, help=None): expected, actual = _truncate(expected, actual), _truncate(actual, expected) - # rationale = _("expected {}, not {}").format( - # _raw(expected), - # _raw(actual) - # ) rationale = _("expected: {}\n actual: {}").format( _raw(expected), _raw(actual) diff --git a/check50/renderer/_renderers.py b/check50/renderer/_renderers.py index 80eb827..9581524 100644 --- a/check50/renderer/_renderers.py +++ b/check50/renderer/_renderers.py @@ -1,6 +1,5 @@ import json import pathlib -# import re import jinja2 import termcolor @@ -37,10 +36,6 @@ def to_ansi(slug, results, version, _log=False): lines += (f" {line.rstrip()}" for line in result["cause"]["error"]["traceback"]) else: lines.append(termcolor.colored(f":( {result['description']}", "red")) - # if result["cause"].get("rationale") is not None: - # match = re.match(r'expected (".+?"), not (".+?")', result["cause"].get("rationale")) - # lines.append(termcolor.colored(f" expected: {match.group(1)}", "red")) - # lines.append(termcolor.colored(f" actual: {match.group(2)}", "red")) if result["cause"].get("rationale") is not None: lines.append(termcolor.colored(f" {result['cause']['rationale']}", "red")) if result["cause"].get("help") is not None: diff --git a/tests/check50_tests.py b/tests/check50_tests.py index 68278f0..5ab3357 100644 --- a/tests/check50_tests.py +++ b/tests/check50_tests.py @@ -100,7 +100,6 @@ 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) @@ -148,8 +147,6 @@ 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(r"expected: \"hello bar\"\n actual: \"\"") process.expect_exact("expected: \"hello bar\"") process.expect_exact("actual: \"\"") process.close(force=True) From ae3a99cda8983b6637a727ed1f88c4ccd8cb82b9 Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Tue, 15 Jul 2025 11:21:39 -0400 Subject: [PATCH 18/79] add motivational msgs --- check50/renderer/_renderers.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/check50/renderer/_renderers.py b/check50/renderer/_renderers.py index 0ddc252..cc5ac37 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 @@ -44,5 +45,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): + 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) From dc563c0112a97f9a951231040b57df046291a11b Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Tue, 15 Jul 2025 11:29:41 -0400 Subject: [PATCH 19/79] tweaked formatting --- check50/_api.py | 1 - check50/renderer/_renderers.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/check50/_api.py b/check50/_api.py index 4059bfa..cf16ade 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -541,7 +541,6 @@ def _raw(s): return "EOF" s = f'"{repr(str(s))[1:-1]}"' - return s diff --git a/check50/renderer/_renderers.py b/check50/renderer/_renderers.py index 9581524..0ddc252 100644 --- a/check50/renderer/_renderers.py +++ b/check50/renderer/_renderers.py @@ -8,6 +8,7 @@ TEMPLATES = pathlib.Path(files("check50.renderer").joinpath("templates")) + def to_html(slug, results, version): with open(TEMPLATES / "results.html") as f: content = f.read() From af90cfb784ebff39682bf56a9fcb5d9aabcdd6c6 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Tue, 15 Jul 2025 12:01:09 -0400 Subject: [PATCH 20/79] Use hex value for color in the renderer --- check50/renderer/_renderers.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/check50/renderer/_renderers.py b/check50/renderer/_renderers.py index 46fa524..0d42244 100644 --- a/check50/renderer/_renderers.py +++ b/check50/renderer/_renderers.py @@ -22,7 +22,7 @@ def to_html(slug, results, version): results=results, version=version, fmt_special_chars=_fmt_special_chars, - color="rgba(161, 161, 161, 0.5)" + color="808080" # RGB (128, 128, 128) ) return html @@ -91,5 +91,3 @@ def to_ansi(slug, results, version, _log=False): lines += (f" {line}" for line in result["log"]) return "\n".join(lines) - - From dcfb57308f9bb3ed783d26f24c93554331304992 Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:16:32 -0400 Subject: [PATCH 21/79] update msg display case --- check50/renderer/_renderers.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/check50/renderer/_renderers.py b/check50/renderer/_renderers.py index cc5ac37..c7882d3 100644 --- a/check50/renderer/_renderers.py +++ b/check50/renderer/_renderers.py @@ -27,8 +27,11 @@ 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 + total_tests = len(results) 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")) @@ -46,8 +49,8 @@ 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): - if random.random() < 0.20: + if not all(result["passed"] for result in results) and num_passed > total_tests // 2: + if random.random() < 1.0: message = random.choice([ "~~~~~ You can do it! ~~~~~", "~~~~~ Keep it up! ~~~~~", From 74340bae5d3d9bc27771b4d49321b593d39341ec Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:17:10 -0400 Subject: [PATCH 22/79] update probability --- check50/renderer/_renderers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/check50/renderer/_renderers.py b/check50/renderer/_renderers.py index c7882d3..1230f49 100644 --- a/check50/renderer/_renderers.py +++ b/check50/renderer/_renderers.py @@ -50,7 +50,7 @@ def to_ansi(slug, results, version, _log=False): lines += (f" {line}" for line in result["log"]) if not all(result["passed"] for result in results) and num_passed > total_tests // 2: - if random.random() < 1.0: + if random.random() < 0.20: message = random.choice([ "~~~~~ You can do it! ~~~~~", "~~~~~ Keep it up! ~~~~~", From dcd23d61789457e8cd89ef813cc13793cb93d468 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Tue, 15 Jul 2025 12:26:36 -0400 Subject: [PATCH 23/79] reduce code redundancy --- check50/renderer/_renderers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/check50/renderer/_renderers.py b/check50/renderer/_renderers.py index 1230f49..0795032 100644 --- a/check50/renderer/_renderers.py +++ b/check50/renderer/_renderers.py @@ -28,7 +28,6 @@ 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 - total_tests = len(results) for result in results: if result["passed"]: num_passed += 1 @@ -49,7 +48,7 @@ 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 > total_tests // 2: + 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! ~~~~~", From 63bfb17b3c168da86f1d97f77215287b8256707d Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Tue, 15 Jul 2025 15:26:31 -0400 Subject: [PATCH 24/79] hash --- check50/renderer/_renderers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/check50/renderer/_renderers.py b/check50/renderer/_renderers.py index 46a8ea9..2752d86 100644 --- a/check50/renderer/_renderers.py +++ b/check50/renderer/_renderers.py @@ -23,7 +23,7 @@ def to_html(slug, results, version): results=results, version=version, fmt_special_chars=_fmt_special_chars, - color="808080" # RGB (128, 128, 128) + color="#808080" # RGB (128, 128, 128) ) return html From b85465420c3e29e97d307791f9222b1f20932574 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Tue, 15 Jul 2025 15:26:59 -0400 Subject: [PATCH 25/79] bump version to 4.0.0-dev in setup.py for now --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 550225f..b94ff83 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ "console_scripts": ["check50=check50.__main__:main"] }, url="https://github.com/cs50/check50", - version="3.3.11", + version="4.0.0-dev", include_package_data=True ) From 3e1e0b0e654a8c0e2ef47855130b9d87c95cc3c5 Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Tue, 15 Jul 2025 16:01:32 -0400 Subject: [PATCH 26/79] custom truncation --- check50/__init__.py | 6 ++++-- check50/_api.py | 19 ++++++++++++++++--- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/check50/__init__.py b/check50/__init__.py index 5f05291..202b7fd 100644 --- a/check50/__init__.py +++ b/check50/__init__.py @@ -33,7 +33,8 @@ def _setup_translation(): run, log, _log, hidden, - Failure, Mismatch, Missing + Failure, Mismatch, Missing, Config, + configure ) @@ -42,4 +43,5 @@ def _setup_translation(): 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", "configure"] diff --git a/check50/_api.py b/check50/_api.py index 4059bfa..3a20535 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -473,6 +473,18 @@ def __init__(self, expected, actual, help=None): self.payload.update({"expected": expected, "actual": actual}) +class Config: + def __init__(self): + self.truncate_len = 10 + +config = Config() + +def configure(truncate_len=None): + if truncate_len: + if not isinstance(truncate_len, int) or truncate_len < 1: + raise ValueError("truncate length must be a positive integer") + config.truncate_len = truncate_len + def hidden(failure_rationale): """ Decorator that marks a check as a 'hidden' check. This will suppress the log @@ -502,7 +514,8 @@ def wrapper(*args, **kwargs): return wrapper return decorator -def _truncate(s, other, max_len=10): +def _truncate(s, other): + truncate_len = config.truncate_len if isinstance(s, list): s = "\n".join(s) @@ -518,8 +531,8 @@ def _truncate(s, other, max_len=10): break # center around diff - start = max(i - (max_len // 2), 0) - end = min(start + max_len, len(s)) + start = max(i - (truncate_len // 2), 0) + end = min(start + truncate_len, len(s)) snippet = s[start:end] From f7fd5cf66ee1f2a525ce905743e55be5cf013aa2 Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Tue, 15 Jul 2025 16:30:26 -0400 Subject: [PATCH 27/79] update custom truncation --- check50/_api.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/check50/_api.py b/check50/_api.py index 3a20535..3b8e70f 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -474,16 +474,35 @@ def __init__(self, expected, actual, help=None): class Config: + """ + Configuration for check50 behavior. + + This class stores user-defined configuration options (currently only + truncation length) that influence check50’s output formatting. + """ def __init__(self): self.truncate_len = 10 + self.dynamic_truncate = True config = Config() def configure(truncate_len=None): + """ + Configure check50 behavior. + + By default, check50 truncates strings around their first point of difference. + However, if the user specifies a custom `truncate_len` via `check50.configure`, + then string outputs will be sliced from the beginning instead. + + Example usage:: + import check50 + check50.configure(truncate_len=15) + """ if truncate_len: if not isinstance(truncate_len, int) or truncate_len < 1: - raise ValueError("truncate length must be a positive integer") + raise ValueError("truncation length must be a positive integer") config.truncate_len = truncate_len + config.dynamic_truncate = False def hidden(failure_rationale): """ @@ -515,13 +534,16 @@ def wrapper(*args, **kwargs): return decorator def _truncate(s, other): - truncate_len = config.truncate_len - if isinstance(s, list): s = "\n".join(s) if isinstance(other, list): other = "\n".join(other) + if config.dynamic_truncate is False: + if len(s) > config.truncate_len: + s = s[:config.truncate_len] + "..." + return s + # find the index of first difference limit = min(len(s), len(other)) i = limit @@ -531,8 +553,8 @@ def _truncate(s, other): break # center around diff - start = max(i - (truncate_len // 2), 0) - end = min(start + truncate_len, len(s)) + start = max(i - (config.truncate_len // 2), 0) + end = min(start + config.truncate_len, len(s)) snippet = s[start:end] From 41b9f3f9f05504d4056fa2933d7ffeac4073d1c3 Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Tue, 15 Jul 2025 16:35:38 -0400 Subject: [PATCH 28/79] updated docs to reflect raised exceptions during timeout more accurately --- check50/_api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/check50/_api.py b/check50/_api.py index cf16ade..c92e42d 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -240,7 +240,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:: 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 29/79] 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 0000000..96efefc --- /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 5f05291..a49e8a2 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 2a4eaa9..49f386f 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 0000000..63216e2 --- /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 0000000..7a71cbf --- /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 0000000..c8e4923 --- /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 167de3f..372c530 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 0ccf0c8..9d7db71 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 b94ff83..b8ad217 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 30/79] 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 49f386f..74f7579 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 7a71cbf..6058f3b 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 c8e4923..2820831 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 31/79] 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 6058f3b..c1a86c9 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 2820831..5307f8d 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 32/79] 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 5307f8d..bc2efd5 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 33/79] import shutil --- check50/__main__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/check50/__main__.py b/check50/__main__.py index f80aa87..0fc21d4 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 72d08b22a8f115a3d4b998cfb16d587054d4cfe2 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Wed, 23 Jul 2025 13:28:30 -0400 Subject: [PATCH 34/79] simplify type handling in _truncate function --- check50/_api.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/check50/_api.py b/check50/_api.py index 5d784b4..6ad3c9f 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -504,10 +504,8 @@ def wrapper(*args, **kwargs): def _truncate(s, other, max_len=10): - if isinstance(s, list): - s = "\n".join(s) - if isinstance(other, list): - other = "\n".join(other) + s = "\n".join(s) if isinstance(s, list) else str(s) + other = "\n".join(other) if isinstance(other, list) else str(other) # find the index of first difference limit = min(len(s), len(other)) From 1860888eae66f460322109065729a09778ee7dc2 Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Wed, 23 Jul 2025 14:24:05 -0400 Subject: [PATCH 35/79] Fixed EOF and TIMEOUT bug --- check50/_api.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/check50/_api.py b/check50/_api.py index 6ad3c9f..c4cef29 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -464,12 +464,6 @@ def __init__(self, expected, actual, help=None): super().__init__(rationale=rationale, help=help) - if expected == EOF: - expected = "EOF" - - if actual == EOF: - actual = "EOF" - self.payload.update({"expected": expected, "actual": actual}) @@ -504,8 +498,17 @@ def wrapper(*args, **kwargs): def _truncate(s, other, max_len=10): - s = "\n".join(s) if isinstance(s, list) else str(s) - other = "\n".join(other) if isinstance(other, list) else str(other) + def normalize(obj): + if isinstance(obj, list): + return "\n".join(map(str, obj)) + elif obj == EOF: + return "EOF" + elif obj == TIMEOUT: + return "TIMEOUT" + else: + return str(obj) + + s, other = normalize(s), normalize(other) # find the index of first difference limit = min(len(s), len(other)) @@ -530,7 +533,7 @@ def _truncate(s, other, max_len=10): def _raw(s): - """Get raw representation of s, truncating if too long.""" + """Get raw representation of s.""" if isinstance(s, list): s = "\n".join(_raw(item) for item in s) From 450a3fe596bc63dafb98db3d18f96ef8ecbfe33b Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Wed, 23 Jul 2025 14:55:04 -0400 Subject: [PATCH 36/79] fixed quotes around EOF and TIMEOUT --- check50/_api.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/check50/_api.py b/check50/_api.py index c4cef29..b7567c0 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -455,7 +455,10 @@ class Mismatch(Failure): """ def __init__(self, expected, actual, help=None): - expected, actual = _truncate(expected, actual), _truncate(actual, expected) + def _safe_truncate(x, y): + return _truncate(x, y) if x != EOF and x != TIMEOUT else x + + expected, actual = _safe_truncate(expected, actual), _safe_truncate(actual, expected) rationale = _("expected: {}\n actual: {}").format( _raw(expected), @@ -497,14 +500,9 @@ def wrapper(*args, **kwargs): return decorator def _truncate(s, other, max_len=10): - def normalize(obj): if isinstance(obj, list): return "\n".join(map(str, obj)) - elif obj == EOF: - return "EOF" - elif obj == TIMEOUT: - return "TIMEOUT" else: return str(obj) @@ -540,6 +538,8 @@ def _raw(s): if s == EOF: return "EOF" + elif s == TIMEOUT: + return "TIMEOUT" s = f'"{repr(str(s))[1:-1]}"' return s From 0efa1169f9d8e9d3120d9d4c01fbcbd14e1d282e Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:31:53 -0400 Subject: [PATCH 37/79] added warning about use in interactive mode --- check50/__init__.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/check50/__init__.py b/check50/__init__.py index f389d56..fe79f1b 100644 --- a/check50/__init__.py +++ b/check50/__init__.py @@ -17,12 +17,19 @@ def _setup_translation(): "check50", str(files("check50").joinpath("locale")), fallback=True) _translation.install() - - # Encapsulated inside a function so their local variables/imports aren't seen by autocompleters _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, From 1b639cc32f536675e23277361646d1119af3d895 Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:40:57 -0400 Subject: [PATCH 38/79] added space --- check50/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/check50/__init__.py b/check50/__init__.py index fe79f1b..2706791 100644 --- a/check50/__init__.py +++ b/check50/__init__.py @@ -27,7 +27,7 @@ def _setup_translation(): import sys if hasattr(sys, 'ps1') or sys.flags.interactive: import warnings - warnings.warn(_("check50 is not intended for use in interactive mode." + warnings.warn(_("check50 is not intended for use in interactive mode. " "Some behavior may not function as expected.")) from ._api import ( From b4454aab57c93d4d91097831bd1ecced3c551b37 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Wed, 23 Jul 2025 16:32:17 -0400 Subject: [PATCH 39/79] tweaked formatting --- check50/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/check50/__init__.py b/check50/__init__.py index 2706791..d2c4ef7 100644 --- a/check50/__init__.py +++ b/check50/__init__.py @@ -17,6 +17,8 @@ def _setup_translation(): "check50", str(files("check50").joinpath("locale")), fallback=True) _translation.install() + + # Encapsulated inside a function so their local variables/imports aren't seen by autocompleters _set_version() _setup_translation() From 7d3b2844d17d602fa5002b02d4b9e5923803a4c7 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Wed, 23 Jul 2025 17:56:12 -0400 Subject: [PATCH 40/79] Use tuple membership test for EOF/TIMEOUT check --- check50/_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/check50/_api.py b/check50/_api.py index b7567c0..08f3a8d 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -456,7 +456,7 @@ class Mismatch(Failure): def __init__(self, expected, actual, help=None): def _safe_truncate(x, y): - return _truncate(x, y) if x != EOF and x != TIMEOUT else x + return _truncate(x, y) if x not in (EOF, TIMEOUT) else x expected, actual = _safe_truncate(expected, actual), _safe_truncate(actual, expected) 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 41/79] 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 c1a86c9..67ac986 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 bc2efd5..02a7104 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 42/79] 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 67ac986..87ec241 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 02a7104..317a914 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 43/79] 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 87ec241..8566cab 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 317a914..cda329b 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 44/79] 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 cda329b..fdf1a56 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 c7ff47d9acd81308250167378cfab71577619206 Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:29:31 -0400 Subject: [PATCH 45/79] refactored config into its own file --- check50/__init__.py | 6 ++-- check50/_api.py | 35 ++------------------- check50/config.py | 77 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 36 deletions(-) create mode 100644 check50/config.py diff --git a/check50/__init__.py b/check50/__init__.py index 2e26589..fb5122e 100644 --- a/check50/__init__.py +++ b/check50/__init__.py @@ -41,15 +41,15 @@ def _setup_translation(): run, log, _log, hidden, - Failure, Mismatch, Missing, Config, - configure + Failure, Mismatch, Missing, ) 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", - "Config", "configure"] + "config"] diff --git a/check50/_api.py b/check50/_api.py index ed5cedc..7f9b19a 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) @@ -469,38 +470,6 @@ def _safe_truncate(x, y): self.payload.update({"expected": expected, "actual": actual}) - -class Config: - """ - Configuration for check50 behavior. - - This class stores user-defined configuration options (currently only - truncation length) that influence check50’s output formatting. - """ - def __init__(self): - self.truncate_len = 10 - self.dynamic_truncate = True - -config = Config() - -def configure(truncate_len=None): - """ - Configure check50 behavior. - - By default, check50 truncates strings around their first point of difference. - However, if the user specifies a custom `truncate_len` via `check50.configure`, - then string outputs will be sliced from the beginning instead. - - Example usage:: - import check50 - check50.configure(truncate_len=15) - """ - if truncate_len: - if not isinstance(truncate_len, int) or truncate_len < 1: - raise ValueError("truncation length must be a positive integer") - config.truncate_len = truncate_len - config.dynamic_truncate = False - def hidden(failure_rationale): """ Decorator that marks a check as a 'hidden' check. This will suppress the log @@ -530,7 +499,7 @@ def wrapper(*args, **kwargs): return wrapper return decorator -def _truncate(s, other, max_len=10): +def _truncate(s, other): def normalize(obj): if isinstance(obj, list): return "\n".join(map(str, obj)) diff --git a/check50/config.py b/check50/config.py new file mode 100644 index 0000000..f8dc457 --- /dev/null +++ b/check50/config.py @@ -0,0 +1,77 @@ +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 = 10 + 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"), + } + + # Dynamically generates setter functions based on variable names and + # the type of the default values + self._generate_setters() + + def _generate_setters(self): + def create_method(name, func): + setattr(self.__class__, name, func) + + def make_toggle(attr): + """Factory for making functions like `toggle_()`""" + def toggler(self): + setattr(self, attr, not getattr(self, attr)) + return toggler + + 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 + + for attribute_name in dir(self): + if attribute_name.startswith('_'): + continue # skip "private" attributes (denoted with a prefix `_`) + value = getattr(self, attribute_name) + if callable(value): + continue # skip functions/methods + + # For variables with the default boolean type, make a setter that + # starts with `toggle_`. Otherwise, have it start with `set_`. + if isinstance(value, bool): + create_method(f"toggle_{attribute_name}", make_toggle(attribute_name)) + else: + create_method(f"set_{attribute_name}", make_setter(attribute_name)) + + +config = Config() From 3736d092584cf604174556eef414b7d61518d904 Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Thu, 31 Jul 2025 17:01:29 -0400 Subject: [PATCH 46/79] removed toggle and cleaned up --- check50/config.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/check50/config.py b/check50/config.py index f8dc457..91eb19b 100644 --- a/check50/config.py +++ b/check50/config.py @@ -11,6 +11,7 @@ class Config: configuration needs to be validated before the user can modify it, add your validation into the `_validators` dictionary. """ + def __init__(self): self.truncate_len = 10 self.dynamic_truncate = True @@ -20,6 +21,8 @@ def __init__(self): 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), + "dynamic_truncate must be a boolean") } # Dynamically generates setter functions based on variable names and @@ -27,17 +30,9 @@ def __init__(self): self._generate_setters() def _generate_setters(self): - def create_method(name, func): - setattr(self.__class__, name, func) - - def make_toggle(attr): - """Factory for making functions like `toggle_()`""" - def toggler(self): - setattr(self, attr, not getattr(self, attr)) - return toggler - 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 @@ -59,19 +54,16 @@ def setter(self, value): setattr(self, attr, value) return setter - for attribute_name in dir(self): + # 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 `_`) + continue # skip "private" attributes (denoted with a prefix `_`) value = getattr(self, attribute_name) if callable(value): - continue # skip functions/methods + continue # skip functions/methods - # For variables with the default boolean type, make a setter that - # starts with `toggle_`. Otherwise, have it start with `set_`. - if isinstance(value, bool): - create_method(f"toggle_{attribute_name}", make_toggle(attribute_name)) - else: - create_method(f"set_{attribute_name}", make_setter(attribute_name)) + # Create a class method with the given name and function + setattr(self.__class__, f"set_{attribute_name}", make_setter(attribute_name)) config = Config() From 12d00524d099377755dfe6316727d218041cddab Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Thu, 31 Jul 2025 17:07:10 -0400 Subject: [PATCH 47/79] Remove trailing comma --- check50/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/check50/__init__.py b/check50/__init__.py index fb5122e..3436692 100644 --- a/check50/__init__.py +++ b/check50/__init__.py @@ -41,7 +41,7 @@ def _setup_translation(): run, log, _log, hidden, - Failure, Mismatch, Missing, + Failure, Mismatch, Missing ) From 73741a551f7ec0afc11f37604d5e87cacd36f703 Mon Sep 17 00:00:00 2001 From: Rongxin Liu <10591665+rongxin-liu@users.noreply.github.com> Date: Thu, 31 Jul 2025 17:16:08 -0400 Subject: [PATCH 48/79] Update check50/_api.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- check50/_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/check50/_api.py b/check50/_api.py index 7f9b19a..9dc12d5 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -508,7 +508,7 @@ def normalize(obj): s, other = normalize(s), normalize(other) - if config.dynamic_truncate is False: + if not config.dynamic_truncate: if len(s) > config.truncate_len: s = s[:config.truncate_len] + "..." return s From d1a9547d0c75a0f1333ddd4ff283afaa929cf7ea Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Thu, 31 Jul 2025 17:19:16 -0400 Subject: [PATCH 49/79] added boolean integer support --- check50/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/check50/config.py b/check50/config.py index 91eb19b..06921a1 100644 --- a/check50/config.py +++ b/check50/config.py @@ -21,7 +21,7 @@ def __init__(self): 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), + "dynamic_truncate": (lambda val: isinstance(val, bool) or val in (0, 1), "dynamic_truncate must be a boolean") } From 4f145bd1d89e76102b4b72ae1e2c645909b91252 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Thu, 31 Jul 2025 17:20:12 -0400 Subject: [PATCH 50/79] Add a blank line --- check50/_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/check50/_api.py b/check50/_api.py index 9dc12d5..f1120e3 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -470,6 +470,7 @@ def _safe_truncate(x, y): self.payload.update({"expected": expected, "actual": actual}) + def hidden(failure_rationale): """ Decorator that marks a check as a 'hidden' check. This will suppress the log From 83eb248f4dd6792447a37e6723b0e85abb60dd99 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Thu, 31 Jul 2025 17:37:19 -0400 Subject: [PATCH 51/79] 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 96efefc..0000000 --- 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 f916ff2..3436692 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 2ed9628..3e4d032 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 ef54c6b..2c99244 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 52/79] 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 0fc21d4..a675d6e 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 63216e2..3b77f65 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 8566cab..a4f92da 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 53/79] 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 0000000..21960fb --- /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 0000000..9c44a49 --- /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 54/79] 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 12b2584..4c3dc1e 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 0000000..be5ecce --- /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 0000000..be5ecce --- /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 21960fb..bc10555 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 55/79] 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 2c99244..ab321c6 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 From 4234eaeb788a22e7540b5b991dcfd7acceb47f33 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Fri, 1 Aug 2025 15:13:25 -0400 Subject: [PATCH 56/79] update deprecated set-out usage in workflow --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c130c86..bbab585 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' }} From 16573bc9851e8a5f0189f441c1c94b4e4f86bd56 Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Fri, 1 Aug 2025 16:05:38 -0400 Subject: [PATCH 57/79] fixed bug with globals not being imported and incorrect name replacement --- check50/assertions/runtime.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/check50/assertions/runtime.py b/check50/assertions/runtime.py index fdf1a56..6bab7a8 100644 --- a/check50/assertions/runtime.py +++ b/check50/assertions/runtime.py @@ -1,4 +1,6 @@ from check50 import Failure, Missing, Mismatch +import re +import inspect def check50_assert(src, msg_or_exc=None, cond_type="unknown", left=None, right=None, context=None): """ @@ -50,16 +52,18 @@ def check50_assert(src, msg_or_exc=None, cond_type="unknown", left=None, right=N :raises check50.Failure: If msg_or_exc is a string, or if cond_type is \ unrecognized. """ + # 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): - 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) + context[expr_str] = eval(expr_str, caller_globals, caller_locals) except Exception as e: context[expr_str] = f"[error evaluating: {e}]" @@ -70,7 +74,12 @@ def check50_assert(src, msg_or_exc=None, cond_type="unknown", left=None, right=N # 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) + + # Merge globals with expression context for evaluation + eval_globals = caller_globals.copy() + eval_globals.update(eval_context) + + cond = eval(eval_src, eval_globals, eval_context) # Finally, quit if the condition evaluated to True. if cond: @@ -125,7 +134,13 @@ def substitute_expressions(src: str, context: dict) -> tuple[str, dict]: 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] + + # Use regex to replace only full matches of expr + # Escape expr if it has special characters (like function calls) + pattern = re.escape(expr) + new_src, count = re.subn(rf'\b{pattern}\b', placeholder, new_src) + + if count > 0: + new_context[placeholder] = context[expr] return new_src, new_context From db7c6ff7b2d0b888214232bbcf424b6d9050d58f Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Fri, 1 Aug 2025 17:37:45 -0400 Subject: [PATCH 58/79] fixed bugs in which functions and module names would appear in context; in which builtins were overwritten by context; added tokenization of vars --- check50/assertions/rewrite.py | 4 +-- check50/assertions/runtime.py | 57 ++++++++++++++++++++++++----------- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/check50/assertions/rewrite.py b/check50/assertions/rewrite.py index a4f92da..a6683b0 100644 --- a/check50/assertions/rewrite.py +++ b/check50/assertions/rewrite.py @@ -190,9 +190,7 @@ def visit_Call(self, node): self.visit(kw) def visit_Name(self, node): - if not self._in_func_chain: # ignore Names of modules/libraries - self.names.add(node.id) - # 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 6bab7a8..474829e 100644 --- a/check50/assertions/runtime.py +++ b/check50/assertions/runtime.py @@ -1,6 +1,10 @@ from check50 import Failure, Missing, Mismatch import re 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): """ @@ -67,13 +71,27 @@ def check50_assert(src, msg_or_exc=None, cond_type="unknown", left=None, right=N 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()) + # filter out modules, functions, and built-ins + 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, context) + eval_src, eval_context = substitute_expressions(src, filtered_context) # Merge globals with expression context for evaluation eval_globals = caller_globals.copy() @@ -129,18 +147,23 @@ def substitute_expressions(src: str, context: dict) -> tuple[str, dict]: } ``` """ - new_src = src - new_context = {} - - for i, expr in enumerate(sorted(context.keys(), key=len, reverse=True)): - placeholder = f"__expr{i}" - - # Use regex to replace only full matches of expr - # Escape expr if it has special characters (like function calls) - pattern = re.escape(expr) - new_src, count = re.subn(rf'\b{pattern}\b', placeholder, new_src) + tokens = tokenize.generate_tokens(StringIO(src).readline) - if count > 0: - new_context[placeholder] = context[expr] - - return new_src, new_context + new_tokens = [] + new_context = {} + placeholder_map = {} + 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 + new_tokens.append((tok_type, placeholder)) + else: + new_tokens.append((tok_type, tok_string)) + + eval_src = tokenize.untokenize(new_tokens) + return eval_src, new_context From cc54d6cf966a8884b51c0a4a50dc845281ecaf1a Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Mon, 4 Aug 2025 13:44:19 -0400 Subject: [PATCH 59/79] added more documentation and fixed bugs with duplicate expressions and empty contexts --- check50/assertions/rewrite.py | 2 +- check50/assertions/runtime.py | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/check50/assertions/rewrite.py b/check50/assertions/rewrite.py index a6683b0..1b3bee6 100644 --- a/check50/assertions/rewrite.py +++ b/check50/assertions/rewrite.py @@ -47,7 +47,7 @@ def rewrite_enabled(path: str): # ENABLE_CHECK50_ASSERT = 1 ``` - Ignores whitespace. + Ignores whitespace and case. :param path: The path to the file you wish to check. :type path: str diff --git a/check50/assertions/runtime.py b/check50/assertions/runtime.py index 474829e..5b5dedd 100644 --- a/check50/assertions/runtime.py +++ b/check50/assertions/runtime.py @@ -1,5 +1,4 @@ from check50 import Failure, Missing, Mismatch -import re import inspect import tokenize import types, builtins @@ -56,6 +55,9 @@ def check50_assert(src, msg_or_exc=None, cond_type="unknown", left=None, right=N :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 @@ -71,7 +73,9 @@ def check50_assert(src, msg_or_exc=None, cond_type="unknown", left=None, right=N except Exception as e: context[expr_str] = f"[error evaluating: {e}]" - # filter out modules, functions, and built-ins + # 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)) @@ -87,7 +91,7 @@ def is_builtin_name(name): 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 @@ -147,11 +151,12 @@ def substitute_expressions(src: str, context: dict) -> tuple[str, dict]: } ``` """ + # Parse the src into a stream of tokens tokens = tokenize.generate_tokens(StringIO(src).readline) new_tokens = [] new_context = {} - placeholder_map = {} + 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: @@ -161,8 +166,13 @@ def substitute_expressions(src: str, context: dict) -> tuple[str, dict]: 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) From 5d20ff76aa24793d2591332617325109f0cdf4d1 Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Mon, 4 Aug 2025 16:08:02 -0400 Subject: [PATCH 60/79] added flag support --- check50/__main__.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/check50/__main__.py b/check50/__main__.py index a675d6e..24bbdc6 100644 --- a/check50/__main__.py +++ b/check50/__main__.py @@ -334,6 +334,12 @@ 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("-V", "--version", action="version", version=f"%(prog)s {__version__}") @@ -392,7 +398,13 @@ def main(): original_checks_file = (internal.check_dir / config["checks"]).resolve() # If the user has enabled the rewrite feature - if assertions.rewrite_enabled(str(original_checks_file)): + 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) From 36bed3d40db045c977f163cb90595886cc9d4806 Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Tue, 5 Aug 2025 14:10:49 -0400 Subject: [PATCH 61/79] fixed bug in which local functions were not included in the output upon failure --- check50/assertions/runtime.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/check50/assertions/runtime.py b/check50/assertions/runtime.py index 5b5dedd..83c86a9 100644 --- a/check50/assertions/runtime.py +++ b/check50/assertions/runtime.py @@ -101,7 +101,11 @@ def is_builtin_name(name): eval_globals = caller_globals.copy() eval_globals.update(eval_context) - cond = eval(eval_src, eval_globals, 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: From bb798d70c2aa9ccacb287a5fd0c131b9a54fdfc7 Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Tue, 5 Aug 2025 15:43:14 -0400 Subject: [PATCH 62/79] link locale for internationalization --- check50/__main__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/check50/__main__.py b/check50/__main__.py index a675d6e..5957f07 100644 --- a/check50/__main__.py +++ b/check50/__main__.py @@ -27,6 +27,8 @@ 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")) From 72b19363709c0834a8e60a037d06bb7cc07f314f Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Tue, 5 Aug 2025 17:14:40 -0400 Subject: [PATCH 63/79] added support for --https and --ssh --- check50/__main__.py | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/check50/__main__.py b/check50/__main__.py index a675d6e..1b14160 100644 --- a/check50/__main__.py +++ b/check50/__main__.py @@ -259,6 +259,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): @@ -334,6 +348,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__}") @@ -355,7 +381,7 @@ def main(): # If remote, push files to GitHub and await results if not args.local: - commit_hash = lib50.push("check50", internal.slug, internal.CONFIG_LOADER, data={"check50": True})[1] + commit_hash = lib50.push("check50", internal.slug, internal.CONFIG_LOADER, data={"check50": True}, auth_method=args.auth_method)[1] with lib50.ProgressBar("Waiting for results") if "ansi" in args.output else nullcontext(): tag_hash, results = await_results(commit_hash, internal.slug) @@ -392,7 +418,13 @@ def main(): original_checks_file = (internal.check_dir / config["checks"]).resolve() # If the user has enabled the rewrite feature - if assertions.rewrite_enabled(str(original_checks_file)): + 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) From f698cf5bb4423088af5ac079888e1bf0f008ebac Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Wed, 6 Aug 2025 16:42:13 -0400 Subject: [PATCH 64/79] added debug messages --- check50/__main__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/check50/__main__.py b/check50/__main__.py index 1b14160..d0bdf4b 100644 --- a/check50/__main__.py +++ b/check50/__main__.py @@ -14,6 +14,7 @@ import sys import tempfile import time +import traceback import attr import lib50 @@ -381,7 +382,14 @@ def main(): # If remote, push files to GitHub and await results if not args.local: - commit_hash = lib50.push("check50", internal.slug, internal.CONFIG_LOADER, data={"check50": True}, auth_method=args.auth_method)[1] + try: + commit_hash = lib50.push("check50", internal.slug, internal.CONFIG_LOADER, data={"check50": True}, auth_method=args.auth_method)[1] + except lib50.ConnectionError: + LOGGER.debug(traceback.format_exc()) # log the traceback + raise _exceptions.Error(_( + "check50 failed to authenticate your Github account. Try running check50 again with --https or --ssh, " + "or try restarting your codespace. If the problem persists, please email us at sysadmins@cs50.harvard.edu." + )) with lib50.ProgressBar("Waiting for results") if "ansi" in args.output else nullcontext(): tag_hash, results = await_results(commit_hash, internal.slug) From 39da322d9698bbe7cd61a27d51cd7f584defd46f Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Wed, 6 Aug 2025 23:45:34 -0400 Subject: [PATCH 65/79] improve error handling during authentication failure --- check50/__main__.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/check50/__main__.py b/check50/__main__.py index d0bdf4b..7adc73d 100644 --- a/check50/__main__.py +++ b/check50/__main__.py @@ -385,11 +385,16 @@ def main(): try: commit_hash = lib50.push("check50", internal.slug, internal.CONFIG_LOADER, data={"check50": True}, auth_method=args.auth_method)[1] except lib50.ConnectionError: - LOGGER.debug(traceback.format_exc()) # log the traceback - raise _exceptions.Error(_( - "check50 failed to authenticate your Github account. Try running check50 again with --https or --ssh, " - "or try restarting your codespace. If the problem persists, please email us at sysadmins@cs50.harvard.edu." - )) + LOGGER.debug(traceback.format_exc() + if not os.environ.get("CODESPACES"): + raise _exceptions.Error(_( + "check50 failed to authenticate your Github account. Please make sure you are connected to the internet and try again." + )) + except Exception as e: + LOGGER.debug(traceback.format_exc()) + raise _exceptions.Error(_("Sorry, something's wrong, please try again.\n" + "If the problem persists, please visit our status page https://cs50.statuspage.io for more information.")) from e + with lib50.ProgressBar("Waiting for results") if "ansi" in args.output else nullcontext(): tag_hash, results = await_results(commit_hash, internal.slug) From 3c50d812480e7b4532fcba31c8458168b67fc11c Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Wed, 6 Aug 2025 23:56:30 -0400 Subject: [PATCH 66/79] fix syntax error --- check50/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/check50/__main__.py b/check50/__main__.py index 7adc73d..75a2ef7 100644 --- a/check50/__main__.py +++ b/check50/__main__.py @@ -385,8 +385,8 @@ def main(): try: commit_hash = lib50.push("check50", internal.slug, internal.CONFIG_LOADER, data={"check50": True}, auth_method=args.auth_method)[1] except lib50.ConnectionError: - LOGGER.debug(traceback.format_exc() - if not os.environ.get("CODESPACES"): + LOGGER.debug(traceback.format_exc()) + if not os.environ.get("CODESPACES"): raise _exceptions.Error(_( "check50 failed to authenticate your Github account. Please make sure you are connected to the internet and try again." )) From 2250164ae1bedfe0e1b39f90938bca20cc43ffa9 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Thu, 7 Aug 2025 10:05:56 -0400 Subject: [PATCH 67/79] update Mismatch class to serialize expected and actual values correctly; add unit tests for JSON serialization --- check50/_api.py | 2 +- tests/api_tests.py | 104 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/check50/_api.py b/check50/_api.py index f1120e3..e847fb3 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -468,7 +468,7 @@ def _safe_truncate(x, y): super().__init__(rationale=rationale, help=help) - self.payload.update({"expected": expected, "actual": actual}) + self.payload.update({"expected": _raw(expected), "actual": _raw(actual)}) def hidden(failure_rationale): diff --git a/tests/api_tests.py b/tests/api_tests.py index b3b86a9..f75beb0 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() From 52cc80e809112f8003fdfcaadc9e4c703c862d22 Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Thu, 7 Aug 2025 10:47:16 -0400 Subject: [PATCH 68/79] vietnamese support --- check50/locale/vi/LC_MESSAGES/check50.mo | Bin 0 -> 9907 bytes check50/locale/vi/LC_MESSAGES/check50.po | 431 +++++++++++++++++++++++ 2 files changed, 431 insertions(+) create mode 100644 check50/locale/vi/LC_MESSAGES/check50.mo create mode 100644 check50/locale/vi/LC_MESSAGES/check50.po diff --git a/check50/locale/vi/LC_MESSAGES/check50.mo b/check50/locale/vi/LC_MESSAGES/check50.mo new file mode 100644 index 0000000000000000000000000000000000000000..110d8bee4f62b1ffb6b1fe90586b0e5570949e4f GIT binary patch literal 9907 zcmc(k`;#2kRmYn+3HCxD4&m*DYeDb|?~GQ86+0`x^m3%i%8{j2aH4WT&rHuuvpwCT ze(Y+?I2E%+xm>PbizG!{iOSk6&cm@HDM~QeDdVb)%D)6+!w;m2qFf9G{{R%_q9DNM zoZH>gv%4nd7mzJ|xBK2c_uO;7=W*}8_O7>n-r>)2{@%pjFTF*c#(!>lo8x?d$M=9e zZ~^=o@O$6^@UOu4fbV|0N;4grG2Hpa`0)7*G9sDEkD?e$! z`{4DCb0g261m6pu0cXIcz`Ma0!CS%q0N(*l-{3d~`~dhM_}k#GftSJG0bd1AgS)33 zXBYSx@OJP;@aMpP1%DO1;iv3(?*nB&hrwS5n_wM$47?TmQ}9>7e*kxYKLSNh^V3%E zdq7$L0q_&x8E`N7Dk%GYJ4%cEIyei?gJQ2o!1sgepsf2ta1Z$Jpw7qWGCmK=dY=a0 z2|fe96}$}oBKVyg{}SX==bylz1^)$v6 z7!-Sb8vG^jbD-#T5fr<8AG{5G9b5xv5Plu_0}vNDe+`P={tgtLybj8K-T_IL!Tlg6 za=r`h178N8_zA~(3yYrO`48XiIG+Ol5o9Y)6Cn?QF?cihDkygOXRr?52-$uaycawL zejJqX%b=|L3iwU%I*jo(@LS*q!P9I`?D2VU2D}390bc{>!5a}O^F9HJJ-z_ez~@0( z=WoH^0%zH50-nzC6>vAt2N8Y*%s}zOpM%1qzsd36z*(MeA{cK6kAa85KLADV?}IY` zzd+Gz2TqsUo3fFB261s?)$g|rgSkAwTbzW_zg z>y72-1K>S8FN3n)3m~GMzX!#?{|W8{ug4j}=RF`)a2^D4rQ?Hd1Ah<16wdF1Y{5AX z-U)7ie*(S^2H=<2K(JShJD0Vw+Z2>c;<-)~s{y%%Lf zza1b$&J4)boa5jFU>|%6{2nOwJNcWAa}@MJXX;R7Vz2GRrr|ahO&XcB>zRHWH3F|^ zlD^mU+kVqc{U|hk+hk#q#a`W+I@&fXQD)rOGtDS$`zt|U8^>NVHE!6_ znO%3ucyd#%4Z=H5Ikejc*Vq782VwyEc+k@4DXS!=oLg{+bIqCgn2crQYt>C%hyJ(n49&@j@>~sg`N1 z6oSnX^G@P`m6?iEY_c3j%T`Q+E3M3vTa^|flX#v{U0Yu2`TDqIYs<ZVy7vT{EmbWMvlj-8b(`u)Dw%AZod$DSn|-8OM6TueM7 zx0TolduNH`B9vk;jeUXo;r!)3zjEYqvxk}CA%N(%6G5}Yi)8#DSJ!k;duB} zWmo3#zB|Qk{pYvC921(>8Kw{s$IsWvG=>yrq$JT4i!f9#Ok^)N%}?pAOmli zo~M>3z7!jj(xIr=SG#&=GN0HrXfG*_Xbe3Pe1P&gMeHJyL!!E| zLXwxYtb~H!c{y-ee$o$Mkc1o-(1MejQ^@ADA|;muRS>yFb(t{@tRe*M#~v)ql3+yx zPv+oS>v04osmDjtQONjm6jPVfI(dTFS%{a3ZV;H3*BAbWO`qMp@mF4%GV(Mcnvau< zBpXdQMHr}O)Ci2s=!QskgTGp0QVX95#ckO6reX8ja4T)`w*lI&kH${f94fOqHbSlu0}# zpeV-5+p+}Q^EQw8q@(#geH*yw<;+2WFmfQIba8DjmlI|o;O9IxESe0bI30=Agv0%=} zefg*I9zxsZ4t=CkRyh(~ZqTQH$a*w0P3F5XnT2MW#Yo?A!hy--L8zF(Zo5rAg-D(h z#q-2%T|ZH^eoPm5JdUIe*N(PoAC>k!3g@iC#PDtPS^gu8XQxhhk~M0_l8)c19mqOK zZ2?ywKDPhp{Jr}R9XfIN~0f+&QuI-Ba^!*vVkboLTbA zhuVVY_T-JZbAJBbz^~JI-#v9a$VeNt`^c8boC*8-nB28{9|s&6xodXc)Ul(-4v+1z zyFNR05C)ORY6~lU7Ljzfi$d>*;$AmSy!5UtZP)G?&l9ydJJ$|}bPF0ccQpKT>O;ku ztabnf9dH}uygl{Zx3h#Z_0h~X!SGu|?O9IP!DwU6H{F*nj|N}lRco~I4R|;jeAP9L z;RWBMqm7Gw6AmxXu#Prf%FJSS_-!UOyQ7UuLL(sLJbu3pHjpMXu8i6AssgT^8*cFF z((r<@6M3<27Wpzo_~0DJ=KOFzW4goj6&lLX;G&9KTQNO8%NK7Azl)qQ!E-<#p3g-F z!wVA!^g`X?Rg(_anrv|JEZ^F_Xy=sA;LVjD=T14WH)ZCvbECl%VOLDD#ycK2zM2sO z&WEi9(&6*WXRFWV=UxgxS59R0?Pd@@RyH~I+Ed$0W($KSn5MhKF&Fz9{>Qt+H8Qe~ zE@3B9e+8q#V;t;H%3VjUKc-sm1gnb&lrb30$lC_ zPGBXPDv{fq!Ms;ZXZXB1iwN#r+!?b}a?-k7l#I0#)T#r>vsWac z5$i9hC7(9w%ad`{Ub*Yv(A3Dw*UQ48NRF zMW9V%_&kX!)rxXH?9`_otc2nl-&1Vk<#9xohVG0uUeF}>$G*BXHwX!9@EDFRIX%9& z&=O-+Fa4%lS!HVkgQgT~O|lox5|6J2l}Q*~;+TQ~GP|RGUPkO@q^xec(58Lju}A{Zvav^9 zPGd;}a(g|bL$Jq&EjyA)>+;c~)aN((jntLV;Irfw7Et9HzN8O!@bG05+z>XE@R?`<;@6rGZ|J=v8H3}ennzOa-}-aGJ#Ht88bQYiXH;nZDx-6iW?35 zBs(6{PSwYY^m|-L(Lr*HLvTSUoA?UPjK6TIs%%~+8Ew31FyNDZ!Sf3BOVPQ|qQ$8C z*{LwAx=@9~_OfR}%gQKwuYR{3;Y&Tr=Gv&V7IIWk^py{nVPiI`b7HzwVQ>}2nr@>eZ6=rFUYTAn1D((i2=TD1Ji z%c|&+r}W$y3>QuIbp9&m{SqrkgS{Ray{wYPg!DH4mDuJ4WBVR{VSy<^GaYui*S^ku z6G6A>bff1bX{0vYUg>Z1lt+cFjj~ti?I&mC1_AY^1 z`5CSqwyaJ&IlSuNk@0<6k$12v0Wnd@rny(!DYGYIPl84oPN~SxHn$(om#ze`O|}(} zOR*DQK3=t?R>*Bv)KHRwlxKu}bksssy*sgkR+^rT8g69Z>1TUparmMH;}u=&lJ*bh zW+X^CVbR~N(E_#feC3_g^g_a2Yi#rzt)5R2wUX-|V64 FzX664X6pa| literal 0 HcmV?d00001 diff --git a/check50/locale/vi/LC_MESSAGES/check50.po b/check50/locale/vi/LC_MESSAGES/check50.po new file mode 100644 index 0000000..f031927 --- /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" From df0f0272ab18bdcdac79b94e256c6ebc16dbd504 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Thu, 7 Aug 2025 11:03:10 -0400 Subject: [PATCH 69/79] simplified error handling during GitHub authentication --- check50/__main__.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/check50/__main__.py b/check50/__main__.py index 75a2ef7..dc8e5d1 100644 --- a/check50/__main__.py +++ b/check50/__main__.py @@ -14,7 +14,6 @@ import sys import tempfile import time -import traceback import attr import lib50 @@ -382,19 +381,7 @@ def main(): # If remote, push files to GitHub and await results if not args.local: - try: - commit_hash = lib50.push("check50", internal.slug, internal.CONFIG_LOADER, data={"check50": True}, auth_method=args.auth_method)[1] - except lib50.ConnectionError: - LOGGER.debug(traceback.format_exc()) - if not os.environ.get("CODESPACES"): - raise _exceptions.Error(_( - "check50 failed to authenticate your Github account. Please make sure you are connected to the internet and try again." - )) - except Exception as e: - LOGGER.debug(traceback.format_exc()) - raise _exceptions.Error(_("Sorry, something's wrong, please try again.\n" - "If the problem persists, please visit our status page https://cs50.statuspage.io for more information.")) from e - + commit_hash = lib50.push("check50", internal.slug, internal.CONFIG_LOADER, data={"check50": True})[1] with lib50.ProgressBar("Waiting for results") if "ansi" in args.output else nullcontext(): tag_hash, results = await_results(commit_hash, internal.slug) From bcc5bf432563fb6aaa019fc854ada03c8c092d91 Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <112731698+bxngyn@users.noreply.github.com> Date: Thu, 7 Aug 2025 11:20:58 -0400 Subject: [PATCH 70/79] draft of lang contribution guideline --- CONTRIBUTING_LANG.md | 48 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 CONTRIBUTING_LANG.md diff --git a/CONTRIBUTING_LANG.md b/CONTRIBUTING_LANG.md new file mode 100644 index 0000000..394bc4c --- /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) From 225357912552996f640d0f967da80908a257015f Mon Sep 17 00:00:00 2001 From: ivanharvard <144486839+ivanharvard@users.noreply.github.com> Date: Thu, 7 Aug 2025 12:06:12 -0400 Subject: [PATCH 71/79] added _process_list --- check50/_api.py | 47 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/check50/_api.py b/check50/_api.py index e847fb3..421ef28 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -425,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: @@ -500,10 +502,49 @@ 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 "\n".join(map(str, obj)) + return _process_list(obj, str) else: return str(obj) @@ -538,10 +579,6 @@ def normalize(obj): def _raw(s): """Get raw representation of s.""" - - if isinstance(s, list): - s = "\n".join(_raw(item) for item in s) - if s == EOF: return "EOF" elif s == TIMEOUT: From d8610a005b8ca2de8dbca1b8455573570c289b43 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Thu, 7 Aug 2025 21:22:14 -0400 Subject: [PATCH 72/79] increase default truncate length to improve diff visibility --- check50/_api.py | 12 +++++++++--- check50/config.py | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/check50/_api.py b/check50/_api.py index 421ef28..6ed1d4f 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -563,9 +563,15 @@ def normalize(obj): i = index break - # center around diff - start = max(i - (config.truncate_len // 2), 0) - end = min(start + config.truncate_len, len(s)) + # 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] diff --git a/check50/config.py b/check50/config.py index 06921a1..a7c00d1 100644 --- a/check50/config.py +++ b/check50/config.py @@ -13,7 +13,7 @@ class Config: """ def __init__(self): - self.truncate_len = 10 + self.truncate_len = 30 self.dynamic_truncate = True # Create boolean validators for your variables here (if needed): From f682d8f2b208dbb5ef382ac751ba7f61cbc517ce Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 3 Sep 2025 15:12:04 +0200 Subject: [PATCH 73/79] rm attrs --- check50/__main__.py | 4 ++-- check50/runner.py | 22 +++++++++++----------- setup.py | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/check50/__main__.py b/check50/__main__.py index b95ed94..b7d5869 100644 --- a/check50/__main__.py +++ b/check50/__main__.py @@ -1,5 +1,6 @@ import argparse import contextlib +import dataclasses import enum import gettext import importlib @@ -15,7 +16,6 @@ import tempfile import time -import attr import lib50 import packaging import requests @@ -449,7 +449,7 @@ def main(): check_results = check_runner.run(args.target) results = { "slug": internal.slug, - "results": [attr.asdict(result) for result in check_results], + "results": [dataclasses.asdict(result) for result in check_results], "version": __version__ } diff --git a/check50/runner.py b/check50/runner.py index ab321c6..b42a912 100644 --- a/check50/runner.py +++ b/check50/runner.py @@ -1,6 +1,7 @@ import collections from contextlib import contextmanager import concurrent.futures as futures +import dataclasses import functools import inspect import importlib @@ -13,8 +14,8 @@ import sys import tempfile import traceback +import typing -import attr import lib50 from . import internal, _exceptions, __version__ @@ -23,16 +24,16 @@ _check_names = [] -@attr.s(slots=True) +@dataclasses.dataclass class CheckResult: """Record returned by each check""" - name = attr.ib() - description = attr.ib() - passed = attr.ib(default=None) - log = attr.ib(default=attr.Factory(list)) - cause = attr.ib(default=None) - data = attr.ib(default=attr.Factory(dict)) - dependency = attr.ib(default=None) + name: str + description: str + passed: typing.Optional[bool] = None + log: typing.List[str] = dataclasses.field(default_factory=list) + cause: typing.Optional[typing.Dict] = None + data: typing.Dict = dataclasses.field(default_factory=dict) + dependency: typing.Optional[str] = None @classmethod def from_check(cls, check, *args, **kwargs): @@ -48,8 +49,7 @@ def from_check(cls, check, *args, **kwargs): def from_dict(cls, d): """Create a CheckResult given a dict. Dict must contain at least the fields in the CheckResult. Throws a KeyError if not.""" - return cls(**{field.name: d[field.name] for field in attr.fields(cls)}) - + return cls(**{field.name: d[field.name] for field in dataclasses.fields(cls)}) class Timeout(Failure): diff --git a/setup.py b/setup.py index 7c3d64c..ea73ceb 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ message_extractors = { 'check50': [('**.py', 'python', None),], }, - install_requires=["attrs>=18", "beautifulsoup4>=0", "lib50>=3,<4", "packaging", "pexpect>=4.6", "pyyaml>6,<7", "requests>=2.19", "setuptools", "termcolor>=1.1", "jinja2>=2.10"], + install_requires=["beautifulsoup4>=0", "lib50>=3,<4", "packaging", "pexpect>=4.6", "pyyaml>6,<7", "requests>=2.19", "setuptools", "termcolor>=1.1", "jinja2>=2.10"], extras_require = { "develop": ["sphinx", "sphinx-autobuild", "sphinx_rtd_theme"] }, From 386cc8a0c51f50f85ffef8bc0c6a138ad3755d7d Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 3 Sep 2025 15:23:17 +0200 Subject: [PATCH 74/79] python >= 3.10 --- check50/runner.py | 13 ++++++------- setup.py | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/check50/runner.py b/check50/runner.py index b42a912..811f2c0 100644 --- a/check50/runner.py +++ b/check50/runner.py @@ -14,7 +14,6 @@ import sys import tempfile import traceback -import typing import lib50 @@ -24,16 +23,16 @@ _check_names = [] -@dataclasses.dataclass +@dataclasses.dataclass(slots=True) class CheckResult: """Record returned by each check""" name: str description: str - passed: typing.Optional[bool] = None - log: typing.List[str] = dataclasses.field(default_factory=list) - cause: typing.Optional[typing.Dict] = None - data: typing.Dict = dataclasses.field(default_factory=dict) - dependency: typing.Optional[str] = None + passed: bool | None = None + log: list[str] = dataclasses.field(default_factory=list) + cause: dict | None = None + data: dict = dataclasses.field(default_factory=dict) + dependency: str | None = None @classmethod def from_check(cls, check, *args, **kwargs): diff --git a/setup.py b/setup.py index ea73ceb..704f585 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ keywords=["check", "check50"], name="check50", packages=["check50", "check50.renderer", "check50.assertions"], - python_requires=">= 3.8", + python_requires=">= 3.10", entry_points={ "console_scripts": ["check50=check50.__main__:main"] }, From b961866bbf3c589c1e5a689a56d4a9e3e75c1977 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Sun, 4 Jan 2026 12:32:19 -0500 Subject: [PATCH 75/79] update GitHub Actions to use latest versions of checkout, setup-python, and github-script --- .github/workflows/main.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bbab585..9c08ef0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,11 +3,11 @@ jobs: test-and-deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: - python-version: "3.13" + python-version: "3.14" - name: Run tests run: | @@ -35,7 +35,7 @@ jobs: - name: Create Release if: ${{ github.ref == 'refs/heads/main' }} - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: github-token: ${{ github.token }} script: | From 85fc06e91ca24e876b096ef5771a2fd3d600c719 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Sun, 4 Jan 2026 12:36:01 -0500 Subject: [PATCH 76/79] fix: downgrade Python version to 3.13 in GitHub Actions workflow --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9c08ef0..63cfb35 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,7 +7,7 @@ jobs: - uses: actions/setup-python@v6 with: - python-version: "3.14" + python-version: "3.13" - name: Run tests run: | From df5a9fc0bce042c1eded51731529e6c14de5ac2c Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Sun, 4 Jan 2026 12:50:37 -0500 Subject: [PATCH 77/79] update Python version to 3.14 in GitHub Actions workflow --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 63cfb35..9c08ef0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,7 +7,7 @@ jobs: - uses: actions/setup-python@v6 with: - python-version: "3.13" + python-version: "3.14" - name: Run tests run: | From 1c5b8c1870f5df1836ccfc1b9333ca77b0c8f7cc Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Sun, 4 Jan 2026 12:52:25 -0500 Subject: [PATCH 78/79] refactor: update regex pattern syntax and improve code readability --- check50/_simple.py | 2 +- check50/regex.py | 2 +- check50/runner.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/check50/_simple.py b/check50/_simple.py index 9339b05..ca4a188 100644 --- a/check50/_simple.py +++ b/check50/_simple.py @@ -59,7 +59,7 @@ def _compile_check(name, check): if check_name[0].isdigit(): check_name = f"_{check_name}" - if not re.match("\w+", check_name): + if not re.match(r"\w+", check_name): raise CompileError( _("{} is not a valid name for a check; check names should consist only of alphanumeric characters, underscores, and spaces").format(name)) diff --git a/check50/regex.py b/check50/regex.py index 215c65a..a3116d9 100644 --- a/check50/regex.py +++ b/check50/regex.py @@ -2,7 +2,7 @@ def decimal(number): - """ + r""" Create a regular expression to match the number exactly: In case of a positive number:: diff --git a/check50/runner.py b/check50/runner.py index 811f2c0..17e7888 100644 --- a/check50/runner.py +++ b/check50/runner.py @@ -160,7 +160,8 @@ def wrapper(run_root_dir, dependency_state): finally: result.log = _log if len(_log) <= max_log_lines else ["..."] + _log[-max_log_lines:] result.data = _data - return result, state + + return result, state return wrapper return decorator From e9688ba17c07d0be7aba22a177dfaeb508b22751 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Sun, 4 Jan 2026 12:57:25 -0500 Subject: [PATCH 79/79] refactor: enhance attribute handling in multiprocessing by ensuring compatibility with all start methods --- check50/runner.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/check50/runner.py b/check50/runner.py index 17e7888..4cc675e 100644 --- a/check50/runner.py +++ b/check50/runner.py @@ -343,13 +343,8 @@ def __init__(self, check_name, spec, state=None): def _store_attributes(self): """" Store all values from the attributes from run_check.CROSS_PROCESS_ATTRIBUTES on this object, - in case multiprocessing is using spawn as its starting method. + to ensure they are available in child processes regardless of the multiprocessing start method. """ - - # Attributes only need to be passed explicitly to child processes when using spawn - if multiprocessing.get_start_method() != "spawn": - return - self._attribute_values = [eval(name) for name in self.CROSS_PROCESS_ATTRIBUTES] # Replace all unpickle-able values with nothing, assuming they've been set externally, @@ -358,7 +353,7 @@ def _store_attributes(self): for i, value in enumerate(self._attribute_values): try: pickle.dumps(value) - except (pickle.PicklingError, AttributeError): + except (pickle.PicklingError, AttributeError, TypeError): self._attribute_values[i] = None self._attribute_values = tuple(self._attribute_values) @@ -373,7 +368,9 @@ def _set_attributes(self): return for name, val in zip(self.CROSS_PROCESS_ATTRIBUTES, self._attribute_values): - self._set_attribute(name, val) + # Skip None values - these were unpicklable and should be set by module import + if val is not None: + self._set_attribute(name, val) @staticmethod