Skip to content

Commit 5d0a3f4

Browse files
committed
feat: add utilities, simplify test case
1 parent 1578e03 commit 5d0a3f4

File tree

8 files changed

+168
-59
lines changed

8 files changed

+168
-59
lines changed

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,26 @@
11
<!-- This file is included in the documentation so the header is removed. -->
22

3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://packaging.python.org/en/latest/discussions/versioning/).
7+
8+
Note that the log for version before v0.1.1 may not be in the mentioned format.
9+
310
## Unreleased
411

12+
### Added
13+
14+
- Add `common.points` for decorating the test method for points.
15+
- Add `common.file_has_correct_sha512_checksum` for checking file checksum.
16+
- Add `common.MinimalistTestRunner` that show collected points at the end.
17+
- Add `repository.RepositoryBaseTestCase.assertHasOnlyGitCommand` that check if file contain only Git command and nothing else.
18+
19+
## Changed
20+
21+
- `common.MinimalistTestResult` now track points if they are presence.
22+
- Some test cases now use the builtin `tmp_path` instead of our own version of it.
23+
524
## v0.1.1rc2
625

726
- Fix gunzip is not available on Windows.

MAINTENANCE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Run the test for various python's versions, run
4444
tox
4545
```
4646

47-
## Running test coverage
47+
With test coverage
4848

4949
```console
5050
pytest --cov=grading_lib tests/

grading_lib/common.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import datetime
2+
import hashlib
23
import os
34
import subprocess
45
import sys
56
import tempfile
67
import time
8+
import typing as ty
79
import unittest
810
from collections import namedtuple
911
from pathlib import Path
1012
from typing import Any, TypeVar
1113

1214
T = TypeVar("T")
15+
P = ty.ParamSpec("P")
1316

1417
COMMAND_FAILED_TEXT_TEMPLATE = "An error occurred while trying to run a command '{command}'. The command's output is\n\n{output}"
1518
FILE_NOT_EXIST_TEXT_TEMPLATE = "File '{path}' does not exist"
@@ -18,6 +21,29 @@
1821
NAME_POOL = ["herta", "cat", "dog", "dolphin", "falcon", "dandilion", "fox", "jett"]
1922

2023

