Skip to content

Commit b4faa53

Browse files
authored
Resolve IO.output_gen's issues (#155)
* Resolve end-of-line sequence issues * Add a buffer * Add test * Fix type annotations * Rewrite output_gen * Ensure all child processes are terminated * Fix test * Rewrite output_gen test * Fix type hints; fix some io test * Use a simpler and more straightforward method to terminate the process tree
1 parent 49a998e commit b4faa53

File tree

5 files changed

+132
-82
lines changed

5 files changed

+132
-82
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
config.py
2+
*.cpp
23
*.in
34
*.out
45
*.exe

.pylintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
[MASTER]
2-
py-version=3.5
2+
py-version=3.6
33
disable=R0902,R0903,R0913,R0917,R0912

cyaron/io.py

Lines changed: 92 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
Classes:
44
IO: IO tool class. It will process the input and output files.
55
"""
6+
67
from __future__ import absolute_import
78
import os
89
import re
10+
import signal
911
import subprocess
1012
import tempfile
11-
from typing import Union, overload, Optional
13+
from typing import Union, overload, Optional, List, cast
1214
from io import IOBase
1315
from . import log
1416
from .utils import list_like, make_unicode
@@ -18,34 +20,39 @@ class IO:
1820
"""IO tool class. It will process the input and output files."""
1921

2022
@overload
21-
def __init__(self,
22-
input_file: Optional[Union[IOBase, str, int]] = None,
23-
output_file: Optional[Union[IOBase, str, int]] = None,
24-
data_id: Optional[int] = None,
25-
disable_output: bool = False,
26-
make_dirs: bool = False):
23+
def __init__(
24+
self,
25+
input_file: Optional[Union[IOBase, str, int]] = None,
26+
output_file: Optional[Union[IOBase, str, int]] = None,
27+
data_id: Optional[int] = None,
28+
disable_output: bool = False,
29+
make_dirs: bool = False,
30+
):
2731
...
2832

2933
@overload
30-
def __init__(self,
31-
data_id: Optional[int] = None,
32-
file_prefix: Optional[str] = None,
33-
input_suffix: str = '.in',
34-
output_suffix: str = '.out',
35-
disable_output: bool = False,
36-
make_dirs: bool = False):
34+
def __init__(
35+
self,
36+
data_id: Optional[int] = None,
37+
file_prefix: Optional[str] = None,
38+
input_suffix: str = ".in",
39+
output_suffix: str = ".out",
40+
disable_output: bool = False,
41+
make_dirs: bool = False,
42+
):
3743
...
3844

3945
def __init__( # type: ignore
40-
self,
41-
input_file: Optional[Union[IOBase, str, int]] = None,
42-
output_file: Optional[Union[IOBase, str, int]] = None,
43-
data_id: Optional[int] = None,
44-
file_prefix: Optional[str] = None,
45-
input_suffix: str = '.in',
46-
output_suffix: str = '.out',
47-
disable_output: bool = False,
48-
make_dirs: bool = False):
46+
self,
47+
input_file: Optional[Union[IOBase, str, int]] = None,
48+
output_file: Optional[Union[IOBase, str, int]] = None,
49+
data_id: Optional[int] = None,
50+
file_prefix: Optional[str] = None,
51+
input_suffix: str = ".in",
52+
output_suffix: str = ".out",
53+
disable_output: bool = False,
54+
make_dirs: bool = False,
55+
):
4956
"""
5057
Args:
5158
input_file (optional): input file object or filename or file descriptor.
@@ -84,12 +91,13 @@ def __init__( # type: ignore
8491
# if the dir "./io" not found it will be created
8592
"""
8693
self.__closed = False
87-
self.input_file, self.output_file = None, None
94+
self.input_file = cast(IOBase, None)
95+
self.output_file = None
8896
if file_prefix is not None:
8997
# legacy mode
90-
input_file = '{}{{}}{}'.format(self.__escape_format(file_prefix),
98+
input_file = "{}{{}}{}".format(self.__escape_format(file_prefix),
9199
self.__escape_format(input_suffix))
92-
output_file = '{}{{}}{}'.format(
100+
output_file = "{}{{}}{}".format(
93101
self.__escape_format(file_prefix),
94102
self.__escape_format(output_suffix))
95103
self.input_filename, self.output_filename = None, None
@@ -101,9 +109,13 @@ def __init__( # type: ignore
101109
self.output_file = None
102110
self.is_first_char = {}
103111

104-
def __init_file(self, f: Union[IOBase, str, int,
105-
None], data_id: Union[int, None],
106-
file_type: str, make_dirs: bool):
112+
def __init_file(
113+
self,
114+
f: Union[IOBase, str, int, None],
115+
data_id: Union[int, None],
116+
file_type: str,
117+
make_dirs: bool,
118+
):
107119
if isinstance(f, IOBase):
108120
# consider ``f`` as a file object
109121
if file_type == "i":
@@ -112,8 +124,12 @@ def __init_file(self, f: Union[IOBase, str, int,
112124
self.output_file = f
113125
elif isinstance(f, int):
114126
# consider ``f`` as a file descor
115-
self.__init_file(open(f, 'w+', encoding="utf-8", newline='\n'),
116-
data_id, file_type, make_dirs)
127+
self.__init_file(
128+
open(f, "w+", encoding="utf-8", newline="\n"),
129+
data_id,
130+
file_type,
131+
make_dirs,
132+
)
117133
elif f is None:
118134
# consider wanna temp file
119135
fd, self.input_filename = tempfile.mkstemp()
@@ -133,8 +149,11 @@ def __init_file(self, f: Union[IOBase, str, int,
133149
else:
134150
self.output_filename = filename
135151
self.__init_file(
136-
open(filename, 'w+', newline='\n', encoding='utf-8'), data_id,
137-
file_type, make_dirs)
152+
open(filename, "w+", newline="\n", encoding="utf-8"),
153+
data_id,
154+
file_type,
155+
make_dirs,
156+
)
138157

139158
def __escape_format(self, st: str):
140159
"""replace "{}" to "{{}}" """
@@ -211,6 +230,15 @@ def __clear(self, file: IOBase, pos: int = 0):
211230
self.is_first_char[file] = True
212231
file.seek(pos)
213232

233+
@staticmethod
234+
def _kill_process_and_children(proc: subprocess.Popen):
235+
if os.name == "posix":
236+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
237+
elif os.name == "nt":
238+
os.system(f"TASKKILL /F /T /PID {proc.pid} > nul")
239+
else:
240+
proc.kill() # Not currently supported
241+
214242
def input_write(self, *args, **kwargs):
215243
"""
216244
Write every element in *args into the input file. Splits with `separator`.
@@ -243,38 +271,49 @@ def input_clear_content(self, pos: int = 0):
243271

244272
self.__clear(self.input_file, pos)
245273

246-
def output_gen(self, shell_cmd, time_limit=None):
274+
def output_gen(self,
275+
shell_cmd: Union[str, List[str]],
276+
time_limit: Optional[float] = None,
277+
*,
278+
replace_EOL: bool = True):
247279
"""
248280
Run the command `shell_cmd` (usually the std program) and send it the input file as stdin.
249281
Write its output to the output file.
250282
Args:
251283
shell_cmd: the command to run, usually the std program.
252284
time_limit: the time limit (seconds) of the command to run.
253285
None means infinity. Defaults to None.
286+
replace_EOL: Set whether to replace the end-of-line sequence with `'\\n'`.
287+
Defaults to True.
254288
"""
255289
if self.output_file is None:
256290
raise ValueError("Output file is disabled")
257291
self.flush_buffer()
258292
origin_pos = self.input_file.tell()
259293
self.input_file.seek(0)
260-
if time_limit is not None:
261-
subprocess.check_call(
262-
shell_cmd,
263-
shell=True,
264-
timeout=time_limit,
265-
stdin=self.input_file.fileno(),
266-
stdout=self.output_file.fileno(),
267-
universal_newlines=True,
268-
)
294+
295+
proc = subprocess.Popen(
296+
shell_cmd,
297+
shell=True,
298+
stdin=self.input_file.fileno(),
299+
stdout=subprocess.PIPE,
300+
universal_newlines=replace_EOL,
301+
preexec_fn=os.setsid if os.name == "posix" else None,
302+
)
303+
304+
try:
305+
output, _ = proc.communicate(timeout=time_limit)
306+
except subprocess.TimeoutExpired:
307+
# proc.kill() # didn't work because `shell=True`.
308+
self._kill_process_and_children(proc)
309+
raise
269310
else:
270-
subprocess.check_call(
271-
shell_cmd,
272-
shell=True,
273-
stdin=self.input_file.fileno(),
274-
stdout=self.output_file.fileno(),
275-
universal_newlines=True,
276-
)
277-
self.input_file.seek(origin_pos)
311+
if replace_EOL:
312+
self.output_file.write(output)
313+
else:
314+
os.write(self.output_file.fileno(), output)
315+
finally:
316+
self.input_file.seek(origin_pos)
278317

279318
log.debug(self.output_filename, " done")
280319

@@ -309,6 +348,8 @@ def output_clear_content(self, pos: int = 0):
309348
Args:
310349
pos: Where file will truncate
311350
"""
351+
if self.output_file is None:
352+
raise ValueError("Output file is disabled")
312353
self.__clear(self.output_file, pos)
313354

314355
def flush_buffer(self):

cyaron/tests/io_test.py

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import unittest
2+
import sys
23
import os
4+
import time
35
import shutil
46
import tempfile
57
import subprocess
@@ -68,38 +70,47 @@ def test_output_gen(self):
6870
with IO("test_gen.in", "test_gen.out") as test:
6971
test.output_gen("echo 233")
7072

71-
with open("test_gen.out") as f:
73+
with open("test_gen.out", "rb") as f:
7274
output = f.read()
73-
self.assertEqual(output.strip("\n"), "233")
75+
self.assertEqual(output, b"233\n")
7476

7577
def test_output_gen_time_limit_exceeded(self):
76-
time_limit_exceeded = False
77-
with captured_output() as (out, err):
78-
with open("long_time.py", "w") as f:
79-
f.write("import time\ntime.sleep(10)\nprint(1)")
78+
with captured_output():
79+
TIMEOUT = 0.02
80+
WAIT_TIME = 0.4 # If the wait time is too short, an error may occur
81+
with open("long_time.py", "w", encoding="utf-8") as f:
82+
f.write("import time, os\n"
83+
"fn = input()\n"
84+
f"time.sleep({WAIT_TIME})\n"
85+
"os.remove(fn)\n")
8086

81-
try:
82-
with IO("test_gen.in", "test_gen.out") as test:
83-
test.output_gen("python long_time.py", time_limit=1)
84-
except subprocess.TimeoutExpired:
85-
time_limit_exceeded = True
86-
self.assertEqual(time_limit_exceeded, True)
87+
with IO("test_gen.in", "test_gen.out") as test:
88+
fd, input_filename = tempfile.mkstemp()
89+
os.close(fd)
90+
abs_input_filename: str = os.path.abspath(input_filename)
91+
with self.assertRaises(subprocess.TimeoutExpired):
92+
test.input_writeln(abs_input_filename)
93+
test.output_gen(f'"{sys.executable}" long_time.py',
94+
time_limit=TIMEOUT)
95+
time.sleep(WAIT_TIME)
96+
try:
97+
os.remove(input_filename)
98+
except FileNotFoundError:
99+
self.fail("Child processes have not been terminated.")
87100

88101
def test_output_gen_time_limit_not_exceeded(self):
89-
time_limit_exceeded = False
90-
with captured_output() as (out, err):
91-
with open("short_time.py", "w") as f:
92-
f.write("import time\ntime.sleep(0.2)\nprint(1)")
93-
94-
try:
95-
with IO("test_gen.in", "test_gen.out") as test:
96-
test.output_gen("python short_time.py", time_limit=1)
97-
except subprocess.TimeoutExpired:
98-
time_limit_exceeded = True
99-
with open("test_gen.out") as f:
102+
with captured_output():
103+
with open("short_time.py", "w", encoding="utf-8") as f:
104+
f.write("import time\n"
105+
"time.sleep(0.1)\n"
106+
"print(1)")
107+
108+
with IO("test_gen.in", "test_gen.out") as test:
109+
test.output_gen(f'"{sys.executable}" short_time.py',
110+
time_limit=0.5)
111+
with open("test_gen.out", encoding="utf-8") as f:
100112
output = f.read()
101-
self.assertEqual(output.strip("\n"), "1")
102-
self.assertEqual(time_limit_exceeded, False)
113+
self.assertEqual(output, "1\n")
103114

104115
def test_init_overload(self):
105116
with IO(file_prefix="data{", data_id=5) as test:
@@ -124,10 +135,7 @@ def test_make_dirs(self):
124135

125136
mkdir_false = False
126137
try:
127-
with IO(
128-
"./automkdir_false/data.in",
129-
"./automkdir_false/data.out",
130-
):
138+
with IO("./automkdir_false/data.in", "./automkdir_false/data.out"):
131139
pass
132140
except FileNotFoundError:
133141
mkdir_false = True

poetry.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)