├── .github
└── workflows
│ └── main.yml
├── .gitignore
├── .readthedocs.yml
├── LICENSE
├── MANIFEST.in
├── README.md
├── check50
├── __init__.py
├── __main__.py
├── _api.py
├── _exceptions.py
├── _simple.py
├── c.py
├── contextmanagers.py
├── flask.py
├── internal.py
├── py.py
├── regex.py
├── renderer
│ ├── __init__.py
│ ├── _renderers.py
│ └── templates
│ │ └── results.html
└── runner.py
├── docs
├── Makefile
├── requirements.txt
└── source
│ ├── ansi_output.png
│ ├── api.rst
│ ├── check50_user.rst
│ ├── check_writer.rst
│ ├── commit.png
│ ├── conf.py
│ ├── extension_writer.rst
│ ├── html_output.png
│ ├── index.rst
│ ├── json_specification.rst
│ ├── new_file.png
│ ├── new_yaml.png
│ └── repo.png
├── setup.cfg
├── setup.py
└── tests
├── __init__.py
├── __main__.py
├── api_tests.py
├── c_tests.py
├── check50_tests.py
├── checks
├── compile_exit
│ └── .cs50.yaml
├── compile_prompt
│ └── .cs50.yaml
├── compile_std
│ └── .cs50.yaml
├── exists
│ ├── .cs50.yaml
│ └── check.py
├── exit_code
│ ├── error
│ │ ├── .cs50.yaml
│ │ └── check.py
│ ├── failure
│ │ ├── .cs50.yaml
│ │ └── __init__.py
│ └── success
│ │ ├── .cs50.yaml
│ │ └── __init__.py
├── exit_py
│ ├── .cs50.yaml
│ └── check.py
├── hidden
│ ├── .cs50.yaml
│ └── __init__.py
├── internal_directories
│ ├── .cs50.yaml
│ └── __init__.py
├── output
│ ├── .cs50.yaml
│ └── __init__.py
├── payload
│ ├── .cs50.yaml
│ └── __init__.py
├── remote_exception_no_traceback
│ ├── .cs50.yaml
│ └── check.py
├── remote_exception_traceback
│ ├── .cs50.yaml
│ └── check.py
├── stdin_human_readable_py
│ ├── .cs50.yaml
│ └── check.py
├── stdin_multiline
│ ├── .cs50.yaml
│ └── check.py
├── stdin_prompt_py
│ ├── .cs50.yaml
│ └── check.py
├── stdin_py
│ ├── .cs50.yaml
│ └── check.py
├── stdout_py
│ ├── .cs50.yaml
│ └── check.py
├── target
│ ├── .cs50.yaml
│ └── __init__.py
└── unpicklable_attribute
│ ├── .cs50.yaml
│ └── __init__.py
├── flask_tests.py
├── internal_tests.py
├── py_tests.py
├── runner_tests.py
└── simple_tests.py
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | on: push
2 | jobs:
3 | test-and-deploy:
4 | runs-on: ubuntu-latest
5 | steps:
6 | - uses: actions/checkout@v4
7 |
8 | - uses: actions/setup-python@v5
9 | with:
10 | python-version: "3.13"
11 |
12 | - name: Run tests
13 | run: |
14 | pip install babel flask
15 | pip install .
16 | python -m tests
17 |
18 | - name: Install pypa/build
19 | run: python -m pip install build --user
20 |
21 | - name: Build a binary wheel and a source tarball
22 | run: python -m build --sdist --wheel --outdir dist/ .
23 |
24 | - name: Deploy to PyPI
25 | if: ${{ github.ref == 'refs/heads/main' }}
26 | uses: pypa/gh-action-pypi-publish@release/v1
27 | with:
28 | user: __token__
29 | password: ${{ secrets.PYPI_API_TOKEN }}
30 |
31 | - name: Extract program version
32 | id: program_version
33 | run: |
34 | echo ::set-output name=version::$(check50 --version | cut --delimiter ' ' --fields 2)
35 |
36 | - name: Create Release
37 | if: ${{ github.ref == 'refs/heads/main' }}
38 | uses: actions/github-script@v7
39 | with:
40 | github-token: ${{ github.token }}
41 | script: |
42 | github.rest.repos.createRelease({
43 | owner: context.repo.owner,
44 | repo: context.repo.repo,
45 | tag_name: "v${{ steps.program_version.outputs.version }}",
46 | tag_commitish: "${{ github.sha }}"
47 | })
48 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_store
2 |
3 | # Byte-compiled / optimized / DLL files
4 | __pycache__/
5 | *.py[cod]
6 | *$py.class
7 |
8 | # C extensions
9 | *.so
10 |
11 | # Distribution / packaging
12 | .Python
13 | build/
14 | develop-eggs/
15 | dist/
16 | downloads/
17 | eggs/
18 | .eggs/
19 | lib/
20 | lib64/
21 | parts/
22 | sdist/
23 | var/
24 | wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | .hypothesis/
50 | .pytest_cache/
51 |
52 | # Translations
53 | *.mo
54 | *.pot
55 |
56 | # Django stuff:
57 | *.log
58 | local_settings.py
59 | db.sqlite3
60 |
61 | # Flask stuff:
62 | instance/
63 | .webassets-cache
64 |
65 | # Scrapy stuff:
66 | .scrapy
67 |
68 | # Sphinx documentation
69 | docs/build/
70 |
71 | # Jupyter Notebook
72 | .ipynb_checkpoints
73 |
74 | # pyenv
75 | .python-version
76 |
77 | # celery beat schedule file
78 | celerybeat-schedule
79 |
80 | # SageMath parsed files
81 | *.sage.py
82 |
83 | # Environments
84 | .env
85 | .venv
86 | env/
87 | venv/
88 | ENV/
89 | env.bak/
90 | venv.bak/
91 |
92 | # Spyder project settings
93 | .spyderproject
94 | .spyproject
95 |
96 | # Rope project settings
97 | .ropeproject
98 |
99 | # mkdocs documentation
100 | /site
101 |
102 | # mypy
103 | .mypy_cache/
104 |
--------------------------------------------------------------------------------
/.readthedocs.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | build:
3 | os: "ubuntu-22.04"
4 | tools:
5 | python: "3.7"
6 |
7 | python:
8 | install:
9 | - requirements: docs/requirements.txt
10 |
11 | sphinx:
12 | builder: dirhtml
13 |
14 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | recursive-include check50/locale *
2 | graft check50/renderer/templates
3 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # check50
2 |
3 | check50 is a testing tool for checking student code. As a student you can use check50 to check your CS50 problem sets or any other Problem sets for which check50 checks exist. check50 allows teachers to automatically grade code on correctness and to provide automatic feedback while students are coding.
4 |
5 | You can find documentation and instructions for writing your own checks at https://cs50.readthedocs.io/projects/check50/.
6 |
7 |
--------------------------------------------------------------------------------
/check50/__init__.py:
--------------------------------------------------------------------------------
1 | def _set_version():
2 | """Set check50 __version__"""
3 | global __version__
4 | from importlib.metadata import PackageNotFoundError, version
5 | import os
6 | # https://stackoverflow.com/questions/17583443/what-is-the-correct-way-to-share-package-version-with-setup-py-and-the-package
7 | try:
8 | __version__ = version("check50")
9 | except PackageNotFoundError:
10 | __version__ = "UNKNOWN"
11 |
12 |
13 | def _setup_translation():
14 | import gettext
15 | from importlib.resources import files
16 | global _translation
17 | _translation = gettext.translation(
18 | "check50", str(files("check50").joinpath("locale")), fallback=True)
19 | _translation.install()
20 |
21 |
22 |
23 | # Encapsulated inside a function so their local variables/imports aren't seen by autocompleters
24 | _set_version()
25 | _setup_translation()
26 |
27 | from ._api import (
28 | import_checks,
29 | data, _data,
30 | exists,
31 | hash,
32 | include,
33 | run,
34 | log, _log,
35 | hidden,
36 | Failure, Mismatch, Missing
37 | )
38 |
39 |
40 | from . import regex
41 | from .runner import check
42 | from pexpect import EOF
43 |
44 | __all__ = ["import_checks", "data", "exists", "hash", "include", "regex",
45 | "run", "log", "Failure", "Mismatch", "Missing", "check", "EOF"]
46 |
--------------------------------------------------------------------------------
/check50/__main__.py:
--------------------------------------------------------------------------------
1 | import argparse
2 | import contextlib
3 | import enum
4 | import gettext
5 | import importlib
6 | import inspect
7 | import itertools
8 | from json import JSONDecodeError
9 | import logging
10 | import os
11 | import platform
12 | import site
13 | from pathlib import Path
14 | import shutil
15 | import signal
16 | import subprocess
17 | import sys
18 | import tempfile
19 | import time
20 |
21 | import attr
22 | import lib50
23 | import requests
24 | import termcolor
25 |
26 | from . import _exceptions, internal, renderer, __version__
27 | from .contextmanagers import nullcontext
28 | from .runner import CheckRunner
29 |
30 | LOGGER = logging.getLogger("check50")
31 |
32 | lib50.set_local_path(os.environ.get("CHECK50_PATH", "~/.local/share/check50"))
33 |
34 |
35 | class LogLevel(enum.IntEnum):
36 | DEBUG = logging.DEBUG
37 | INFO = logging.INFO
38 | WARNING = logging.WARNING
39 | ERROR = logging.ERROR
40 |
41 |
42 | class ColoredFormatter(logging.Formatter):
43 | COLORS = {
44 | "ERROR": "red",
45 | "WARNING": "yellow",
46 | "DEBUG": "cyan",
47 | "INFO": "magenta",
48 | }
49 |
50 | def __init__(self, fmt, use_color=True):
51 | super().__init__(fmt=fmt)
52 | self.use_color = use_color
53 |
54 | def format(self, record):
55 | msg = super().format(record)
56 | return msg if not self.use_color else termcolor.colored(msg, getattr(record, "color", self.COLORS.get(record.levelname)))
57 |
58 |
59 | _exceptions.ExceptHook.initialize()
60 |
61 |
62 | def install_dependencies(dependencies):
63 | """Install all packages in dependency list via pip."""
64 | if not dependencies:
65 | return
66 |
67 | with tempfile.TemporaryDirectory() as req_dir:
68 | req_file = Path(req_dir) / "requirements.txt"
69 |
70 | with open(req_file, "w") as f:
71 | for dependency in dependencies:
72 | f.write(f"{dependency}\n")
73 |
74 | pip = [sys.executable or "python3", "-m", "pip", "install", "-r", req_file]
75 | # Unless we are in a virtualenv, we need --user
76 | if sys.base_prefix == sys.prefix and not hasattr(sys, "real_prefix"):
77 | pip.append("--user")
78 |
79 | try:
80 | output = subprocess.check_output(pip, stderr=subprocess.STDOUT)
81 | except subprocess.CalledProcessError:
82 | raise _exceptions.Error(_("failed to install dependencies"))
83 |
84 | LOGGER.info(output)
85 |
86 | # Reload sys.path, to find recently installed packages
87 | importlib.reload(site)
88 |
89 |
90 | def install_translations(config):
91 | """Add check translations according to ``config`` as a fallback to existing translations"""
92 |
93 | if not config:
94 | return
95 |
96 | from . import _translation
97 | checks_translation = gettext.translation(domain=config["domain"],
98 | localedir=internal.check_dir / config["localedir"],
99 | fallback=True)
100 | _translation.add_fallback(checks_translation)
101 |
102 |
103 | def compile_checks(checks, prompt=False):
104 | # Prompt to replace __init__.py (compile destination)
105 | if prompt and os.path.exists(internal.check_dir / "__init__.py"):
106 | if not internal._yes_no_prompt("check50 will compile the YAML checks to __init__.py, are you sure you want to overwrite its contents?"):
107 | raise Error("Aborting: could not overwrite to __init__.py")
108 |
109 | # Compile simple checks
110 | with open(internal.check_dir / "__init__.py", "w") as f:
111 | f.write(simple.compile(checks))
112 |
113 | return "__init__.py"
114 |
115 |
116 | def setup_logging(level):
117 | """
118 | Sets up logging for lib50.
119 | level 'info' logs all git commands run to stderr
120 | level 'debug' logs all git commands and their output to stderr
121 | """
122 |
123 | for logger in (logging.getLogger("lib50"), LOGGER):
124 | # Set verbosity level on the lib50 logger
125 | logger.setLevel(level.upper())
126 |
127 | handler = logging.StreamHandler(sys.stderr)
128 | handler.setFormatter(ColoredFormatter("(%(levelname)s) %(message)s", use_color=sys.stderr.isatty()))
129 |
130 | # Direct all logs to sys.stderr
131 | logger.addHandler(handler)
132 |
133 | # Don't animate the progressbar if LogLevel is either info or debug
134 | lib50.ProgressBar.DISABLED = logger.level < LogLevel.WARNING
135 |
136 |
137 | def await_results(commit_hash, slug, pings=45, sleep=2):
138 | """
139 | Ping {url} until it returns a results payload, timing out after
140 | {pings} pings and waiting {sleep} seconds between pings.
141 | """
142 |
143 | try:
144 | for _i in range(pings):
145 | # Query for check results.
146 | res = requests.get(f"https://submit.cs50.io/api/results/check50", params={"commit_hash": commit_hash, "slug": slug})
147 | results = res.json()
148 |
149 | if res.status_code not in [404, 200]:
150 | raise _exceptions.RemoteCheckError(results)
151 |
152 | if res.status_code == 200 and results["received_at"] is not None:
153 | break
154 | time.sleep(sleep)
155 | else:
156 | # Terminate if no response
157 | raise _exceptions.Error(
158 | _("check50 is taking longer than normal!\n"
159 | "See https://submit.cs50.io/check50/{} for more detail").format(commit_hash))
160 |
161 | except JSONDecodeError:
162 | # Invalid JSON object received from submit.cs50.io
163 | raise _exceptions.Error(
164 | _("Sorry, something's wrong, please try again.\n"
165 | "If the problem persists, please visit our status page https://cs50.statuspage.io for more information."))
166 |
167 | if not results["check50"]:
168 | raise _exceptions.RemoteCheckError(results)
169 |
170 | if "error" in results["check50"]:
171 | raise _exceptions.RemoteCheckError(results["check50"])
172 |
173 | # TODO: Should probably check payload["version"] here to make sure major version is same as __version__
174 | # (otherwise we may not be able to parse results)
175 | return results["tag_hash"], {
176 | "slug": results["check50"]["slug"],
177 | "results": results["check50"]["results"],
178 | "version": results["check50"]["version"]
179 | }
180 |
181 |
182 | class LogoutAction(argparse.Action):
183 | """Hook into argparse to allow a logout flag"""
184 |
185 | def __init__(self, option_strings, dest=argparse.SUPPRESS, default=argparse.SUPPRESS, help=_("logout of check50")):
186 | super().__init__(option_strings, dest=dest, nargs=0, default=default, help=help)
187 |
188 | def __call__(self, parser, namespace, values, option_string=None):
189 | try:
190 | lib50.logout()
191 | except lib50.Error:
192 | raise _exceptions.Error(_("failed to logout"))
193 | else:
194 | termcolor.cprint(_("logged out successfully"), "green")
195 | parser.exit()
196 |
197 |
198 | def raise_invalid_slug(slug, offline=False):
199 | """Raise an error signalling slug is invalid for check50."""
200 | msg = _("Could not find checks for {}.").format(slug)
201 |
202 | similar_slugs = lib50.get_local_slugs("check50", similar_to=slug)[:3]
203 | if similar_slugs:
204 | msg += _(" Did you mean:")
205 | for similar_slug in similar_slugs:
206 | msg += f"\n {similar_slug}"
207 | msg += _("\nDo refer back to the problem specification if unsure.")
208 |
209 | if offline:
210 | msg += _("\nIf you are confident the slug is correct and you have an internet connection," \
211 | " try running without --offline.")
212 |
213 | raise _exceptions.Error(msg)
214 |
215 |
216 | def process_args(args):
217 | """Validate arguments and apply defaults that are dependent on other arguments"""
218 |
219 | # dev implies offline, verbose, and log level "INFO" if not overwritten
220 | if args.dev:
221 | args.offline = True
222 | if "ansi" in args.output:
223 | args.ansi_log = True
224 |
225 | if not args.log_level:
226 | args.log_level = "info"
227 |
228 | # offline implies local
229 | if args.offline:
230 | args.no_install_dependencies = True
231 | args.no_download_checks = True
232 | args.local = True
233 |
234 | if not args.log_level:
235 | args.log_level = "warning"
236 |
237 | # Setup logging for lib50
238 | setup_logging(args.log_level)
239 |
240 | # Warning in case of running remotely with no_download_checks or no_install_dependencies set
241 | if not args.local:
242 | useless_args = []
243 | if args.no_download_checks:
244 | useless_args.append("--no-downloads-checks")
245 | if args.no_install_dependencies:
246 | useless_args.append("--no-install-dependencies")
247 |
248 | if useless_args:
249 | LOGGER.warning(_("You should always use --local when using: {}").format(", ".join(useless_args)))
250 |
251 | # Filter out any duplicates from args.output
252 | seen_output = []
253 | for output in args.output:
254 | if output in seen_output:
255 | LOGGER.warning(_("Duplicate output format specified: {}").format(output))
256 | else:
257 | seen_output.append(output)
258 |
259 | args.output = seen_output
260 |
261 | if args.ansi_log and "ansi" not in seen_output:
262 | LOGGER.warning(_("--ansi-log has no effect when ansi is not among the output formats"))
263 |
264 |
265 | class LoggerWriter:
266 | def __init__(self, logger, level):
267 | self.logger = logger
268 | self.level = level
269 |
270 | def write(self, message):
271 | if message != "\n":
272 | self.logger.log(self.level, message)
273 |
274 | def flush(self):
275 | pass
276 |
277 |
278 | def main():
279 | parser = argparse.ArgumentParser(prog="check50", formatter_class=argparse.RawTextHelpFormatter)
280 |
281 | parser.add_argument("slug", help=_("prescribed identifier of work to check"))
282 | parser.add_argument("-d", "--dev",
283 | action="store_true",
284 | help=_("run check50 in development mode (implies --offline, and --log-level info).\n"
285 | "causes slug to be interpreted as a literal path to a checks package."))
286 | parser.add_argument("--offline",
287 | action="store_true",
288 | help=_("run checks completely offline (implies --local, --no-download-checks and --no-install-dependencies)"))
289 | parser.add_argument("-l", "--local",
290 | action="store_true",
291 | help=_("run checks locally instead of uploading to cs50"))
292 | parser.add_argument("-o", "--output",
293 | action="store",
294 | nargs="+",
295 | default=["ansi", "html"],
296 | choices=["ansi", "json", "html"],
297 | help=_("format of check results"))
298 | parser.add_argument("--target",
299 | action="store",
300 | nargs="+",
301 | help=_("target specific checks to run"))
302 | parser.add_argument("--output-file",
303 | action="store",
304 | metavar="FILE",
305 | help=_("file to write output to"))
306 | parser.add_argument("--log-level",
307 | action="store",
308 | choices=[level.name.lower() for level in LogLevel],
309 | type=str.lower,
310 | help=_('warning: displays usage warnings.'
311 | '\ninfo: adds all commands run, any locally installed dependencies and print messages.'
312 | '\ndebug: adds the output of all commands run.'))
313 | parser.add_argument("--ansi-log",
314 | action="store_true",
315 | help=_("display log in ansi output mode"))
316 | parser.add_argument("--no-download-checks",
317 | action="store_true",
318 | help=_("do not download checks, but use previously downloaded checks instead (only works with --local)"))
319 | parser.add_argument("--no-install-dependencies",
320 | action="store_true",
321 | help=_("do not install dependencies (only works with --local)"))
322 | parser.add_argument("-V", "--version",
323 | action="version",
324 | version=f"%(prog)s {__version__}")
325 | parser.add_argument("--logout", action=LogoutAction)
326 |
327 | args = parser.parse_args()
328 |
329 | internal.slug = args.slug
330 |
331 | # Validate arguments and apply defaults
332 | process_args(args)
333 |
334 | # Set excepthook
335 | _exceptions.ExceptHook.initialize(args.output, args.output_file)
336 |
337 | # If remote, push files to GitHub and await results
338 | if not args.local:
339 | commit_hash = lib50.push("check50", internal.slug, internal.CONFIG_LOADER, data={"check50": True})[1]
340 | with lib50.ProgressBar("Waiting for results") if "ansi" in args.output else nullcontext():
341 | tag_hash, results = await_results(commit_hash, internal.slug)
342 |
343 | # Otherwise run checks locally
344 | else:
345 | with lib50.ProgressBar("Checking") if "ansi" in args.output else nullcontext():
346 | # If developing, assume slug is a path to check_dir
347 | if args.dev:
348 | internal.check_dir = Path(internal.slug).expanduser().resolve()
349 | if not internal.check_dir.is_dir():
350 | raise _exceptions.Error(_("{} is not a directory").format(internal.check_dir))
351 | # Otherwise have lib50 create a local copy of slug
352 | else:
353 | try:
354 | internal.check_dir = lib50.local(internal.slug, offline=args.no_download_checks)
355 | except lib50.ConnectionError:
356 | raise _exceptions.Error(_("check50 could not retrieve checks from GitHub. Try running check50 again with --offline.").format(internal.slug))
357 | except lib50.InvalidSlugError:
358 | raise_invalid_slug(internal.slug, offline=args.no_download_checks)
359 |
360 | # Load config
361 | config = internal.load_config(internal.check_dir)
362 |
363 | # Compile local checks if necessary
364 | if isinstance(config["checks"], dict):
365 | config["checks"] = internal.compile_checks(config["checks"], prompt=args.dev)
366 |
367 | install_translations(config["translations"])
368 |
369 | if not args.no_install_dependencies:
370 | install_dependencies(config["dependencies"])
371 |
372 | checks_file = (internal.check_dir / config["checks"]).resolve()
373 |
374 | # Have lib50 decide which files to include
375 | included_files = lib50.files(config.get("files"))[0]
376 |
377 | # Create a working_area (temp dir) named - with all included student files
378 | with CheckRunner(checks_file, included_files) as check_runner, \
379 | contextlib.redirect_stdout(LoggerWriter(LOGGER, logging.NOTSET)), \
380 | contextlib.redirect_stderr(LoggerWriter(LOGGER, logging.NOTSET)):
381 |
382 | check_results = check_runner.run(args.target)
383 | results = {
384 | "slug": internal.slug,
385 | "results": [attr.asdict(result) for result in check_results],
386 | "version": __version__
387 | }
388 |
389 | LOGGER.debug(results)
390 |
391 | # Render output
392 | file_manager = open(args.output_file, "w") if args.output_file else nullcontext(sys.stdout)
393 | with file_manager as output_file:
394 | for output in args.output:
395 | if output == "json":
396 | output_file.write(renderer.to_json(**results))
397 | output_file.write("\n")
398 | elif output == "ansi":
399 | output_file.write(renderer.to_ansi(**results, _log=args.ansi_log))
400 | output_file.write("\n")
401 | elif output == "html":
402 | if os.environ.get("CS50_IDE_TYPE") and args.local:
403 | html = renderer.to_html(**results)
404 | subprocess.check_call(["c9", "exec", "renderresults", "check50", html])
405 | else:
406 | if args.local:
407 | html = renderer.to_html(**results)
408 | with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html") as html_file:
409 | html_file.write(html)
410 |
411 | if "microsoft-standard" in platform.uname().release:
412 | stream = os.popen(f"wslpath -m {html_file.name}")
413 | wsl_path = stream.read().strip()
414 | url = f"file://{wsl_path}"
415 | else:
416 | url = f"file://{html_file.name}"
417 | else:
418 | url = f"https://submit.cs50.io/check50/{tag_hash}"
419 |
420 | termcolor.cprint(_("To see more detailed results go to {}").format(url), "white", attrs=["bold"])
421 |
422 | sys.exit(should_fail(results))
423 |
424 | def should_fail(results):
425 | return "error" in results or any(not result["passed"] for result in results["results"])
426 |
427 |
428 | if __name__ == "__main__":
429 | main()
430 |
--------------------------------------------------------------------------------
/check50/_api.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | import functools
3 | import numbers
4 | import os
5 | import re
6 | import shlex
7 | import shutil
8 | import signal
9 | import sys
10 | import time
11 |
12 | import pexpect
13 | from pexpect.exceptions import EOF, TIMEOUT
14 |
15 | from . import internal, regex
16 |
17 | _log = []
18 | internal.register.before_every(_log.clear)
19 |
20 |
21 | def log(line):
22 | """
23 | Add to check log
24 |
25 | :param line: line to be added to the check log
26 | :type line: str
27 |
28 | The check log is student-visible via the ``--log`` flag to ``check50``.
29 | """
30 | _log.append(line.replace("\n", "\\n"))
31 |
32 |
33 | _data = {}
34 | internal.register.before_every(_data.clear)
35 |
36 |
37 | def data(**kwargs):
38 | """
39 | Add data to the check payload
40 |
41 | :params kwargs: key/value mappings to be added to the check payload
42 |
43 | Example usage::
44 |
45 | check50.data(time=7.3, mem=23)
46 |
47 | """
48 |
49 | _data.update(kwargs)
50 |
51 |
52 | def include(*paths):
53 | """
54 | Copy files/directories from the check directory (:data:`check50.internal.check_dir`),
55 | to the current directory
56 |
57 | :params paths: files/directories to be copied
58 |
59 | Example usage::
60 |
61 | check50.include("foo.txt", "bar.txt")
62 | assert os.path.exists("foo.txt") and os.path.exists("bar.txt")
63 |
64 | """
65 | cwd = os.getcwd()
66 | for path in paths:
67 | _copy((internal.check_dir / path).resolve(), cwd)
68 |
69 |
70 | def hash(file):
71 | """
72 | Hashes file using SHA-256.
73 |
74 | :param file: name of file to be hashed
75 | :type file: str
76 | :rtype: str
77 | :raises check50.Failure: if ``file`` does not exist
78 |
79 | """
80 |
81 | exists(file)
82 | log(_("hashing {}...").format(file))
83 |
84 | # https://stackoverflow.com/a/22058673
85 | with open(file, "rb") as f:
86 | sha256 = hashlib.sha256()
87 | for block in iter(lambda: f.read(65536), b""):
88 | sha256.update(block)
89 | return sha256.hexdigest()
90 |
91 |
92 | def exists(*paths):
93 | """
94 | Assert that all given paths exist.
95 |
96 | :params paths: files/directories to be checked for existence
97 | :raises check50.Failure: if any ``path in paths`` does not exist
98 |
99 | Example usage::
100 |
101 | check50.exists("foo.c", "foo.h")
102 |
103 | """
104 | for path in paths:
105 | log(_("checking that {} exists...").format(path))
106 | if not os.path.exists(path):
107 | raise Failure(_("{} not found").format(path))
108 |
109 |
110 | def import_checks(path):
111 | """
112 | Import checks module given relative path.
113 |
114 | :param path: relative path from which to import checks module
115 | :type path: str
116 | :returns: the imported module
117 | :raises FileNotFoundError: if ``path / .check50.yaml`` does not exist
118 | :raises yaml.YAMLError: if ``path / .check50.yaml`` is not a valid YAML file
119 |
120 | This function is particularly useful when a set of checks logically extends
121 | another, as is often the case in CS50's own problems that have a "less comfy"
122 | and "more comfy" version. The "more comfy" version can include all of the
123 | "less comfy" checks like so::
124 |
125 | less = check50.import_checks("../less")
126 | from less import *
127 |
128 | .. note::
129 | the ``__name__`` of the imported module is given by the basename
130 | of the specified path (``less`` in the above example).
131 |
132 | """
133 | dir = internal.check_dir / path
134 | file = internal.load_config(dir)["checks"]
135 | mod = internal.import_file(dir.name, (dir / file).resolve())
136 | sys.modules[dir.name] = mod
137 | return mod
138 |
139 |
140 |
141 | class run:
142 | """
143 | Run a command.
144 |
145 | :param command: command to be run
146 | :param env: environment in which to run command
147 | :type command: str
148 | :type env: dict
149 |
150 | By default, the command will be run using the same environment as ``check50``,
151 | these mappings may be overridden via the ``env`` parameter::
152 |
153 | check50.run("./foo").stdin("foo").stdout("bar").exit(0)
154 | check50.run("./foo", env={ "HOME": "/" }).stdin("foo").stdout("bar").exit(0)
155 |
156 | """
157 |
158 | def __init__(self, command, env={}):
159 | log(_("running {}...").format(command))
160 |
161 | full_env = os.environ.copy()
162 | full_env.update(env)
163 |
164 | # Workaround for OSX pexpect bug http://pexpect.readthedocs.io/en/stable/commonissues.html#truncated-output-just-before-child-exits
165 | # Workaround from https://github.com/pexpect/pexpect/issues/373
166 | command = "bash -c {}".format(shlex.quote(command))
167 | self.process = pexpect.spawn(command, encoding="utf-8", echo=False, env=full_env)
168 |
169 | def stdin(self, line, str_line=None, prompt=True, timeout=3):
170 | """
171 | Send line to stdin, optionally expect a prompt.
172 |
173 | :param line: line to be send to stdin
174 | :type line: str
175 | :param str_line: what will be displayed as the delivered input, a human \
176 | readable form of ``line``
177 | :type str_line: str
178 | :param prompt: boolean indicating whether a prompt is expected, if True absorbs \
179 | all of stdout before inserting line into stdin and raises \
180 | :class:`check50.Failure` if stdout is empty
181 | :type prompt: bool
182 | :param timeout: maximum number of seconds to wait for prompt
183 | :type timeout: int / float
184 | :raises check50.Failure: if ``prompt`` is set to True and no prompt is given
185 |
186 | """
187 | if str_line is None:
188 | str_line = line
189 |
190 | if line == EOF:
191 | log("sending EOF...")
192 | else:
193 | log(_("sending input {}...").format(str_line))
194 |
195 | if prompt:
196 | try:
197 | self.process.expect(".+", timeout=timeout)
198 | except (TIMEOUT, EOF):
199 | raise Failure(_("expected prompt for input, found none"))
200 | except UnicodeDecodeError:
201 | raise Failure(_("output not valid ASCII text"))
202 |
203 | # Consume everything on the output buffer
204 | try:
205 | for _i in range(int(timeout * 10)):
206 | self.process.expect(".+", timeout=0.1)
207 | except (TIMEOUT, EOF):
208 | pass
209 |
210 | try:
211 | if line == EOF:
212 | self.process.sendeof()
213 | else:
214 | self.process.sendline(line)
215 | except OSError:
216 | pass
217 | return self
218 |
219 | def stdout(self, output=None, str_output=None, regex=True, timeout=3, show_timeout=False):
220 | """
221 | Retrieve all output from stdout until timeout (3 sec by default). If ``output``
222 | is None, ``stdout`` returns all of the stdout outputted by the process, else
223 | it returns ``self``.
224 |
225 | :param output: optional output to be expected from stdout, raises \
226 | :class:`check50.Failure` if no match \
227 | In case output is a float or int, the check50.number_regex \
228 | is used to match just that number". \
229 | In case output is a stream its contents are used via output.read().
230 | :type output: str, int, float, stream
231 | :param str_output: what will be displayed as expected output, a human \
232 | readable form of ``output``
233 | :type str_output: str
234 | :param regex: flag indicating whether ``output`` should be treated as a regex
235 | :type regex: bool
236 | :param timeout: maximum number of seconds to wait for ``output``
237 | :type timeout: int / float
238 | :param show_timeout: flag indicating whether the timeout in seconds \
239 | should be displayed when a timeout occurs
240 | :type show_timeout: bool
241 | :raises check50.Mismatch: if ``output`` is specified and nothing that the \
242 | process outputs matches it
243 | :raises check50.Failure: if process times out or if it outputs invalid UTF-8 text.
244 |
245 | Example usage::
246 |
247 | check50.run("./hello").stdout("[Hh]ello, world!?", "hello, world").exit()
248 |
249 | output = check50.run("./hello").stdout()
250 | if not re.match("[Hh]ello, world!?", output):
251 | raise check50.Mismatch("hello, world", output)
252 | """
253 | if output is None:
254 | self._wait(timeout)
255 | return self.process.before.replace("\r\n", "\n").lstrip("\n")
256 |
257 | # In case output is a stream (file-like object), read from it
258 | try:
259 | output = output.read()
260 | except AttributeError:
261 | pass
262 |
263 | if str_output is None:
264 | str_output = str(output)
265 |
266 | # In case output is an int/float, use a regex to match exactly that int/float
267 | if isinstance(output, numbers.Number):
268 | regex = True
269 | output = globals()["regex"].decimal(output)
270 |
271 | expect = self.process.expect if regex else self.process.expect_exact
272 |
273 | if output == EOF:
274 | log(_("checking for EOF..."))
275 | else:
276 | output = str(output).replace("\n", "\r\n")
277 | log(_("checking for output \"{}\"...").format(str_output))
278 |
279 | try:
280 | expect(output, timeout=timeout)
281 | except EOF:
282 | result = self.process.before + self.process.buffer
283 | if self.process.after != EOF:
284 | result += self.process.after
285 | raise Mismatch(str_output, result.replace("\r\n", "\n"))
286 | except TIMEOUT:
287 | if show_timeout:
288 | raise Missing(str_output, self.process.before,
289 | help=_("check50 waited {} seconds for the output of the program").format(timeout))
290 | raise Missing(str_output, self.process.before)
291 | except UnicodeDecodeError:
292 | raise Failure(_("output not valid ASCII text"))
293 | except Exception:
294 | raise Failure(_("check50 could not verify output"))
295 |
296 | # If we expected EOF and we still got output, report an error.
297 | if output == EOF and self.process.before:
298 | raise Mismatch(EOF, self.process.before.replace("\r\n", "\n"))
299 |
300 | return self
301 |
302 | def reject(self, timeout=1):
303 | """
304 | Check that the process survives for timeout. Useful for checking whether program is waiting on input.
305 |
306 | :param timeout: number of seconds to wait
307 | :type timeout: int / float
308 | :raises check50.Failure: if process ends before ``timeout``
309 |
310 | """
311 | log(_("checking that input was rejected..."))
312 | try:
313 | self._wait(timeout)
314 | except Failure as e:
315 | if not isinstance(e.__cause__, TIMEOUT):
316 | raise
317 | else:
318 | raise Failure(_("expected program to reject input, but it did not"))
319 | return self
320 |
321 | def exit(self, code=None, timeout=5):
322 | """
323 | Wait for process to exit or until timeout (5 sec by default) and asserts
324 | that process exits with ``code``. If ``code`` is ``None``, returns the code
325 | the process exited with.
326 |
327 | ..note:: In order to ensure that spawned child processes do not outlive the check that spawned them, it is good practice to call either method (with no arguments if the exit code doesn't matter) or ``.kill()`` on every spawned process.
328 |
329 | :param code: code to assert process exits with
330 | :type code: int
331 | :param timeout: maximum number of seconds to wait for the program to end
332 | :type timeout: int / float
333 | :raises check50.Failure: if ``code`` is given and does not match the actual exitcode within ``timeout``
334 |
335 | Example usage::
336 |
337 | check50.run("./hello").exit(0)
338 |
339 | code = check50.run("./hello").exit()
340 | if code != 0:
341 | raise check50.Failure(f"expected exit code 0, not {code}")
342 |
343 |
344 | """
345 | self._wait(timeout)
346 |
347 | if code is None:
348 | return self.exitcode
349 |
350 | log(_("checking that program exited with status {}...").format(code))
351 | if self.exitcode != code:
352 | raise Failure(_("expected exit code {}, not {}").format(code, self.exitcode))
353 | return self
354 |
355 | def kill(self):
356 | """Kill the process.
357 |
358 | Child will first be sent a ``SIGHUP``, followed by a ``SIGINT`` and
359 | finally a ``SIGKILL`` if it ignores the first two."""
360 | self.process.close(force=True)
361 | return self
362 |
363 | def _wait(self, timeout=5):
364 | try:
365 | self.process.expect(EOF, timeout=timeout)
366 | except TIMEOUT:
367 | raise Failure(_("timed out while waiting for program to exit")) from TIMEOUT(timeout)
368 | except UnicodeDecodeError:
369 | raise Failure(_("output not valid ASCII text"))
370 |
371 | self.kill()
372 |
373 | if self.process.signalstatus == signal.SIGSEGV:
374 | raise Failure(_("failed to execute program due to segmentation fault"))
375 |
376 | self.exitcode = self.process.exitstatus
377 | return self
378 |
379 |
380 | class Failure(Exception):
381 | """
382 | Exception signifying check failure.
383 |
384 | :param rationale: message to be displayed capturing why the check failed
385 | :type rationale: str
386 | :param help: optional help message to be displayed
387 | :type help: str
388 |
389 | Example usage::
390 |
391 | out = check50.run("./cash").stdin("4.2").stdout()
392 | if 10 not in out:
393 | help = None
394 | if 11 in out:
395 | help = "did you forget to round your result?"
396 | raise check50.Failure("Expected a different result", help=help)
397 | """
398 |
399 | def __init__(self, rationale, help=None):
400 | self.payload = {"rationale": rationale, "help": help}
401 |
402 | def __str__(self):
403 | return self.payload["rationale"]
404 |
405 |
406 | class Missing(Failure):
407 | """
408 | Exception signifying check failure due to an item missing from a collection.
409 | This is typically a specific substring in a longer string, for instance the contents of stdout.
410 |
411 | :param item: the expected item / substring
412 | :param collection: the collection / string
413 | :param help: optional help message to be displayed
414 | :type help: str
415 |
416 | Example usage::
417 |
418 | actual = check50.run("./fibonacci 5").stdout()
419 |
420 | if "5" not in actual and "3" in actual:
421 | help = "Be sure to start the sequence at 1"
422 | raise check50.Missing("5", actual, help=help)
423 |
424 | """
425 |
426 | def __init__(self, missing_item, collection, help=None):
427 | super().__init__(rationale=_("Did not find {} in {}").format(_raw(missing_item), _raw(collection)), help=help)
428 |
429 | if missing_item == EOF:
430 | missing_item = "EOF"
431 |
432 | self.payload.update({"missing_item": str(missing_item), "collection": str(collection)})
433 |
434 |
435 | class Mismatch(Failure):
436 | """
437 | Exception signifying check failure due to a mismatch in expected and actual outputs.
438 |
439 | :param expected: the expected value
440 | :param actual: the actual value
441 | :param help: optional help message to be displayed
442 | :type help: str
443 |
444 | Example usage::
445 |
446 | from re import match
447 | expected = "[Hh]ello, world!?\\n"
448 | actual = check50.run("./hello").stdout()
449 | if not match(expected, actual):
450 | help = None
451 | if match(expected[:-1], actual):
452 | help = r"did you forget a newline ('\\n') at the end of your printf string?"
453 | raise check50.Mismatch("hello, world\\n", actual, help=help)
454 |
455 | """
456 |
457 | def __init__(self, expected, actual, help=None):
458 | super().__init__(rationale=_("expected {}, not {}").format(_raw(expected), _raw(actual)), help=help)
459 |
460 | if expected == EOF:
461 | expected = "EOF"
462 |
463 | if actual == EOF:
464 | actual = "EOF"
465 |
466 | self.payload.update({"expected": expected, "actual": actual})
467 |
468 |
469 | def hidden(failure_rationale):
470 | """
471 | Decorator that marks a check as a 'hidden' check. This will suppress the log
472 | accumulated throughout the check and will catch any :class:`check50.Failure`s thrown
473 | during the check, and reraising a new :class:`check50.Failure` with the given ``failure_rationale``.
474 |
475 | :param failure_rationale: the rationale that will be displayed to the student if the check fails
476 | :type failure_rationale: str
477 |
478 | Example usage::
479 |
480 | @check50.check()
481 | @check50.hidden("Your program isn't returning the expected result. Try running it on some sample inputs.")
482 | def hidden_check():
483 | check50.run("./foo").stdin("bar").stdout("baz").exit()
484 |
485 | """
486 | def decorator(f):
487 | @functools.wraps(f)
488 | def wrapper(*args, **kwargs):
489 | try:
490 | return f(*args, **kwargs)
491 | except Failure:
492 | raise Failure(failure_rationale)
493 | finally:
494 | _log.clear()
495 | return wrapper
496 | return decorator
497 |
498 |
499 | def _raw(s):
500 | """Get raw representation of s, truncating if too long."""
501 |
502 | if isinstance(s, list):
503 | s = "\n".join(_raw(item) for item in s)
504 |
505 | if s == EOF:
506 | return "EOF"
507 |
508 | s = f'"{repr(str(s))[1:-1]}"'
509 | if len(s) > 15:
510 | s = s[:15] + "...\"" # Truncate if too long
511 | return s
512 |
513 |
514 | def _copy(src, dst):
515 | """Copy src to dst, copying recursively if src is a directory."""
516 | try:
517 | shutil.copy(src, dst)
518 | except (FileNotFoundError, IsADirectoryError):
519 | if os.path.isdir(dst):
520 | dst = os.path.join(dst, os.path.basename(src))
521 | shutil.copytree(src, dst)
522 |
--------------------------------------------------------------------------------
/check50/_exceptions.py:
--------------------------------------------------------------------------------
1 | import json
2 | import sys
3 | import traceback
4 |
5 | import lib50
6 | import termcolor
7 |
8 | from . import internal, __version__
9 | from .contextmanagers import nullcontext
10 |
11 | class Error(Exception):
12 | """Exception for internal check50 errors."""
13 | pass
14 |
15 |
16 | class RemoteCheckError(Error):
17 | """An exception for errors that happen in check50's remote operation."""
18 | def __init__(self, remote_json):
19 | super().__init__("check50 ran into an error while running checks! Please visit our status page https://cs50.statuspage.io for more information.")
20 | self.payload = {"remote_json": remote_json}
21 |
22 |
23 | class ExceptHook:
24 | def __init__(self, outputs=("ansi",), output_file=None):
25 | self.outputs = outputs
26 | self.output_file = output_file
27 |
28 | def __call__(self, cls, exc, tb):
29 | # If an error happened remotely, grab its traceback and message
30 | if issubclass(cls, RemoteCheckError) and "error" in exc.payload["remote_json"]:
31 | formatted_traceback = exc.payload["remote_json"]["error"]["traceback"]
32 | show_traceback = exc.payload["remote_json"]["error"]["actions"]["show_traceback"]
33 | message = exc.payload["remote_json"]["error"]["actions"]["message"]
34 | # Otherwise, create the traceback and message from this error
35 | else:
36 | formatted_traceback = traceback.format_exception(cls, exc, tb)
37 | show_traceback = False
38 |
39 | if (issubclass(cls, Error) or issubclass(cls, lib50.Error)) and exc.args:
40 | message = str(exc)
41 | elif issubclass(cls, FileNotFoundError):
42 | message = _("{} not found").format(exc.filename)
43 | elif issubclass(cls, KeyboardInterrupt):
44 | message = _("check cancelled")
45 | elif not issubclass(cls, Exception):
46 | # Class is some other BaseException, better just let it go
47 | return
48 | else:
49 | show_traceback = True
50 | message = _("Sorry, something is wrong! check50 ran into an error, please try again.\n" \
51 | "If the problem persists, please visit our status page https://cs50.statuspage.io for more information.")
52 |
53 | # Output exception as json
54 | if "json" in self.outputs:
55 | ctxmanager = open(self.output_file, "w") if self.output_file else nullcontext(sys.stdout)
56 | with ctxmanager as output_file:
57 | json.dump({
58 | "slug": internal.slug,
59 | "error": {
60 | "type": cls.__name__,
61 | "value": str(exc),
62 | "traceback": formatted_traceback,
63 | "actions": {
64 | "show_traceback": show_traceback,
65 | "message": message
66 | },
67 | "data" : exc.payload if hasattr(exc, "payload") else {}
68 | },
69 | "version": __version__
70 | }, output_file, indent=4)
71 | output_file.write("\n")
72 |
73 | # Output exception to stderr
74 | if "ansi" in self.outputs or "html" in self.outputs:
75 | if show_traceback:
76 | for line in formatted_traceback:
77 | termcolor.cprint(line, end="", file=sys.stderr)
78 | termcolor.cprint(message, "red", file=sys.stderr)
79 |
80 | sys.exit(1)
81 |
82 | @classmethod
83 | def initialize(cls, *args, **kwargs):
84 | sys.excepthook = cls(*args, **kwargs)
85 |
--------------------------------------------------------------------------------
/check50/_simple.py:
--------------------------------------------------------------------------------
1 | """
2 | Functions that compile "simple" YAML checks into our standard python checks
3 | """
4 |
5 | import re
6 |
7 |
8 | def compile(checks):
9 | """Returns compiled check50 checks from simple YAML checks in path."""
10 |
11 | out = ["import check50"]
12 |
13 | for name, check in checks.items():
14 | out.append(_compile_check(name, check))
15 |
16 | return "\n\n".join(out)
17 |
18 |
19 | def _run(arg):
20 | return f'.run("{arg}")'
21 |
22 |
23 | def _stdin(arg):
24 | if isinstance(arg, list):
25 | arg = r"\n".join(str(a) for a in arg)
26 |
27 | arg = str(arg).replace("\n", r"\n").replace("\t", r"\t").replace('"', '\"')
28 | return f'.stdin("{arg}", prompt=False)'
29 |
30 |
31 | def _stdout(arg):
32 | if isinstance(arg, list):
33 | arg = r"\n".join(str(a) for a in arg)
34 | arg = str(arg).replace("\n", r"\n").replace("\t", r"\t").replace('"', '\"')
35 | return f'.stdout("{arg}", regex=False)'
36 |
37 |
38 | def _exit(arg):
39 | if arg is None:
40 | return ".exit()"
41 |
42 | try:
43 | arg = int(arg)
44 | except ValueError:
45 | raise InvalidArgument(f"exit command only accepts integers, not {arg}")
46 | return f'.exit({arg})'
47 |
48 |
49 | COMMANDS = {"run": _run, "stdin": _stdin, "stdout": _stdout, "exit": _exit}
50 |
51 |
52 | def _compile_check(name, check):
53 | indent = " " * 4
54 |
55 | # Allow check names to contain spaces/dashes, but replace them with underscores
56 | check_name = name.replace(' ', '_').replace('-', '_')
57 |
58 | # Allow check names to start with numbers by prepending an _ to them
59 | if check_name[0].isdigit():
60 | check_name = f"_{check_name}"
61 |
62 | if not re.match("\w+", check_name):
63 | raise CompileError(
64 | _("{} is not a valid name for a check; check names should consist only of alphanumeric characters, underscores, and spaces").format(name))
65 |
66 | out = ["@check50.check()",
67 | f"def {check_name}():",
68 | f'{indent}"""{name}"""']
69 |
70 | for run in check:
71 | _validate(name, run)
72 |
73 | # Append exit with no args if unspecified
74 | if "exit" not in run:
75 | run["exit"] = None
76 |
77 | line = [f"{indent}check50"]
78 |
79 | for command_name in COMMANDS:
80 | if command_name in run:
81 | line.append(COMMANDS[command_name](run[command_name]))
82 | out.append("".join(line))
83 |
84 | return "\n".join(out)
85 |
86 |
87 | def _validate(name, run):
88 | if run == "run":
89 | raise CompileError(_("You forgot a - in front of run"))
90 |
91 | for key in run:
92 | if key not in COMMANDS:
93 | raise UnsupportedCommand(
94 | _("{} is not a valid command in check {}, use only: {}").format(key, name, COMMANDS))
95 |
96 | for required_command in ["run"]:
97 | if required_command not in run:
98 | raise MissingCommand(_("Missing {} in check {}").format(required_name, name))
99 |
100 |
101 | class CompileError(Exception):
102 | pass
103 |
104 |
105 | class UnsupportedCommand(CompileError):
106 | pass
107 |
108 |
109 | class MissingCommand(CompileError):
110 | pass
111 |
112 |
113 | class InvalidArgument(CompileError):
114 | pass
115 |
--------------------------------------------------------------------------------
/check50/c.py:
--------------------------------------------------------------------------------
1 | import os
2 | import re
3 | import tempfile
4 | from pathlib import Path
5 | import xml.etree.cElementTree as ET
6 |
7 | from ._api import run, log, Failure
8 | from . import internal
9 |
10 | #: Default compiler for :func:`check50.c.compile`
11 | CC = "clang"
12 |
13 | #: Default CFLAGS for :func:`check50.c.compile`
14 | CFLAGS = {"std": "c11", "ggdb": True, "lm": True}
15 |
16 |
17 | def compile(*files, exe_name=None, cc=CC, max_log_lines=50, **cflags):
18 | """
19 | Compile C source files.
20 |
21 | :param files: filenames to be compiled
22 | :param exe_name: name of resulting executable
23 | :param cc: compiler to use (:data:`check50.c.CC` by default)
24 | :param cflags: additional flags to pass to the compiler
25 | :raises check50.Failure: if compilation failed (i.e., if the compiler returns a non-zero exit status).
26 | :raises RuntimeError: if no filenames are specified
27 |
28 | If ``exe_name`` is None, :func:`check50.c.compile` will default to the first
29 | file specified sans the ``.c`` extension::
30 |
31 |
32 | check50.c.compile("foo.c", "bar.c") # clang foo.c bar.c -o foo -std=c11 -ggdb -lm
33 |
34 | Additional CFLAGS may be passed as keyword arguments like so::
35 |
36 | check50.c.compile("foo.c", "bar.c", lcs50=True) # clang foo.c bar.c -o foo -std=c11 -ggdb -lm -lcs50
37 |
38 | In the same vein, the default CFLAGS may be overridden via keyword arguments::
39 |
40 | check50.c.compile("foo.c", "bar.c", std="c99", lm=False) # clang foo.c bar.c -o foo -std=c99 -ggdb
41 | """
42 |
43 | if not files:
44 | raise RuntimeError(_("compile requires at least one file"))
45 |
46 | if exe_name is None and files[0].endswith(".c"):
47 | exe_name = Path(files[0]).stem
48 |
49 | files = " ".join(files)
50 |
51 | flags = CFLAGS.copy()
52 | flags.update(cflags)
53 | flags = " ".join((f"-{flag}" + (f"={value}" if value is not True else "")).replace("_", "-")
54 | for flag, value in flags.items() if value)
55 |
56 | out_flag = f" -o {exe_name} " if exe_name is not None else " "
57 |
58 | process = run(f"{cc} {files}{out_flag}{flags}")
59 |
60 | # Strip out ANSI codes
61 | stdout = re.sub(r"\x1B\[[0-?]*[ -/]*[@-~]", "", process.stdout())
62 |
63 | # Log max_log_lines lines of output in case compilation fails
64 | if process.exitcode != 0:
65 | lines = stdout.splitlines()
66 |
67 | if len(lines) > max_log_lines:
68 | lines = lines[:max_log_lines // 2] + lines[-(max_log_lines // 2):]
69 |
70 | for line in lines:
71 | log(line)
72 |
73 | raise Failure("code failed to compile")
74 |
75 |
76 | def valgrind(command, env={}):
77 | """Run a command with valgrind.
78 |
79 | :param command: command to be run
80 | :type command: str
81 | :param env: environment in which to run command
82 | :type env: str
83 | :raises check50.Failure: if, at the end of the check, valgrind reports any errors
84 |
85 | This function works exactly like :func:`check50.run`, with the additional effect that ``command`` is run through
86 | ``valgrind`` and ``valgrind``'s output is automatically reviewed at the end of the check for memory leaks and other
87 | bugs. If ``valgrind`` reports any issues, the check is failed and student-friendly messages are printed to the log.
88 |
89 | Example usage::
90 |
91 | check50.c.valgrind("./leaky").stdin("foo").stdout("bar").exit(0)
92 |
93 | .. note::
94 | It is recommended that the student's code is compiled with the `-ggdb`
95 | flag so that additional information, such as the file and line number at which
96 | the issue was detected can be included in the log as well.
97 | """
98 | xml_file = tempfile.NamedTemporaryFile()
99 | internal.register.after_check(lambda: _check_valgrind(xml_file))
100 |
101 | # Ideally we'd like for this whole command not to be logged.
102 | return run(f"valgrind --show-leak-kinds=all --xml=yes --xml-file={xml_file.name} -- {command}", env=env)
103 |
104 |
105 | def _check_valgrind(xml_file):
106 | """Log and report any errors encountered by valgrind."""
107 | log(_("checking for valgrind errors..."))
108 |
109 | # Load XML file created by valgrind
110 | xml = ET.ElementTree(file=xml_file)
111 |
112 | # Ensure that we don't get duplicate error messages.
113 | reported = set()
114 | for error in xml.iterfind("error"):
115 | # Type of error valgrind encountered
116 | kind = error.find("kind").text
117 |
118 | # Valgrind's error message
119 | what = error.find("xwhat/text" if kind.startswith("Leak_") else "what").text
120 |
121 | # Error message that we will report
122 | msg = ["\t", what]
123 |
124 | # Find first stack frame within student's code.
125 | for frame in error.iterfind("stack/frame"):
126 | obj = frame.find("obj")
127 | if obj is not None and internal.run_dir in Path(obj.text).parents:
128 | file, line = frame.find("file"), frame.find("line")
129 | if file is not None and line is not None:
130 | msg.append(f": ({_('file')}: {file.text}, {_('line')}: {line.text})")
131 | break
132 |
133 | msg = "".join(msg)
134 | if msg not in reported:
135 | log(msg)
136 | reported.add(msg)
137 |
138 | # Only raise exception if we encountered errors.
139 | if reported:
140 | raise Failure(_("valgrind tests failed; see log for more information."))
141 |
--------------------------------------------------------------------------------
/check50/contextmanagers.py:
--------------------------------------------------------------------------------
1 | import contextlib
2 |
3 |
4 | @contextlib.contextmanager
5 | def nullcontext(entry_result=None):
6 | """This is just contextlib.nullcontext but that function is only available in 3.7+."""
7 | yield entry_result
8 |
--------------------------------------------------------------------------------
/check50/flask.py:
--------------------------------------------------------------------------------
1 | import pathlib
2 | import re
3 | import sys
4 | import urllib.parse as url
5 | import warnings
6 |
7 | from bs4 import BeautifulSoup
8 |
9 | from ._api import log, Failure
10 | from . import internal
11 |
12 |
13 | class app:
14 | """Spawn a Flask app.
15 |
16 | :param path: path to python file containing Flask app
17 | :param app_name: name of Flask app in file
18 | :type path: str
19 | :type env: str
20 |
21 | Example usage::
22 |
23 | check50.flask.app("application.py").get("/").status(200)
24 | """
25 |
26 | def __init__(self, path="application.py", app_name="app"):
27 |
28 | path = pathlib.Path(path).resolve()
29 |
30 | # Add directory of flask app to sys.path so we can import it properly
31 | prevpath = sys.path[0]
32 | try:
33 | sys.path[0] = str(path.parent)
34 | mod = internal.import_file(path.stem, path.name)
35 | except FileNotFoundError:
36 | raise Failure(_("could not find {}").format(path.name))
37 | finally:
38 | # Restore sys.path
39 | sys.path[0] = prevpath
40 |
41 | try:
42 | app = getattr(mod, app_name)
43 | except AttributeError:
44 | raise Failure(_("{} does not contain an app").format(file))
45 |
46 | app.testing = True
47 | # Initialize flask client
48 | self._client = app.test_client()
49 |
50 | self.response = None
51 |
52 | def get(self, route, data=None, params=None, follow_redirects=True):
53 | """Send GET request to app.
54 |
55 | :param route: route to send request to
56 | :type route: str
57 | :param data: form data to include in request
58 | :type data: dict
59 | :param params: URL parameters to include in request
60 | :param follow_redirects: enable redirection (defaults to ``True``)
61 | :type follow_redirects: bool
62 | :returns: ``self``
63 | :raises check50.Failure: if Flask application throws an uncaught exception
64 |
65 | Example usage::
66 |
67 | check50.flask.app("application.py").get("/buy", params={"q": "02138"}).content()
68 | """
69 | return self._send("GET", route, data, params, follow_redirects=follow_redirects)
70 |
71 | def post(self, route, data=None, params=None, follow_redirects=True):
72 | """Send POST request to app.
73 |
74 | :param route: route to send request to
75 | :type route: str
76 | :param data: form data to include in request
77 | :type data: dict
78 | :param params: URL parameters to include in request
79 | :param follow_redirects: enable redirection (defaults to ``True``)
80 | :type follow_redirects: bool
81 | :raises check50.Failure: if Flask application throws an uncaught exception
82 |
83 | Example usage::
84 |
85 | check50.flask.app("application.py").post("/buy", data={"symbol": "GOOG", "shares": 10}).status(200)
86 | """
87 |
88 | return self._send("POST", route, data, params, follow_redirects=follow_redirects)
89 |
90 | def status(self, code=None):
91 | """Check status code in response returned by application.
92 | If ``code`` is not None, assert that ``code`` is returned by application,
93 | else simply return the status code.
94 |
95 | :param code: ``code`` to assert that application returns
96 | :type code: int
97 |
98 | Example usage::
99 |
100 | check50.flask.app("application.py").status(200)
101 |
102 | status = check50.flask.app("application.py").get("/").status()
103 | if status != 200:
104 | raise check50.Failure(f"expected status code 200, but got {status}")
105 |
106 | """
107 | if code is None:
108 | return self.response.status_code
109 |
110 | log(_("checking that status code {} is returned...").format(code))
111 | if code != self.response.status_code:
112 | raise Failure(_("expected status code {}, but got {}").format(
113 | code, self.response.status_code))
114 | return self
115 |
116 | def raw_content(self, output=None, str_output=None):
117 | """Searches for `output` regex match within content of page, regardless of mimetype."""
118 | return self._search_page(output, str_output, self.response.data, lambda regex, content: regex.search(content.decode()))
119 |
120 | def content(self, output=None, str_output=None, **kwargs):
121 | """Searches for `output` regex within HTML page. kwargs are passed to BeautifulSoup's find function to filter for tags."""
122 | if self.response.mimetype != "text/html":
123 | raise Failure(_("expected request to return HTML, but it returned {}").format(
124 | self.response.mimetype))
125 |
126 | # TODO: Remove once beautiful soup updates to accommodate python 3.7
127 | with warnings.catch_warnings():
128 | warnings.filterwarnings("ignore", category=DeprecationWarning)
129 | content = BeautifulSoup(self.response.data, "html.parser")
130 |
131 | return self._search_page(
132 | output,
133 | str_output,
134 | content,
135 | lambda regex, content: any(regex.search(str(tag)) for tag in content.find_all(**kwargs)))
136 |
137 | def _send(self, method, route, data, params, **kwargs):
138 | """Send request of type `method` to `route`."""
139 | route = self._fmt_route(route, params)
140 | log(_("sending {} request to {}").format(method.upper(), route))
141 | try:
142 | self.response = getattr(self._client, method.lower())(route, data=data, **kwargs)
143 | except BaseException as e: # Catch all exceptions thrown by app
144 | log(_("exception raised in application: {}: {}").format(type(e).__name__, e))
145 | raise Failure(_("application raised an exception (see the log for more details)"))
146 | return self
147 |
148 | def _search_page(self, output, str_output, content, match_fn, **kwargs):
149 | if output is None:
150 | return content
151 |
152 | if str_output is None:
153 | str_output = output
154 |
155 | log(_("checking that \"{}\" is in page").format(str_output))
156 |
157 | regex = re.compile(output)
158 |
159 | if not match_fn(regex, content):
160 | raise Failure(
161 | _("expected to find \"{}\" in page, but it wasn't found").format(str_output))
162 |
163 | return self
164 |
165 | @staticmethod
166 | def _fmt_route(route, params):
167 | parsed = url.urlparse(route)
168 |
169 | # Convert params dict into urlencoded string
170 | params = url.urlencode(params) if params else ""
171 |
172 | # Concatenate params
173 | param_str = "&".join((ps for ps in [params, parsed.query] if ps))
174 | if param_str:
175 | param_str = "?" + param_str
176 |
177 | # Only display netloc if it isn't localhost
178 | return "".join([parsed.netloc if parsed.netloc != "localhost" else "", parsed.path, param_str])
179 |
--------------------------------------------------------------------------------
/check50/internal.py:
--------------------------------------------------------------------------------
1 | """
2 | Additional check50 internals exposed to extension writers in addition to the standard API
3 | """
4 |
5 | from pathlib import Path
6 | import importlib
7 | import json
8 | import sys
9 | import termcolor
10 | import traceback
11 |
12 | import lib50
13 |
14 | from . import _simple, _exceptions
15 |
16 | #: Directory containing the check and its associated files
17 | check_dir = None
18 |
19 | #: Temporary directory in which the current check is being run
20 | run_dir = None
21 |
22 | #: Temporary directory that is the root (parent) of all run_dir(s)
23 | run_root_dir = None
24 |
25 | #: Directory check50 was run from
26 | student_dir = None
27 |
28 | #: Boolean that indicates if a check is currently running
29 | check_running = False
30 |
31 | #: The user specified slug used to indentifies the set of checks
32 | slug = None
33 |
34 | #: ``lib50`` config loader
35 | CONFIG_LOADER = lib50.config.Loader("check50")
36 | CONFIG_LOADER.scope("files", "include", "exclude", "require")
37 |
38 |
39 | class Register:
40 | """
41 | Class with which functions can be registered to run before / after checks.
42 | :data:`check50.internal.register` should be the sole instance of this class.
43 | """
44 | def __init__(self):
45 | self._before_everies = []
46 | self._after_everies = []
47 | self._after_checks = []
48 |
49 | def after_check(self, func):
50 | """Run func once at the end of the check, then discard func.
51 |
52 | :param func: callback to run after check
53 | :raises check50.internal.Error: if called when no check is being run"""
54 | if not check_running:
55 | raise _exceptions.Error("cannot register callback to run after check when no check is running")
56 | self._after_checks.append(func)
57 |
58 | def after_every(self, func):
59 | """Run func at the end of every check.
60 |
61 | :param func: callback to be run after every check
62 | :raises check50.internal.Error: if called when a check is being run"""
63 | if check_running:
64 | raise _exceptions.Error("cannot register callback to run after every check when check is running")
65 | self._after_everies.append(func)
66 |
67 | def before_every(self, func):
68 | """Run func at the start of every check.
69 |
70 | :param func: callback to be run before every check
71 | :raises check50.internal.Error: if called when a check is being run"""
72 |
73 | if check_running:
74 | raise _exceptions.Error("cannot register callback to run before every check when check is running")
75 | self._before_everies.append(func)
76 |
77 | def __enter__(self):
78 | for f in self._before_everies:
79 | f()
80 |
81 | def __exit__(self, exc_type, exc_val, exc_tb):
82 | # Only run 'afters' when check has passed
83 | if exc_type is not None:
84 | return
85 |
86 | # Run and remove all checks registered to run after a single check
87 | while self._after_checks:
88 | self._after_checks.pop()()
89 |
90 | for f in self._after_everies:
91 | f()
92 |
93 |
94 | #: Sole instance of the :class:`check50.internal.Register` class
95 | register = Register()
96 |
97 |
98 | def load_config(check_dir):
99 | """
100 | Load configuration file from ``check_dir / ".cs50.yaml"``, applying
101 | defaults to unspecified values.
102 |
103 | :param check_dir: directory from which to load config file
104 | :type check_dir: str / Path
105 | :rtype: dict
106 | """
107 |
108 | # Defaults for top-level keys
109 | options = {
110 | "checks": "__init__.py",
111 | "dependencies": None,
112 | "translations": None
113 | }
114 |
115 | # Defaults for translation keys
116 | translation_options = {
117 | "localedir": "locale",
118 | "domain": "messages",
119 | }
120 |
121 | # Get config file
122 | try:
123 | config_file = lib50.config.get_config_filepath(check_dir)
124 | except lib50.Error:
125 | raise _exceptions.Error(_("Invalid slug for check50. Did you mean something else?"))
126 |
127 | # Load config
128 | with open(config_file) as f:
129 | try:
130 | config = CONFIG_LOADER.load(f.read())
131 | except lib50.InvalidConfigError:
132 | raise _exceptions.Error(_("Invalid slug for check50. Did you mean something else?"))
133 |
134 | # Update the config with defaults
135 | if isinstance(config, dict):
136 | options.update(config)
137 |
138 | # Apply translations
139 | if options["translations"]:
140 | if isinstance(options["translations"], dict):
141 | translation_options.update(options["translations"])
142 | options["translations"] = translation_options
143 |
144 | return options
145 |
146 |
147 | def compile_checks(checks, prompt=False, out_file="__init__.py"):
148 | """
149 | Compile YAML checks to a Python file
150 |
151 | :param checks: YAML checks read from config
152 | :type checkcs: dict
153 | :param prompt: prompt user if ``out_file`` already exists
154 | :type prompt: bool
155 | :param out_file: file to write compiled checks
156 | :type out_file: str
157 | :returns: ``out_file``
158 | :rtype: str
159 | """
160 |
161 | file_path = check_dir / out_file
162 | # Prompt to replace __init__.py (compile destination)
163 | if prompt and file_path.exists():
164 | if not _yes_no_prompt("check50 will compile the YAML checks to __init__.py, are you sure you want to overwrite its contents?"):
165 | raise _exceptions.Error("Aborting: could not overwrite to __init__.py")
166 |
167 | # Compile simple checks
168 | with open(check_dir / out_file, "w") as f:
169 | f.write(_simple.compile(checks))
170 |
171 | return out_file
172 |
173 |
174 | def import_file(name, path):
175 | """
176 | Import a file given a raw file path.
177 |
178 | :param name: Name of module to be imported
179 | :type name: str
180 | :param path: Path to Python file
181 | :type path: str / Path
182 | """
183 | spec = importlib.util.spec_from_file_location(name, path)
184 | mod = importlib.util.module_from_spec(spec)
185 | spec.loader.exec_module(mod)
186 | return mod
187 |
188 |
189 | def _yes_no_prompt(prompt):
190 | """
191 | Raise a prompt, returns True if yes is entered, False if no is entered.
192 | Will reraise prompt in case of any other reply.
193 | """
194 | return _("yes").startswith(input(_("{} [Y/n] ").format(prompt)).lower())
195 |
--------------------------------------------------------------------------------
/check50/py.py:
--------------------------------------------------------------------------------
1 | import importlib
2 | from pathlib import Path
3 | import py_compile
4 |
5 | from . import internal
6 | from ._api import Failure, exists, log
7 |
8 |
9 | def append_code(original, codefile):
10 | """Append the contents of one file to another.
11 |
12 | :param original: name of file that will be appended to
13 | :type original: str
14 | :param codefile: name of file that will be appende
15 | :type codefile: str
16 |
17 | This function is particularly useful when one wants to replace a function
18 | in student code with their own implementation of one. If two functions are
19 | defined with the same name in Python, the latter definition is taken so overwriting
20 | a function is as simple as writing it to a file and then appending it to the
21 | student's code.
22 |
23 | Example usage::
24 |
25 | # Include a file containing our own implementation of a lookup function.
26 | check50.include("lookup.py")
27 |
28 | # Overwrite the lookup function in helpers.py with our own implementation.
29 | check50.py.append_code("helpers.py", "lookup.py")
30 | """
31 | with open(codefile) as code, open(original, "a") as o:
32 | o.write("\n")
33 | o.writelines(code)
34 |
35 |
36 | def import_(path):
37 | """Import a Python program given a raw file path
38 |
39 | :param path: path to python file to be imported
40 | :type path: str
41 | :raises check50.Failure: if ``path`` doesn't exist, or if the Python file at ``path`` throws an exception when imported.
42 | """
43 | exists(path)
44 | log(_("importing {}...").format(path))
45 | name = Path(path).stem
46 | try:
47 | return internal.import_file(name, path)
48 | except Exception as e:
49 | raise Failure(str(e))
50 |
51 | def compile(file):
52 | """
53 | Compile a Python program into byte code
54 |
55 | :param file: file to be compiled
56 | :raises check50.Failure: if compilation fails e.g. if there is a SyntaxError
57 | """
58 | log(_("compiling {} into byte code...").format(file))
59 |
60 | try:
61 | py_compile.compile(file, doraise=True)
62 | except py_compile.PyCompileError as e:
63 | log(_("Exception raised: "))
64 | for line in e.msg.splitlines():
65 | log(line)
66 |
67 | raise Failure(_("{} raised while compiling {} (see the log for more details)").format(e.exc_type_name, file))
68 |
--------------------------------------------------------------------------------
/check50/regex.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 |
4 | def decimal(number):
5 | """
6 | Create a regular expression to match the number exactly:
7 |
8 | In case of a positive number::
9 |
10 | (?= 0 else ""
33 | return fr"{negative_lookbehind}{re.escape(str(number))}(?!(\.?\d))"
34 |
--------------------------------------------------------------------------------
/check50/renderer/__init__.py:
--------------------------------------------------------------------------------
1 | from ._renderers import to_ansi, to_html, to_json
2 |
--------------------------------------------------------------------------------
/check50/renderer/_renderers.py:
--------------------------------------------------------------------------------
1 | import json
2 | import pathlib
3 |
4 | import jinja2
5 | import termcolor
6 |
7 | from importlib.resources import files
8 |
9 | TEMPLATES = pathlib.Path(files("check50.renderer").joinpath("templates"))
10 |
11 |
12 | def to_html(slug, results, version):
13 | with open(TEMPLATES / "results.html") as f:
14 | content = f.read()
15 |
16 | template = jinja2.Template(
17 | content, autoescape=jinja2.select_autoescape(enabled_extensions=("html",)))
18 | html = template.render(slug=slug, results=results, version=version)
19 |
20 | return html
21 |
22 |
23 | def to_json(slug, results, version):
24 | return json.dumps({"slug": slug, "results": results, "version": version}, indent=4)
25 |
26 |
27 | def to_ansi(slug, results, version, _log=False):
28 | lines = [termcolor.colored(_("Results for {} generated by check50 v{}").format(slug, version), "white", attrs=["bold"])]
29 | for result in results:
30 | if result["passed"]:
31 | lines.append(termcolor.colored(f":) {result['description']}", "green"))
32 | elif result["passed"] is None:
33 | lines.append(termcolor.colored(f":| {result['description']}", "yellow"))
34 | lines.append(termcolor.colored(f" {result['cause'].get('rationale') or _('check skipped')}", "yellow"))
35 | if result["cause"].get("error") is not None:
36 | lines.append(f" {result['cause']['error']['type']}: {result['cause']['error']['value']}")
37 | lines += (f" {line.rstrip()}" for line in result["cause"]["error"]["traceback"])
38 | else:
39 | lines.append(termcolor.colored(f":( {result['description']}", "red"))
40 | if result["cause"].get("rationale") is not None:
41 | lines.append(termcolor.colored(f" {result['cause']['rationale']}", "red"))
42 | if result["cause"].get("help") is not None:
43 | lines.append(termcolor.colored(f" {result['cause']['help']}", "red"))
44 |
45 | if _log:
46 | lines += (f" {line}" for line in result["log"])
47 | return "\n".join(lines)
48 |
49 |
--------------------------------------------------------------------------------
/check50/renderer/templates/results.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | This is check50.
6 |
7 |
8 |
9 |
10 |
11 |
check50
12 |
{{ slug }}
13 |
14 | {% for check in results %}
15 | {% if check.passed == True %}
16 |
:) {{ check.description }}
17 | {% elif check.passed == False %}
18 |
:( {{ check.description }}
19 | {% else %}
20 |
:| {{ check.description }}
21 | {% endif %}
22 |
23 | {% if check.cause or check.log %}
24 |
25 | {# Reason why the check received its status #}
26 | {% if check.cause %}
27 |
28 | Cause
29 | {% autoescape false %}
30 | {{ check.cause.rationale | e | replace(" ", " ") }}
31 | {% endautoescape %}
32 |
33 | {% autoescape false %}
34 | {% if check.cause.help %}
35 | {{ check.cause.help | e | replace(" ", " ") }}
36 |
37 | {% endif %}
38 | {% endautoescape %}
39 |
40 | {% endif %}
41 |
42 | {# Log information #}
43 | {% if check.log %}
44 |
45 | Log
46 |
47 | {% for item in check.log %}
48 | {{ item }}
49 |
50 | {% endfor %}
51 |
52 |
53 | {% endif %}
54 |
55 | {% if check.cause and check.cause.error %}
56 |
57 |
58 |
59 |
Error
60 |
61 |
62 | {{check.cause.error.type}}: {{check.cause.error.value}}
63 |
64 | {% for line in check.cause.error.traceback%}
65 | {{line}}
66 |
67 | {% endfor %}
68 |
69 |
70 |
71 |
72 | {% endif %}
73 |
74 | {# Mismatch if there was one #}
75 | {% if check.cause and "actual" in check.cause and "expected" in check.cause %}
76 |
77 |
78 |
79 |
Expected Output:
80 |
81 |
82 |
83 | {% autoescape false %}
84 | {% if check.cause.expected is not string %}
85 | {% set expected = check.cause.expected | join('\n') | e %}
86 | {% else %}
87 | {% set expected = check.cause.expected | e %}
88 | {% endif %}
89 | {{ expected | replace(" ", " ") | replace("\n", " ") }}
90 | {% endautoescape %}
91 |
92 |
93 |
94 |
95 |
Actual Output:
96 |
97 |
98 |
99 | {% autoescape false %}
100 | {% if check.cause.actual is not string %}
101 | {% set actual = check.cause.actual | join('\n') | e %}
102 | {% else %}
103 | {% set actual = check.cause.actual | e %}
104 | {% endif %}
105 | {{ actual | replace(" ", " ") | replace("\n", " ") }}
106 | {% endautoescape %}
107 |
108 |
109 |
110 |
111 | {% endif %}
112 |
113 | {# Missing if there was one #}
114 | {% if check.cause and "missing_item" in check.cause and "collection" in check.cause %}
115 |
116 |
117 |
118 |
119 |
Could not find the following in the output:
120 |
121 |
122 |
123 | {% autoescape false %}
124 | {% set item = check.cause.missing_item | e %}
125 | {{ item | replace(" ", " ") | replace("\n", " ") }}
126 | {% endautoescape %}
127 |
128 |
129 |
130 |
131 |
Actual Output:
132 |
133 |
134 |
135 | {% autoescape false %}
136 | {% set collection = check.cause.collection | e %}
137 | {{ collection | replace(" ", " ") | replace("\n", " ") }}
138 | {% endautoescape %}
139 |
140 |
141 |
142 |
143 | {% endif %}
144 |
145 |
146 | {% endif %}
147 | {% endfor %}
148 |
149 |
150 |
151 |
152 |
153 |
--------------------------------------------------------------------------------
/check50/runner.py:
--------------------------------------------------------------------------------
1 | import collections
2 | from contextlib import contextmanager
3 | import concurrent.futures as futures
4 | import enum
5 | import functools
6 | import inspect
7 | import importlib
8 | import gettext
9 | import multiprocessing
10 | import os
11 | from pathlib import Path
12 | import pickle
13 | import shutil
14 | import signal
15 | import sys
16 | import tempfile
17 | import traceback
18 |
19 | import attr
20 | import lib50
21 |
22 | from . import internal, _exceptions, __version__
23 | from ._api import log, Failure, _copy, _log, _data
24 |
25 | _check_names = []
26 |
27 |
28 | @attr.s(slots=True)
29 | class CheckResult:
30 | """Record returned by each check"""
31 | name = attr.ib()
32 | description = attr.ib()
33 | passed = attr.ib(default=None)
34 | log = attr.ib(default=attr.Factory(list))
35 | cause = attr.ib(default=None)
36 | data = attr.ib(default=attr.Factory(dict))
37 | dependency = attr.ib(default=None)
38 |
39 | @classmethod
40 | def from_check(cls, check, *args, **kwargs):
41 | """Create a check_result given a check function, automatically recording the name,
42 | the dependency, and the (translated) description.
43 | """
44 | return cls(name=check.__name__, description=_(check.__doc__ if check.__doc__ else check.__name__.replace("_", " ")),
45 | dependency=check._check_dependency.__name__ if check._check_dependency else None,
46 | *args,
47 | **kwargs)
48 |
49 | @classmethod
50 | def from_dict(cls, d):
51 | """Create a CheckResult given a dict. Dict must contain at least the fields in the CheckResult.
52 | Throws a KeyError if not."""
53 | return cls(**{field.name: d[field.name] for field in attr.fields(cls)})
54 |
55 |
56 |
57 | class Timeout(Failure):
58 | def __init__(self, seconds):
59 | super().__init__(rationale=_("check timed out after {} seconds").format(seconds))
60 |
61 |
62 | @contextmanager
63 | def _timeout(seconds):
64 | """Context manager that runs code block until timeout is reached.
65 |
66 | Example usage::
67 |
68 | try:
69 | with _timeout(10):
70 | do_stuff()
71 | except Timeout:
72 | print("do_stuff timed out")
73 | """
74 |
75 | def _handle_timeout(*args):
76 | raise Timeout(seconds)
77 |
78 | signal.signal(signal.SIGALRM, _handle_timeout)
79 | signal.alarm(seconds)
80 | try:
81 | yield
82 | finally:
83 | signal.alarm(0)
84 | signal.signal(signal.SIGALRM, signal.SIG_DFL)
85 |
86 |
87 | def check(dependency=None, timeout=60, max_log_lines=100):
88 | """Mark function as a check.
89 |
90 | :param dependency: the check that this check depends on
91 | :type dependency: function
92 | :param timeout: maximum number of seconds the check can run
93 | :type timeout: int
94 | :param max_log_lines: maximum number of lines that can appear in the log
95 | :type max_log_lines: int
96 |
97 | When a check depends on another, the former will only run if the latter passes.
98 | Additionally, the dependent check will inherit the filesystem of its dependency.
99 | This is particularly useful when writing e.g., a ``compiles`` check that compiles a
100 | student's program (and checks that it compiled successfully). Any checks that run the
101 | student's program will logically depend on this check, and since they inherit the
102 | resulting filesystem of the check, they will immediately have access to the compiled
103 | program without needing to recompile.
104 |
105 | Example usage::
106 |
107 | @check50.check() # Mark 'exists' as a check
108 | def exists():
109 | \"""hello.c exists\"""
110 | check50.exists("hello.c")
111 |
112 | @check50.check(exists) # Mark 'compiles' as a check that depends on 'exists'
113 | def compiles():
114 | \"""hello.c compiles\"""
115 | check50.c.compile("hello.c")
116 |
117 | @check50.check(compiles)
118 | def prints_hello():
119 | \"""prints "Hello, world!\\\\n\"""
120 | # Since 'prints_hello', depends on 'compiles' it inherits the compiled binary
121 | check50.run("./hello").stdout("[Hh]ello, world!?\\n", "hello, world\\n").exit()
122 |
123 | """
124 | def decorator(check):
125 |
126 | # Modules are evaluated from the top of the file down, so _check_names will
127 | # contain the names of the checks in the order in which they are declared
128 | _check_names.append(check.__name__)
129 | check._check_dependency = dependency
130 |
131 | @functools.wraps(check)
132 | def wrapper(run_root_dir, dependency_state):
133 | # Result template
134 | result = CheckResult.from_check(check)
135 | # Any shared (returned) state
136 | state = None
137 |
138 | try:
139 | # Setup check environment, copying disk state from dependency
140 | internal.run_dir = run_root_dir / check.__name__
141 | src_dir = run_root_dir / (dependency.__name__ if dependency else "-")
142 | shutil.copytree(src_dir, internal.run_dir)
143 | os.chdir(internal.run_dir)
144 |
145 | # Run registered functions before/after running check and set timeout
146 | with internal.register, _timeout(seconds=timeout):
147 | args = (dependency_state,) if inspect.getfullargspec(check).args else ()
148 | state = check(*args)
149 | except Failure as e:
150 | result.passed = False
151 | result.cause = e.payload
152 | except BaseException as e:
153 | result.passed = None
154 | result.cause = {"rationale": _("check50 ran into an error while running checks!"),
155 | "error": {
156 | "type": type(e).__name__,
157 | "value": str(e),
158 | "traceback": traceback.format_tb(e.__traceback__),
159 | "data" : e.payload if hasattr(e, "payload") else {}
160 | }}
161 | else:
162 | result.passed = True
163 | finally:
164 | result.log = _log if len(_log) <= max_log_lines else ["..."] + _log[-max_log_lines:]
165 | result.data = _data
166 | return result, state
167 | return wrapper
168 | return decorator
169 |
170 |
171 | class CheckRunner:
172 | def __init__(self, checks_path, included_files):
173 | self.checks_path = checks_path
174 | self.included_files = included_files
175 |
176 | def run(self, targets=None):
177 | """
178 | Run checks concurrently.
179 | Returns a list of CheckResults ordered by declaration order of the checks in the imported module
180 | targets allows you to limit which checks run. If targets is false-y, all checks are run.
181 | """
182 | graph = self.build_subgraph(targets) if targets else self.dependency_graph
183 |
184 | # Ensure that dictionary is ordered by check declaration order (via self.check_names)
185 | # NOTE: Requires CPython 3.6. If we need to support older versions of Python, replace with OrderedDict.
186 | results = {name: None for name in self.check_names}
187 |
188 | try:
189 | max_workers = int(os.environ.get("CHECK50_WORKERS"))
190 | except (ValueError, TypeError):
191 | max_workers = None
192 |
193 | with futures.ProcessPoolExecutor(max_workers=max_workers) as executor:
194 | # Start all checks that have no dependencies
195 | not_done = set(executor.submit(run_check(name, self.checks_spec))
196 | for name in graph[None])
197 | not_passed = []
198 |
199 | while not_done:
200 | done, not_done = futures.wait(not_done, return_when=futures.FIRST_COMPLETED)
201 | for future in done:
202 | # Get result from completed check
203 | result, state = future.result()
204 | results[result.name] = result
205 | if result.passed:
206 | # Dispatch dependent checks
207 | for child_name in graph[result.name]:
208 | not_done.add(executor.submit(
209 | run_check(child_name, self.checks_spec, state)))
210 | else:
211 | not_passed.append(result.name)
212 |
213 | for name in not_passed:
214 | self._skip_children(name, results)
215 |
216 | # Don't include checks we don't have results for (i.e. in the case that targets != None) in the list.
217 | return list(filter(None, results.values()))
218 |
219 |
220 | def build_subgraph(self, targets):
221 | """
222 | Build minimal subgraph of self.dependency_graph that contains each check in targets
223 | """
224 | checks = self.dependencies_of(targets)
225 | subgraph = collections.defaultdict(set)
226 | for dep, children in self.dependency_graph.items():
227 | # If dep is not a dependency of any target,
228 | # none of its children will be either, may as well skip.
229 | if dep is not None and dep not in checks:
230 | continue
231 | for child in children:
232 | if child in checks:
233 | subgraph[dep].add(child)
234 | return subgraph
235 |
236 |
237 | def dependencies_of(self, targets):
238 | """Get all unique dependencies of the targeted checks (tartgets)."""
239 | inverse_graph = self._create_inverse_dependency_graph()
240 | deps = set()
241 | for target in targets:
242 | if target not in inverse_graph:
243 | raise _exceptions.Error(_("Unknown check: {}").format(e.args[0]))
244 | curr_check = target
245 | while curr_check is not None and curr_check not in deps:
246 | deps.add(curr_check)
247 | curr_check = inverse_graph[curr_check]
248 | return deps
249 |
250 |
251 | def _create_inverse_dependency_graph(self):
252 | """Build an inverse dependency map, from a check to its dependency."""
253 | inverse_dependency_graph = {}
254 | for check_name, dependents in self.dependency_graph.items():
255 | for dependent_name in dependents:
256 | inverse_dependency_graph[dependent_name] = check_name
257 | return inverse_dependency_graph
258 |
259 |
260 | def _skip_children(self, check_name, results):
261 | """
262 | Recursively skip the children of check_name (presumably because check_name
263 | did not pass).
264 | """
265 | for name in self.dependency_graph[check_name]:
266 | if results[name] is None:
267 | results[name] = CheckResult(name=name, description=self.check_descriptions[name],
268 | passed=None,
269 | dependency=check_name,
270 | cause={"rationale": _("can't check until a frown turns upside down")})
271 | self._skip_children(name, results)
272 |
273 |
274 | def __enter__(self):
275 | # Remember the student's directory
276 | internal.student_dir = Path.cwd()
277 |
278 | # Set up a temp dir for the checks
279 | self._working_area_manager = lib50.working_area(self.included_files, name='-')
280 | internal.run_root_dir = self._working_area_manager.__enter__().parent
281 |
282 | # Change current working dir to the temp dir
283 | self._cd_manager = lib50.cd(internal.run_root_dir)
284 | self._cd_manager.__enter__()
285 |
286 | # TODO: Naming the module "checks" is arbitrary. Better name?
287 | self.checks_spec = importlib.util.spec_from_file_location("checks", self.checks_path)
288 |
289 | # Clear check_names, import module, then save check_names. Not thread safe.
290 | # Ideally, there'd be a better way to extract declaration order than @check mutating global state,
291 | # but there are a lot of subtleties with using `inspect` or similar here
292 | _check_names.clear()
293 | check_module = importlib.util.module_from_spec(self.checks_spec)
294 | self.checks_spec.loader.exec_module(check_module)
295 | self.check_names = _check_names.copy()
296 | _check_names.clear()
297 |
298 | # Grab all checks from the module
299 | checks = inspect.getmembers(check_module, lambda f: hasattr(f, "_check_dependency"))
300 |
301 | # Map each check to tuples containing the names of the checks that depend on it
302 | self.dependency_graph = collections.defaultdict(set)
303 | for name, check in checks:
304 | dependency = None if check._check_dependency is None else check._check_dependency.__name__
305 | self.dependency_graph[dependency].add(name)
306 |
307 | # Map each check name to its description
308 | self.check_descriptions = {name: check.__doc__ for name, check in checks}
309 |
310 | return self
311 |
312 |
313 | def __exit__(self, type, value, tb):
314 | # Destroy the temporary directory for the checks
315 | self._working_area_manager.__exit__(type, value, tb)
316 |
317 | # cd back to the directory check50 was called from
318 | self._cd_manager.__exit__(type, value, tb)
319 |
320 |
321 | class run_check:
322 | """
323 | Check job that runs in a separate process.
324 | This is only a class to get around the fact that `pickle` can't serialize closures.
325 | This class is essentially a function that reimports the check module and runs the check.
326 | """
327 |
328 | # All attributes shared between check50's main process and each checks' process
329 | # Required for "spawn": https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods
330 | CROSS_PROCESS_ATTRIBUTES = (
331 | "internal.check_dir",
332 | "internal.slug",
333 | "internal.student_dir",
334 | "internal.run_root_dir",
335 | "sys.excepthook",
336 | "__version__"
337 | )
338 |
339 | def __init__(self, check_name, spec, state=None):
340 | self.check_name = check_name
341 | self.spec = spec
342 | self.state = state
343 | self._store_attributes()
344 |
345 | def _store_attributes(self):
346 | """"
347 | Store all values from the attributes from run_check.CROSS_PROCESS_ATTRIBUTES on this object,
348 | in case multiprocessing is using spawn as its starting method.
349 | """
350 |
351 | # Attributes only need to be passed explicitly to child processes when using spawn
352 | if multiprocessing.get_start_method() != "spawn":
353 | return
354 |
355 | self._attribute_values = [eval(name) for name in self.CROSS_PROCESS_ATTRIBUTES]
356 |
357 | # Replace all unpickle-able values with nothing, assuming they've been set externally,
358 | # and will be set again upon re-importing the checks module
359 | # https://github.com/cs50/check50/issues/235
360 | for i, value in enumerate(self._attribute_values):
361 | try:
362 | pickle.dumps(value)
363 | except (pickle.PicklingError, AttributeError):
364 | self._attribute_values[i] = None
365 |
366 | self._attribute_values = tuple(self._attribute_values)
367 |
368 |
369 | def _set_attributes(self):
370 | """
371 | If the parent process set any values in self._attribute_values,
372 | restore them in the child process.
373 | """
374 | if not hasattr(self, "_attribute_values"):
375 | return
376 |
377 | for name, val in zip(self.CROSS_PROCESS_ATTRIBUTES, self._attribute_values):
378 | self._set_attribute(name, val)
379 |
380 |
381 | @staticmethod
382 | def _set_attribute(name, value):
383 | """Get an attribute from a name in global scope and set its value."""
384 | parts = name.split(".")
385 |
386 | obj = sys.modules[__name__]
387 | for part in parts[:-1]:
388 | obj = getattr(obj, part)
389 |
390 | setattr(obj, parts[-1], value)
391 |
392 |
393 | def __call__(self):
394 | # Restore any attributes from the parent process
395 | self._set_attributes()
396 |
397 | # Create the checks module
398 | mod = importlib.util.module_from_spec(self.spec)
399 |
400 | # Execute (effectively import) the checks module
401 | self.spec.loader.exec_module(mod)
402 |
403 | # Run just the check named self.check_name
404 | internal.check_running = True
405 | try:
406 | return getattr(mod, self.check_name)(internal.run_root_dir, self.state)
407 | finally:
408 | internal.check_running = False
409 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | SPHINXPROJ = check50
8 | SOURCEDIR = source
9 | BUILDDIR = build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
21 |
--------------------------------------------------------------------------------
/docs/requirements.txt:
--------------------------------------------------------------------------------
1 | sphinx
2 | sphinx-autobuild
3 | sphinx_rtd_theme
4 |
--------------------------------------------------------------------------------
/docs/source/ansi_output.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cs50/check50/ab2aa996b666f8141d78cf763d23dad422feef07/docs/source/ansi_output.png
--------------------------------------------------------------------------------
/docs/source/api.rst:
--------------------------------------------------------------------------------
1 | .. _api:
2 |
3 | API docs
4 | ========
5 |
6 | .. _check50:
7 |
8 |
9 | check50
10 | *******
11 |
12 | .. automodule:: check50
13 | :members:
14 |
15 |
16 | check50.c
17 | **********
18 |
19 | .. automodule:: check50.c
20 | :members:
21 |
22 |
23 | check50.flask
24 | ****************
25 |
26 | .. automodule:: check50.flask
27 | :members:
28 |
29 |
30 | check50.py
31 | ***********
32 | .. automodule:: check50.py
33 | :members:
34 |
35 |
36 | check50.regex
37 | **************
38 | .. automodule:: check50.regex
39 | :members:
40 |
41 |
42 | check50.internal
43 | *****************
44 |
45 | .. automodule:: check50.internal
46 | :members:
47 |
--------------------------------------------------------------------------------
/docs/source/check50_user.rst:
--------------------------------------------------------------------------------
1 | .. _check50_user:
2 |
3 | Running check50
4 | ======================
5 |
6 | Slug
7 | **********************
8 | check50 requires one positional argument, a so called slug. Something like this:
9 |
10 | .. code-block:: bash
11 |
12 | check50 cs50/problems/2018/x/caesar
13 |
14 | Why? Well, anyone can write checks for check50 without needing to ask for permission. To achieve this goal check50, the tool, is decoupled from any of its checks. The checks themselves are hosted anywhere on a popular hosting platform for code, GitHub. All you need to do to add your own checks is to put them on anywhere on GitHub. With this flexibility comes a price, as check50 does not know where your checks live on GitHub. In order to uniquely identify a collection of checks on GitHub check50 needs the following:
15 |
16 | * org
17 | * repository
18 | * branch
19 | * path to problem
20 |
21 | These four pieces of information separated by a / is what check50 calls a slug, a string that uniquely identifies a set of checks. For instance the slug :code:`cs50/problems/2018/x/caesar` uniquely identifies the org :code:`cs50`, the repository :code:`problems`, the branch :code:`2018/x` and the path :code:`caesar`.
22 |
23 |
24 | Operation modes
25 | **********************
26 | Check50 can run in four mutually exclusive modes of operation.
27 |
28 | **********************
29 | online
30 | **********************
31 | By default check50 runs the checks remotely and then waits for the results to come back.
32 |
33 | **********************
34 | local
35 | **********************
36 | To run checks locally, pass the :code:`--local` flag. The checks are still fetched remotely from GitHub.
37 |
38 | **********************
39 | offline
40 | **********************
41 | Running with :code:`--offline` runs the checks locally and has check50 look for checks locally. check50 will not try to fetch checks remotely in this mode.
42 |
43 | **********************
44 | dev
45 | **********************
46 | The :code:`--dev` flag signals check50 to run in developer mode. This implies :code:`--offline`. More on this mode in :ref:`check_writer`.
47 |
48 |
49 | Additional output
50 | **********************
51 | By default check50 will try to keep its output concise in its :code:`ansi` output mode. For each check you will see at most its description and rationale/help on why the check failed. check50 will by default hide tracebacks and log output. You can show both by running check50 with the :code:`--verbose` flag, or just the log with the :code:`--log` flag.
52 |
53 | **********************
54 | verbose
55 | **********************
56 | Running with :code:`--verbose` lets check50 output any tracebacks in the :code:`ansi` output mode.
57 |
58 | **********************
59 | log-level
60 | **********************
61 | Running check50 with :code:`--log-level INFO` will display any git commands run. :code:`--log-level DEBUG` adds all output of any git commands run.
62 |
63 |
64 | Targeting checks
65 | **********************
66 | Check50 lets you target specific checks by name with the :code:`--target` flags. This will have check50 run just these checks and their dependencies.
67 |
68 | **********************
69 | target
70 | **********************
71 | With :code:`--target` you can target checks from a larger body of checks by name. check50 will only run and show these checks and their dependencies.
72 |
73 |
74 | Output modes
75 | **********************
76 | check50 supports three output modes: :code:`ansi`, :code:`html` and :code:`json`. In short, the :code:`ansi` output mode is text-based output meant to be displayed in a terminal. :code:`html` is an extension of :code:`ansi` showing the same results but in a webpage. This allows for visual comparisons and more information to be displayed in general. Finally, the :code:`json` output mode provides a machine readable form of output, that can for instance be used for automatic grading.
77 |
78 | The output modes can be mixed and matched through the :code:`--output` or :code:`-o` flag.
79 |
80 | .. code-block:: bash
81 |
82 | check50 -o ansi
83 | check50 -o ansi html # equivalent to check50
84 | check50 -o json
85 |
86 | By default check50 shows both :code:`ansi` and :code:`html` output.
87 |
88 | **********************
89 | ansi
90 | **********************
91 | The :code:`ansi` output mode will have check50 print the results from the checks to stdout. This output mode keeps students in the terminal, the environment in which they are running check50 in the first place. Limited by its nature, check50's :code:`ansi` output mode is not suited for large pieces of text or visual comparisons. The output format is sufficient for showing which checks passed and failed, and offering short text based help or explanation on those checks.
92 |
93 | .. image:: ansi_output.png
94 |
95 | **********************
96 | html
97 | **********************
98 | In addition to :code:`ansi`, check50 comes with a :code:`html` output mode. This output mode allows check50 to show results side by side and to display more verbose information like the log by default. check50 creates a local self contained static html file in :code:`/tmp` and will output the path to file in stdout.
99 |
100 | .. image:: html_output.png
101 |
102 | **********************
103 | json
104 | **********************
105 | check50 can provide machine readable output in the form of :code:`json`. By default this output mode will print to stdout, but like any other form of output check50 can write to a file with the :code:`--output-file` command line option. For a complete overview of the :code:`json` output please refer to the :ref:`json_specification`.
106 |
107 | .. code-block:: json
108 |
109 | {
110 | "slug": "cs50/problems/2018/x/caesar",
111 | "results": [
112 | {
113 | "name": "exists",
114 | "description": "caesar.c exists.",
115 | "passed": true,
116 | "log": [
117 | "checking that caesar.c exists..."
118 | ],
119 | "cause": null,
120 | "data": {},
121 | "dependency": null
122 | },
123 | {
124 | "name": "compiles",
125 | "description": "caesar.c compiles.",
126 | "passed": false,
127 | "log": [
128 | "running clang caesar.c -o caesar -std=c11 -ggdb -lm -lcs50...",
129 | "caesar.c:24:5: warning: implicit declaration of function 'f' is invalid in C99",
130 | " [-Wimplicit-function-declaration]",
131 | " f (argc != 2)",
132 | " ^",
133 | "caesar.c:24:18: error: expected ';' after expression",
134 | " f (argc != 2)",
135 | " ^",
136 | " ;",
137 | "1 warning and 1 error generated."
138 | ],
139 | "cause": {
140 | "rationale": "code failed to compile",
141 | "help": null
142 | },
143 | "data": {},
144 | "dependency": "exists"
145 | },
146 | {
147 | "name": "encrypts_a_as_b",
148 | "description": "encrypts \"a\" as \"b\" using 1 as key",
149 | "passed": null,
150 | "log": [],
151 | "cause": {
152 | "rationale": "can't check until a frown turns upside down"
153 | },
154 | "data": {},
155 | "dependency": "compiles"
156 | },
157 | {
158 | "name": "encrypts_barfoo_as_yxocll",
159 | "description": "encrypts \"barfoo\" as \"yxocll\" using 23 as key",
160 | "passed": null,
161 | "log": [],
162 | "cause": {
163 | "rationale": "can't check until a frown turns upside down"
164 | },
165 | "data": {},
166 | "dependency": "compiles"
167 | },
168 | {
169 | "name": "encrypts_BARFOO_as_EDUIRR",
170 | "description": "encrypts \"BARFOO\" as \"EDUIRR\" using 3 as key",
171 | "passed": null,
172 | "log": [],
173 | "cause": {
174 | "rationale": "can't check until a frown turns upside down"
175 | },
176 | "data": {},
177 | "dependency": "compiles"
178 | },
179 | {
180 | "name": "encrypts_BaRFoo_FeVJss",
181 | "description": "encrypts \"BaRFoo\" as \"FeVJss\" using 4 as key",
182 | "passed": null,
183 | "log": [],
184 | "cause": {
185 | "rationale": "can't check until a frown turns upside down"
186 | },
187 | "data": {},
188 | "dependency": "compiles"
189 | },
190 | {
191 | "name": "encrypts_barfoo_as_onesbb",
192 | "description": "encrypts \"barfoo\" as \"onesbb\" using 65 as key",
193 | "passed": null,
194 | "log": [],
195 | "cause": {
196 | "rationale": "can't check until a frown turns upside down"
197 | },
198 | "data": {},
199 | "dependency": "compiles"
200 | },
201 | {
202 | "name": "checks_for_handling_non_alpha",
203 | "description": "encrypts \"world, say hello!\" as \"iadxp, emk tqxxa!\" using 12 as key",
204 | "passed": null,
205 | "log": [],
206 | "cause": {
207 | "rationale": "can't check until a frown turns upside down"
208 | },
209 | "data": {},
210 | "dependency": "compiles"
211 | },
212 | {
213 | "name": "handles_no_argv",
214 | "description": "handles lack of argv[1]",
215 | "passed": null,
216 | "log": [],
217 | "cause": {
218 | "rationale": "can't check until a frown turns upside down"
219 | },
220 | "data": {},
221 | "dependency": "compiles"
222 | }
223 | ],
224 | "version": "3.0.0"
225 | }
226 |
--------------------------------------------------------------------------------
/docs/source/check_writer.rst:
--------------------------------------------------------------------------------
1 | .. _check_writer:
2 |
3 | Writing check50 checks
4 | ======================
5 |
6 | check50 checks live in a git repo on Github. check50 finds the git repo based on the slug that is passed to check50. For instance, consider the following execution of check50:
7 |
8 | .. code-block:: bash
9 |
10 | check50 cs50/problems/2018/x/hello
11 |
12 | check50 will look for an owner called `cs50`, a repo called `problems`, a branch called `2018` or `2018/x` and a problem called `x/hello` or `hello`. The slug is thus parsed like so:
13 |
14 | .. code-block:: bash
15 |
16 | check50 ///
17 |
18 | Creating a git repo
19 | *******************
20 |
21 | To get you started, the first thing you need to do is |register_github|. Once you have done so, or if you already have an account with Github, |create_repo|. Make sure to think of a good name for your repo, as this is what students will be typing. Also make sure your repo is set to public, it is initialised with a `README`, and finally add a Python `.gitignore`. Ultimately you should have something looking like this:
22 |
23 | .. |register_github| raw:: html
24 |
25 | register with Github
26 |
27 | .. |create_repo| raw:: html
28 |
29 | create a new git repo
30 |
31 | .. image:: repo.png
32 |
33 | Creating a check and running it
34 | *******************************
35 |
36 | Your new repo should live at ``https://github.com//``, that is ``https://github.com/cs50/example_checks`` in our example. Once you have created your new repo, create a new file by clicking the `Create new file` button:
37 |
38 | .. image:: new_file.png
39 |
40 | Then continue by creating the following `.cs50.yaml` file. All indentation is done by 2 spaces, as per YAML syntax.
41 |
42 | .. image:: new_yaml.png
43 |
44 | Or in text, if you want to quickly copy-paste:
45 |
46 | .. code-block:: yaml
47 | :linenos:
48 | :caption: **.cs50.yaml**
49 |
50 | check50:
51 | checks:
52 | hello world:
53 | - run: python3 hello.py
54 | stdout: Hello, world!
55 | exit: 0
56 |
57 | Note that you should create a directory like in the example above by typing: `example/.cs50.yaml`. Once you have populated the file with the code above. Scroll down the page and hit the commit button:
58 |
59 | .. image:: commit.png
60 |
61 | That's it! You know have a repo that check50 can use to check whether a python file called `hello.py` prints ``Hello, world!`` and exits with a ``0`` as exit code. To try it, simply execute:
62 |
63 | .. code-block:: bash
64 |
65 | check50 //master/example --local
66 |
67 | Where you substitute `` for your own username, `` for the repo you've just created. Given that a file called `hello.py` is in your current working directory, and it actually prints ``Hello, world!`` when run, you should now see the following:
68 |
69 | .. code-block:: bash
70 |
71 | :) hello world
72 |
73 | Simple YAML checks
74 | ******************
75 |
76 | To get you started, and to cover the basics of input/output checking, check50 lets you write simple checks in YAML syntax. Under the hood, check50 compiles these YAML checks to Python checks that check50 then runs.
77 |
78 | YAML checks in check50 all live in `.cs50.yaml` and start with a top-level key called ``check50``, that specifies a ``checks``. The ``checks`` record contains all checks, where the name of the check is the name of the YAML record. Like so:
79 |
80 | .. code-block:: yaml
81 | :linenos:
82 | :caption: **.cs50.yaml**
83 |
84 | check50:
85 | checks:
86 | hello world: # define a check named hello world
87 | # check code
88 | foo: # define a check named foo
89 | # check code
90 | bar: # define a check named bar
91 | # check code
92 |
93 |
94 | This code snippet defines three checks, named ``hello world``, ``foo`` and ``bar`` respectively. These checks should contain a list of ``run`` records, that can each contain a combination of ``stdin``, ``stdout`` and ``exit``. See below:
95 |
96 | .. code-block:: yaml
97 | :linenos:
98 | :caption: **.cs50.yaml**
99 |
100 | check50:
101 | checks:
102 | hello world:
103 | - run: python3 hello.py # run python3 hello.py
104 | stdout: Hello, world! # expect Hello, world! in stdout
105 | exit: 0 # expect program to exit with exitcode 0
106 | foo:
107 | - run: python3 foo.py # run python3 foo.py
108 | stdin: baz # insert baz into stdin
109 | stdout: baz # expect baz in stdout
110 | exit: 0 # expect program to exit with exitcode 0
111 | bar:
112 | - run: python3 bar.py # run python3 bar.py
113 | stdin: baz # insert baz into stdin
114 | stdout: bar baz # expect bar baz in stdout
115 | - run: python3 bar.py # run python3 bar.py
116 | stdin:
117 | - baz # insert baz into stdin
118 | - qux # insert qux into stdin
119 | stdout:
120 | - bar baz # first expect bar baz in stdout
121 | - bar qux # then expect bar qux in stdout
122 |
123 | The code snippet above again defines three checks: `hello world`, `foo` and `bar`.
124 |
125 | The ``hello world`` check runs ``python3 hello.py`` in the terminal, expects ``Hello, world!`` to be outputted in stdout, and then expects the program to exit with exitcode ``0``.
126 |
127 | The ``foo`` check runs ``python3 foo.py`` in the terminal, inserts ``baz`` into stdin, expects ``baz`` to be outputted in stdout, and finally expects the program to exit with exitcode ``0``.
128 |
129 | The ``bar`` check runs two commands in order in the terminal. First ``python3 bar.py`` gets run, ``baz`` gets put in stdin and ``bar baz`` is expected in stdout. There is no mention of ``exit`` here, so the exitcode is not checked. Secondly, ``python3 bar.py`` gets run, ``baz`` and ``qux`` get put into stdin, and first ``bar baz`` is expected in stdout, then ``bar qux``.
130 |
131 | We encourage you to play around with the example above by copying its code to your checks git repo. Then try to write a `bar.py` and `foo.py` that make you pass these tests.
132 |
133 | In case you want to check for multiline input, you can make use of YAML's ``|`` operator like so:
134 |
135 | .. code-block:: yaml
136 | :linenos:
137 | :caption: **.cs50.yaml**
138 |
139 | check50:
140 | checks:
141 | multiline hello world:
142 | - run: python3 multi_hello.py
143 | stdout: | # expect Hello\nWorld!\n in stdout
144 | Hello
145 | World!
146 | exit: 0
147 |
148 | Developing locally
149 | ******************
150 |
151 | To write checks on your own machine, rather than on the Github webpage, you can clone the repo via:
152 |
153 | .. code-block:: bash
154 |
155 | git clone https://github.com//
156 |
157 | Where ```` is your Github username, and ```` is the name of your checks repository. Head on over to the new directory that git just created, and open up `.cs50.yaml` with your favorite editor.
158 |
159 | To run the cloned checks locally, check50 comes with a ``--dev`` mode. That will let you target a local checks repo, rather than a github repo. So if your checks live in ``/Users/cs50/Documents/example_checks``, you would execute check50 like so:
160 |
161 | .. code-block:: bash
162 |
163 | check50 --dev /Users/cs50/Documents/example_checks/example
164 |
165 | This runs the `example` check from ``/Users/cs50/Documents/example_checks``. You can also specify a relative path, so if your current working directory is ``/Users/cs50/Documents/solutions``, you can execute check50 like so:
166 |
167 | .. code-block:: bash
168 |
169 | check50 --dev ../example_checks/example
170 |
171 | Now you're all set to develop new checks locally. Just remember to ``git add``, ``git commit`` and ``git push`` when you're done writing checks. Quick refresher:
172 |
173 | .. code-block:: bash
174 |
175 | git add .cs50.yaml
176 | git commit -m "wrote some awesome new checks!"
177 | git push
178 |
179 | Getting started with Python checks
180 | **********************************
181 |
182 | If you need a little more than strict input / output testing, check50 lets you write checks in Python. A good starting point is the result of the compilation of the YAML checks. To get these, please make sure you have cloned the repo (via ``git clone`` ), and thus have the checks locally. First we need to run the .YAML checks once, so that check50 compiles the checks to Python. To do this execute:
183 |
184 | .. code-block:: bash
185 |
186 | check50 --dev /
187 |
188 | Where ```` is the local git repo of your checks, and ```` is the directory in which ``.cs50.yaml`` lives. Alternatively you could navigate to this directory and simply call:
189 |
190 | .. code-block:: bash
191 |
192 | check50 --dev .
193 |
194 | As a result you should now find a file called ``__init__.py`` in the check directory. This is the result of check50's compilation from YAML to Python. For instance, if your ``.cs50.yaml`` contains the following:
195 |
196 | .. code-block:: yaml
197 | :linenos:
198 | :caption: **.cs50.yaml**
199 |
200 | check50:
201 | checks:
202 | hello world:
203 | - run: python3 hello.py
204 | stdout: Hello, world!
205 | exit: 0
206 |
207 | You should now find the following ``__init__.py``:
208 |
209 | .. code-block:: python
210 | :linenos:
211 | :caption: **__init__.py**
212 |
213 | import check50
214 |
215 | @check50.check()
216 | def hello_world():
217 | """hello world"""
218 | check50.run("python3 hello.py").stdout("Hello, world!", regex=False).exit(0)
219 |
220 | check50 will by default ignore and overwrite what is in ``__init__.py`` for as long as there are checks in ``.cs50.yaml``. To change this you have to edit ``.cs50.yaml`` to:
221 |
222 | .. code-block:: yaml
223 | :caption: **.cs50.yaml**
224 |
225 | check50: true
226 |
227 | If the ``checks`` key is not specified (as is the case above), ``check50`` will look for Python checks written in a file called ``__init__.py``. If you would like to write the Python checks in a file called ``foo.py`` instead, you could specify it like so:
228 |
229 | .. code-block:: yaml
230 | :caption: **.cs50.yaml**
231 |
232 | check50:
233 | checks: foo.py
234 |
235 |
236 | To test whether everything is still in working order, run check50 again with:
237 |
238 | .. code-block:: bash
239 |
240 | check50 --dev /
241 |
242 | You should see the same results as the YAML checks gave you. Now that there are no YAML checks in ``.cs50.yaml`` and check50 knows where to look for Python checks, you can start writing Python checks. You can find documentation in :ref:`api`, and examples of Python checks below.
243 |
244 | Python check specification
245 | **************************
246 |
247 | A Python check is made up as follows:
248 |
249 | .. code-block:: Python
250 | :linenos:
251 | :caption: **__init__.py**
252 |
253 | import check50 # import the check50 module
254 |
255 | @check50.check() # tag the function below as check50 check
256 | def exists(): # the name of the check
257 | """description""" # this is what you will see when running check50
258 | check50.exists("hello.py") # the actual check
259 |
260 | @check50.check(exists) # only run this check if the exists check has passed
261 | def prints_hello():
262 | """prints "hello, world\\n" """
263 | check50.run("python3 hello.py").stdout("[Hh]ello, world!?\n", regex=True).exit(0)
264 |
265 | check50 uses its check decorator to tag functions as checks. You can pass another check as argument to specify a dependency. Docstrings are used as check descriptions, this is what will ultimately be shown when running check50. The checks themselves are just Python code. check50 comes with a simple API to run programs, send input to stdin, and check or retrieve output from stdout. A check fails if a ``check50.Failure`` exception or an exception inheriting from ``check50.Failure`` like ``check50.Mismatch`` is thrown. This allows you to write your own custom check code like so:
266 |
267 | .. code-block:: Python
268 | :linenos:
269 | :caption: **__init__.py**
270 |
271 | import check50
272 |
273 | @check50.check()
274 | def prints_hello():
275 | """prints "hello, world\\n" """
276 | from re import match
277 |
278 | expected = "[Hh]ello, world!?\n"
279 | actual = check50.run("python3 hello.py").stdout()
280 | if not match(expected, actual):
281 | help = None
282 | if match(expected[:-1], actual):
283 | help = r"did you forget a newline ('\n') at the end of your printf string?"
284 | raise check50.Mismatch("hello, world\n", actual, help=help)
285 |
286 | The above check breaks out of check50's API by calling ``stdout()`` on line 9 with no args, effectively retrieving all output from stdout in a string. Then there is some plain Python code, matching the output through Python's builtin regex module ``re`` against a regular expression with the expected outcome. If it doesn't match, a help message is provided only if there is a newline missing at the end. This help message is provided through an optional argument ``help`` passed to check50's ``Mismatch`` exception.
287 |
288 | You can share state between checks if you make them dependent on each other. By default file state is shared, allowing you to for instance test compilation in one check, and then depend on the result of the compilation in dependent checks.
289 |
290 | .. code-block:: Python
291 | :linenos:
292 | :caption: **__init__.py**
293 |
294 | import check50
295 | import check50.c
296 |
297 | @check50.check()
298 | def compiles():
299 | """hello.c compiles"""
300 | check50.c.compile("hello.c")
301 |
302 | @check50.check(compiles)
303 | def prints_hello():
304 | """prints "hello, world\\n" """
305 | check50.run("./hello").stdout("[Hh]ello, world!?\n", regex=True).exit(0)
306 |
307 | You can also share Python state between checks by returning what you want to share from a check. It's dependent can accept this by accepting an additional argument.
308 |
309 | .. code-block:: Python
310 | :linenos:
311 | :caption: **__init__.py**
312 |
313 | import check50
314 |
315 | @check50.check()
316 | def foo():
317 | return 1
318 |
319 | @check50.check(foo)
320 | def bar(state)
321 | print(state) # prints 1
322 |
323 | Python check examples
324 | *********************
325 |
326 | Below you will find examples of Python checks. Don't forget to |cs50_checks| for more examples. You can try them yourself by copying them to ``__init__.py`` and running:
327 |
328 | .. |cs50_checks| raw:: html
329 |
330 | checkout CS50's own checks
331 |
332 | .. code-block:: bash
333 |
334 | check50 --dev /
335 |
336 | Check whether a file exists:
337 |
338 | .. code-block:: python
339 | :linenos:
340 | :caption: **__init__.py**
341 |
342 | import check50
343 |
344 | @check50.check()
345 | def exists():
346 | """hello.py exists"""
347 | check50.exists("hello.py")
348 |
349 | Check stdout for an exact string:
350 |
351 | .. code-block:: python
352 | :linenos:
353 | :caption: **__init__.py**
354 |
355 | @check50.check(exists)
356 | def prints_hello_world():
357 | """prints Hello, world!"""
358 | check50.run("python3 hello.py").stdout("Hello, world!", regex=False).exit(0)
359 |
360 | Check stdout for a rough match:
361 |
362 | .. code-block:: python
363 | :linenos:
364 | :caption: **__init__.py**
365 |
366 | @check50.check(exists)
367 | def prints_hello():
368 | """prints "hello, world\\n" """
369 | # regex=True by default :)
370 | check50.run("python3 hello.py").stdout("[Hh]ello, world!?\n").exit(0)
371 |
372 | Put something in stdin, expect it in stdout:
373 |
374 | .. code-block:: python
375 | :linenos:
376 | :caption: **__init__.py**
377 |
378 | import check50
379 |
380 | @check50.check()
381 | def id():
382 | """id.py prints what you give it"""
383 | check50.run("python3 hello.py").stdin("foo").stdout("foo").stdin("bar").stdout("bar")
384 |
385 | Be helpful, check for common mistakes:
386 |
387 | .. code-block:: python
388 | :linenos:
389 | :caption: **__init__.py**
390 |
391 | import check50
392 | import re
393 |
394 | def coins(num):
395 | # regex that matches `num` not surrounded by any other numbers
396 | # (so coins(2) won't match e.g. 123)
397 | return fr"(?``, by default ``check50`` will look for an ``__init__.py`` containing Python checks.
445 |
446 | .. code-block:: YAML
447 | :linenos:
448 | :caption: **.cs50.yaml**
449 |
450 | check50:
451 | checks: "my_filename.py"
452 |
453 | Specifies that this is a valid slug for check50, and has check50 look for ``my_filename.py`` instead of ``__init__.py``.
454 |
455 | .. code-block:: YAML
456 | :linenos:
457 | :caption: **.cs50.yaml**
458 |
459 | check50:
460 | checks:
461 | hello world:
462 | - run: python3 hello.py
463 | stdout: Hello, world!
464 | exit: 0
465 |
466 | Specifies that this is a valid slug for check50, and has check50 compile and run the YAML check. For more on YAML checks in check50 see :ref:``check_writer``.
467 |
468 |
469 | ******
470 | files:
471 | ******
472 |
473 | ``files:`` takes a list of files/patterns. Every item in the list must be tagged by either ``!include``, ``!exclude`` or ``!require``. All files matching a pattern tagged with ``!include`` are included and likewise for ``!exclude``. ``!require`` is similar to ``!include``, however it does not accept patterns, only filenames, and will cause ``check50`` to display an error if that file is missing. The list that is given to ``files:`` is processed top to bottom. Later items in ``files:`` win out over earlier items.
474 |
475 | The patterns that ``!include`` and ``!exclude`` accept are globbed, any matching files are added. check50 introduces one exception for convenience, similarly to how git treats .gitignore: If and only if a pattern does not contain a ``/``, and starts with a ``*``, it is considered recursive in such a way that ``*.o`` will exclude all files in any directory ending with ``.o``. This special casing is just for convenience. Alternatively you could write ``**/*.o`` that is functionally identical to ``*.o``, or write ``./*.o`` if you only want to exclude files ending with ``.o`` from the top-level directory.
476 |
477 | .. code-block:: YAML
478 | :linenos:
479 | :caption: **.cs50.yaml**
480 |
481 | check50:
482 | files:
483 | - !exclude "*.pyc"
484 |
485 | Excludes all files ending with ``.pyc``.
486 |
487 | .. code-block:: YAML
488 | :linenos:
489 | :caption: **.cs50.yaml**
490 |
491 | check50:
492 | files:
493 | - !exclude "*"
494 | - !include "*.py"
495 |
496 | Exclude all files, but include all files ending with ``.py``. Note that order is important here, if you would inverse the two lines it would read: include all files ending with ``.py``, exclude everything. Effectively excluding everything!
497 |
498 | .. code-block:: YAML
499 | :linenos:
500 | :caption: **.cs50.yaml**
501 |
502 | check50:
503 | files:
504 | - !exclude "*"
505 | - !include "source/"
506 |
507 | Exclude all files, but include all files in the source directory.
508 |
509 | .. code-block:: YAML
510 | :linenos:
511 | :caption: **.cs50.yaml**
512 |
513 | check50:
514 | files:
515 | - !exclude "build/"
516 | - !exclude "docs/"
517 |
518 | Include everything, but exclude everything in the build and docs directories.
519 |
520 | .. code-block:: YAML
521 | :linenos:
522 | :caption: **.cs50.yaml**
523 |
524 | check50:
525 | files:
526 | - !exclude "*"
527 | - !include "source/"
528 | - !exclude "*.pyc"
529 |
530 | Exclude everything, include everything from the source directory, but exclude all files ending with ``.pyc``.
531 |
532 | .. code-block:: YAML
533 | :linenos:
534 | :caption: **.cs50.yaml**
535 |
536 | check50:
537 | files:
538 | - !exclude "source/**/*.pyc"
539 |
540 | Include everything, but any files ending on ``.pyc`` within the source directory. The ``**`` here pattern matches any directory.
541 |
542 | .. code-block:: YAML
543 | :linenos:
544 | :caption: **.cs50.yaml**
545 |
546 | check50:
547 | files:
548 | - !require "foo.py"
549 | - !require "bar.c"
550 |
551 | Require that both foo.py and bar.c are present and include them.
552 |
553 | .. code-block:: YAML
554 | :linenos:
555 | :caption: **.cs50.yaml**
556 |
557 | check50:
558 | files:
559 | - !exclude "*"
560 | - !include "*.py"
561 | - !require "foo.py"
562 | - !require "bar.c"
563 |
564 | Exclude everything, include all files ending with ``.py`` and require (and include) both foo.py and bar.c. It is generally recommended to place any ``!require``d files at the end of the ``files:``, this ensures they are always included.
565 |
566 | *************
567 | dependencies:
568 | *************
569 |
570 | ``dependencies:`` is a list of ``pip`` installable dependencies that check50 will install.
571 |
572 | .. code-block:: YAML
573 | :linenos:
574 | :caption: **.cs50.yaml**
575 |
576 | check50:
577 | dependencies:
578 | - pyyaml
579 | - flask
580 |
581 | Has check50 install both ``pyyaml`` and ``flask`` via ``pip``.
582 |
583 | .. code-block:: YAML
584 | :linenos:
585 | :caption: **.cs50.yaml**
586 |
587 | check50:
588 | dependencies:
589 | - git+https://github.com/cs50/submit50#egg=submit50
590 |
591 | Has check50 ``pip install`` submit50 from GitHub, especially useful for projects that are not hosted on PyPi. See https://pip.pypa.io/en/stable/reference/pip_install/#vcs-support for more info on installing from a VCS.
592 |
593 |
594 | Internationalizing checks
595 | *************************
596 | TODO
597 |
--------------------------------------------------------------------------------
/docs/source/commit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cs50/check50/ab2aa996b666f8141d78cf763d23dad422feef07/docs/source/commit.png
--------------------------------------------------------------------------------
/docs/source/conf.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import time
4 |
5 | _tool = "check50"
6 |
7 | # Add path to module for autodoc
8 | sys.path.insert(0, os.path.abspath(f'../../{_tool}'))
9 |
10 | # Include __init__ in autodoc
11 | # Source: https://stackoverflow.com/questions/5599254/how-to-use-sphinxs-autodoc-to-document-a-classs-init-self-method
12 | def skip(app, what, name, obj, would_skip, options):
13 | if name == "__init__":
14 | return False
15 | return would_skip
16 |
17 | def setup(app):
18 | app.connect("autodoc-skip-member", skip)
19 |
20 | extensions = ['sphinx.ext.autodoc']
21 |
22 | html_css_files = ["https://cs50.readthedocs.io/_static/custom.css?" + str(round(time.time()))]
23 | html_js_files = ["https://cs50.readthedocs.io/_static/custom.js?" + str(round(time.time()))]
24 | html_theme = "sphinx_rtd_theme"
25 | html_theme_options = {
26 | "display_version": False,
27 | "prev_next_buttons_location": False,
28 | "sticky_navigation": False
29 | }
30 | html_title = f'{_tool} Docs'
31 |
32 | project = f'{_tool}'
33 |
--------------------------------------------------------------------------------
/docs/source/extension_writer.rst:
--------------------------------------------------------------------------------
1 | .. _extension_writer:
2 |
3 | Writing check50 extensions
4 | ==========================
5 |
6 | Core to check50's design is extensibility. Not only in checks, but also in the tool itself. We designed :code:`check50.c`, :code:`check50.py` and :code:`check50.flask` to be extensions of check50 that ship with check50. By design these three modules are all standalone and only depend on the core of check50, and no other part of check50 depends on them. In other words, you can remove or add any of these modules and check50 would still function as expected.
7 |
8 | We ship check50 with three extensions because these extensions are core to the material cs50 teaches. But different courses have different needs, and we realize we cannot predict and cater for every usecase. This is why check50 comes with the ability to `pip install` other Python packages. You can configure this via the :code:`dependencies` key in :code:`.cs50.yaml`. Through this mechanism you can write your own extension and then have check50 install it for you whenever check50 runs. Host your extension anywhere `pip` can install from, for instance GitHub or PyPi. And all you have to do then is to fill in the :code:`dependencies` key of :code:`.cs50.yaml` with the location of your extension. check50 will make sure your extension is always there when the checks are run.
9 |
10 |
11 | check50.internal
12 | *******************************
13 |
14 | 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`.
15 |
16 |
17 | Example: a JavaScript extension
18 | *******************************
19 | 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 ')`. 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
20 | https://github.com/cs50/check50/tree/sample-extension.
21 |
22 |
23 | *******************************
24 | check50_js
25 | *******************************
26 | The challenge in writing this extension is that check50 itself is written in Python, so we need an interface between the two languages. This could be as simple as an intermediate JavaScript script that runs the students function and then outputs the results to stdout for check50 to read. But this approach does create indirection and creates quite some clutter in the checks codebase. Luckily, in case of Python and JavaScript (and PHP and Perl) we have access to a Python package called :code:`python-bond`. This package lets us "bond" two languages together, and lets you evaluate code in another language's interpreter. Effectively creating a channel through which you can evaluate code and call functions from the other language. This is what we ended up doing for our JavaScript extension.
27 |
28 | You can find example checks using check50_js and their solutions at:
29 |
30 | * **hello.js**: `checks `__ `solution `__
31 | * **line.js**: `checks `__ `solution `__
32 | * **addition.js**: `checks `__ `solution `__
33 |
34 |
35 | To try any of these examples for yourself, simply run:
36 |
37 | * **hello.js**:
38 |
39 | .. code-block:: bash
40 |
41 | wget https://raw.githubusercontent.com/cs50/check50/examples/solutions/hello_js/hello.js
42 | check50 cs50/check50/examples/js/hello
43 |
44 | * **line.js**:
45 |
46 | .. code-block:: bash
47 |
48 | wget https://raw.githubusercontent.com/cs50/check50/examples/solutions/line_js/line.js
49 | check50 cs50/check50/examples/js/line
50 |
51 | * **addition.js**:
52 |
53 | .. code-block:: bash
54 |
55 | wget https://raw.githubusercontent.com/cs50/check50/examples/solutions/addition_js/addition.js
56 | check50 cs50/check50/examples/js/addition
57 |
--------------------------------------------------------------------------------
/docs/source/html_output.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cs50/check50/ab2aa996b666f8141d78cf763d23dad422feef07/docs/source/html_output.png
--------------------------------------------------------------------------------
/docs/source/index.rst:
--------------------------------------------------------------------------------
1 | ``check50``
2 | ===========
3 |
4 | .. toctree::
5 | :hidden:
6 | :maxdepth: 3
7 | :caption: Contents:
8 |
9 | check50_user.rst
10 | api.rst
11 | json_specification.rst
12 | check_writer.rst
13 | extension_writer.rst
14 |
15 | .. Indices and tables
16 | .. ==================
17 |
18 | .. * :ref:`genindex`
19 | .. * :ref:`api`
20 | .. * :ref:`modindex`
21 | .. * :ref:`search`
22 |
23 | check50 is a tool for checking student code. As a student you can use check50 to check your CS50 problem sets or any other Problem sets for which check50 checks exist. check50 allows teachers to automatically grade code on correctness and to provide automatic feedback while students are coding.
24 |
25 | Installation
26 | ************
27 |
28 | First make sure you have Python 3.6 or higher installed. You can download Python |download_python|.
29 |
30 | .. |download_python| raw:: html
31 |
32 | here
33 |
34 | check50 has a dependency on git, please make sure to |install_git| if git is not already installed.
35 |
36 | .. |install_git| raw:: html
37 |
38 | install git
39 |
40 | To install check50 under Linux / OS X:
41 |
42 | .. code-block:: bash
43 |
44 | pip install check50
45 |
46 | Under Windows, please |install_windows_sub|. Then install check50 within the subsystem.
47 |
48 | .. |install_windows_sub| raw:: html
49 |
50 | install the Linux subsystem
51 |
52 | Usage
53 | *******
54 |
55 | To use check50 to check a problem, execute check50 like so:
56 |
57 | .. code-block:: bash
58 |
59 | check50 ///
60 |
61 | For instance, if you want to check |2018x_caesar| you call:
62 |
63 | .. |2018x_caesar| raw:: html
64 |
65 | CS50's Caesar problem from edX 2018
66 |
67 | .. code-block:: bash
68 |
69 | check50 cs50/problems/2018/x/caesar
70 |
71 | You can choose to run checks locally by passing the ``--local`` flag like so:
72 |
73 | .. code-block:: bash
74 |
75 | check50 --local ///
76 |
77 | For an overview of all flags run:
78 |
79 | .. code-block:: bash
80 |
81 | check50 --help
82 |
83 | Design
84 | *******
85 |
86 | * **Write checks for code in code** check50 uses pure Python for checks and exposes a small Python api for common functionality.
87 | * **Extensibility in checks** Anyone can add checks to check50 without asking for permission. In fact, here is a tutorial to get you started: :ref:`check_writer`
88 | * **Extensibility in the tool itself** We cannot predict everything you need, nor can we cater for every use-case out of the box. This is why check50 provides you with a mechanism for adding your own code to the tool, once more without asking for permission. This lets you support different programming languages and add new functionality. Jump to :ref:`extension_writer` to learn more.
89 | * **PaaS** check50 can run online. This guarantees a consistent environment and lets you check code for correctness without introducing your own hardware.
90 |
91 | Checks
92 | *******
93 | In check50 the checks are decoupled from the tool. You can find CS50's set of checks for CS50 problem sets at |cs50_checks|. If you would like to develop your own set of checks such that you can use check50 in your own course jump to :ref:`check_writer`.
94 |
95 | .. |cs50_checks| raw:: html
96 |
97 | /cs50/problems
98 |
99 | Under the hood, checks are naked Python functions decorated with the ``@check50.check`` decorator. check50 exposes several functions, see :ref:`api`, that allow you to easily write checks for input/output testing. check50 comes with three builtin extensions: ``c``, ``py`` and ``flask``. These extensions add extra functionality for C, Python and Python's Flask framework to check50's core.
100 |
101 | By design check50 is extensible. If you want to add support for other programming languages / frameworks and you are comfortable with Python please check out :ref:`extension_writer`.
102 |
--------------------------------------------------------------------------------
/docs/source/json_specification.rst:
--------------------------------------------------------------------------------
1 | .. _json_specification:
2 |
3 | JSON specification
4 | ==========================
5 | Check50 can create a machine readable output in the form of `json`. For instance, running check50 with `-o json` on a non compiling implementation of one of our problems called caesar:
6 |
7 | .. code-block:: bash
8 |
9 | check50 cs50/problems/2018/x/caesar -o json
10 |
11 | Produces the following:
12 |
13 | .. code-block:: json
14 |
15 | {
16 | "slug": "cs50/problems/2018/x/caesar",
17 | "results": [
18 | {
19 | "name": "exists",
20 | "description": "caesar.c exists.",
21 | "passed": true,
22 | "log": [
23 | "checking that caesar.c exists..."
24 | ],
25 | "cause": null,
26 | "data": {},
27 | "dependency": null
28 | },
29 | {
30 | "name": "compiles",
31 | "description": "caesar.c compiles.",
32 | "passed": false,
33 | "log": [
34 | "running clang caesar.c -o caesar -std=c11 -ggdb -lm -lcs50...",
35 | "caesar.c:24:5: warning: implicit declaration of function 'f' is invalid in C99",
36 | " [-Wimplicit-function-declaration]",
37 | " f (argc != 2)",
38 | " ^",
39 | "caesar.c:24:18: error: expected ';' after expression",
40 | " f (argc != 2)",
41 | " ^",
42 | " ;",
43 | "1 warning and 1 error generated."
44 | ],
45 | "cause": {
46 | "rationale": "code failed to compile",
47 | "help": null
48 | },
49 | "data": {},
50 | "dependency": "exists"
51 | },
52 | {
53 | "name": "encrypts_a_as_b",
54 | "description": "encrypts \"a\" as \"b\" using 1 as key",
55 | "passed": null,
56 | "log": [],
57 | "cause": {
58 | "rationale": "can't check until a frown turns upside down"
59 | },
60 | "data": {},
61 | "dependency": "compiles"
62 | }
63 | ],
64 | "version": "3.0.0"
65 | }
66 |
67 | Top level
68 | *********
69 | Assuming `check50` is able to run successfully, you will find three keys at the top level of the json output: `slug`, `results` and `version`.
70 |
71 | * **slug** (`string`) is the slug with which check50 was run, `cs50/problems/2018/x/caesar` in the above example.
72 | * **results** (`[object]`) is a list containing the results of each run check. More on this key below.
73 | * **version** (`string`) is the version of check50 used to run the checks.
74 |
75 | If check50 encounters an error while running, e.g. due to an invalid slug, the `results` key will be replaced by an `error` key containing information about the error encountered.
76 |
77 | results
78 | *******
79 | If the results key exists (that is, check50 was able to run the checks successfully), it will contain a list of objects each corresponding to a check. The order of these objects corresponds to the order the checks appear in the file in which they were written. Each object will contain the following fields:
80 |
81 | * **name** (`string`) is the unique name of the check (the literal name of the Python function specifying the check).
82 | * **description** (`string`) is a description of the check.
83 | * **passed** (`bool`, nullable) is `true` if the check passed, `false` if the check failed, or `null` if the check was skipped (either because the check's dependency did not pass or because the check threw some unexpected error).
84 | * **log** (`[string]`) contains the log accrewed during the execution of the check. Each element of the list is a line from the log.
85 | * **cause** (`object`, nullable) contains the reason that a check did not pass. If `passed` is `true`, `cause` will be `null` and `cause` will never be `null` if `passed` is not `true`. More detail about keys that may appear within `cause` below.
86 | * **data** (`object`) contains arbitrary data communicated by the check via the `check50.data` API call. Checks could use this to add additional information such as memory usage to the results, but check50 itself does not add anything to `data` by default.
87 | * **dependency** (`string`, nullable) is the name of the check upon which this check depends, or `null` if the check has no dependency.
88 |
89 | *****
90 | cause
91 | *****
92 | The cause key is `null` if the check passed and non-null. This key is by design an open-ended object. Everything in the `.payload` attribute of a `check50.Failure` will be put in the `cause` key. Through this mechanism you can communicate any information you want from a failing check to the results. Depending on what occurred, check50 adds the following keys to `cause`:
93 |
94 | * **rationale** (`string`) is a stduent-facing explanation of why the check did not pass (e.g. the student's program did not output what was expected).
95 | * **help** (`string`) is an additional help message that may appear alongside the rationale giving additional context.
96 | * **expected** (`string`) and **actual** (`string`) are keys that always appear in a pair. In case you are expecting X as output, but Y was found instead, you will find these keys containing X and Y in the `cause` field. These appear when a check raises a `check50.Mismatch` exception.
97 | * **error** (`object`) appears in `cause` when an unexpected error occurred during a check. It will contain the keys `type`, `value`, `traceback` and `data` with the same properties as in the top-level `error` key described below.
98 |
99 |
100 |
101 | error
102 | *****
103 | If check50 encounters an unexpected error, the `error` key will replace the `results` key in the JSON output. It will contain the following keys:
104 |
105 | * **type** (`string`) contains the type name of the thrown exception.
106 | * **value** (`string`) contains the result of converting the exception to a string.
107 | * **traceback** (`[string]`) contains the stack trace of the thrown exception.
108 | * **data** (`object`) contains any additional data the exception may carry in its `payload` attribute.
109 |
110 |
--------------------------------------------------------------------------------
/docs/source/new_file.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cs50/check50/ab2aa996b666f8141d78cf763d23dad422feef07/docs/source/new_file.png
--------------------------------------------------------------------------------
/docs/source/new_yaml.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cs50/check50/ab2aa996b666f8141d78cf763d23dad422feef07/docs/source/new_yaml.png
--------------------------------------------------------------------------------
/docs/source/repo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cs50/check50/ab2aa996b666f8141d78cf763d23dad422feef07/docs/source/repo.png
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [compile_catalog]
2 | domain = check50
3 | directory = check50/locale/
4 |
5 | [extract_messages]
6 | keywords = _ gettext ngettext
7 | width = 100
8 | output_file = check50/locale/check50.pot
9 |
10 | [update_catalog]
11 | input_file = check50/locale/check50.pot
12 | domain = check50
13 | output_dir = check50/locale/
14 |
15 | [init_catalog]
16 | input_file = check50/locale/check50.pot
17 | domain = check50
18 | output_dir = check50/locale
19 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | if __import__("os").name == "nt":
2 | 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.")
3 |
4 | from setuptools import setup
5 |
6 | setup(
7 | author="CS50",
8 | author_email="sysadmins@cs50.harvard.edu",
9 | classifiers=[
10 | "Intended Audience :: Education",
11 | "Programming Language :: Python :: 3",
12 | "Topic :: Education",
13 | "Topic :: Utilities"
14 | ],
15 | description="This is check50, with which you can check solutions to problems for CS50.",
16 | long_description=open("README.md").read(),
17 | license="GPLv3",
18 | message_extractors = {
19 | 'check50': [('**.py', 'python', None),],
20 | },
21 | 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"],
22 | extras_require = {
23 | "develop": ["sphinx", "sphinx-autobuild", "sphinx_rtd_theme"]
24 | },
25 | keywords=["check", "check50"],
26 | name="check50",
27 | packages=["check50", "check50.renderer"],
28 | python_requires=">= 3.6",
29 | entry_points={
30 | "console_scripts": ["check50=check50.__main__:main"]
31 | },
32 | url="https://github.com/cs50/check50",
33 | version="3.3.11",
34 | include_package_data=True
35 | )
36 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cs50/check50/ab2aa996b666f8141d78cf763d23dad422feef07/tests/__init__.py
--------------------------------------------------------------------------------
/tests/__main__.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | import sys
3 |
4 | from . import *
5 |
6 | suite = unittest.TestLoader().discover("tests", pattern="*_tests.py")
7 | result = unittest.TextTestRunner(verbosity=2).run(suite)
8 | sys.exit(bool(result.errors or result.failures))
9 |
--------------------------------------------------------------------------------
/tests/api_tests.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | import os
3 | import pathlib
4 | import shutil
5 | import sys
6 | import tempfile
7 |
8 | import check50
9 | import check50.internal
10 |
11 |
12 | class Base(unittest.TestCase):
13 | def setUp(self):
14 | self.working_directory = tempfile.TemporaryDirectory()
15 | os.chdir(self.working_directory.name)
16 |
17 | self.filename = "foo.py"
18 | self.write("")
19 |
20 | self.process = None
21 |
22 | def tearDown(self):
23 | if self.process and self.process.process.isalive():
24 | self.process.kill()
25 | self.working_directory.cleanup()
26 |
27 | def write(self, source):
28 | with open(self.filename, "w") as f:
29 | f.write(source)
30 |
31 | def runpy(self):
32 | self.process = check50.run(f"python3 ./{self.filename}")
33 |
34 | class TestInclude(Base):
35 | def setUp(self):
36 | super().setUp()
37 | self._old_check_dir = check50.internal.check_dir
38 | os.mkdir("bar")
39 | with open("./bar/baz.txt", "w") as f:
40 | pass
41 | check50.internal.check_dir = pathlib.Path("./bar").absolute()
42 |
43 | def tearDown(self):
44 | super().tearDown()
45 | check50.internal.check_dir = self._old_check_dir
46 |
47 | def test_include(self):
48 | check50.include("baz.txt")
49 | self.assertTrue((pathlib.Path(".").absolute() / "baz.txt").exists())
50 | self.assertTrue((check50.internal.check_dir / "baz.txt").exists())
51 |
52 | class TestExists(Base):
53 | def test_file_does_not_exist(self):
54 | with self.assertRaises(check50.Failure):
55 | check50.exists("i_do_not_exist")
56 |
57 | def test_file_exists(self):
58 | check50.exists(self.filename)
59 |
60 |
61 | class TestImportChecks(Base):
62 | def setUp(self):
63 | super().setUp()
64 | self._old_check_dir = check50.internal.check_dir
65 | os.mkdir("bar")
66 | check50.internal.check_dir = pathlib.Path(".").absolute()
67 |
68 | def tearDown(self):
69 | super().tearDown()
70 | check50.internal.check_dir = self._old_check_dir
71 |
72 | def test_simple_import(self):
73 | with open(".cs50.yaml", "w") as f:
74 | f.write("check50:\n")
75 | f.write(" checks: foo.py")
76 | mod = check50.import_checks(".")
77 | self.assertEqual(mod.__name__, pathlib.Path(self.working_directory.name).name)
78 |
79 | def test_relative_import(self):
80 | with open("./bar/baz.py", "w") as f:
81 | f.write("qux = 0")
82 |
83 | with open("./bar/.cs50.yaml", "w") as f:
84 | f.write("check50:\n")
85 | f.write(" checks: baz.py")
86 |
87 | mod = check50.import_checks("./bar")
88 | self.assertEqual(mod.__name__, "bar")
89 | self.assertEqual(mod.qux, 0)
90 |
91 |
92 | class TestRun(Base):
93 | def test_returns_process(self):
94 | self.process = check50.run("python3 ./{self.filename}")
95 |
96 |
97 | class TestProcessKill(Base):
98 | def test_kill(self):
99 | self.runpy()
100 | self.assertTrue(self.process.process.isalive())
101 | self.process.kill()
102 | self.assertFalse(self.process.process.isalive())
103 |
104 | class TestProcessStdin(Base):
105 | def test_expect_prompt_no_prompt(self):
106 | self.write("x = input()")
107 | self.runpy()
108 | with self.assertRaises(check50.Failure):
109 | self.process.stdin("bar")
110 |
111 | def test_expect_prompt(self):
112 | self.write("x = input('foo')")
113 | self.runpy()
114 | self.process.stdin("bar")
115 | self.assertTrue(self.process.process.isalive())
116 |
117 | def test_no_prompt(self):
118 | self.write("x = input()\n")
119 | self.runpy()
120 | self.process.stdin("bar", prompt=False)
121 | self.assertTrue(self.process.process.isalive())
122 |
123 | class TestProcessStdout(Base):
124 | def test_no_out(self):
125 | self.runpy()
126 | out = self.process.stdout(timeout=1)
127 | self.assertEqual(out, "")
128 | self.assertFalse(self.process.process.isalive())
129 |
130 | self.write("print('foo')")
131 | self.runpy()
132 | out = self.process.stdout()
133 | self.assertEqual(out, "foo\n")
134 | self.assertFalse(self.process.process.isalive())
135 |
136 | def test_out(self):
137 | self.runpy()
138 | with self.assertRaises(check50.Failure):
139 | self.process.stdout("foo")
140 | self.assertFalse(self.process.process.isalive())
141 |
142 | self.write("print('foo')")
143 | self.runpy()
144 | self.process.stdout("foo\n")
145 |
146 | def test_outs(self):
147 | self.write("print('foo')\nprint('bar')\n")
148 | self.runpy()
149 | self.process.stdout("foo\n")
150 | self.process.stdout("bar")
151 | self.process.stdout("\n")
152 |
153 | def test_out_regex(self):
154 | self.write("print('foo')")
155 | self.runpy()
156 | self.process.stdout(".o.")
157 | self.process.stdout("\n")
158 |
159 | def test_out_no_regex(self):
160 | self.write("print('foo')")
161 | self.runpy()
162 | with self.assertRaises(check50.Failure):
163 | self.process.stdout(".o.", regex=False)
164 | self.assertFalse(self.process.process.isalive())
165 |
166 | def test_int(self):
167 | self.write("print(123)")
168 | self.runpy()
169 | with self.assertRaises(check50.Failure):
170 | self.process.stdout(1)
171 |
172 | self.write("print(21)")
173 | self.runpy()
174 | with self.assertRaises(check50.Failure):
175 | self.process.stdout(1)
176 |
177 | self.write("print(1.0)")
178 | self.runpy()
179 | with self.assertRaises(check50.Failure):
180 | self.process.stdout(1)
181 |
182 | self.write("print('a1b')")
183 | self.runpy()
184 | self.process.stdout(1)
185 |
186 | self.write("print(1)")
187 | self.runpy()
188 | self.process.stdout(1)
189 |
190 | def test_float(self):
191 | self.write("print(1.01)")
192 | self.runpy()
193 | with self.assertRaises(check50.Failure):
194 | self.process.stdout(1.0)
195 |
196 | self.write("print(21.0)")
197 | self.runpy()
198 | with self.assertRaises(check50.Failure):
199 | self.process.stdout(1.0)
200 |
201 | self.write("print(1)")
202 | self.runpy()
203 | with self.assertRaises(check50.Failure):
204 | self.process.stdout(1.0)
205 |
206 | self.write("print('a1.0b')")
207 | self.runpy()
208 | self.process.stdout(1.0)
209 |
210 | self.write("print(1.0)")
211 | self.runpy()
212 | self.process.stdout(1.0)
213 |
214 | def test_negative_number(self):
215 | self.write("print(1)")
216 | self.runpy()
217 | with self.assertRaises(check50.Failure):
218 | self.process.stdout(-1)
219 |
220 | self.write("print(-1)")
221 | self.runpy()
222 | with self.assertRaises(check50.Failure):
223 | self.process.stdout(1)
224 |
225 | self.write("print('2-1')")
226 | self.runpy()
227 | self.process.stdout(-1)
228 |
229 | self.write("print(-1)")
230 | self.runpy()
231 | self.process.stdout(-1)
232 |
233 |
234 | class TestProcessStdoutFile(Base):
235 | def setUp(self):
236 | super().setUp()
237 | self.txt_filename = "foo.txt"
238 | with open(self.txt_filename, "w") as f:
239 | f.write("foo")
240 |
241 | def test_file(self):
242 | self.write("print('bar')")
243 | self.runpy()
244 | with open(self.txt_filename, "r") as f:
245 | with self.assertRaises(check50.Failure):
246 | self.process.stdout(f, regex=False)
247 |
248 | self.write("print('foo')")
249 | self.runpy()
250 | with open(self.txt_filename, "r") as f:
251 | self.process.stdout(f, regex=False)
252 |
253 | def test_file_regex(self):
254 | self.write("print('bar')")
255 | with open(self.txt_filename, "w") as f:
256 | f.write(".a.")
257 | self.runpy()
258 | with open(self.txt_filename, "r") as f:
259 | self.process.stdout(f)
260 |
261 | class TestProcessExit(Base):
262 | def test_exit(self):
263 | self.write("sys.exit(1)")
264 | self.runpy()
265 | with self.assertRaises(check50.Failure):
266 | self.process.exit(0)
267 | self.process.kill()
268 |
269 | self.write("sys.exit(1)")
270 | self.runpy()
271 | self.process.exit(1)
272 |
273 | def test_no_exit(self):
274 | self.write("sys.exit(1)")
275 | self.runpy()
276 | exit_code = self.process.exit()
277 | self.assertEqual(exit_code, 1)
278 |
279 | class TestProcessKill(Base):
280 | def test_kill(self):
281 | self.runpy()
282 | self.process.kill()
283 | self.assertFalse(self.process.process.isalive())
284 |
285 | class TestProcessReject(Base):
286 | def test_reject(self):
287 | self.write("input()")
288 | self.runpy()
289 | self.process.reject()
290 | self.process.stdin("foo", prompt=False)
291 | with self.assertRaises(check50.Failure):
292 | self.process.reject()
293 |
294 | def test_no_reject(self):
295 | self.runpy()
296 | with self.assertRaises(check50.Failure):
297 | self.process.reject()
298 |
299 | if __name__ == '__main__':
300 | unittest.main()
301 |
--------------------------------------------------------------------------------
/tests/c_tests.py:
--------------------------------------------------------------------------------
1 | import pexpect
2 | import unittest
3 | import sys
4 | import shutil
5 | import os
6 | import functools
7 | import tempfile
8 | import pathlib
9 | import check50
10 | import check50.c
11 | import check50.internal
12 |
13 | CLANG_INSTALLED = bool(shutil.which("clang"))
14 | VALGRIND_INSTALLED = bool(shutil.which("valgrind"))
15 | CHECKS_DIRECTORY = pathlib.Path(__file__).absolute().parent / "checks"
16 |
17 | class Base(unittest.TestCase):
18 | def setUp(self):
19 | if not CLANG_INSTALLED:
20 | raise unittest.SkipTest("clang not installed")
21 | if not VALGRIND_INSTALLED:
22 | raise unittest.SkipTest("valgrind not installed")
23 |
24 | self.working_directory = tempfile.TemporaryDirectory()
25 | os.chdir(self.working_directory.name)
26 |
27 | def tearDown(self):
28 | self.working_directory.cleanup()
29 |
30 | class TestCompile(Base):
31 | def test_compile_incorrect(self):
32 | open("blank.c", "w").close()
33 |
34 | with self.assertRaises(check50.Failure):
35 | check50.c.compile("blank.c")
36 |
37 | def test_compile_hello_world(self):
38 | with open("hello.c", "w") as f:
39 | src = '#include \n'\
40 | 'int main() {\n'\
41 | ' printf("hello, world!\\n");\n'\
42 | '}'
43 | f.write(src)
44 |
45 | check50.c.compile("hello.c")
46 |
47 | self.assertTrue(os.path.isfile("hello"))
48 | check50.run("./hello").stdout("hello, world!", regex=False)
49 |
50 | class TestValgrind(Base):
51 | def setUp(self):
52 | super().setUp()
53 | if not (sys.platform == "linux" or sys.platform == "linux2"):
54 | raise unittest.SkipTest("skipping valgrind checks under anything other than Linux due to false positives")
55 |
56 | def test_no_leak(self):
57 | check50.internal.check_running = True
58 | with open("foo.c", "w") as f:
59 | src = 'int main() {}'
60 | f.write(src)
61 |
62 | check50.c.compile("foo.c")
63 | with check50.internal.register:
64 | check50.c.valgrind("./foo").exit()
65 | check50.internal.check_running = False
66 |
67 | def test_leak(self):
68 | check50.internal.check_running = True
69 | with open("leak.c", "w") as f:
70 | src = '#include \n'\
71 | 'void leak() {malloc(sizeof(int));}\n'\
72 | 'int main() {\n'\
73 | ' leak();\n'\
74 | '}'
75 | f.write(src)
76 |
77 | check50.c.compile("leak.c")
78 | with self.assertRaises(check50.Failure):
79 | with check50.internal.register:
80 | check50.c.valgrind("./leak").exit()
81 | check50.internal.check_running = False
82 |
83 |
84 | if __name__ == "__main__":
85 | suite = unittest.TestLoader().loadTestsFromModule(module=sys.modules[__name__])
86 | unittest.TextTestRunner(verbosity=2).run(suite)
87 |
--------------------------------------------------------------------------------
/tests/check50_tests.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | import json
3 | import pexpect
4 | import pathlib
5 | import shutil
6 | import subprocess
7 | import os
8 | import tempfile
9 |
10 | CHECKS_DIRECTORY = pathlib.Path(__file__).absolute().parent / "checks"
11 |
12 | class Base(unittest.TestCase):
13 | def setUp(self):
14 | self.working_directory = tempfile.TemporaryDirectory()
15 | os.chdir(self.working_directory.name)
16 |
17 | def tearDown(self):
18 | self.working_directory.cleanup()
19 |
20 |
21 | class SimpleBase(Base):
22 | compiled_loc = None
23 |
24 | def setUp(self):
25 | super().setUp()
26 | if os.path.exists(self.compiled_loc):
27 | os.remove(self.compiled_loc)
28 |
29 | def tearDown(self):
30 | super().tearDown()
31 | if os.path.exists(self.compiled_loc):
32 | os.remove(self.compiled_loc)
33 |
34 |
35 | class TestExists(Base):
36 | def test_no_file(self):
37 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/exists")
38 | process.expect_exact(":(")
39 | process.expect_exact("foo.py exists")
40 | process.expect_exact("foo.py not found")
41 | process.close(force=True)
42 |
43 | def test_with_file(self):
44 | open("foo.py", "w").close()
45 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/exists")
46 | process.expect_exact(":)")
47 | process.expect_exact("foo.py exists")
48 | process.close(force=True)
49 |
50 |
51 | class TestExitPy(Base):
52 | def test_no_file(self):
53 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/exit_py")
54 | process.expect_exact(":(")
55 | process.expect_exact("foo.py exists")
56 | process.expect_exact("foo.py not found")
57 | process.expect_exact(":|")
58 | process.expect_exact("foo.py exits properly")
59 | process.expect_exact("can't check until a frown turns upside down")
60 | process.close(force=True)
61 |
62 | def test_with_correct_file(self):
63 | open("foo.py", "w").close()
64 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/exit_py")
65 | process.expect_exact(":)")
66 | process.expect_exact("foo.py exists")
67 | process.expect_exact(":)")
68 | process.expect_exact("foo.py exits properly")
69 | process.close(force=True)
70 |
71 | def test_with_incorrect_file(self):
72 | with open("foo.py", "w") as f:
73 | f.write("from sys import exit\nexit(1)")
74 |
75 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/exit_py")
76 | process.expect_exact(":)")
77 | process.expect_exact("foo.py exists")
78 | process.expect_exact(":(")
79 | process.expect_exact("foo.py exits properly")
80 | process.expect_exact("expected exit code 0, not 1")
81 | process.close(force=True)
82 |
83 |
84 | class TestStdoutPy(Base):
85 | def test_no_file(self):
86 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/stdout_py")
87 | process.expect_exact(":(")
88 | process.expect_exact("foo.py exists")
89 | process.expect_exact("foo.py not found")
90 | process.expect_exact(":|")
91 | process.expect_exact("prints hello")
92 | process.expect_exact("can't check until a frown turns upside down")
93 | process.close(force=True)
94 |
95 | def test_with_empty_file(self):
96 | open("foo.py", "w").close()
97 |
98 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/stdout_py")
99 | process.expect_exact(":)")
100 | process.expect_exact("foo.py exists")
101 | process.expect_exact(":(")
102 | process.expect_exact("prints hello")
103 | process.expect_exact("expected \"hello\", not \"\"")
104 | process.close(force=True)
105 |
106 |
107 | def test_with_correct_file(self):
108 | with open("foo.py", "w") as f:
109 | f.write('print("hello")')
110 |
111 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/stdout_py")
112 | process.expect_exact(":)")
113 | process.expect_exact("foo.py exists")
114 | process.expect_exact(":)")
115 | process.expect_exact("prints hello")
116 | process.close(force=True)
117 |
118 | class TestStdoutTimeout(Base):
119 | def test_stdout_timeout(self):
120 | with open("foo.py", "w") as f:
121 | f.write("while True: pass")
122 |
123 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/stdout_py")
124 | process.expect_exact(":)")
125 | process.expect_exact("foo.py exists")
126 | process.expect_exact(":(")
127 | process.expect_exact("check50 waited 1 seconds for the output of the program")
128 | process.close(force=True)
129 |
130 | class TestStdinPy(Base):
131 | def test_no_file(self):
132 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/stdin_py")
133 | process.expect_exact(":(")
134 | process.expect_exact("foo.py exists")
135 | process.expect_exact("foo.py not found")
136 | process.expect_exact(":|")
137 | process.expect_exact("prints hello name")
138 | process.expect_exact("can't check until a frown turns upside down")
139 | process.close(force=True)
140 |
141 | def test_with_empty_file(self):
142 | open("foo.py", "w").close()
143 |
144 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/stdin_py")
145 | process.expect_exact(":)")
146 | process.expect_exact("foo.py exists")
147 | process.expect_exact(":(")
148 | process.expect_exact("prints hello name")
149 | process.expect_exact("expected \"hello bar\", not \"\"")
150 | process.close(force=True)
151 |
152 | def test_with_correct_file(self):
153 | with open("foo.py", "w") as f:
154 | f.write('name = input()\nprint("hello", name)')
155 |
156 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/stdin_py")
157 | process.expect_exact(":)")
158 | process.expect_exact("foo.py exists")
159 | process.expect_exact(":)")
160 | process.expect_exact("prints hello name")
161 | process.close(force=True)
162 |
163 |
164 | class TestStdinPromptPy(Base):
165 | def test_no_file(self):
166 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/stdin_prompt_py")
167 | process.expect_exact(":(")
168 | process.expect_exact("prints hello name")
169 | process.close(force=True)
170 |
171 | def test_with_empty_file(self):
172 | open("foo.py", "w").close()
173 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/stdin_prompt_py")
174 | process.expect_exact(":(")
175 | process.expect_exact("prints hello name")
176 | process.expect_exact("expected prompt for input, found none")
177 | process.close(force=True)
178 |
179 | def test_with_incorrect_file(self):
180 | with open("foo.py", "w") as f:
181 | f.write('name = input()\nprint("hello", name)')
182 |
183 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/stdin_prompt_py")
184 | process.expect_exact(":(")
185 | process.expect_exact("prints hello name")
186 | process.expect_exact("expected prompt for input, found none")
187 | process.close(force=True)
188 |
189 | def test_with_correct_file(self):
190 | with open("foo.py", "w") as f:
191 | f.write('name = input("prompt")\nprint("hello", name)')
192 |
193 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/stdin_prompt_py")
194 | process.expect_exact(":)")
195 | process.expect_exact("prints hello name")
196 | process.close(force=True)
197 |
198 |
199 | class TestStdinMultiline(Base):
200 | def test_no_file(self):
201 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/stdin_multiline")
202 | process.expect_exact(":(")
203 | process.expect_exact("prints hello name (non chaining)")
204 | process.expect_exact(":(")
205 | process.expect_exact("prints hello name (non chaining) (prompt)")
206 | process.expect_exact(":(")
207 | process.expect_exact("prints hello name (chaining)")
208 | process.expect_exact(":(")
209 | process.expect_exact("prints hello name (chaining) (order)")
210 | process.close(force=True)
211 |
212 | def test_with_empty_file(self):
213 | open("foo.py", "w").close()
214 |
215 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/stdin_multiline")
216 | process.expect_exact(":(")
217 | process.expect_exact("prints hello name (non chaining)")
218 | process.expect_exact(":(")
219 | process.expect_exact("prints hello name (non chaining) (prompt)")
220 | process.expect_exact(":(")
221 | process.expect_exact("prints hello name (chaining)")
222 | process.expect_exact(":(")
223 | process.expect_exact("prints hello name (chaining) (order)")
224 | process.close(force=True)
225 |
226 | def test_with_incorrect_file(self):
227 | with open("foo.py", "w") as f:
228 | f.write('name = input()\nprint("hello", name)')
229 |
230 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/stdin_multiline")
231 | process.expect_exact(":(")
232 | process.expect_exact("prints hello name (non chaining)")
233 | process.expect_exact(":(")
234 | process.expect_exact("prints hello name (non chaining) (prompt)")
235 | process.expect_exact("expected prompt for input, found none")
236 | process.expect_exact(":(")
237 | process.expect_exact("prints hello name (chaining)")
238 | process.expect_exact(":(")
239 | process.expect_exact("prints hello name (chaining) (order)")
240 | process.close(force=True)
241 |
242 | def test_with_correct_file(self):
243 | with open("foo.py", "w") as f:
244 | f.write('for _ in range(2):\n name = input("prompt")\n print("hello", name)')
245 |
246 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/stdin_multiline")
247 | process.expect_exact(":)")
248 | process.expect_exact("prints hello name (non chaining)")
249 | process.expect_exact(":)")
250 | process.expect_exact("prints hello name (non chaining) (prompt)")
251 | process.expect_exact(":)")
252 | process.expect_exact("prints hello name (chaining)")
253 | process.expect_exact(":)")
254 | process.expect_exact("prints hello name (chaining) (order)")
255 | process.close(force=True)
256 |
257 | class TestStdinHumanReadable(Base):
258 | def test_without_human_readable_string(self):
259 | with open("foo.py", "w") as f:
260 | f.write('name = input()\nprint("hello", name)')
261 |
262 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/stdin_py")
263 | process.expect_exact(":)")
264 | process.expect_exact("foo.py exists")
265 | process.expect_exact("checking that foo.py exists...")
266 | process.expect_exact(":)")
267 | process.expect_exact("prints hello name")
268 | process.expect_exact("running python3 foo.py...")
269 | process.expect_exact("sending input bar...")
270 | process.expect_exact("checking for output \"hello bar\"...")
271 | process.close(force=True)
272 |
273 | def test_with_human_readable_string(self):
274 | with open("foo.py", "w") as f:
275 | f.write('name = input("prompt")')
276 |
277 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/stdin_human_readable_py")
278 | process.expect_exact(":)")
279 | process.expect_exact("foo.py exists")
280 | process.expect_exact("checking that foo.py exists...")
281 | process.expect_exact(":)")
282 | process.expect_exact("takes input")
283 | process.expect_exact("running python3 foo.py...")
284 | process.expect_exact("sending input bbb...")
285 | process.close(force=True)
286 |
287 |
288 | class TestCompileExit(SimpleBase):
289 | compiled_loc = CHECKS_DIRECTORY / "compile_exit" / "__init__.py"
290 |
291 | def test_no_file(self):
292 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/compile_exit")
293 | process.expect_exact(":(")
294 | process.expect_exact("exit")
295 | process.close(force=True)
296 |
297 | def test_with_correct_file(self):
298 | open("foo.py", "w").close()
299 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/compile_exit")
300 | process.expect_exact(":)")
301 | process.expect_exact("exit")
302 | process.close(force=True)
303 |
304 |
305 | class TestCompileStd(SimpleBase):
306 | compiled_loc = CHECKS_DIRECTORY / "compile_std" / "__init__.py"
307 |
308 | def test_no_file(self):
309 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/compile_std")
310 | process.expect_exact(":(")
311 | process.expect_exact("std")
312 | process.close(force=True)
313 |
314 | def test_with_incorrect_stdout(self):
315 | with open("foo.py", "w") as f:
316 | f.write('name = input()\nprint("hello", name)')
317 |
318 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/compile_std")
319 | process.expect_exact(":)")
320 | process.expect_exact("std")
321 | process.close(force=True)
322 |
323 | def test_correct(self):
324 | with open("foo.py", "w") as f:
325 | f.write('name = input()\nprint(name)')
326 |
327 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/compile_std")
328 | process.expect_exact(":)")
329 | process.expect_exact("std")
330 | process.close(force=True)
331 |
332 |
333 | class TestCompilePrompt(SimpleBase):
334 | compiled_loc = CHECKS_DIRECTORY / "compile_prompt" / "__init__.py"
335 |
336 | def test_prompt_dev(self):
337 | with open("foo.py", "w"), open(self.compiled_loc, "w"):
338 | pass
339 |
340 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/compile_prompt")
341 | process.expect_exact("check50 will compile the YAML checks to __init__.py")
342 | process.close(force=True)
343 |
344 |
345 | class TestOutputModes(Base):
346 | def test_json_output(self):
347 | pexpect.run(f"check50 --dev -o json --output-file foo.json {CHECKS_DIRECTORY}/output")
348 | with open("foo.json", "r") as f:
349 | json_out = json.load(f)
350 | self.assertEqual(json_out["results"][0]["name"], "exists")
351 |
352 | def test_ansi_output(self):
353 | process = pexpect.spawn(f"check50 --dev -o ansi -- {CHECKS_DIRECTORY}/output")
354 | process.expect_exact(":(")
355 | process.close(force=True)
356 |
357 | def test_html_output(self):
358 | process = pexpect.spawn(f"check50 --dev -o html -- {CHECKS_DIRECTORY}/output")
359 | process.expect_exact("file://")
360 | process.close(force=True)
361 |
362 | def test_multiple_outputs(self):
363 | process = pexpect.spawn(f"check50 --dev -o html ansi -- {CHECKS_DIRECTORY}/output")
364 | process.expect_exact("file://")
365 | process.expect_exact(":(")
366 | process.close(force=True)
367 |
368 | def test_default(self):
369 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/output")
370 | process.expect_exact(":(")
371 | process.expect_exact("file://")
372 | process.close(force=True)
373 |
374 |
375 | class TestHiddenCheck(Base):
376 | def test_hidden_check(self):
377 | pexpect.run(f"check50 --dev -o json --output-file foo.json {CHECKS_DIRECTORY}/hidden")
378 | expected = [{'name': 'check', 'description': "check", 'passed': False, 'log': [], 'cause': {"rationale": "foo", "help": None}, 'data': {}, 'dependency': None}]
379 | with open("foo.json", "r") as f:
380 | self.assertEqual(json.load(f)["results"], expected)
381 |
382 |
383 | class TestPayloadCheck(Base):
384 | def test_payload_check(self):
385 | pexpect.run(f"check50 --dev -o json --output-file foo.json {CHECKS_DIRECTORY}/payload")
386 | with open("foo.json", "r") as f:
387 | error = json.load(f)["error"]
388 | self.assertEqual(error["type"], "MissingFilesError")
389 | self.assertEqual(error["data"]["files"], ["missing.c"])
390 | self.assertEqual(pathlib.Path(error["data"]["dir"]).stem, pathlib.Path(self.working_directory.name).stem)
391 |
392 |
393 | class TestTarget(Base):
394 | def test_target(self):
395 | open("foo.py", "w").close()
396 |
397 | pexpect.run(f"check50 --dev -o json --output-file foo.json --target exists1 -- {CHECKS_DIRECTORY}/target")
398 | with open("foo.json", "r") as f:
399 | output = json.load(f)
400 |
401 | self.assertEqual(len(output["results"]), 1)
402 | self.assertEqual(output["results"][0]["name"], "exists1")
403 |
404 |
405 | def test_target_with_dependency(self):
406 | open("foo.py", "w").close()
407 |
408 | pexpect.run(f"check50 --dev -o json --output-file foo.json --target exists3 -- {CHECKS_DIRECTORY}/target")
409 | with open("foo.json", "r") as f:
410 | output = json.load(f)
411 |
412 | self.assertEqual(len(output["results"]), 2)
413 | self.assertEqual(output["results"][0]["name"], "exists1")
414 | self.assertEqual(output["results"][1]["name"], "exists3")
415 |
416 |
417 | def test_two_targets(self):
418 | open("foo.py", "w").close()
419 |
420 | pexpect.run(f"check50 --dev -o json --output-file foo.json --target exists1 exists2 -- {CHECKS_DIRECTORY}/target")
421 | with open("foo.json", "r") as f:
422 | output = json.load(f)
423 |
424 | self.assertEqual(len(output["results"]), 2)
425 | self.assertEqual(output["results"][0]["name"], "exists1")
426 | self.assertEqual(output["results"][1]["name"], "exists2")
427 |
428 |
429 | def test_target_failing_dependency(self):
430 | open("foo.py", "w").close()
431 |
432 | pexpect.run(f"check50 --dev -o json --output-file foo.json --target exists5 -- {CHECKS_DIRECTORY}/target")
433 | with open("foo.json", "r") as f:
434 | output = json.load(f)
435 |
436 | self.assertEqual(len(output["results"]), 2)
437 | self.assertEqual(output["results"][0]["name"], "exists4")
438 | self.assertEqual(output["results"][1]["name"], "exists5")
439 |
440 |
441 | class TestRemoteException(Base):
442 | def test_no_traceback(self):
443 | # Check that bar (part of traceback) is not shown
444 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/remote_exception_no_traceback")
445 | self.assertRaises(pexpect.exceptions.EOF, lambda: process.expect("bar"))
446 |
447 | # Check that foo (the message) is shown
448 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/remote_exception_no_traceback")
449 | process.expect("foo")
450 |
451 | def test_traceback(self):
452 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/remote_exception_traceback")
453 | process.expect("bar")
454 | process.expect("foo")
455 |
456 |
457 | class TestInternalDirectories(Base):
458 | def test_directories_exist(self):
459 | with open("foo.py", "w") as f:
460 | f.write(os.getcwd())
461 |
462 | process = pexpect.spawn(f"check50 --dev {CHECKS_DIRECTORY}/internal_directories")
463 | process.expect_exact(":)")
464 |
465 |
466 | class TestExitCode(Base):
467 | def test_error_result_exit_code(self):
468 | process = subprocess.run(
469 | ["check50", "--dev", f"{CHECKS_DIRECTORY}/exit_code/error"],
470 | stdout=subprocess.DEVNULL,
471 | stderr=subprocess.DEVNULL,
472 | timeout=2
473 | )
474 | self.assertEqual(process.returncode, 1)
475 |
476 | def test_failed_check_exit_code(self):
477 | process = subprocess.run(
478 | ["check50", "--dev", f"{CHECKS_DIRECTORY}/exit_code/failure"],
479 | stdout=subprocess.DEVNULL,
480 | stderr=subprocess.DEVNULL,
481 | timeout=2
482 | )
483 | self.assertEqual(process.returncode, 1)
484 |
485 | def test_successful_exit(self):
486 | process = subprocess.run(
487 | ["check50", "--dev", f"{CHECKS_DIRECTORY}/exit_code/success"],
488 | stdout=subprocess.DEVNULL,
489 | stderr=subprocess.DEVNULL,
490 | timeout=2
491 | )
492 | self.assertEqual(process.returncode, 0)
493 |
494 |
495 | if __name__ == "__main__":
496 | unittest.main()
497 |
--------------------------------------------------------------------------------
/tests/checks/compile_exit/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50:
2 | checks:
3 | exit:
4 | - run: python3 foo.py
5 | exit: 0
6 |
--------------------------------------------------------------------------------
/tests/checks/compile_prompt/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50:
2 | checks:
3 | prompt:
4 | - run: python3 foo.py
5 | exit: 0
6 |
--------------------------------------------------------------------------------
/tests/checks/compile_std/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50:
2 | checks:
3 | std:
4 | - run: python3 foo.py
5 | stdin: bar
6 | stdout: bar
7 | exit: 0
8 |
--------------------------------------------------------------------------------
/tests/checks/exists/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50:
2 | checks: check.py
3 |
--------------------------------------------------------------------------------
/tests/checks/exists/check.py:
--------------------------------------------------------------------------------
1 | import check50
2 |
3 | @check50.check()
4 | def exists():
5 | """foo.py exists"""
6 | check50.exists("foo.py")
7 |
--------------------------------------------------------------------------------
/tests/checks/exit_code/error/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50:
2 | checks: check.py
3 |
--------------------------------------------------------------------------------
/tests/checks/exit_code/error/check.py:
--------------------------------------------------------------------------------
1 | import check50
2 | from check50._exceptions import RemoteCheckError
3 |
4 | json = {
5 | "slug": "jelleas/foo/master",
6 | "error": {
7 | "type": "InvalidSlugError",
8 | "value": "foo",
9 | "traceback": [
10 | "Traceback (most recent call last):\n",
11 | "bar\n"
12 | ],
13 | "actions": {
14 | "show_traceback": False,
15 | "message": "foo"
16 | },
17 | "data": {}
18 | },
19 | "version": "3.1.1"
20 | }
21 |
22 | raise RemoteCheckError(json)
23 |
--------------------------------------------------------------------------------
/tests/checks/exit_code/failure/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50: true
2 |
--------------------------------------------------------------------------------
/tests/checks/exit_code/failure/__init__.py:
--------------------------------------------------------------------------------
1 | import check50
2 |
3 | @check50.check()
4 | def should_fail():
5 | raise check50.Failure("BANG!")
6 |
--------------------------------------------------------------------------------
/tests/checks/exit_code/success/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50: true
2 |
--------------------------------------------------------------------------------
/tests/checks/exit_code/success/__init__.py:
--------------------------------------------------------------------------------
1 | import check50
2 |
3 | @check50.check()
4 | def should_pass():
5 | pass
6 |
--------------------------------------------------------------------------------
/tests/checks/exit_py/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50:
2 | checks: check.py
3 |
--------------------------------------------------------------------------------
/tests/checks/exit_py/check.py:
--------------------------------------------------------------------------------
1 | import check50
2 |
3 | @check50.check()
4 | def exists():
5 | """foo.py exists"""
6 | check50.exists("foo.py")
7 |
8 | @check50.check(exists)
9 | def exits():
10 | """foo.py exits properly"""
11 | check50.run("python3 foo.py").exit(0)
12 |
--------------------------------------------------------------------------------
/tests/checks/hidden/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50: true
2 |
--------------------------------------------------------------------------------
/tests/checks/hidden/__init__.py:
--------------------------------------------------------------------------------
1 | import check50
2 |
3 | @check50.check()
4 | @check50.hidden("foo")
5 | def check():
6 | check50.log("AHHHHHHHHHHHHHHHHHHH")
7 | raise check50.Failure("AHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH")
8 |
--------------------------------------------------------------------------------
/tests/checks/internal_directories/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50: true
2 |
--------------------------------------------------------------------------------
/tests/checks/internal_directories/__init__.py:
--------------------------------------------------------------------------------
1 | import pathlib
2 | import check50
3 | import check50.internal as internal
4 |
5 | @check50.check()
6 | def foo():
7 | """directories exist"""
8 | assert internal.run_dir.resolve() == pathlib.Path.cwd()
9 | assert internal.run_dir.parent == internal.run_root_dir
10 |
11 | assert internal.run_root_dir.exists()
12 | assert internal.run_root_dir.resolve() == pathlib.Path.cwd()
13 |
14 | assert internal.student_dir.exists()
15 | with open(internal.student_dir / "foo.py") as f:
16 | student_dir = pathlib.Path(f.read().strip())
17 | assert internal.student_dir == student_dir
18 |
19 | assert internal.check_dir.exists()
20 | assert internal.check_dir.name == "internal_directories"
21 |
--------------------------------------------------------------------------------
/tests/checks/output/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50: true
2 |
--------------------------------------------------------------------------------
/tests/checks/output/__init__.py:
--------------------------------------------------------------------------------
1 | import check50
2 |
3 | @check50.check()
4 | def exists():
5 | """foo.py exists"""
6 | check50.exists("foo.py")
7 |
--------------------------------------------------------------------------------
/tests/checks/payload/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50:
2 | files:
3 | - !require missing.c
4 |
--------------------------------------------------------------------------------
/tests/checks/payload/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cs50/check50/ab2aa996b666f8141d78cf763d23dad422feef07/tests/checks/payload/__init__.py
--------------------------------------------------------------------------------
/tests/checks/remote_exception_no_traceback/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50:
2 | checks: check.py
3 |
--------------------------------------------------------------------------------
/tests/checks/remote_exception_no_traceback/check.py:
--------------------------------------------------------------------------------
1 | import check50
2 | from check50._exceptions import RemoteCheckError
3 |
4 | json = {
5 | "slug": "jelleas/foo/master",
6 | "error": {
7 | "type": "InvalidSlugError",
8 | "value": "foo",
9 | "traceback": [
10 | "Traceback (most recent call last):\n",
11 | "bar\n"
12 | ],
13 | "actions": {
14 | "show_traceback": False,
15 | "message": "foo"
16 | },
17 | "data": {}
18 | },
19 | "version": "3.1.1"
20 | }
21 |
22 | raise RemoteCheckError(json)
23 |
--------------------------------------------------------------------------------
/tests/checks/remote_exception_traceback/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50:
2 | checks: check.py
3 |
--------------------------------------------------------------------------------
/tests/checks/remote_exception_traceback/check.py:
--------------------------------------------------------------------------------
1 | import check50
2 | from check50._exceptions import RemoteCheckError
3 |
4 | json = {
5 | "slug": "jelleas/foo/master",
6 | "error": {
7 | "type": "InvalidSlugError",
8 | "value": "foo",
9 | "traceback": [
10 | "Traceback (most recent call last):\n",
11 | "bar\n"
12 | ],
13 | "actions": {
14 | "show_traceback": True,
15 | "message": "foo"
16 | },
17 | "data": {}
18 | },
19 | "version": "3.1.1"
20 | }
21 |
22 | raise RemoteCheckError(json)
23 |
--------------------------------------------------------------------------------
/tests/checks/stdin_human_readable_py/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50:
2 | checks: check.py
3 |
--------------------------------------------------------------------------------
/tests/checks/stdin_human_readable_py/check.py:
--------------------------------------------------------------------------------
1 | import check50
2 |
3 | @check50.check()
4 | def exists():
5 | """foo.py exists"""
6 | check50.exists("foo.py")
7 |
8 | @check50.check(exists)
9 | def takes_input():
10 | """takes input"""
11 | check50.run("python3 foo.py").stdin("aaa", prompt=False, str_line="bbb")
12 |
--------------------------------------------------------------------------------
/tests/checks/stdin_multiline/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50:
2 | checks: check.py
3 |
--------------------------------------------------------------------------------
/tests/checks/stdin_multiline/check.py:
--------------------------------------------------------------------------------
1 | import check50
2 |
3 | @check50.check()
4 | def prints_hello_name_non_chaining():
5 | """prints hello name (non chaining)"""
6 | check50.run("python3 foo.py").stdin("bar\nbaz", prompt=False).stdout("hello bar").stdout("hello baz")
7 |
8 | @check50.check()
9 | def prints_hello_name_non_chaining_prompt():
10 | """prints hello name (non chaining) (prompt)"""
11 | check50.run("python3 foo.py").stdin("bar\nbaz").stdout("hello bar").stdout("hello baz")
12 |
13 | @check50.check()
14 | def prints_hello_name_chaining():
15 | """prints hello name (chaining)"""
16 | check50.run("python3 foo.py").stdin("bar", prompt=False).stdout("hello bar").stdin("baz", prompt=False).stdout("hello baz")
17 |
18 | @check50.check()
19 | def prints_hello_name_chaining_order():
20 | """prints hello name (chaining) (order)"""
21 | check50.run("python3 foo.py").stdin("bar", prompt=False).stdin("baz", prompt=False).stdout("hello bar").stdout("hello baz")
22 |
--------------------------------------------------------------------------------
/tests/checks/stdin_prompt_py/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50:
2 | checks: check.py
3 |
--------------------------------------------------------------------------------
/tests/checks/stdin_prompt_py/check.py:
--------------------------------------------------------------------------------
1 | import check50
2 |
3 | @check50.check()
4 | def prints_hello_name():
5 | """prints hello name"""
6 | check50.run("python3 foo.py").stdin("bar", prompt=True).stdout("hello bar")
7 |
--------------------------------------------------------------------------------
/tests/checks/stdin_py/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50:
2 | checks: check.py
3 |
--------------------------------------------------------------------------------
/tests/checks/stdin_py/check.py:
--------------------------------------------------------------------------------
1 | import check50
2 |
3 | @check50.check()
4 | def exists():
5 | """foo.py exists"""
6 | check50.exists("foo.py")
7 |
8 | @check50.check(exists)
9 | def prints_hello_name():
10 | """prints hello name"""
11 | check50.run("python3 foo.py").stdin("bar", prompt=False).stdout("hello bar")
12 |
--------------------------------------------------------------------------------
/tests/checks/stdout_py/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50:
2 | checks: check.py
3 |
--------------------------------------------------------------------------------
/tests/checks/stdout_py/check.py:
--------------------------------------------------------------------------------
1 | import check50
2 |
3 | @check50.check()
4 | def exists():
5 | """foo.py exists"""
6 | check50.exists("foo.py")
7 |
8 | @check50.check(exists)
9 | def prints_hello():
10 | """prints hello"""
11 | check50.run("python3 foo.py").stdout("hello", show_timeout=True, timeout=1)
12 |
--------------------------------------------------------------------------------
/tests/checks/target/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50:
2 | files:
3 | - !require foo.py
4 |
--------------------------------------------------------------------------------
/tests/checks/target/__init__.py:
--------------------------------------------------------------------------------
1 | import check50
2 |
3 | @check50.check()
4 | def exists1():
5 | """foo.py exists"""
6 | check50.exists("foo.py")
7 |
8 | @check50.check()
9 | def exists2():
10 | """foo.py exists"""
11 | check50.exists("foo.py")
12 |
13 | @check50.check(exists1)
14 | def exists3():
15 | """foo.py exists"""
16 | check50.exists("foo.py")
17 |
18 | @check50.check()
19 | def exists4():
20 | """foo.py exists"""
21 | raise check50.Failure()
22 |
23 | @check50.check(exists4)
24 | def exists5():
25 | """foo.py exists"""
26 | check50.exists("foo.py")
27 |
--------------------------------------------------------------------------------
/tests/checks/unpicklable_attribute/.cs50.yaml:
--------------------------------------------------------------------------------
1 | check50: true
2 |
--------------------------------------------------------------------------------
/tests/checks/unpicklable_attribute/__init__.py:
--------------------------------------------------------------------------------
1 | import check50
2 | import sys
3 |
4 | # Set the excepthook to an unpicklable value, the run_check process will try to copy this
5 | sys.excepthook = lambda type, value, traceback: "bar"
6 |
7 | @check50.check()
8 | def foo():
9 | """foo"""
--------------------------------------------------------------------------------
/tests/flask_tests.py:
--------------------------------------------------------------------------------
1 | import pathlib
2 | import os
3 | import sys
4 | import tempfile
5 | import unittest
6 | import check50
7 | import check50.flask
8 |
9 | try:
10 | import flask
11 | FLASK_INSTALLED = True
12 | except ModuleNotFoundError:
13 | FLASK_INSTALLED = False
14 | CHECKS_DIRECTORY = pathlib.Path(__file__).absolute().parent / "checks"
15 |
16 | class Base(unittest.TestCase):
17 | def setUp(self):
18 | if not FLASK_INSTALLED:
19 | raise unittest.SkipTest("flask not installed")
20 |
21 | self.working_directory = tempfile.TemporaryDirectory()
22 | os.chdir(self.working_directory.name)
23 |
24 | def tearDown(self):
25 | self.working_directory.cleanup()
26 |
27 | class TestApp(Base):
28 | def test_app(self):
29 | src = \
30 | """from flask import Flask
31 | app = Flask(__name__)
32 |
33 | @app.route('/')
34 | def root():
35 | return ''"""
36 | with open("hello.py", "w") as f:
37 | f.write(src)
38 |
39 | app = check50.flask.app("hello.py")
40 | self.assertIsInstance(app, check50.flask.app)
41 |
42 | def test_no_app(self):
43 | with self.assertRaises(check50.Failure):
44 | check50.flask.app("doesnt_exist.py")
45 |
46 |
47 |
48 | class TestFlask(Base):
49 | def test_status(self):
50 | src = \
51 | """from flask import Flask
52 | app = Flask(__name__)
53 |
54 | @app.route('/')
55 | def root():
56 | return ''"""
57 | with open("hello.py", "w") as f:
58 | f.write(src)
59 |
60 | app = check50.flask.app("hello.py")
61 | app.get("/").status(200)
62 | app.get("/foo").status(404)
63 | app.post("/").status(405)
64 |
65 | def test_raw_content(self):
66 | src = \
67 | """from flask import Flask
68 | app = Flask(__name__)
69 |
70 | @app.route('/')
71 | def hello():
72 | return 'hello world'"""
73 | with open("hello.py", "w") as f:
74 | f.write(src)
75 |
76 | app = check50.flask.app("hello.py")
77 | app.get("/").raw_content("hello world")
78 |
79 | with self.assertRaises(check50.Failure):
80 | app.get("/").raw_content("foo")
81 |
82 | def test_content(self):
83 | src = \
84 | """from flask import Flask
85 | app = Flask(__name__)
86 |
87 | @app.route('/')
88 | def hello():
89 | return 'foobar'"""
90 | with open("hello.py", "w") as f:
91 | f.write(src)
92 |
93 | app = check50.flask.app("hello.py")
94 | app.get("/").content("foo")
95 |
96 | with self.assertRaises(check50.Failure):
97 | app.get("/").content("foo", name="body")
98 |
99 | app.get("/").content("bar")
100 |
101 | if __name__ == "__main__":
102 | suite = unittest.TestLoader().loadTestsFromModule(module=sys.modules[__name__])
103 | unittest.TextTestRunner(verbosity=2).run(suite)
104 |
--------------------------------------------------------------------------------
/tests/internal_tests.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | import check50
3 | import check50.internal
4 |
5 | class TestRegisterAfterCheck(unittest.TestCase):
6 | def test_after_check(self):
7 | check50.internal.check_running = True
8 | l = []
9 | check50.internal.register.after_check(lambda : l.append("foo"))
10 |
11 | with check50.internal.register:
12 | self.assertEqual(l, [])
13 |
14 | self.assertEqual(l, ["foo"])
15 |
16 | with check50.internal.register:
17 | self.assertEqual(l, ["foo"])
18 |
19 | self.assertEqual(l, ["foo"])
20 | check50.internal.check_running = False
21 |
22 | class TestRegisterAfterEvery(unittest.TestCase):
23 | def test_after_every(self):
24 | l = []
25 | check50.internal.register.after_every(lambda : l.append("foo"))
26 |
27 | with check50.internal.register:
28 | self.assertEqual(l, [])
29 |
30 | self.assertEqual(l, ["foo"])
31 |
32 | with check50.internal.register:
33 | self.assertEqual(l, ["foo"])
34 |
35 | self.assertEqual(l, ["foo", "foo"])
36 |
37 | class TestRegisterBeforeEvery(unittest.TestCase):
38 | def test_before_every(self):
39 | l = []
40 | check50.internal.register.before_every(lambda : l.append("foo"))
41 |
42 | with check50.internal.register:
43 | self.assertEqual(l, ["foo"])
44 |
45 | with check50.internal.register:
46 | self.assertEqual(l, ["foo", "foo"])
47 |
48 | self.assertEqual(l, ["foo", "foo"])
49 |
50 |
51 | if __name__ == "__main__":
52 | unittest.main()
53 |
--------------------------------------------------------------------------------
/tests/py_tests.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | import os
3 | import pathlib
4 | import tempfile
5 |
6 | import check50.py
7 | import check50.internal
8 |
9 | class Base(unittest.TestCase):
10 | def setUp(self):
11 | self.working_directory = tempfile.TemporaryDirectory()
12 | os.chdir(self.working_directory.name)
13 |
14 | self.filename = "foo.py"
15 | self.write("")
16 |
17 | def write(self, source):
18 | with open(self.filename, "w") as f:
19 | f.write(source)
20 |
21 | def tearDown(self):
22 | self.working_directory.cleanup()
23 |
24 | def runpy(self):
25 | self.process = check50.run(f"python3 ./{self.filename}")
26 |
27 | class TestAppendCode(Base):
28 | def setUp(self):
29 | super().setUp()
30 | self.other_filename = "bar.py"
31 | with open(self.other_filename, "w") as f:
32 | f.write("baz")
33 |
34 | def test_empty_append(self):
35 | check50.py.append_code(self.filename, self.other_filename)
36 | with open(self.filename, "r") as f1, open(self.other_filename, "r") as f2:
37 | self.assertEqual(f1.read(), f"\n{f2.read()}")
38 |
39 | def test_append(self):
40 | with open(self.other_filename, "r") as f:
41 | old_content2 = f.read()
42 |
43 | self.write("qux")
44 | check50.py.append_code(self.filename, self.other_filename)
45 | with open(self.filename, "r") as f1, open(self.other_filename, "r") as f2:
46 | content1 = f1.read()
47 | content2 = f2.read()
48 |
49 | self.assertNotEqual(content1, content2)
50 | self.assertEqual(content2, old_content2)
51 | self.assertEqual(content1, "qux\nbaz")
52 |
53 |
54 | class TestImport_(Base):
55 | def setUp(self):
56 | super().setUp()
57 | self._old_check_dir = check50.internal.check_dir
58 | os.mkdir("bar")
59 | with open("./bar/baz.py", "w") as f:
60 | f.write("qux = 0")
61 | check50.internal.check_dir = pathlib.Path(".").absolute()
62 |
63 | def tearDown(self):
64 | super().tearDown()
65 | check50.internal.check_dir = self._old_check_dir
66 |
67 | def test_simple_import(self):
68 | mod = check50.py.import_("foo.py")
69 | self.assertEqual(mod.__name__, "foo")
70 |
71 | def test_relative_import(self):
72 | mod = check50.py.import_("./bar/baz.py")
73 | self.assertEqual(mod.__name__, "baz")
74 | self.assertEqual(mod.qux, 0)
75 |
76 |
77 | if __name__ == "__main__":
78 | unittest.main()
79 |
--------------------------------------------------------------------------------
/tests/runner_tests.py:
--------------------------------------------------------------------------------
1 | import check50
2 | import check50.runner
3 |
4 | import importlib
5 | import multiprocessing
6 | import os
7 | import pathlib
8 | import pexpect
9 | import sys
10 | import tempfile
11 | import unittest
12 |
13 |
14 | CHECKS_DIRECTORY = pathlib.Path(__file__).absolute().parent / "checks"
15 | CHECK50_SUPPORTED_START_METHODS = ("fork", "spawn")
16 |
17 |
18 | # Just test spawn under OS X due to a bug with "fork": https://bugs.python.org/issue33725
19 | if sys.platform == "darwin":
20 | SUPPORTED_START_METHODS = ("spawn",)
21 |
22 | # Don't test forkserver under linux, serves no usecase for check50
23 | else:
24 | SUPPORTED_START_METHODS = tuple(set(CHECK50_SUPPORTED_START_METHODS) & set(multiprocessing.get_all_start_methods()))
25 |
26 |
27 | class TestMultiprocessingStartMethods(unittest.TestCase):
28 | def setUp(self):
29 | self.working_directory = tempfile.TemporaryDirectory()
30 | os.chdir(self.working_directory.name)
31 |
32 | # Keep track of get_start_method
33 | # This function gets monkey patched to ensure run_check is aware of the multiprocessing context,
34 | # without needing to explicitly pass the context to run_check.
35 | # The same behavior can't be achieved by multiprocessing.set_start_method as that can only run once per program
36 | # https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods
37 | self._get_start_method = multiprocessing.get_start_method()
38 |
39 | def tearDown(self):
40 | self.working_directory.cleanup()
41 | multiprocessing.get_start_method = self._get_start_method
42 |
43 | def test_unpicklable_attribute(self):
44 | # Create the checks_spec and check_name needed for run_check
45 | checks_path = CHECKS_DIRECTORY / "unpicklable_attribute/__init__.py"
46 | check_name = "foo"
47 | spec = importlib.util.spec_from_file_location("checks", checks_path)
48 |
49 | # Execute the module once in the main process, as the Runner does too
50 | # This will set sys.excepthook to an unpicklable lambda
51 | check_module = importlib.util.module_from_spec(spec)
52 | spec.loader.exec_module(check_module)
53 |
54 | # For each available method
55 | for start_method in SUPPORTED_START_METHODS:
56 |
57 | # Create a multiprocessing context for that method
58 | ctx = multiprocessing.get_context(start_method)
59 |
60 | # Monkey patch get_start_method() used by run_check to check for its method
61 | multiprocessing.get_start_method = lambda: start_method
62 |
63 | # Start and join each check process
64 | p = ctx.Process(target=check50.runner.run_check(check_name, spec))
65 | p.start()
66 | p.join()
67 |
68 |
69 | if __name__ == "__main__":
70 | unittest.main()
--------------------------------------------------------------------------------
/tests/simple_tests.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | import os
3 | import yaml
4 | import pathlib
5 | import tempfile
6 |
7 | from check50 import _simple
8 |
9 | class Base(unittest.TestCase):
10 |
11 | config_file = ".check50.yaml"
12 |
13 | def setUp(self):
14 | self.working_directory = tempfile.TemporaryDirectory()
15 | os.chdir(self.working_directory.name)
16 |
17 | def tearDown(self):
18 | self.working_directory.cleanup()
19 |
20 | def write(self, source):
21 | with open(self.config_file, "w") as f:
22 | f.write(source)
23 |
24 | class TestCompile(Base):
25 | def test_one_complete_check(self):
26 | checks = yaml.safe_load(\
27 | """checks:
28 | bar:
29 | - run: python3 foo.py
30 | stdin: baz
31 | stdout: baz
32 | exit: 0
33 | """)["checks"]
34 |
35 | expectation = \
36 | """import check50
37 |
38 | @check50.check()
39 | def bar():
40 | \"\"\"bar\"\"\"
41 | check50.run("python3 foo.py").stdin("baz", prompt=False).stdout("baz", regex=False).exit(0)"""
42 |
43 | result = _simple.compile(checks)
44 | self.assertEqual(result, expectation)
45 |
46 | def test_multiple_checks(self):
47 | checks = yaml.safe_load(\
48 | """checks:
49 | bar:
50 | - run: python3 foo.py
51 | exit: 0
52 | baz:
53 | - run: python3 foo.py
54 | exit: 0
55 | """)["checks"]
56 |
57 | expectation = \
58 | """import check50
59 |
60 | @check50.check()
61 | def bar():
62 | \"\"\"bar\"\"\"
63 | check50.run("python3 foo.py").exit(0)
64 |
65 | @check50.check()
66 | def baz():
67 | \"\"\"baz\"\"\"
68 | check50.run("python3 foo.py").exit(0)"""
69 |
70 | result = _simple.compile(checks)
71 | self.assertEqual(result, expectation)
72 |
73 | def test_multi(self):
74 | checks = yaml.safe_load(\
75 | """checks:
76 | bar:
77 | - run: python3 foo.py
78 | stdin:
79 | - foo
80 | - bar
81 | stdout:
82 | - baz
83 | - qux
84 | exit: 0
85 | """)["checks"]
86 |
87 | expectation = \
88 | """import check50
89 |
90 | @check50.check()
91 | def bar():
92 | \"\"\"bar\"\"\"
93 | check50.run("python3 foo.py").stdin("foo\\nbar", prompt=False).stdout("baz\\nqux", regex=False).exit(0)"""
94 |
95 | result = _simple.compile(checks)
96 | self.assertEqual(result, expectation)
97 |
98 | def test_multiline(self):
99 | checks = yaml.safe_load(\
100 | """checks:
101 | bar:
102 | - run: python3 foo.py
103 | stdout: |
104 | Hello
105 | World!
106 | exit: 0
107 | """)["checks"]
108 |
109 | expectation = \
110 | """import check50
111 |
112 | @check50.check()
113 | def bar():
114 | \"\"\"bar\"\"\"
115 | check50.run("python3 foo.py").stdout("Hello\\nWorld!\\n", regex=False).exit(0)"""
116 |
117 | result = _simple.compile(checks)
118 | self.assertEqual(result, expectation)
119 |
120 | def test_number_in_name(self):
121 | checks = yaml.safe_load(\
122 | """checks:
123 | 0bar:
124 | - run: python3 foo.py
125 | """)["checks"]
126 |
127 | result = _simple.compile(checks)
128 | self.assertTrue("def _0bar" in result)
129 | self.assertTrue("\"\"\"0bar\"\"\"" in result)
130 |
131 | def test_space_in_name(self):
132 | checks = yaml.safe_load(\
133 | """checks:
134 | bar baz:
135 | - run: python3 foo.py
136 | """)["checks"]
137 |
138 | result = _simple.compile(checks)
139 | self.assertTrue("def bar_baz" in result)
140 | self.assertTrue("\"\"\"bar baz\"\"\"" in result)
141 |
142 | def test_dash_in_name(self):
143 | checks = yaml.safe_load(\
144 | """checks:
145 | bar-baz:
146 | - run: python3 foo.py
147 | """)["checks"]
148 |
149 | result = _simple.compile(checks)
150 | self.assertTrue("def bar_baz" in result)
151 | self.assertTrue("\"\"\"bar-baz\"\"\"" in result)
152 |
153 | def test_missing_exit(self):
154 | checks = yaml.safe_load(\
155 | """checks:
156 | bar:
157 | - run: python3 foo.py
158 | """)["checks"]
159 |
160 | result = _simple.compile(checks)
161 | self.assertTrue(".exit()" in result)
162 |
163 |
164 | if __name__ == "__main__":
165 | unittest.main()
166 |
--------------------------------------------------------------------------------