Skip to content

Commit

Permalink
Merge pull request #175 from cs50/develop
Browse files Browse the repository at this point in the history
v.3.0.6
  • Loading branch information
Kareem Zidane authored Aug 14, 2019
2 parents 9f8d8b0 + c41c1ba commit 6b4507c
Show file tree
Hide file tree
Showing 13 changed files with 78 additions and 61 deletions.
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
language: python
python: 3.6
branches:
except: /^v\.\d\.\d\.\d/
except: /^v\d+\.\d+\.\d+/
addons:
apt:
update: true
Expand Down
1 change: 0 additions & 1 deletion MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
recursive-include check50/locale *
graft check50/renderer/static
graft check50/renderer/templates
1 change: 1 addition & 0 deletions check50/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def _setup_translation():
include,
run,
log, _log,
hidden,
Failure, Mismatch
)

Expand Down
9 changes: 5 additions & 4 deletions check50/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import termcolor

from . import internal, renderer, __version__
from .runner import CheckRunner, CheckResult
from .runner import CheckRunner

lib50.set_local_path(os.environ.get("CHECK50_PATH", "~/.local/share/check50"))

Expand Down Expand Up @@ -132,6 +132,7 @@ def install_dependencies(dependencies, verbose=False):
# Reload sys.path, to find recently installed packages
importlib.reload(site)


def install_translations(config):
"""Add check translations according to ``config`` as a fallback to existing translations"""

Expand Down Expand Up @@ -194,7 +195,7 @@ def await_results(commit_hash, slug, pings=45, sleep=2):
# (otherwise we may not be able to parse results)
return results["tag_hash"], {
"slug": results["check50"]["slug"],
"results": list(map(CheckResult.from_dict, results["check50"]["results"])),
"results": results["check50"]["results"],
"version": results["check50"]["version"]
}

Expand Down Expand Up @@ -360,7 +361,7 @@ def main():

results = {
"slug": SLUG,
"results": check_results,
"results": [attr.asdict(result) for result in check_results],
"version": __version__
}

Expand All @@ -381,7 +382,7 @@ def main():
else:
html = renderer.to_html(**results)
if os.environ.get("CS50_IDE_TYPE"):
subprocess.check_call(["c9", "exec", "rendercheckresults", html])
subprocess.check_call(["c9", "exec", "renderresults", "check50", html])
else:
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html") as html_file:
html_file.write(html)
Expand Down
39 changes: 39 additions & 0 deletions check50/_api.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import hashlib
import functools
import os
import shlex
import shutil
Expand Down Expand Up @@ -189,6 +190,14 @@ def stdin(self, line, prompt=True, timeout=3):
raise Failure(_("expected prompt for input, found none"))
except UnicodeDecodeError:
raise Failure(_("output not valid ASCII text"))

# Consume everything on the output buffer
try:
for _i in range(int(timeout * 10)):
self.process.expect(".+", timeout=0.1)
except (TIMEOUT, EOF):
pass

try:
if line == EOF:
self.process.sendeof()
Expand Down Expand Up @@ -397,6 +406,36 @@ def __init__(self, expected, actual, help=None):
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
accumulated throughout the check and will catch any :class:`check50.Failure`s thrown
during the check, and reraising a new :class:`check50.Failure` with the given ``failure_rationale``.
:param failure_rationale: the rationale that will be displayed to the student if the check fails
:type failure_rationale: str
Exaple usage::
@check50.check()
@check50.hidden("Your program isn't returning the expected result. Try running it on some sample inputs.")
def hidden_check():
check50.run("./foo").stdin("bar").stdout("baz").exit()
"""
def decorator(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
try:
return f(*args, **kwargs)
except Failure:
raise Failure(failure_rationale)
finally:
_log.clear()
return wrapper
return decorator


def _raw(s):
"""Get raw representation of s, truncating if too long."""

Expand Down
2 changes: 1 addition & 1 deletion check50/renderer/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
from ._renderers import to_ansi, to_html, to_json, from_json
from ._renderers import to_ansi, to_html, to_json
52 changes: 16 additions & 36 deletions check50/renderer/_renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,60 +12,40 @@
TEMPLATES = pathlib.Path(pkg_resources.resource_filename("check50.renderer", "templates"))


class Encoder(json.JSONEncoder):
"""Custom class for JSON encoding."""

def default(self, o):
if o == EOF:
return "EOF"
elif isinstance(o, CheckResult):
return attr.asdict(o)
else:
return o.__dict__


def to_html(slug, results, version):
with open(TEMPLATES / "results.html") as f:
content = f.read()

template = jinja2.Template(
content, autoescape=jinja2.select_autoescape(enabled_extensions=("html",)))
html = template.render(slug=slug, checks=list(map(attr.asdict, results)), version=version)
html = template.render(slug=slug, results=results, version=version)

return html


def to_json(slug, results, version):
return json.dumps({"slug": slug, "results": results, "version": version},
cls=Encoder,
indent=4)
return json.dumps({"slug": slug, "results": results, "version": version}, indent=4)


def to_ansi(slug, results, version, log=False):
lines = [termcolor.colored(_("Results for {} generated by check50 v{}").format(slug, version), "white", attrs=["bold"])]
for result in results:
if result.passed:
lines.append(termcolor.colored(f":) {result.description}", "green"))
elif result.passed is None:
lines.append(termcolor.colored(f":| {result.description}", "yellow"))
lines.append(termcolor.colored(f" {result.cause.get('rationale') or _('check skipped')}", "yellow"))
if result.cause.get("error") is not None:
lines.append(f" {result.cause['error']['type']}: {result.cause['error']['value']}")
lines += (f" {line.rstrip()}" for line in result.cause["error"]["traceback"])
if result["passed"]:
lines.append(termcolor.colored(f":) {result['description']}", "green"))
elif result["passed"] is None:
lines.append(termcolor.colored(f":| {result['description']}", "yellow"))
lines.append(termcolor.colored(f" {result['cause'].get('rationale') or _('check skipped')}", "yellow"))
if result["cause"].get("error") is not None:
lines.append(f" {result['cause']['error']['type']}: {result['cause']['error']['value']}")
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:
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"))
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"))
if result["cause"].get("help") is not None:
lines.append(termcolor.colored(f" {result['cause']['help']}", "red"))

if log:
lines += (f" {line}" for line in result.log)
lines += (f" {line}" for line in result["log"])
return "\n".join(lines)


def from_json(json_str):
data = json.loads(json_str)
data["results"] = list(map(CheckResult.from_dict, data["results"]))
return data

2 changes: 1 addition & 1 deletion check50/renderer/templates/results.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<h1>check50</h1>
<h2>{{ slug }}</h2>
<hr>
{% for check in checks %}
{% for check in results %}
{% if check.passed == True %}
<h3 style="color:green">:) {{ check.description }}</h3>
{% elif check.passed == False %}
Expand Down
14 changes: 3 additions & 11 deletions check50/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,13 @@ def _handle_timeout(*args):
signal.signal(signal.SIGALRM, signal.SIG_DFL)


def check(dependency=None, timeout=60, hidden=False):
def check(dependency=None, timeout=60):
"""Mark function as a check.
:param dependency: the check that this check depends on
:type dependency: function
:param timeout: maximum number of seconds the check can run
:type timeout: int
:param hidden: true if cause and log should be hidden from student
:type hidden: bool
When a check depends on another, the former will only run if the latter passes.
Additionally, the dependent check will inherit the filesystem of its dependency.
Expand Down Expand Up @@ -144,7 +142,7 @@ def wrapper(checks_root, dependency_state):
state = check(*args)
except Failure as e:
result.passed = False
result.cause = e.payload if not hidden else {}
result.cause = e.payload
except BaseException as e:
result.passed = None
result.cause = {"rationale": _("check50 ran into an error while running checks!"),
Expand All @@ -154,16 +152,10 @@ def wrapper(checks_root, dependency_state):
"traceback": traceback.format_tb(e.__traceback__),
"data" : e.payload if hasattr(e, "payload") else {}
}}