24+
def points(value: float) -> ty.Callable[P, T]:
25+
"""Assign points to the test case."""
26+
27+
def decorator(test_item: T) -> T:
28+
test_item.__gradinglib_points = value
29+
return test_item
30+
31+
return decorator
32+
33+
34+
def file_has_correct_sha512_checksum(expected_checksum: str, file_path: Path) -> bool:
35+
"""Return True if the file has the same SHA512 checksum."""
36+
37+
with open(file_path, "rb") as f:
38+
data = f.read()
39+
m = hashlib.sha512()
40+
m.update(data)
41+
checksum = m.hexdigest()
42+
return checksum == expected_checksum
43+
44+
return False
45+
46+
2147
def is_debug_mode(
2248
variable_name: str = "DEBUG",
2349
vals_for_true: tuple[str, ...] = ("true", "t", "on", "1"),
@@ -120,13 +146,29 @@ def __init__(self, *args, **kwargs) -> None:
120146
super().__init__(*args, **kwargs)
121147
self.dots = False
122148

149+
# Points tracking.
150+
self.points = 0.0
151+
self.total_points = 0.0
152+
123153
def getDescription(self, test: unittest.TestCase) -> str:
124154
doc_first_line = test.shortDescription()
125155
if self.descriptions and doc_first_line:
126156
return doc_first_line
127157
else:
128158
return str(test)
129159

160+
def startTest(self, test):
161+
super().startTest(test)
162+
test_method = getattr(test, test._testMethodName)
163+
if hasattr(test_method, "__gradinglib_points"):
164+
self.total_points += getattr(test_method, "__gradinglib_points")
165+
166+
def addSuccess(self, test):
167+
super().addSuccess(test)
168+
test_method = getattr(test, test._testMethodName)
169+
if hasattr(test_method, "__gradinglib_points"):
170+
self.points += getattr(test_method, "__gradinglib_points")
171+
130172
def addFailure(self, test: unittest.TestCase, err) -> None:
131173
self.failures.append((test, str(err[1]) + "\n"))
132174
self._mirrorOutput = True
@@ -138,6 +180,14 @@ def addFailure(self, test: unittest.TestCase, err) -> None:
138180
self.stream.flush()
139181

140182

183+
class MinimalistTestRunner(unittest.TextTestRunner):
184+
def run(self, test):
185+
result = super().run(test)
186+
self.stream.writeln(f"POINTS: {result.points} / {result.total_points}")
187+
self.stream.flush()
188+
return result
189+
190+
141191
class BaseTestCaseMeta(type):
142192
def __new__(cls: T, name: str, bases: list[Any], attrs: dict[str, Any]) -> T:
143193
if "with_temporary_dir" not in attrs:

grading_lib/qa.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
def import_as_non_testcase(
1111
module_name: str, name: str, package: str | None = None
1212
) -> ty.Any:
13-
"""Import the name from a module and prevent pytest from running it as testcase."""
13+
"""Import the name from a module and prevent pytest from running it as a testcase."""
1414
mod = import_module(module_name, package=package)
1515
cls = mod.__dict__[name]
1616
cls.__test__ = False

grading_lib/repository.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,12 @@ class Repository:
4444
:raise ValueError: When the repository does not have a working tree directory.
4545
"""
4646

47-
def __init__(self, path: str | Path, *args, **kwargs) -> None:
47+
def __init__(
48+
self: Self,
49+
path: str | Path,
50+
*args,
51+
**kwargs,
52+
) -> None:
4853
self.repo: Repo
4954
self.temp_dir: tempfile.TemporaryDirectory[str] | None = None
5055

@@ -198,6 +203,21 @@ def visualize(self) -> str:
198203

199204

200205
class RepositoryBaseTestCase(BaseTestCase):
206+
def assertHasOnlyGitCommand(
207+
self,
208+
path: Path,
209+
msg_template="{path!s} appears to have non git command(s). Please comment out unrelated commands.",
210+
) -> None:
211+
with open(path) as f:
212+
lines = f.readlines()
213+
for raw_line in lines:
214+
line = raw_line.strip()
215+
if len(line) == 0 or line[0] == "#":
216+
continue
217+
218+
if not line.startswith("git"):
219+
self.fail(msg_template.format(path=path))
220+
201221
def assertHasTagWithNameAt(
202222
self, repo: Repository, name: str, commit_hash: str
203223
) -> None:
@@ -208,7 +228,7 @@ def assertHasTagWithNameAt(
208228
return
209229

210230
tags_text = "\n".join(tag_ref.path for tag_ref in tag_refs)
211-
raise self.failureException(
231+
self.fail(
212232
f"Expect to see a tag '{name}' at commit '{commit_hash}', but found none. Tags at commit {commit_hash}:\n{tags_text}"
213233
)
214234

@@ -234,6 +254,6 @@ def assertHasTagWithNameAndMessageAt(
234254
text = f"{tag_ref.path}: {tag_ref.tag.message}"
235255
tags_texts.append(text)
236256
tags_text = "\n".join(tags_texts)
237-
raise self.failureException(
257+
self.fail(
238258
f"Expect to see a tag '{name}' with message '{message}' at commit '{commit_hash}', but found none. Tags at commit {commit_hash}:\n{tags_text}"
239259
)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ select = [
5555
"RUF", # ruff-specific rules
5656
"UP", # pyupgrade
5757
"W", # pycodestyle warning
58+
"T10", # flake8-debugger e.g. breakpoint() is presence
5859
]
5960
ignore = [
6061
"E501" # Line too long

tests/test_common.py

Lines changed: 40 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import os
2-
import tempfile
32
import time
43
from pathlib import Path
54

@@ -8,6 +7,7 @@
87
from grading_lib import is_debug_mode
98
from grading_lib.common import (
109
BaseTestCase,
10+
file_has_correct_sha512_checksum,
1111
get_mtime_as_datetime,
1212
get_seed_from_env,
1313
has_file_changed,
@@ -50,52 +50,41 @@ def test_get_seed_from_env() -> None:
5050
assert res != 0
5151

5252

53-
@pytest.fixture
54-
def a_temp_file():
55-
f = tempfile.NamedTemporaryFile()
56-
f.seek(0)
57-
yield f
58-
f.close()
59-
60-
61-
def test_has_file_changed(a_temp_file) -> None:
62-
path = Path(a_temp_file.name)
53+
def test_has_file_changed(tmp_path) -> None:
54+
temp_file = tmp_path / "README.md"
55+
temp_file.write_text("Hello World!")
6356

6457
# Take snapshot of the mtime.
65-
last_known_mtime = get_mtime_as_datetime(path)
66-
assert not has_file_changed(last_known_mtime, path)
58+
last_known_mtime = get_mtime_as_datetime(temp_file)
59+
assert not has_file_changed(last_known_mtime, temp_file)
6760

6861
# Modify the mtime and test.
6962
time.sleep(1.2)
70-
path.touch()
71-
assert has_file_changed(last_known_mtime, path)
63+
temp_file.touch()
64+
assert has_file_changed(last_known_mtime, temp_file)
7265

7366
# Take another snapshot.
74-
last_known_mtime = get_mtime_as_datetime(str(path))
75-
assert not has_file_changed(last_known_mtime, path)
76-
67+
last_known_mtime = get_mtime_as_datetime(str(temp_file))
68+
assert not has_file_changed(last_known_mtime, temp_file)
7769

78-
def test_populate_folder_with_filenames() -> None:
79-
with tempfile.TemporaryDirectory() as tmpdir:
80-
tmpdir_path = Path(tmpdir)
8170

82-
with pytest.raises(ValueError):
83-
(tmpdir_path / "a.txt").touch()
84-
populate_folder_with_filenames(tmpdir_path / "a.txt", ["a", "b", "c"])
71+
def test_populate_folder_with_filenames(tmp_path) -> None:
72+
expected_files = ["a", "b", "c"]
73+
populate_folder_with_filenames(tmp_path, expected_files)
8574

86-
with pytest.raises(ValueError):
87-
populate_folder_with_filenames(tmpdir_path / "src", ["a", "b", "c"])
75+
# Order should not matter, we just want check that all files are created.
76+
result_names = sorted([item.name for item in tmp_path.iterdir()])
8877

89-
with tempfile.TemporaryDirectory() as tmpdir:
90-
tmpdir_path = Path(tmpdir)
78+
assert result_names == expected_files
9179

92-
expected_files = ["a", "b", "c"]
93-
populate_folder_with_filenames(tmpdir, expected_files)
9480

95-
# Order should not matter, we just want check that all files are created.
96-
result_names = sorted([item.name for item in tmpdir_path.iterdir()])
81+
def test_populate_folder_with_filenames_error_handling(tmp_path) -> None:
82+
with pytest.raises(ValueError):
83+
(tmp_path / "a.txt").touch()
84+
populate_folder_with_filenames(tmp_path / "a.txt", ["a", "b", "c"])
9785

98-
assert result_names == expected_files
86+
with pytest.raises(ValueError):
87+
populate_folder_with_filenames(tmp_path / "src", ["a", "b", "c"])
9988

10089

10190
def test_run_executable() -> None:
@@ -126,16 +115,30 @@ class ChildClsWithTempDir(BaseTestCase):
126115
instance.tearDown()
127116

128117

129-
def test_BaseTestCase_assertArchiveFileIsGzip() -> None:
118+
def test_BaseTestCase_assertArchiveFileIsGzip(tmp_path) -> None:
130119
class ChildClsWithTempDir(BaseTestCase):
131120
with_temporary_dir = True
132121

133122
instance = ChildClsWithTempDir()
134123
instance.setUp()
135124

136125
try:
137-
with tempfile.NamedTemporaryFile() as tmp_file:
138-
with pytest.raises(AssertionError):
139-
instance.assertArchiveFileIsGzip(tmp_file.name)
126+
tmp_file = tmp_path / "sdist.tar.gz"
127+
tmp_file.write_bytes(b"\x1f\x65")
128+
with pytest.raises(AssertionError):
129+
instance.assertArchiveFileIsGzip(str(tmp_file))
140130
finally:
141131
instance.tearDown()
132+
133+
134+
def test_file_has_correct_sha512_checksum(tmp_path):
135+
file_path = tmp_path / "repo.tar.gz"
136+
with open(file_path, "wb") as f:
137+
f.write(
138+
b";fb\xc80^\xd1u*p\x00\xf50\xd1\xc1\x17\xb6\xec\x13\xb6\x1fd\xd5\xe2\xe1\xa0\xc4\xe5a\x0f"
139+
)
140+
141+
assert file_has_correct_sha512_checksum(
142+
"f58345b442700529c9f488df0eb76b805bd26fc347b83f9ff5aead0e06fee6b7fc480a556578be9a202813da0c322b48c5004a9c764f1f6b051a6467827338c8",
143+
file_path,
144+
)

tests/test_repository.py

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,17 @@
1-
import sys
2-
import tempfile
31
from pathlib import Path
42

53
import pytest
64

7-
from grading_lib.repository import Repository
5+
from grading_lib.repository import Repository, RepositoryBaseTestCase
86

97

10-
@pytest.fixture
11-
def an_empty_folder():
12-
if sys.version_info < (3, 12, 0):
13-
temp_dir = tempfile.TemporaryDirectory()
14-
else:
15-
temp_dir = tempfile.TemporaryDirectory(delete=False)
16-
yield temp_dir
17-
temp_dir.cleanup()
18-
19-
20-
def test_Repository_init_with_an_empty_folder(an_empty_folder) -> None:
21-
repo = Repository(an_empty_folder.name)
8+
def test_Repository_init_with_an_empty_folder(tmp_path) -> None:
9+
repo = Repository(tmp_path)
2210
assert isinstance(repo.working_tree_dir, Path)
2311

2412

25-
def test_Repository_create_and_add_random_file(an_empty_folder) -> None:
26-
repo = Repository(an_empty_folder.name)
13+
def test_Repository_create_and_add_random_file(tmp_path) -> None:
14+
repo = Repository(tmp_path)
2715
assert isinstance(repo.working_tree_dir, Path)
2816

2917
repo.create_and_add_random_file(name="a.txt")
@@ -32,3 +20,31 @@ def test_Repository_create_and_add_random_file(an_empty_folder) -> None:
3220

3321
repo.create_and_add_random_file()
3422
assert len(repo.repo.index.entries) == 2
23+
24+
25+
def test_RepositoryBaseTestCase_assertHasOnlyGitCommand(tmp_path) -> None:
26+
class Child(RepositoryBaseTestCase):
27+
pass
28+
29+
instance = Child()
30+
instance.setUp()
31+
try:
32+
file_path = tmp_path / "answer.sh"
33+
with open(file_path, "w") as f:
34+
f.write("# Comment\ntar -xzf repo.tar.gz\ngit status")
35+
36+
with pytest.raises(AssertionError):
37+
instance.assertHasOnlyGitCommand(file_path)
38+
finally:
39+
instance.tearDown()
40+
41+
instance.setUp()
42+
try:
43+
file_path = tmp_path / "answer.sh"
44+
with open(file_path, "w") as f:
45+
f.write("# Comment\n# tar -xzf repo.tar.gz\ngit status")
46+
47+
instance.assertHasOnlyGitCommand(file_path)
48+
49+
finally:
50+
instance.tearDown()

0 commit comments

Comments
 (0)