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)
DEFAULT_MAX_LINE_LENGTH: int = 150
BASE_STYLE_OPTIONS: Dict[str, Any] = {'max_line_length': 150, 'max_doc_length': 150, 'select': 'E,W,F', 'show_source': True, 'color': 'never', 'ignore': ['E123', 'E124', 'E126', 'E128', 'E251', 'E302', 'E305', 'E731', 'W292', 'W293', 'W503']}
class Style(autograder.question.Question):
 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.

Style( paths: Union[str, List[str], NoneType] = None, ignore_paths: Optional[List[str]] = None, ignore_patterns: Optional[Sequence[Union[str, re.Pattern]]] = None, max_points: float = 5, fake_path: Optional[str] = None, shorten_path: bool = True, style_overrides: Optional[Dict[str, Any]] = None, **kwargs: Any)
 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. """
def score_question(self, submission: Any, **kwargs: Any) -> None:
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.

def check_path(path: str, **kwargs: Any) -> Tuple[int, List[Tuple[str, List[str]]]]:
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.

def check_paths( paths: List[str], ignore_paths: Optional[List[str]] = None, ignore_patterns: Optional[Sequence[Union[str, re.Pattern]]] = None, include_clean_paths: bool = False, **kwargs: Any) -> Tuple[int, List[Tuple[str, List[str]]]]:
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.

def check_file( path: str, fake_path: Optional[str] = None, shorten_path: bool = False, style_overrides: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Tuple[int, List[str]]:
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.