# log(repr(e))
# for line in traceback.format_tb(e.__traceback__):
# log(line.rstrip())
# log(_("Contact [email protected] with the slug of this check!"))
else:
result.passed = True
finally:
if not hidden:
result.log = _log
result.log = _log
result.data = _data
return result, state
return wrapper
Expand Down
5 changes: 3 additions & 2 deletions docs/source/extension_writer.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ We ship check50 with three extensions because these extensions are core to the m
check50.internal
*******************************

In addition to all the functionality check50 exposes, we expose an extra api for extensions in :code:`check50.internal`. You can find the documentation in :ref:`api`.
In addition to all the functionality check50 exposes, we expose an extra API for extensions in :code:`check50.internal`. You can find the documentation in :ref:`api`.


Example: a JavaScript extension
*******************************
Out of the box check50 does not ship with any JavaScript specific functionality. You can use check50's generic api and run a :code:`.js` file through an interpreter such as :code:`node`: :code:`check50.run('node <student_file.js>')`. But we realize most JavaScript classes are not about writing command-line scripts, and we do need a way to call functions. This is why we wrote a small javascript extension for check50 dubbed check50_js at https://github.com/cs50/check50_js.
Out of the box check50 does not ship with any JavaScript specific functionality. You can use check50's generic API and run a :code:`.js` file through an interpreter such as :code:`node`: :code:`check50.run('node <student_file.js>')`. But we realize most JavaScript classes are not about writing command-line scripts, and we do need a way to call functions. This is why we wrote a small javascript extension for check50 dubbed check50_js at
https://github.com/cs50/check50/tree/sample-extension.


*******************************
Expand Down
7 changes: 5 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from setuptools import find_packages, setup
if __import__("os").name == "nt":
raise RuntimeError("check50 does not support Windows directly. Instead, you should install the Windows Subsystem for Linux (https://docs.microsoft.com/en-us/windows/wsl/install-win10) and then install check50 within that.")

from setuptools import setup

setup(
author="CS50",
Expand Down Expand Up @@ -26,6 +29,6 @@
"console_scripts": ["check50=check50.__main__:main"]
},
url="https://github.com/cs50/check50",
version="3.0.5",
version="3.0.6",
include_package_data=True
)
2 changes: 1 addition & 1 deletion tests/check50_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ def test_default(self):
class TestHiddenCheck(Base):
def test_hidden_check(self):
pexpect.run(f"check50 --dev -o json --output-file foo.json {CHECKS_DIRECTORY}/hidden")
expected = [{'name': 'check', 'description': None, 'passed': False, 'log': [], 'cause': {}, 'data': {}, 'dependency': None}]
expected = [{'name': 'check', 'description': None, 'passed': False, 'log': [], 'cause': {"rationale": "foo", "help": None}, 'data': {}, 'dependency': None}]
with open("foo.json", "r") as f:
self.assertEqual(json.load(f)["results"], expected)

Expand Down
3 changes: 2 additions & 1 deletion tests/checks/hidden/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import check50

@check50.check(hidden=True)
@check50.check()
@check50.hidden("foo")
def check():
check50.log("AHHHHHHHHHHHHHHHHHHH")
raise check50.Failure("AHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH")

0 comments on commit 6b4507c

Please sign in to comment.