Skip to content

Commit a1ff1f7

Browse files
committed
Isolate running tests and running files using processes
1 parent d4bf153 commit a1ff1f7

7 files changed

Lines changed: 249 additions & 35 deletions

File tree

pythonwhat/local.py

Lines changed: 126 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@
22
import os
33
import random
44
from pathlib import Path
5+
from contextlib import redirect_stdout
6+
7+
from multiprocessing import Process, Queue
8+
from pythonbackend import CaptureErrors
9+
from pythonbackend.shell_utils import create
10+
from pythonbackend.tasks import TaskKillProcess, TaskCaptureFullOutput
511

612
from pythonwhat.reporter import Reporter
7-
from contextlib import redirect_stdout
813

914

10-
class StubShell(object):
15+
class StubShell:
1116
def __init__(self, init_code=None):
1217
self.user_ns = {}
1318
if init_code:
@@ -17,7 +22,7 @@ def run_code(self, code):
1722
exec(code, self.user_ns)
1823

1924

20-
class StubProcess(object):
25+
class StubProcess:
2126
def __init__(self, init_code=None, pid=None):
2227
self.shell = StubShell(init_code)
2328
self._identity = (pid,) if pid else (random.randint(0, 1e12),)
@@ -26,6 +31,82 @@ def executeTask(self, task):
2631
return task(self.shell)
2732

2833

