check-pfda 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: check-pfda
3
+ Version: 0.0.1
4
+ Summary: PFDA test running package.
5
+ Requires-Python: >=3.8
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: click
8
+ Requires-Dist: pytest
9
+ Requires-Dist: requests
10
+ Requires-Dist: PyYAML
11
+
12
+ # check-pfda
13
+
14
+ > Compatibility goes here
15
+
16
+ Check-pfda is a package that tests student code and provides helpful feedback.
17
+
18
+ ## Installation
19
+ `pip install check-pfda`
20
+
21
+
22
+ ### Usage
23
+ 1. Navigate to the root directory of a cloned assignment.
24
+ 2. Run `pfda check`.
25
+
26
+ ## Documentation
27
+ > Read The Docs page goes here
@@ -0,0 +1,16 @@
1
+ # check-pfda
2
+
3
+ > Compatibility goes here
4
+
5
+ Check-pfda is a package that tests student code and provides helpful feedback.
6
+
7
+ ## Installation
8
+ `pip install check-pfda`
9
+
10
+
11
+ ### Usage
12
+ 1. Navigate to the root directory of a cloned assignment.
13
+ 2. Run `pfda check`.
14
+
15
+ ## Documentation
16
+ > Read The Docs page goes here
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "check-pfda"
7
+ version = "0.0.1"
8
+ description = "PFDA test running package."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ dependencies = [
12
+ "click",
13
+ "pytest",
14
+ "requests",
15
+ "PyYAML"
16
+ ]
17
+
18
+ [project.scripts]
19
+ pfda = "check_pfda.cli:cli"
20
+
21
+ [tool.setuptools]
22
+ include-package-data = true
23
+
24
+ [tool.pydoclint]
25
+ style = 'sphinx'
26
+ exclude = '\.git|\.venv'
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,29 @@
1
+ """Command-line interface for package usage."""
2
+
3
+ import click
4
+
5
+ from .core import check_student_code
6
+
7
+
8
+ @click.group()
9
+ def cli():
10
+ """Group for CLI entrypoint."""
11
+ pass
12
+
13
+
14
+ @cli.command()
15
+ @click.option(
16
+ '-v',
17
+ '--verbosity',
18
+ count=True,
19
+ help='Verbosity of test output'
20
+ )
21
+ @click.option(
22
+ '-d',
23
+ '--debug',
24
+ is_flag=True,
25
+ help='Swap to offline tests'
26
+ )
27
+ def check(verbosity: int, debug: bool) -> None:
28
+ """Run student checks."""
29
+ check_student_code(verbosity, debug)
@@ -0,0 +1,54 @@
1
+ """Collect tests and run them on supplied code."""
2
+
3
+ import os
4
+ print(f"PID: {os.getpid()}")
5
+
6
+ from tempfile import NamedTemporaryFile
7
+ import sys
8
+
9
+
10
+ from check_pfda.utils import get_current_assignment, get_tests
11
+
12
+ from click import echo, secho
13
+
14
+ import pytest
15
+
16
+
17
+ def check_student_code(verbosity: int, debug: bool = False) -> None:
18
+ """Check student code."""
19
+ chapter, assignment = get_current_assignment()
20
+ echo(f"Checking assignment {assignment} at verbosity {verbosity}...")
21
+ cwd_src = os.path.join(os.getcwd(), "src")
22
+ if cwd_src not in sys.path:
23
+ sys.path.insert(0, cwd_src)
24
+ if debug:
25
+ secho("\nIN DEBUG MODE\n", fg="blue", bold=True)
26
+ base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
27
+ test_path = os.path.join(base_dir, "check_pfda",
28
+ ".test_static_imports",
29
+ f"test_{assignment}.py")
30
+ args = [test_path]
31
+ if verbosity > 0:
32
+ args.append(f"-{'v' * verbosity}")
33
+ exit_code = pytest.main(args)
34
+ echo(f"Pytest finished with exit code {exit_code}")
35
+ return
36
+ tests = get_tests(chapter, assignment)
37
+
38
+ temp_file = NamedTemporaryFile(suffix=".py", delete=False)
39
+ try:
40
+ temp_file.write(tests.encode("utf-8"))
41
+ temp_file.flush()
42
+ temp_file.close()
43
+
44
+ echo(f"Temp test file at: {temp_file.name}")
45
+ args = [temp_file.name]
46
+ if verbosity > 0:
47
+ args.append(f"-{'v' * verbosity}")
48
+ exit_code = pytest.main(args)
49
+ echo(f"Pytest finished with exit code {exit_code}")
50
+ finally:
51
+ os.remove(temp_file.name)
52
+ echo("Removed temp test file.")
53
+ if cwd_src in sys.path:
54
+ sys.path.remove(cwd_src)
@@ -0,0 +1,348 @@
1
+ """Public modules."""
2
+ import os
3
+ import sys
4
+ from importlib import import_module
5
+ from io import StringIO
6
+ from pathlib import Path
7
+ import re
8
+ from typing import Any
9
+
10
+ import click
11
+
12
+ import py.path
13
+
14
+ import pytest
15
+
16
+ import requests
17
+
18
+ import yaml
19
+
20
+ # Constants.
21
+ STRING_LEN_LIMIT = 1000
22
+
23
+
24
+ """
25
+ Public functions. These are intended for direct implementation in unit tests.
26
+ """
27
+
28
+
29
+ def assert_script_exists(module_name: str, accepted_dirs: list) -> None:
30
+ """Check accepted subfolders for the module script.
31
+
32
+ :param module_name: The name of the module to check.
33
+ :type module_name: str
34
+ :param accepted_dirs: The accepted subfolders for the script.
35
+ :type accepted_dirs: list
36
+ :return: None
37
+ :rtype: None
38
+ """
39
+ curr_dir = os.getcwd()
40
+ for subfolder in accepted_dirs:
41
+ filename = os.path.join(curr_dir, subfolder, f"{module_name}.py")
42
+ print(filename)
43
+ if os.path.exists(filename):
44
+ return None
45
+ pytest.fail(reason=f"The script '{module_name}.py' does not exist in "
46
+ f"the accepted directories: {accepted_dirs}.")
47
+
48
+
49
+ def build_user_friendly_err(actual: Any, expected: Any) -> str:
50
+ """Build a user-friendly error to accompany a pytest AssertionError.
51
+
52
+ :param actual: The actual output of the tested program.
53
+ :type actual: Any
54
+ :param expected: The expected output of the tested program.
55
+ :type expected: Any
56
+ :return: A user-friendly error message.
57
+ :rtype: str
58
+ """
59
+ errors = []
60
+
61
+ if actual is None and expected is not None:
62
+ errors.append("Your function/program did not produce any output.")
63
+ elif actual is not None and expected is None:
64
+ errors.append("Your function/program produced output when it was "
65
+ "not expected.")
66
+
67
+ if _is_different_type(expected, actual):
68
+ errors.append(
69
+ f"The expected data type is {_format_type(type(expected))}, "
70
+ f"but your actual output data type is "
71
+ f"{_format_type(type(actual))}.")
72
+ elif isinstance(expected, str):
73
+ for error in _find_string_comparison_errors(expected, actual):
74
+ errors.append(error)
75
+ else:
76
+ errors.append("Your output does not match the expected "
77
+ "format or values.")
78
+
79
+ errors_formatted = "\n- ".join(errors)
80
+ error_msg = (
81
+ f"ANGM2305 Autograder User-friendly Message:"
82
+ f"\n--------------------------------------------------------"
83
+ f"\nThe Test Failed."
84
+ f"\n\nWhat the Test Expected:"
85
+ f"\n{expected}"
86
+ f"\n\nWhat your Function/Program output:"
87
+ f"\n{actual}"
88
+ f"\n\nIssues Found:"
89
+ f"\n- {errors_formatted}"
90
+ f"\n\nPytest Error Message:"
91
+ f"\n---------------------")
92
+ return error_msg
93
+
94
+
95
+ def generate_temp_file(filename: str,
96
+ tmpdir: py.path.local,
97
+ contents: Any) -> str:
98
+ """Generate a temporary file to test with.
99
+
100
+ :param filename: The name of the temporary file.
101
+ :type filename: str
102
+ :param tmpdir: Pytest's tmpdir fixture.
103
+ :type tmpdir: py.path.local
104
+ :param contents: The contents to write to the temporary file.
105
+ :type contents: Any
106
+ :return: The path to the temporary file.
107
+ :rtype: str
108
+ """
109
+ filepath = os.path.join(tmpdir, filename)
110
+ with open(filepath, "w") as f:
111
+ f.write(contents)
112
+ return filepath
113
+
114
+
115
+ def get_tests(chapter, assignment: str) -> str:
116
+ """Get tests for a given assignment."""
117
+ tests_repo_url = _construct_test_url(chapter, assignment)
118
+ try:
119
+ r = requests.get(tests_repo_url, timeout=10)
120
+ r.raise_for_status()
121
+ except requests.exceptions.RequestException as e:
122
+ click.secho(f"Error fetching test file for assignment '"
123
+ f"{assignment}': {e}", fg="red", bold=True)
124
+ sys.exit(1)
125
+
126
+ if not r.text.strip():
127
+ click.secho("Error: Received empty test file. Contact your "
128
+ "instructor", fg="red", bold=True)
129
+ sys.exit(1)
130
+
131
+ if "def test_" not in r.text:
132
+ click.secho(
133
+ "Warning: This may not be a valid test file.",
134
+ fg="yellow"
135
+ )
136
+
137
+ return r.text
138
+
139
+
140
+ def reload_module(module_name: str) -> None:
141
+ """Reload the module. Ensures it is reloaded if previously loaded.
142
+
143
+ :param module_name: The name of the module to reload.
144
+ :type module_name: str
145
+ """
146
+ sys.modules.pop(module_name, None)
147
+ import_module(name=module_name)
148
+
149
+
150
+ def patch_input_output(monkeypatch: Any,
151
+ test_inputs: list,
152
+ module_name: str) -> StringIO:
153
+ """Patch input() and standard out.
154
+
155
+ :param monkeypatch: Pytest's monkeypatch fixture.
156
+ :type monkeypatch: Any
157
+ :param test_inputs: The inputs to test known outputs against.
158
+ :type test_inputs: list
159
+ :param module_name: The name of the module to test.
160
+ :type module_name: str
161
+ :return: The patched standard out.
162
+ :rtype: StringIO
163
+ """
164
+ # patches the standard output to catch the output of print()
165
+ patch_stdout = StringIO()
166
+ # Returns a new mock object which undoes any patching done inside
167
+ # the with block on exit to avoid breaking pytest itself.
168
+ with monkeypatch.context() as m:
169
+ # patches the input()
170
+ m.setattr("builtins.input", lambda _: test_inputs.pop(0))
171
+ m.setattr("sys.stdout", patch_stdout)
172
+ reload_module(module_name)
173
+ return patch_stdout
174
+
175
+
176
+ """
177
+ Private functions. Do not implement these directly in any unit tests.
178
+ """
179
+
180
+
181
+ def _format_type(var_type: str) -> str:
182
+ """Format repr class type.
183
+
184
+ :param var_type: The name of a type.
185
+ :type var_type: str
186
+ :return: The formatted type.
187
+ :rtype: str
188
+ """
189
+ return var_type.split("'")[1::2][0]
190
+
191
+
192
+ def _is_different_type(expected: Any, actual: Any) -> bool:
193
+ """Evaluate if the two arguments are the same type.
194
+
195
+ :param expected: The expected object.
196
+ :type expected: Any
197
+ :param actual: The actual object.
198
+ :type actual: Any
199
+ :return: If the two objects are the same type.
200
+ :rtype: bool
201
+ """
202
+ return not isinstance(actual, type(expected))
203
+
204
+
205
+ def _find_string_comparison_errors(expected: str, actual: str) -> list:
206
+ """Handle string comparison for asserting equivalency.
207
+
208
+ :param expected: The expected string.
209
+ :type expected: str
210
+ :param actual: The actual string.
211
+ :type actual: str
212
+ :return: An error message.
213
+ :rtype: list
214
+ """
215
+ errors = []
216
+ expected_len = len(expected)
217
+ actual_len = len(actual)
218
+ # Enforce a length limit in case a student accidentally makes
219
+ # an enormous string.
220
+ check_length_error_msg = _check_length_limit(actual, STRING_LEN_LIMIT)
221
+ if check_length_error_msg:
222
+ errors.append(check_length_error_msg)
223
+ return errors
224
+ check_functions = [_check_trailing_newline, _check_double_spaces]
225
+ for f in check_functions:
226
+ if f(expected, actual):
227
+ errors.append(f(expected, actual))
228
+ # Highlight which character differs.
229
+ if expected_len == actual_len:
230
+ errors.append(_find_incorrect_char(expected, actual))
231
+ # Else highlight the length difference.
232
+ else:
233
+ errors.append(f"The expected and actual string lengths are "
234
+ f"different. Expected length: {expected_len}, but "
235
+ f"got length: {actual_len}.")
236
+ return errors
237
+
238
+
239
+ def _find_incorrect_char(expected: str, actual: str) -> str:
240
+ """Find the index of the first actual char that doesn't match expected.
241
+
242
+ :param expected: The expected string.
243
+ :type expected: str
244
+ :param actual: The actual string.
245
+ :type actual: str
246
+ :return: A string containing the incorrect character and its index.
247
+ :rtype: str
248
+ """
249
+ for idx, expected_char in enumerate(expected):
250
+ actual_char = actual[idx]
251
+ if expected_char != actual_char:
252
+ return (f"Character '{actual[idx]}' at index {idx} does "
253
+ f"not match with the expect output. "
254
+ f"This is the first mismatched character. There "
255
+ f"may be others.")
256
+
257
+
258
+ def _check_trailing_newline(expected: str, actual: str) -> str | None:
259
+ """Check the actual string for common errors.
260
+
261
+ :param expected: The expected string.
262
+ :type expected: str
263
+ :param actual: The actual string.
264
+ :type actual: str
265
+ :return: A string to concatenate to the error if there are
266
+ common errors, otherwise None.
267
+ :rtype: str | None
268
+ """
269
+ if actual.endswith("\n") and not expected.endswith("\n"):
270
+ return ("Your program/function's output has an extra newline "
271
+ "character \'\\n\' at the end.")
272
+
273
+
274
+ def _check_double_spaces(expected: str, actual: str) -> str | None:
275
+ """Check the actual string for double spaces.
276
+
277
+ :param expected: The expected string.
278
+ :type expected: str
279
+ :param actual: The actual string.
280
+ :type actual: str
281
+ :return: A string to concatenate to the error if there are
282
+ common errors, otherwise None.
283
+ :rtype: str | None
284
+ """
285
+ if " " in actual and " " not in expected:
286
+ return (f"There are two spaces at index {actual.index(' ')} "
287
+ f"of your program/function's output.")
288
+
289
+
290
+ def _check_length_limit(actual: str, limit: int) -> str | None:
291
+ """Enforce a length limit on the actual string.
292
+
293
+ :param actual: The actual string.
294
+ :type actual: str
295
+ :param limit: The expected length.
296
+ :type limit: int
297
+ :return: A string to concatenate to the error if there are
298
+ common errors, otherwise None.
299
+ :rtype: str | None
300
+ """
301
+ actual_len = len(actual)
302
+ if actual_len > limit:
303
+ return (f"The actual string exceeds the maximum allowed "
304
+ f"length.\n Actual length is: {actual_len}\n"
305
+ f"Limit is: {limit}")
306
+
307
+
308
+ def _construct_test_url(chapter, assignment: str) -> str:
309
+ base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
310
+ config_path = os.path.join(base_dir, "check_pfda", "config.yaml")
311
+
312
+ with open(config_path, "r") as f:
313
+ config = yaml.safe_load(f)
314
+
315
+ base_url = config["tests"]["tests_repo_url"]
316
+
317
+ # query at the end forces browser to flush cache
318
+ return f"{base_url}/c{chapter}/test_{assignment}.py?now=0423"
319
+
320
+
321
+ def get_module_in_src() -> str:
322
+ """Get the name of the assignment the student is working on."""
323
+ src_dir = Path.cwd() / "src"
324
+ py_files = list(src_dir.glob("*.py"))
325
+ if not py_files:
326
+ raise FileNotFoundError("No Python module found in the src/"
327
+ " directory.")
328
+ if len(py_files) > 1:
329
+ raise RuntimeError("Multiple Python modules found in src/."
330
+ " Expected only one.")
331
+ return py_files[0].stem
332
+
333
+
334
+ def get_current_assignment():
335
+ """
336
+ Parses a path string to extract chapter and assignment information.
337
+ return:
338
+ A dictionary with 'chapter' and 'assignment' if found, otherwise an empty dictionary.
339
+ """
340
+ path = str(Path.cwd())
341
+ match = re.search(r"c(\d+)-lab-(.*?)-bencres-demo", path)
342
+ if match:
343
+ chapter = match.group(1).replace('-', '_')
344
+ assignment = match.group(2).replace('-', '_')
345
+ return (chapter, assignment)
346
+ # return {"chapter": chapter, "assignment": assignment}
347
+ else:
348
+ return {}
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: check-pfda
3
+ Version: 0.0.1
4
+ Summary: PFDA test running package.
5
+ Requires-Python: >=3.8
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: click
8
+ Requires-Dist: pytest
9
+ Requires-Dist: requests
10
+ Requires-Dist: PyYAML
11
+
12
+ # check-pfda
13
+
14
+ > Compatibility goes here
15
+
16
+ Check-pfda is a package that tests student code and provides helpful feedback.
17
+
18
+ ## Installation
19
+ `pip install check-pfda`
20
+
21
+
22
+ ### Usage
23
+ 1. Navigate to the root directory of a cloned assignment.
24
+ 2. Run `pfda check`.
25
+
26
+ ## Documentation
27
+ > Read The Docs page goes here
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/check_pfda/__init__.py
4
+ src/check_pfda/cli.py
5
+ src/check_pfda/core.py
6
+ src/check_pfda/utils.py
7
+ src/check_pfda.egg-info/PKG-INFO
8
+ src/check_pfda.egg-info/SOURCES.txt
9
+ src/check_pfda.egg-info/dependency_links.txt
10
+ src/check_pfda.egg-info/entry_points.txt
11
+ src/check_pfda.egg-info/requires.txt
12
+ src/check_pfda.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pfda = check_pfda.cli:cli
@@ -0,0 +1,4 @@
1
+ click
2
+ pytest
3
+ requests
4
+ PyYAML
@@ -0,0 +1 @@
1
+ check_pfda