diff --git a/check50/__main__.py b/check50/__main__.py index 552394d5..0b321db1 100644 --- a/check50/__main__.py +++ b/check50/__main__.py @@ -159,6 +159,27 @@ def compile_checks(checks, prompt=False): return "__init__.py" +def setup_logging(level): + """ + Sets up logging for lib50. + level 'info' logs all git commands run to stderr + level 'debug' logs all git commands and their output to stderr + """ + # No verbosity level set, don't log anything + if not level: + return + + lib50_logger = logging.getLogger("lib50") + + # Set verbosity level on the lib50 logger + lib50_logger.setLevel(getattr(logging, level.upper())) + + # Direct all logs to sys.stderr + lib50_logger.addHandler(logging.StreamHandler(sys.stderr)) + + # Don't animate the progressbar + lib50.ProgressBar.DISABLED = True + def await_results(commit_hash, slug, pings=45, sleep=2): """ @@ -183,14 +204,12 @@ def await_results(commit_hash, slug, pings=45, sleep=2): _("check50 is taking longer than normal!\n" "See https://submit.cs50.io/check50/{} for more detail").format(commit_hash)) - if not results["check50"]: raise RemoteCheckError(results) if "error" in results["check50"]: raise RemoteCheckError(results["check50"]) - # TODO: Should probably check payload["version"] here to make sure major version is same as __version__ # (otherwise we may not be able to parse results) return results["tag_hash"], { @@ -240,11 +259,11 @@ def main(): parser.add_argument("slug", help=_("prescribed identifier of work to check")) parser.add_argument("-d", "--dev", action="store_true", - help=_("run check50 in development mode (implies --offline and --verbose).\n" + help=_("run check50 in development mode (implies --offline and --verbose info).\n" "causes SLUG to be interpreted as a literal path to a checks package")) parser.add_argument("--offline", action="store_true", - help=_("run checks completely offline (implies --local)")) + help=_("run checks completely offline (implies --local, --no-download-checks and --no-install-dependencies)")) parser.add_argument("-l", "--local", action="store_true", help=_("run checks locally instead of uploading to cs50")) @@ -266,8 +285,21 @@ def main(): metavar="FILE", help=_("file to write output to")) parser.add_argument("-v", "--verbose", + action="store", + nargs="?", + default="", + const="info", + choices=[attr for attr in dir(logging) if attr.isupper() and isinstance(getattr(logging, attr), int)], + type=str.upper, + help=_("sets the verbosity level." + ' "INFO" displays the full tracebacks of errors and shows all commands run.' + ' "DEBUG" adds the output of all command run.')) + parser.add_argument("--no-download-checks", + action="store_true", + help=_("do not download checks, but use previously downloaded checks instead (only works with --local)")) + parser.add_argument("--no-install-dependencies", action="store_true", - help=_("display the full tracebacks of any errors (also implies --log)")) + help=_("do not install dependencies (only works with --local)")) parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}") @@ -278,58 +310,74 @@ def main(): global SLUG SLUG = args.slug - + # dev implies offline and verbose "info" if not overwritten if args.dev: args.offline = True - args.verbose = True + if not args.verbose: + args.verbose = "info" + # offline implies local if args.offline: + args.no_install_dependencies = True + args.no_download_checks = True args.local = True - if args.verbose: - # Show lib50 commands being run in verbose mode - logging.basicConfig(level=os.environ.get("CHECK50_LOGLEVEL", "INFO")) - lib50.ProgressBar.DISABLED = True - args.log = True + # Setup logging for lib50 depending on verbosity level + setup_logging(args.verbose) + + # Warning in case of running remotely with no_download_checks or no_install_dependencies set + if args.local: + useless_args = [] + if args.no_download_checks: + useless_args.append("--no-downloads-checks") + if args.no_install_dependencies: + useless_args.append("--no-install-dependencies") + + if useless_args: + termcolor.cprint(_("Warning: you should always use --local when using: {}".format(", ".join(useless_args))), + "yellow", attrs=["bold"]) # Filter out any duplicates from args.output seen_output = set() args.output = [output for output in args.output if not (output in seen_output or seen_output.add(output))] # Set excepthook - excepthook.verbose = args.verbose + excepthook.verbose = bool(args.verbose) excepthook.outputs = args.output excepthook.output_file = args.output_file + # If remote, push files to GitHub and await results if not args.local: commit_hash = lib50.push("check50", SLUG, internal.CONFIG_LOADER, data={"check50": True})[1] with lib50.ProgressBar("Waiting for results") if "ansi" in args.output else nullcontext(): tag_hash, results = await_results(commit_hash, SLUG) + # Otherwise run checks locally else: - with lib50.ProgressBar("Checking") if not args.verbose and "ansi" in args.output else nullcontext(): + with lib50.ProgressBar("Checking") if "ansi" in args.output else nullcontext(): # If developing, assume slug is a path to check_dir if args.dev: internal.check_dir = Path(SLUG).expanduser().resolve() if not internal.check_dir.is_dir(): raise internal.Error(_("{} is not a directory").format(internal.check_dir)) + # Otherwise have lib50 create a local copy of slug else: - # Otherwise have lib50 create a local copy of slug try: - internal.check_dir = lib50.local(SLUG, offline=args.offline) + internal.check_dir = lib50.local(SLUG, offline=args.no_download_checks) except lib50.ConnectionError: raise internal.Error(_("check50 could not retrieve checks from GitHub. Try running check50 again with --offline.").format(SLUG)) except lib50.InvalidSlugError: - raise_invalid_slug(SLUG, offline=args.offline) + raise_invalid_slug(SLUG, offline=args.no_download_checks) # Load config config = internal.load_config(internal.check_dir) + # Compile local checks if necessary if isinstance(config["checks"], dict): config["checks"] = internal.compile_checks(config["checks"], prompt=args.dev) install_translations(config["translations"]) - if not args.offline: + if not args.no_install_dependencies: install_dependencies(config["dependencies"], verbose=args.verbose) checks_file = (internal.check_dir / config["checks"]).resolve() @@ -337,28 +385,20 @@ def main(): # Have lib50 decide which files to include included = lib50.files(config.get("files"))[0] - # Only open devnull conditionally - ctxmanager = open(os.devnull, "w") if not args.verbose else nullcontext() - with ctxmanager as devnull: + with open(os.devnull, "w") if args.verbose else nullcontext() as devnull: + # Redirect stdout to devnull if some verbosity level is set if args.verbose: + stdout = stderr = devnull + else: stdout = sys.stdout stderr = sys.stderr - else: - stdout = stderr = devnull - # Create a working_area (temp dir) with all included student files named - + # Create a working_area (temp dir) named - with all included student files with lib50.working_area(included, name='-') as working_area, \ contextlib.redirect_stdout(stdout), \ contextlib.redirect_stderr(stderr): - runner = CheckRunner(checks_file) - - # Run checks - if args.target: - check_results = runner.run(args.target, included, working_area) - else: - check_results = runner.run_all(included, working_area) - + check_results = CheckRunner(checks_file).run(included, working_area, args.target) results = { "slug": SLUG, "results": [attr.asdict(result) for result in check_results], diff --git a/check50/_api.py b/check50/_api.py index 8cceaf8b..abd2d69e 100644 --- a/check50/_api.py +++ b/check50/_api.py @@ -263,7 +263,7 @@ def stdout(self, output=None, str_output=None, regex=True, timeout=3): result += self.process.after raise Mismatch(str_output, result.replace("\r\n", "\n")) except TIMEOUT: - raise Failure(_("did not find {}").format(_raw(str_output))) + raise Missing(str_output, self.process.before) except UnicodeDecodeError: raise Failure(_("output not valid ASCII text")) except Exception: @@ -379,6 +379,31 @@ def __str__(self): return self.payload["rationale"] +class Missing(Failure): + """ + Exception signifying check failure due to an item missing from a collection. + This is typically a specific substring in a longer string, for instance the contents of stdout. + + :param item: the expected item / substring + :param collection: the collection / string + :param help: optional help message to be displayed + :type help: str + + Example usage:: + + actual = check50.run("./fibonacci 5").stdout() + + if "5" not in actual and "3" in actual: + help = "Be sure to start the sequence at 1" + raise check50.Missing("5", actual, help=help) + + """ + + def __init__(self, missing_item, collection, help=None): + super().__init__(rationale=_("Did not find {} in {}").format(_raw(missing_item), _raw(collection)), help=help) + self.payload.update({"missing_item": str(missing_item), "collection": str(collection)}) + + class Mismatch(Failure): """ Exception signifying check failure due to a mismatch in expected and actual outputs. diff --git a/check50/internal.py b/check50/internal.py index fcc722b4..b5996c45 100644 --- a/check50/internal.py +++ b/check50/internal.py @@ -178,6 +178,7 @@ def import_file(name, path): spec.loader.exec_module(mod) return mod + def _yes_no_prompt(prompt): """ Raise a prompt, returns True if yes is entered, False if no is entered. diff --git a/check50/renderer/templates/results.html b/check50/renderer/templates/results.html index 9d66ff39..683ef88f 100644 --- a/check50/renderer/templates/results.html +++ b/check50/renderer/templates/results.html @@ -24,6 +24,8 @@