34+
class TaskCaptureOutput:
35+
def __init__(self, code):
36+
self.code = code
37+
38+
def __call__(self, shell):
39+
raw_output, error = run_code(shell.run_code, self.code)
40+
41+
return raw_output, error
42+
43+
44+
# todo: merge with pythonbackend
45+
class WorkerProcess(Process):
46+
instances = []
47+
48+
def __init__(self, pid=None):
49+
Process.__init__(self)
50+
self.task_queue = Queue()
51+
self.result_queue = Queue()
52+
self.daemon = (
53+
True
54+
) # when parent process is killed, sub/childprocess get also killed
55+
self.instances.append(self)
56+
# used to detect single process exercise
57+
self._identity = (pid,) if pid else (random.randint(0, 1e12),)
58+
59+
def get_shell(self):
60+
return create({})
61+
62+
def run(self):
63+
shell = self.get_shell()
64+
while True:
65+
output = []
66+
with CaptureErrors(output):
67+
next_task = self.task_queue.get()
68+
answer = next_task(shell)
69+
if len(output) > 0: # means backend error happened
70+
answer = output
71+
output = []
72+
with CaptureErrors(output):
73+
self.result_queue.put_nowait(answer)
74+
if len(output) > 0: # means backend error happened
75+
self.result_queue.put_nowait(output)
76+
if isinstance(next_task, TaskKillProcess):
77+
break # break while loop -> we do not wait upon new task
78+
return
79+
80+
def executeTask(self, task):
81+
self.task_queue.put_nowait(task)
82+
return self.result_queue.get() # wait and fetches next item in queue
83+
84+
def kill(self):
85+
try:
86+
if self.is_alive():
87+
self.executeTask(TaskKillProcess())
88+
self.terminate()
89+
self.join(timeout=3.0)
90+
if self.is_alive():
91+
raise Exception
92+
if self in self.instances:
93+
self.instances.remove(self)
94+
finally:
95+
pass
96+
# python 3.7:
97+
# self.close()
98+
99+
@classmethod
100+
def kill_all(cls):
101+
for instance in list(cls.instances):
102+
instance.kill()
103+
104+
105+
class SimpleProcess(WorkerProcess):
106+
def get_shell(self):
107+
return StubShell()
108+
109+
29110
class ChDir(object):
30111
"""
31112
Step into a directory temporarily.
@@ -42,31 +123,57 @@ def __exit__(self, *args):
42123
os.chdir(self.old_dir)
43124

44125

45-
def run_code(process, code):
46-
output = io.StringIO()
47-
try:
48-
with redirect_stdout(output):
49-
process.shell.run_code(code)
50-
raw_output = output.getvalue()
51-
error = None
52-
except BaseException as e:
53-
raw_output = ""
54-
error = str(e)
126+
def run_code(executor, code):
127+
with io.StringIO() as output:
128+
try:
129+
with redirect_stdout(output):
130+
executor(code)
131+
raw_output = output.getvalue()
132+
error = None
133+
except BaseException as e:
134+
raw_output = ""
135+
error = str(e)
55136
return raw_output, error
56137

57138

58-
def run_single_process(pec, code, pid=None):
59-
process = StubProcess(init_code=pec, pid=pid)
60-
raw_stu_output, error = run_code(process, code)
139+
def run_single_process(pec, code, pid=None, mode="simple"):
140+
if mode == "stub":
141+
# no isolation
142+
process = StubProcess(init_code=pec, pid=pid)
143+
raw_stu_output, error = run_code(process.shell.run_code, code)
144+
145+
elif mode == "simple":
146+
# no advanced functionality
147+
process = SimpleProcess(pid)
148+
process.start()
149+
_ = process.executeTask(TaskCaptureOutput(pec))
150+
raw_stu_output, error = process.executeTask(TaskCaptureOutput(code))
151+
152+
elif mode == "full":
153+
# slow
154+
process = WorkerProcess(pid)
155+
process.start()
156+
_ = process.executeTask(
157+
TaskCaptureFullOutput((pec,), "<PEC>", None, silent=True)
158+
)
159+
output, raw_output = process.executeTask(
160+
TaskCaptureFullOutput((code,), "script.py", None, silent=True)
161+
)
162+
raw_stu_output = raw_output["output_stream"]
163+
error = raw_output["error"]
164+
165+
else:
166+
raise ValueError("Invalid mode")
167+
61168
return process, raw_stu_output, error
62169

63170

64-
def run_exercise(pec, sol_code, stu_code, pid=None, sol_wd=None, stu_wd=None):
171+
def run_exercise(pec, sol_code, stu_code, sol_wd=None, stu_wd=None, **kwargs):
65172
with ChDir(sol_wd or os.getcwd()):
66-
sol_process, _, _ = run_single_process(pec, sol_code, pid=pid)
173+
sol_process, _, _ = run_single_process(pec, sol_code, **kwargs)
67174

68175
with ChDir(stu_wd or os.getcwd()):
69-
stu_process, raw_stu_output, error = run_single_process(pec, stu_code, pid=pid)
176+
stu_process, raw_stu_output, error = run_single_process(pec, stu_code, **kwargs)
70177

71178
return sol_process, stu_process, raw_stu_output, error
72179

@@ -135,7 +242,6 @@ def run(state, relative_working_dir="", solution_dir="solution"):
135242
pec="",
136243
sol_code=state.solution_code or "",
137244
stu_code=state.student_code,
138-
pid=None,
139245
sol_wd=sol_wd,
140246
stu_wd=stu_wd,
141247
)

pythonwhat/test_exercise.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,9 @@ def prep_context():
104104
return tree, cntxt
105105

106106

107-
def setup_state(stu_code="", sol_code="", pec="", pid=None):
108-
sol_process, stu_process, raw_stu_output, _ = run_exercise(
109-
pec, sol_code, stu_code, pid=pid
107+
def setup_state(stu_code="", sol_code="", pec="", **kwargs):
108+
sol_process, stu_process, raw_stu_output, error = run_exercise(
109+
pec, sol_code, stu_code, **kwargs
110110
)
111111

112112
state = State(
@@ -116,7 +116,7 @@ def setup_state(stu_code="", sol_code="", pec="", pid=None):
116116
student_process=stu_process,
117117
solution_process=sol_process,
118118
raw_student_output=raw_stu_output,
119-
reporter=Reporter(),
119+
reporter=Reporter(errors=[error] if error else []),
120120
)
121121

122122
State.root_state = state

tests/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import pytest
2+
from pythonwhat.local import WorkerProcess
3+
4+
5+
@pytest.fixture(scope="function", autouse=True)
6+
def kill_processes():
7+
yield
8+
WorkerProcess.kill_all()

tests/helper.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from collections import defaultdict
55
from functools import wraps
66

7-
from pythonwhat.local import StubProcess, run_exercise, ChDir
7+
from pythonwhat.local import StubProcess, run_exercise, ChDir, WorkerProcess
88
from contextlib import contextmanager
99
from protowhat.Test import TestFail as TF
1010
from pythonwhat.test_exercise import test_exercise
@@ -59,8 +59,8 @@ def run(data, run_code=True):
5959
)
6060
else:
6161
raw_stu_output = ""
62-
stu_process = StubProcess()
63-
sol_process = StubProcess()
62+
stu_process = StubProcess() # WorkerProcess()
63+
sol_process = StubProcess() # WorkerProcess()
6464
error = None
6565

6666
res = test_exercise(

tests/run.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import os
2+
import sys
3+
import pytest
4+
5+
os.environ["OBJC_DISABLE_INITIALIZE_FORK_SAFETY"] = "YES"
6+
sys.setrecursionlimit(5000)
7+
pytest.main()

tests/test_check_function.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22
import tests.helper as helper
3+
from inspect import getsource
34
from pythonwhat.test_exercise import setup_state
45
from protowhat.Test import TestFail as TF
56
from protowhat.Feedback import InstructorError
@@ -84,18 +85,19 @@ def test_bind_args():
8485
from inspect import signature
8586
from pythonwhat.checks.check_function import bind_args
8687

87-
pec = "def my_fun(a, b, *args, **kwargs): pass"
88+
def my_fun(a, b, *args, **kwargs): pass
89+
pec = getsource(my_fun).strip()
8890
s = setup_state(pec=pec, stu_code="my_fun(1, 2, 3, 4, c = 5)")
8991
args = s._state.ast_dispatcher.find("function_calls", s._state.student_ast)[
9092
"my_fun"
9193
][0]["args"]
92-
sig = signature(s._state.student_process.shell.user_ns["my_fun"])
93-
binded_args = bind_args(sig, args)
94-
assert binded_args["a"]["node"].n == 1
95-
assert binded_args["b"]["node"].n == 2
96-
assert binded_args["args"][0]["node"].n == 3
97-
assert binded_args["args"][1]["node"].n == 4
98-
assert binded_args["kwargs"]["c"]["node"].n == 5
94+
sig = signature(my_fun)
95+
bound_args = bind_args(sig, args)
96+
assert bound_args["a"]["node"].n == 1
97+
assert bound_args["b"]["node"].n == 2
98+
assert bound_args["args"][0]["node"].n == 3
99+
assert bound_args["args"][1]["node"].n == 4
100+
assert bound_args["kwargs"]["c"]["node"].n == 5
99101

100102

101103
@pytest.mark.parametrize("argspec", [["args", 0], ["args", 1], ["kwargs", "c"]])

tests/test_local.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import pytest
2+
3+
from protowhat.Test import TestFail as TF
4+
from pythonwhat.test_exercise import setup_state
5+
from tests.helper import verify_sct
6+
7+
8+
modify_sys = (
9+
"""
10+
import sys
11+
sys.modules["foo"] = "bar"
12+
""",
13+
"""
14+
import sys
15+
bar = sys.modules.get("foo")
16+
""",
17+
)
18+
19+
asset = "https://s3.amazonaws.com/assets.datacamp.com/production/course_998/datasets/L-L1_LOSC_4_V1-1126259446-32.hdf5"
20+
21+
22+
@pytest.mark.parametrize("sol_code, stu_code", [modify_sys])
23+
def test_running_code_isolation_stub(sol_code, stu_code):
24+
chain = setup_state(stu_code, sol_code, pec="", mode="stub")
25+
26+
chain.has_equal_value(name="bar", override="bar")
27+
28+
# clean up
29+
import sys
30+
31+
del sys.modules["foo"]
32+
33+
34+
@pytest.mark.parametrize("sol_code, stu_code", [modify_sys])
35+
def test_running_code_isolation_run(sol_code, stu_code):
36+
# test that setup_state is isolated
37+
chain = setup_state(sol_code, stu_code, pec="")
38+
chain._state.solution_code = sol_code
39+
chain._state.student_code = stu_code
40+
41+
with verify_sct(False):
42+
# test that run is isolated
43+
chain.run().has_equal_value(name="bar", override="bar")
44+
45+
46+
@pytest.mark.parametrize(
47+
"sol_code, stu_code",
48+
[
49+
(
50+
"""from urllib.request import urlretrieve;
51+
urlretrieve('{}', 'LIGO_data.hdf5')""".format(
52+
asset
53+
),
54+
)
55+
* 2,
56+
],
57+
)
58+
def test_urlretrieve_in_process(sol_code, stu_code):
59+
# on mac an env var needs to be set:
60+
# OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES
61+
chain = setup_state("", "", pec="")
62+
63+
chain._state.solution_code = sol_code
64+
chain._state.student_code = stu_code
65+
66+
with verify_sct(True):
67+
chain.run()
68+
69+
70+
@pytest.mark.parametrize(
71+
"sol_code, stu_code",
72+
[
73+
(
74+
"""import urllib.request; import shutil
75+
with urllib.request.urlopen({}) as response, open('LIGO_data.hdf5', 'wb') as out_file:
76+
shutil.copyfileobj(response, out_file)
77+
""".format(
78+
asset
79+
),
80+
)
81+
* 2,
82+
],
83+
)
84+
def test_urlopen_in_process(sol_code, stu_code):
85+
chain = setup_state("", "", pec="")
86+
87+
chain._state.solution_code = sol_code
88+
chain._state.student_code = stu_code
89+
90+
with verify_sct(True):
91+
chain.run()

0 commit comments

Comments
 (0)