autograder.style
1import contextlib 2import logging 3import os 4import re 5import sys 6import typing 7 8import edq.util.code 9import edq.util.dirent 10import flake8.api.legacy 11 12import autograder.question 13import autograder.util.prepare_submission 14 15DEFAULT_MAX_LINE_LENGTH: int = 150 16 17# For codes, see: 18# flake8: https://flake8.pycqa.org/en/latest/user/error-codes.html 19# pycodestyle: https://pycodestyle.pycqa.org/en/latest/intro.html#error-codes 20BASE_STYLE_OPTIONS: typing.Dict[str, typing.Any] = { 21 'max_line_length': DEFAULT_MAX_LINE_LENGTH, 22 'max_doc_length': DEFAULT_MAX_LINE_LENGTH, 23 'select': 'E,W,F', 24 'show_source': True, 25 'color': 'never', 26 'ignore': [ 27 # Allow flexibility for function closing parens. 28 'E123', 29 'E124', 30 31 # Don't force continuation line alignment. 32 'E126', 33 'E128', 34 35 # Allow spaces around parameter/keyword equals. 36 'E251', 37 38 # Don't force two spaces between functions/class. 39 # We only want one space. 40 'E302', 41 'E305', 42 43 # Allow lambdas to be assigned into a local variable. 44 'E731', 45 46 # Ignore missing newlines at the end of the file. 47 # This is a result of striping incoming code. 48 'W292', 49 50 # Do not enforce spaces in blank lines. 51 # This should typically be enforced, 52 # but because code from notebooks is hard to give a nice line number for 53 # students have difficulty finding where the issue is. 54 'W293', 55 56 # PEP-8 recommends breaking a line before a binary operator. 57 # This was a recent reversal of idiomatic Python. 58 # W503 enforces the old style, while W504 enforces the new style. 59 'W503', 60 ] 61} 62 63class Style(autograder.question.Question): 64 """ 65 A question that can be added to assignments that checks style. 66 """ 67 68 def __init__(self, 69 paths: typing.Union[str, typing.List[str], None] = None, 70 ignore_paths: typing.Union[typing.List[str], None] = None, 71 ignore_patterns: typing.Union[typing.Sequence[typing.Union[str, re.Pattern]], None] = None, 72 max_points: float = 5, 73 fake_path: typing.Union[str, None] = None, 74 shorten_path: bool = True, 75 style_overrides: typing.Union[typing.Dict[str, typing.Any], None] = None, 76 **kwargs: typing.Any) -> None: 77 super().__init__(max_points) 78 79 if (paths is None): 80 paths = [] 81 82 if (isinstance(paths, str)): 83 paths = [paths] 84 85 self._paths: typing.List[str] = paths 86 """ The paths to check. """ 87 88 if (ignore_paths is None): 89 ignore_paths = [] 90 91 self._ignore_paths = ignore_paths 92 """ 93 Paths to ignore when checking style. 94 These should be relative to where the style checker will be run or absolute. 95 Use `ignore_patterns` for more flexiblity. 96 """ 97 98 if (ignore_patterns is None): 99 ignore_patterns = [] 100 101 clean_ignore_patterns = [] 102 for pattern in ignore_patterns: 103 if (isinstance(pattern, re.Pattern)): 104 clean_ignore_patterns.append(pattern) 105 else: 106 clean_ignore_patterns.append(re.compile(pattern)) 107 108 self._ignore_patterns = clean_ignore_patterns 109 """ Patterns to check against every file path before making checks. """ 110 111 self._fake_path: typing.Union[str, None] = fake_path 112 """ 113 A fake path to use when reporting style errors. 114 This can help make output cleaner. 115 """ 116 117 self._shorten_paths: bool = shorten_path 118 """ 119 Shorten the reported path when reporting style errors. 120 This can help make output cleaner. 121 """ 122 123 if (style_overrides is None): 124 style_overrides = {} 125 126 self._style_overrides = style_overrides 127 """ Overrides for options passed directly to flake8. """ 128 129 def score_question(self, submission: typing.Any, **kwargs: typing.Any) -> None: 130 error_count, style_output = check_paths( 131 self._paths, 132 ignore_paths = self._ignore_paths, 133 ignore_patterns = self._ignore_patterns, 134 fake_path = self._fake_path, 135 shorten_path = self._shorten_paths, 136 style_overrides = self._style_overrides, 137 include_clean_paths = False, 138 ) 139 140 if (error_count == 0): 141 self.full_credit(message = 'Style is clean!') 142 return 143 144 self.add_message(f"Code has {error_count} style issues (shown below). Note that line numbers may be offset in iPython notebooks.") 145 self.set_score(max(0, self.max_points - error_count)) 146 147 self.add_message("--- Style Output BEGIN ---") 148 for (path, lines) in style_output: 149 self.add_message(f"\nStyle Issues for: '{path}':") 150 self.add_message('---') 151 self.add_message("\n".join(lines)) 152 self.add_message("---\n") 153 self.add_message("--- Style Output END ---") 154 155def check_path( 156 path: str, 157 **kwargs: typing.Any) -> typing.Tuple[int, typing.List[typing.Tuple[str, typing.List[str]]]]: 158 """ 159 Check a single path for style. 160 See check_paths() and check_file() for kwargs. 161 See check_paths() for return value. 162 """ 163 164 return check_paths([path], **kwargs) 165 166def check_paths( 167 paths: typing.List[str], 168 ignore_paths: typing.Union[typing.List[str], None] = None, 169 ignore_patterns: typing.Union[typing.Sequence[typing.Union[str, re.Pattern]], None] = None, 170 include_clean_paths: bool = False, 171 **kwargs: typing.Any) -> typing.Tuple[int, typing.List[typing.Tuple[str, typing.List[str]]]]: 172 """ 173 Check the style of all the listed paths (recursively). 174 175 If `include_clean_paths` is false, then no information is returned on empty files, 176 otherwise, these files will be included in the results (with empty description lines). 177 Ignored paths are never included in the results. 178 179 Returns a two-item tuple of: 180 - The total number of style violations. 181 - A list of two-item tuples of: 182 - The path to the context file. 183 - A list of strings that describe the style issues in the context file. 184 """ 185 186 if (ignore_paths is None): 187 ignore_paths = [] 188 189 ignore_paths = [os.path.abspath(path) for path in ignore_paths] 190 191 if (ignore_patterns is None): 192 ignore_patterns = [] 193 194 clean_ignore_patterns = [] 195 for pattern in ignore_patterns: 196 if (isinstance(pattern, re.Pattern)): 197 clean_ignore_patterns.append(pattern) 198 else: 199 clean_ignore_patterns.append(re.compile(pattern)) 200 201 total_count = 0 202 203 # [(path, lines), ...] 204 total_lines = [] 205 206 for path in sorted(paths): 207 path = os.path.abspath(path) 208 209 skip = False 210 211 for ignore_path in ignore_paths: 212 if (path == ignore_path): 213 skip = True 214 break 215 216 for ignore_pattern in clean_ignore_patterns: 217 if (re.search(ignore_pattern, path) is not None): 218 skip = True 219 break 220 221 if (skip): 222 continue 223 224 if (os.path.isfile(path)): 225 if (os.path.splitext(path)[1] not in autograder.util.prepare_submission.ALLOWED_EXTENSIONS): 226 continue 227 228 count, raw_lines = check_file(path, **kwargs) 229 lines = [(path, raw_lines)] 230 else: 231 dirents = [os.path.join(path, dirent) for dirent in os.listdir(path)] 232 count, lines = check_paths(dirents, 233 ignore_paths = ignore_paths, ignore_patterns = ignore_patterns, include_clean_paths = include_clean_paths, **kwargs) 234 235 if (include_clean_paths or (count > 0)): 236 total_count += count 237 total_lines += lines 238 239 return total_count, total_lines 240 241def check_file( 242 path: str, 243 fake_path: typing.Union[str, None] = None, 244 shorten_path: bool = False, 245 style_overrides: typing.Union[typing.Dict[str, typing.Any], None] = None, 246 **kwargs: typing.Any) -> typing.Tuple[int, typing.List[str]]: 247 """ 248 Check the style of a file. 249 Return a two-item tuple of: 250 - The number of style violations. 251 - A list of strings that describe the style issues. 252 """ 253 254 if (style_overrides is None): 255 style_overrides = {} 256 257 if (not os.path.isfile(path)): 258 raise ValueError(f"Can only check style on a file, got a directory: '{path}'.") 259 260 cleanup_paths = [] 261 262 if (path.endswith('.py')): 263 pass 264 elif (path.endswith('.ipynb')): 265 contents = edq.util.code.extract_notebook_code(path) 266 267 temp_path = edq.util.dirent.get_temp_path(prefix = 'style_', suffix = '_notebook') 268 cleanup_paths.append(temp_path) 269 edq.util.dirent.write_file(temp_path, contents, strip = False, newline = False) 270 271 path = temp_path 272 else: 273 raise ValueError(f"Can only check style on .py or .ipynb files, got '{path}'.") 274 275 path = os.path.realpath(path) 276 277 replacement_path = path 278 279 if (fake_path is not None): 280 replacement_path = fake_path 281 282 if (shorten_path): 283 replacement_path = os.path.basename(replacement_path) 284 285 output_path = edq.util.dirent.get_temp_path(prefix = 'style_', suffix = '_output') 286 cleanup_paths.append(output_path) 287 288 # Ignore most flake8 logging. 289 logging.getLogger("flake8").setLevel(logging.WARNING) 290 291 # argparse (used by flake8) will look for a program name on sys.argv[0]. 292 if (len(sys.argv) == 0): 293 sys.argv = [''] 294 295 style_options = BASE_STYLE_OPTIONS.copy() 296 style_options.update(style_overrides) 297 298 style_guide = flake8.api.legacy.get_style_guide(**style_options) 299 300 with open(output_path, 'w', encoding = edq.util.dirent.DEFAULT_ENCODING) as file: 301 with contextlib.redirect_stdout(file): 302 report = style_guide.check_files([path]) 303 304 with open(output_path, 'r', encoding = edq.util.dirent.DEFAULT_ENCODING) as file: 305 lines = file.readlines() 306 307 lines = [line.rstrip() for line in lines] 308 309 if (path != replacement_path): 310 lines = [line.replace(path, replacement_path) for line in lines] 311 312 for cleanup_path in cleanup_paths: 313 edq.util.dirent.remove(cleanup_path) 314 315 return (report._application.result_count, lines)
64class Style(autograder.question.Question): 65 """ 66 A question that can be added to assignments that checks style. 67 """ 68 69 def __init__(self, 70 paths: typing.Union[str, typing.List[str], None] = None, 71 ignore_paths: typing.Union[typing.List[str], None] = None, 72 ignore_patterns: typing.Union[typing.Sequence[typing.Union[str, re.Pattern]], None] = None, 73 max_points: float = 5, 74 fake_path: typing.Union[str, None] = None, 75 shorten_path: bool = True, 76 style_overrides: typing.Union[typing.Dict[str, typing.Any], None] = None, 77 **kwargs: typing.Any) -> None: 78 super().__init__(max_points) 79 80 if (paths is None): 81 paths = [] 82 83 if (isinstance(paths, str)): 84 paths = [paths] 85 86 self._paths: typing.List[str] = paths 87 """ The paths to check. """ 88 89 if (ignore_paths is None): 90 ignore_paths = [] 91 92 self._ignore_paths = ignore_paths 93 """ 94 Paths to ignore when checking style. 95 These should be relative to where the style checker will be run or absolute. 96 Use `ignore_patterns` for more flexiblity. 97 """ 98 99 if (ignore_patterns is None): 100 ignore_patterns = [] 101 102 clean_ignore_patterns = [] 103 for pattern in ignore_patterns: 104 if (isinstance(pattern, re.Pattern)): 105 clean_ignore_patterns.append(pattern) 106 else: 107 clean_ignore_patterns.append(re.compile(pattern)) 108 109 self._ignore_patterns = clean_ignore_patterns 110 """ Patterns to check against every file path before making checks. """ 111 112 self._fake_path: typing.Union[str, None] = fake_path 113 """ 114 A fake path to use when reporting style errors. 115 This can help make output cleaner. 116 """ 117 118 self._shorten_paths: bool = shorten_path 119 """ 120 Shorten the reported path when reporting style errors. 121 This can help make output cleaner. 122 """ 123 124 if (style_overrides is None): 125 style_overrides = {} 126 127 self._style_overrides = style_overrides 128 """ Overrides for options passed directly to flake8. """ 129 130 def score_question(self, submission: typing.Any, **kwargs: typing.Any) -> None: 131 error_count, style_output = check_paths( 132 self._paths, 133 ignore_paths = self._ignore_paths, 134 ignore_patterns = self._ignore_patterns, 135 fake_path = self._fake_path, 136 shorten_path = self._shorten_paths, 137 style_overrides = self._style_overrides, 138 include_clean_paths = False, 139 ) 140 141 if (error_count == 0): 142 self.full_credit(message = 'Style is clean!') 143 return 144 145 self.add_message(f"Code has {error_count} style issues (shown below). Note that line numbers may be offset in iPython notebooks.") 146 self.set_score(max(0, self.max_points - error_count)) 147 148 self.add_message("--- Style Output BEGIN ---") 149 for (path, lines) in style_output: 150 self.add_message(f"\nStyle Issues for: '{path}':") 151 self.add_message('---') 152 self.add_message("\n".join(lines)) 153 self.add_message("---\n") 154 self.add_message("--- Style Output END ---")
A question that can be added to assignments that checks style.
69 def __init__(self, 70 paths: typing.Union[str, typing.List[str], None] = None, 71 ignore_paths: typing.Union[typing.List[str], None] = None, 72 ignore_patterns: typing.Union[typing.Sequence[typing.Union[str, re.Pattern]], None] = None, 73 max_points: float = 5, 74 fake_path: typing.Union[str, None] = None, 75 shorten_path: bool = True, 76 style_overrides: typing.Union[typing.Dict[str, typing.Any], None] = None, 77 **kwargs: typing.Any) -> None: 78 super().__init__(max_points) 79 80 if (paths is None): 81 paths = [] 82 83 if (isinstance(paths, str)): 84 paths = [paths] 85 86 self._paths: typing.List[str] = paths 87 """ The paths to check. """ 88 89 if (ignore_paths is None): 90 ignore_paths = [] 91 92 self._ignore_paths = ignore_paths 93 """ 94 Paths to ignore when checking style. 95 These should be relative to where the style checker will be run or absolute. 96 Use `ignore_patterns` for more flexiblity. 97 """ 98 99 if (ignore_patterns is None): 100 ignore_patterns = [] 101 102 clean_ignore_patterns = [] 103 for pattern in ignore_patterns: 104 if (isinstance(pattern, re.Pattern)): 105 clean_ignore_patterns.append(pattern) 106 else: 107 clean_ignore_patterns.append(re.compile(pattern)) 108 109 self._ignore_patterns = clean_ignore_patterns 110 """ Patterns to check against every file path before making checks. """ 111 112 self._fake_path: typing.Union[str, None] = fake_path 113 """ 114 A fake path to use when reporting style errors. 115 This can help make output cleaner. 116 """ 117 118 self._shorten_paths: bool = shorten_path 119 """ 120 Shorten the reported path when reporting style errors. 121 This can help make output cleaner. 122 """ 123 124 if (style_overrides is None): 125 style_overrides = {} 126 127 self._style_overrides = style_overrides 128 """ Overrides for options passed directly to flake8. """
130 def score_question(self, submission: typing.Any, **kwargs: typing.Any) -> None: 131 error_count, style_output = check_paths( 132 self._paths, 133 ignore_paths = self._ignore_paths, 134 ignore_patterns = self._ignore_patterns, 135 fake_path = self._fake_path, 136 shorten_path = self._shorten_paths, 137 style_overrides = self._style_overrides, 138 include_clean_paths = False, 139 ) 140 141 if (error_count == 0): 142 self.full_credit(message = 'Style is clean!') 143 return 144 145 self.add_message(f"Code has {error_count} style issues (shown below). Note that line numbers may be offset in iPython notebooks.") 146 self.set_score(max(0, self.max_points - error_count)) 147 148 self.add_message("--- Style Output BEGIN ---") 149 for (path, lines) in style_output: 150 self.add_message(f"\nStyle Issues for: '{path}':") 151 self.add_message('---') 152 self.add_message("\n".join(lines)) 153 self.add_message("---\n") 154 self.add_message("--- Style Output END ---")
Assign an actual score to this question. The implementer has full access to instance variables. However, users should generally just call the grading methods to manipulate the result.
156def check_path( 157 path: str, 158 **kwargs: typing.Any) -> typing.Tuple[int, typing.List[typing.Tuple[str, typing.List[str]]]]: 159 """ 160 Check a single path for style. 161 See check_paths() and check_file() for kwargs. 162 See check_paths() for return value. 163 """ 164 165 return check_paths([path], **kwargs)
Check a single path for style. See check_paths() and check_file() for kwargs. See check_paths() for return value.
167def check_paths( 168 paths: typing.List[str], 169 ignore_paths: typing.Union[typing.List[str], None] = None, 170 ignore_patterns: typing.Union[typing.Sequence[typing.Union[str, re.Pattern]], None] = None, 171 include_clean_paths: bool = False, 172 **kwargs: typing.Any) -> typing.Tuple[int, typing.List[typing.Tuple[str, typing.List[str]]]]: 173 """ 174 Check the style of all the listed paths (recursively). 175 176 If `include_clean_paths` is false, then no information is returned on empty files, 177 otherwise, these files will be included in the results (with empty description lines). 178 Ignored paths are never included in the results. 179 180 Returns a two-item tuple of: 181 - The total number of style violations. 182 - A list of two-item tuples of: 183 - The path to the context file. 184 - A list of strings that describe the style issues in the context file. 185 """ 186 187 if (ignore_paths is None): 188 ignore_paths = [] 189 190 ignore_paths = [os.path.abspath(path) for path in ignore_paths] 191 192 if (ignore_patterns is None): 193 ignore_patterns = [] 194 195 clean_ignore_patterns = [] 196 for pattern in ignore_patterns: 197 if (isinstance(pattern, re.Pattern)): 198 clean_ignore_patterns.append(pattern) 199 else: 200 clean_ignore_patterns.append(re.compile(pattern)) 201 202 total_count = 0 203 204 # [(path, lines), ...] 205 total_lines = [] 206 207 for path in sorted(paths): 208 path = os.path.abspath(path) 209 210 skip = False 211 212 for ignore_path in ignore_paths: 213 if (path == ignore_path): 214 skip = True 215 break 216 217 for ignore_pattern in clean_ignore_patterns: 218 if (re.search(ignore_pattern, path) is not None): 219 skip = True 220 break 221 222 if (skip): 223 continue 224 225 if (os.path.isfile(path)): 226 if (os.path.splitext(path)[1] not in autograder.util.prepare_submission.ALLOWED_EXTENSIONS): 227 continue 228 229 count, raw_lines = check_file(path, **kwargs) 230 lines = [(path, raw_lines)] 231 else: 232 dirents = [os.path.join(path, dirent) for dirent in os.listdir(path)] 233 count, lines = check_paths(dirents, 234 ignore_paths = ignore_paths, ignore_patterns = ignore_patterns, include_clean_paths = include_clean_paths, **kwargs) 235 236 if (include_clean_paths or (count > 0)): 237 total_count += count 238 total_lines += lines 239 240 return total_count, total_lines
Check the style of all the listed paths (recursively).
If include_clean_paths is false, then no information is returned on empty files,
otherwise, these files will be included in the results (with empty description lines).
Ignored paths are never included in the results.
Returns a two-item tuple of: - The total number of style violations. - A list of two-item tuples of: - The path to the context file. - A list of strings that describe the style issues in the context file.
242def check_file( 243 path: str, 244 fake_path: typing.Union[str, None] = None, 245 shorten_path: bool = False, 246 style_overrides: typing.Union[typing.Dict[str, typing.Any], None] = None, 247 **kwargs: typing.Any) -> typing.Tuple[int, typing.List[str]]: 248 """ 249 Check the style of a file. 250 Return a two-item tuple of: 251 - The number of style violations. 252 - A list of strings that describe the style issues. 253 """ 254 255 if (style_overrides is None): 256 style_overrides = {} 257 258 if (not os.path.isfile(path)): 259 raise ValueError(f"Can only check style on a file, got a directory: '{path}'.") 260 261 cleanup_paths = [] 262 263 if (path.endswith('.py')): 264 pass 265 elif (path.endswith('.ipynb')): 266 contents = edq.util.code.extract_notebook_code(path) 267 268 temp_path = edq.util.dirent.get_temp_path(prefix = 'style_', suffix = '_notebook') 269 cleanup_paths.append(temp_path) 270 edq.util.dirent.write_file(temp_path, contents, strip = False, newline = False) 271 272 path = temp_path 273 else: 274 raise ValueError(f"Can only check style on .py or .ipynb files, got '{path}'.") 275 276 path = os.path.realpath(path) 277 278 replacement_path = path 279 280 if (fake_path is not None): 281 replacement_path = fake_path 282 283 if (shorten_path): 284 replacement_path = os.path.basename(replacement_path) 285 286 output_path = edq.util.dirent.get_temp_path(prefix = 'style_', suffix = '_output') 287 cleanup_paths.append(output_path) 288 289 # Ignore most flake8 logging. 290 logging.getLogger("flake8").setLevel(logging.WARNING) 291 292 # argparse (used by flake8) will look for a program name on sys.argv[0]. 293 if (len(sys.argv) == 0): 294 sys.argv = [''] 295 296 style_options = BASE_STYLE_OPTIONS.copy() 297 style_options.update(style_overrides) 298 299 style_guide = flake8.api.legacy.get_style_guide(**style_options) 300 301 with open(output_path, 'w', encoding = edq.util.dirent.DEFAULT_ENCODING) as file: 302 with contextlib.redirect_stdout(file): 303 report = style_guide.check_files([path]) 304 305 with open(output_path, 'r', encoding = edq.util.dirent.DEFAULT_ENCODING) as file: 306 lines = file.readlines() 307 308 lines = [line.rstrip() for line in lines] 309 310 if (path != replacement_path): 311 lines = [line.replace(path, replacement_path) for line in lines] 312 313 for cleanup_path in cleanup_paths: 314 edq.util.dirent.remove(cleanup_path) 315 316 return (report._application.result_count, lines)
Check the style of a file. Return a two-item tuple of: - The number of style violations. - A list of strings that describe the style issues.