:| {{ check.description }}

{# Reason why the check received its status #} {% if check.cause %} +

+ Cause
{% autoescape false %} {{ check.cause.rationale | e | replace(" ", " ") }} {% endautoescape %} @@ -34,16 +36,20 @@

:| {{ check.description }}


{% endif %} {% endautoescape %} +

{% endif %} {# Log information #} {% if check.log %} - Log -
- {% for item in check.log %} - {{ item }} -
- {% endfor %} + + Log
+
+ {% for item in check.log %} + {{ item }} +
+ {% endfor %} +
+
{% endif %} {% if check.cause and check.cause.error %} @@ -104,6 +110,38 @@

:| {{ check.description }}

{% endif %} + {# Missing if there was one #} + {% if check.cause and "missing_item" in check.cause and "collection" in check.cause %} +
+ +
+
+ Could not find the following in the output: +
+
+ + {% autoescape false %} + {% set item = check.cause.missing_item | e %} + {{ item | replace(" ", " ") | replace("\n", "
") }} + {% endautoescape %} +
+
+
+
+ Actual Output: +
+
+ + {% autoescape false %} + {% set collection = check.cause.collection | e %} + {{ collection | replace(" ", " ") | replace("\n", "
") }} + {% endautoescape %} +
+
+
+
+ {% endif %} + {% endif %} {% endfor %} diff --git a/check50/runner.py b/check50/runner.py index 176e5e98..d0d7f44d 100644 --- a/check50/runner.py +++ b/check50/runner.py @@ -164,7 +164,6 @@ def wrapper(checks_root, dependency_state): class CheckRunner: def __init__(self, checks_path): - # TODO: Naming the module "checks" is arbitray. Better name? self.checks_spec = importlib.util.spec_from_file_location("checks", checks_path) @@ -177,29 +176,41 @@ def __init__(self, checks_path): self.check_names = _check_names.copy() _check_names.clear() - # Map each check to tuples containing the names and descriptions of the checks that depend on it - self.dependency_map = collections.defaultdict(set) - for name, check in inspect.getmembers(check_module, lambda f: hasattr(f, "_check_dependency")): - dependency = check._check_dependency.__name__ if check._check_dependency is not None else None - self.dependency_map[dependency].add((name, check.__doc__)) + # Grab all checks from the module + checks = inspect.getmembers(check_module, lambda f: hasattr(f, "_check_dependency")) + + # Map each check to tuples containing the names of the checks that depend on it + self.dependency_graph = collections.defaultdict(set) + for name, check in checks: + dependency = None if check._check_dependency is None else check._check_dependency.__name__ + self.dependency_graph[dependency].add(name) + # Map each check name to its description + self.check_descriptions = {name: check.__doc__ for name, check in checks} - def run_all(self, files, working_area): + + def run(self, files, working_area, targets=None): """ Run checks concurrently. Returns a list of CheckResults ordered by declaration order of the checks in the imported module + targets allows you to limit which checks run. If targets is false-y, all checks are run. """ + graph = self.build_subgraph(targets) if targets else self.dependency_graph # Ensure that dictionary is ordered by check declaration order (via self.check_names) # NOTE: Requires CPython 3.6. If we need to support older versions of Python, replace with OrderedDict. results = {name: None for name in self.check_names} checks_root = working_area.parent - max_workers = os.environ.get("CHECK50_WORKERS") - with futures.ProcessPoolExecutor(max_workers=max_workers if max_workers is None else int(max_workers)) as executor: + try: + max_workers = int(os.environ.get("CHECK50_WORKERS")) + except (ValueError, TypeError): + max_workers = None + + with futures.ProcessPoolExecutor(max_workers=max_workers) as executor: # Start all checks that have no dependencies not_done = set(executor.submit(run_check(name, self.checks_spec, checks_root)) - for name, _ in self.dependency_map[None]) + for name in graph[None]) not_passed = [] while not_done: @@ -210,7 +221,7 @@ def run_all(self, files, working_area): results[result.name] = result if result.passed: # Dispatch dependent checks - for child_name, _ in self.dependency_map[result.name]: + for child_name in graph[result.name]: not_done.add(executor.submit( run_check(child_name, self.checks_spec, checks_root, state))) else: @@ -219,74 +230,48 @@ def run_all(self, files, working_area): for name in not_passed: self._skip_children(name, results) - return list(results.values()) + # Don't include checks we don't have results for (i.e. in the case that targets != None) in the list. + return list(filter(None, results.values())) - def run(self, check_names, files, working_area): + def build_subgraph(self, targets): """ - Run just the targeted checks, and the checks they depends on. - Returns just the result of the targetted checks. + Build minimal subgraph of self.dependency_graph that contains each check in targets """ - if len(set(check_names)) < len(check_names): - raise internal.Error(_("Duplicate checks targetted: {}".format(check_names))) - - # Find the docs for every check in the dependency map - check_docs = {} - for dependents in self.dependency_map.values(): - for dependent, doc in dependents: - check_docs[dependent] = doc - - # For every targetted check, validate that it exists - for check_name in check_names: - if check_name not in check_docs: - raise internal.Error(_("Unknown check {}").format(check_name)) - - # Build an inverse dependency map, from a check to its dependency - inverse_dependency_map = self._create_inverse_dependency_map() - - # Reconstruct a new dependency_map, consisting of the targetted checks and their dependencies - new_dependency_map = collections.defaultdict(set) - for check_name in check_names: - cur_check_name = check_name - while cur_check_name != None: - dependency_name = inverse_dependency_map[cur_check_name] - new_dependency_map[dependency_name].add((cur_check_name, check_docs[cur_check_name])) - cur_check_name = dependency_name - - # Temporarily replace dependency_map and run - try: - old_dependency_map = self.dependency_map - self.dependency_map = new_dependency_map - results = self.run_all(files, working_area) - finally: - self.dependency_map = old_dependency_map - - # Filter out all occurances of None in results (results of non targetted checks) - return [result for result in results if result != None] - - - def dependencies_of(self, check_names): - """Get the names of all direct and indirect dependencies of check_names.""" - # Build an inverse dependency map, from a check to its dependency - inverse_dependency_map = self._create_inverse_dependency_map() - - # Gather all dependencies from inverse_dependency_map - dependencies = set() - for check_name in check_names: - cur_check_name = inverse_dependency_map[check_name] - while cur_check_name != None: - dependencies.add(cur_check_name) - cur_check_name = inverse_dependency_map[cur_check_name] - - return dependencies - - def _create_inverse_dependency_map(self): + checks = self.dependencies_of(targets) + subgraph = collections.defaultdict(set) + for dep, children in self.dependency_graph.items(): + # If dep is not a dependency of any target, + # none of its children will be either, may as well skip. + if dep is not None and dep not in checks: + continue + for child in children: + if child in checks: + subgraph[dep].add(child) + return subgraph + + + def dependencies_of(self, targets): + """Get all unique dependencies of the targetted checks (tartgets).""" + inverse_graph = self._create_inverse_dependency_graph() + deps = set() + for target in targets: + if target not in inverse_graph: + raise internal.Error(_("Unknown check: {}").format(e.args[0])) + curr_check = target + while curr_check is not None and curr_check not in deps: + deps.add(curr_check) + curr_check = inverse_graph[curr_check] + return deps + + + def _create_inverse_dependency_graph(self): """Build an inverse dependency map, from a check to its dependency.""" - inverse_dependency_map = {} - for check_name, dependents in self.dependency_map.items(): - for dependent_name, _ in dependents: - inverse_dependency_map[dependent_name] = check_name - return inverse_dependency_map + inverse_dependency_graph = {} + for check_name, dependents in self.dependency_graph.items(): + for dependent_name in dependents: + inverse_dependency_graph[dependent_name] = check_name + return inverse_dependency_graph def _skip_children(self, check_name, results): @@ -294,9 +279,9 @@ def _skip_children(self, check_name, results): Recursively skip the children of check_name (presumably because check_name did not pass). """ - for name, description in self.dependency_map[check_name]: + for name in self.dependency_graph[check_name]: if results[name] is None: - results[name] = CheckResult(name=name, description=_(description), + results[name] = CheckResult(name=name, description=self.check_descriptions[name], passed=None, dependency=check_name, cause={"rationale": _("can't check until a frown turns upside down")}) diff --git a/setup.py b/setup.py index 954377e4..eb0bba08 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ message_extractors = { 'check50': [('**.py', 'python', None),], }, - install_requires=["attrs>=18", "bs4>=0", "pexpect>=4.6", "lib50>=2,<3", "pyyaml>=3.10", "requests>=2.19", "termcolor>=1.1", "jinja2>=2.10"], + install_requires=["attrs>=18", "bs4>=0", "pexpect>=4.6", "lib50>=2,<4", "pyyaml>=3.10", "requests>=2.19", "termcolor>=1.1", "jinja2>=2.10"], extras_require = { "develop": ["sphinx", "sphinx_rtd_theme"] }, @@ -29,6 +29,6 @@ "console_scripts": ["check50=check50.__main__:main"] }, url="https://github.com/cs50/check50", - version="3.0.10", + version="3.1.0", include_package_data=True ) diff --git a/tests/api_tests.py b/tests/api_tests.py index 465503a5..4a3ca803 100644 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -149,14 +149,12 @@ def test_outs(self): self.process.stdout("foo\n") self.process.stdout("bar") self.process.stdout("\n") - self.assertTrue(self.process.process.isalive()) def test_out_regex(self): self.write("print('foo')") self.runpy() self.process.stdout(".o.") self.process.stdout("\n") - self.assertTrue(self.process.process.isalive()) def test_out_no_regex(self): self.write("print('foo')")