Skip to content

Commit a4e36e8

Browse files
committed
Make tests runnable on Windows and add appveyor.yaml (#1593)
* Make waiter cross-platform Waiter depended on a couple of posix tricks. The first was a clever use of os.waitpid(-1, 0) to wait for any spawned processes to return. On Windows, this raises an exception - Windows has no concept of a process group. The second was the use of NamedTempFile(). On anything but Windows, a file can be open for writing and for reading at the same time. This trick actually isn't necessary the way NamedTempFile is used here. It exposes a file-like object pointing to a file already opened in binary mode. All we have to do is seek and read from it. * Add appveyor config for Windows CI The only tricky bit of this is renaming python.exe to python2.exe. This is due to util.try_find_python2_interpreter(), which may well need work for Windows since the version symlinks don't exist on Windows. * Fixing Windows tests Most of these fixes revolve around the path separator and the way Windows handles files and locking. There is one bug fix in here - build.write_cache() was using os.rename to replace a file, which fails on Windows. I was only able to fix that for Python 3.3 and up. * More subtle translation of / to \ in error messages for Windows. (The goal is to avoid having to mark up tests just because they need this kind of translation.) All but the last bullet were by @jtatum (James Tatum).
1 parent e764f8c commit a4e36e8

10 files changed

+96
-58
lines changed

appveyor.yml

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
environment:
2+
matrix:
3+
- PYTHON: C:\\Python33
4+
- PYTHON: C:\\Python34
5+
- PYTHON: C:\\Python35
6+
- PYTHON: C:\\Python33-x64
7+
- PYTHON: C:\\Python34-x64
8+
- PYTHON: C:\\Python35-x64
9+
install:
10+
- "git submodule update --init typeshed"
11+
- "SET PATH=%PYTHON%;%PYTHON%\\Scripts;C:\\Python27;%PATH%"
12+
- "REN C:\\Python27\\python.exe python2.exe"
13+
- "python --version"
14+
- "python2 --version"
15+
build_script:
16+
- "pip install -r test-requirements.txt"
17+
- "python setup.py install"
18+
test_script:
19+
- cmd: python runtests.py -v

mypy/build.py

+8-4
Original file line numberDiff line numberDiff line change
@@ -836,10 +836,14 @@ def write_cache(id: str, path: str, tree: MypyFile,
836836
with open(meta_json_tmp, 'w') as f:
837837
json.dump(meta, f, sort_keys=True)
838838
f.write('\n')
839-
# TODO: On Windows, os.rename() may not be atomic, and we could
840-
# use os.replace(). However that's new in Python 3.3.
841-
os.rename(data_json_tmp, data_json)
842-
os.rename(meta_json_tmp, meta_json)
839+
# TODO: This is a temporary change until Python 3.2 support is dropped, see #1504
840+
# os.rename will raise an exception rather than replace files on Windows
841+
try:
842+
replace = os.replace
843+
except AttributeError:
844+
replace = os.rename
845+
replace(data_json_tmp, data_json)
846+
replace(meta_json_tmp, meta_json)
843847

844848

845849
"""Dependency manager.

mypy/test/data.py

+19-2
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ def parse_test_cases(
1616
perform: Callable[['DataDrivenTestCase'], None],
1717
base_path: str = '.',
1818
optional_out: bool = False,
19-
include_path: str = None) -> List['DataDrivenTestCase']:
19+
include_path: str = None,
20+
native_sep: bool = False) -> List['DataDrivenTestCase']:
2021
"""Parse a file with test case descriptions.
2122
2223
Return an array of test cases.
@@ -65,6 +66,8 @@ def parse_test_cases(
6566
tcout = [] # type: List[str]
6667
if i < len(p) and p[i].id == 'out':
6768
tcout = p[i].data
69+
if native_sep and os.path.sep == '\\':
70+
tcout = [fix_win_path(line) for line in tcout]
6871
ok = True
6972
i += 1
7073
elif optional_out:
@@ -291,7 +294,7 @@ def expand_includes(a: List[str], base_path: str) -> List[str]:
291294
return res
292295

293296

294-
def expand_errors(input, output, fnam):
297+
def expand_errors(input: List[str], output: List[str], fnam: str) -> None:
295298
"""Transform comments such as '# E: message' in input.
296299
297300
The result is lines like 'fnam:line: error: message'.
@@ -302,3 +305,17 @@ def expand_errors(input, output, fnam):
302305
if m:
303306
severity = 'error' if m.group(1) == 'E' else 'note'
304307
output.append('{}:{}: {}: {}'.format(fnam, i + 1, severity, m.group(2)))
308+
309+
310+
def fix_win_path(line: str) -> str:
311+
r"""Changes paths to Windows paths in error messages.
312+
313+
E.g. foo/bar.py -> foo\bar.py.
314+
"""
315+
m = re.match(r'^([\S/]+):(\d+:)?(\s+.*)', line)
316+
if not m:
317+
return line
318+
else:
319+
filename, lineno, message = m.groups()
320+
return '{}:{}{}'.format(filename.replace('/', '\\'),
321+
lineno or '', message)

mypy/test/data/pythoneval.test

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import re
1616
from typing import Sized, Sequence, Iterator, Iterable, Mapping, AbstractSet
1717

1818
def check(o, t):
19-
rep = re.sub('0x[0-9a-f]+', '0x...', repr(o))
19+
rep = re.sub('0x[0-9a-fA-F]+', '0x...', repr(o))
2020
rep = rep.replace('sequenceiterator', 'str_iterator')
2121
trep = str(t).replace('_abcoll.Sized', 'collections.abc.Sized')
2222
print(rep, trep, isinstance(o, t))

mypy/test/testcheck.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,8 @@ def find_error_paths(self, a: List[str]) -> Set[str]:
154154
for line in a:
155155
m = re.match(r'([^\s:]+):\d+: error:', line)
156156
if m:
157-
hits.add(m.group(1))
157+
p = m.group(1).replace('/', os.path.sep)
158+
hits.add(p)
158159
return hits
159160

160161
def find_module_files(self) -> Dict[str, str]:

mypy/test/testcmdline.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ def cases(self) -> List[DataDrivenTestCase]:
2929
c = [] # type: List[DataDrivenTestCase]
3030
for f in cmdline_files:
3131
c += parse_test_cases(os.path.join(test_data_prefix, f),
32-
test_python_evaluation, test_temp_dir, True)
32+
test_python_evaluation,
33+
base_path=test_temp_dir,
34+
optional_out=True,
35+
native_sep=True)
3336
return c
3437

3538

mypy/test/testsemanal.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ def cases(self):
3636
c = []
3737
for f in semanal_files:
3838
c += parse_test_cases(os.path.join(test_data_prefix, f),
39-
test_semanal, test_temp_dir, optional_out=True)
39+
test_semanal,
40+
base_path=test_temp_dir,
41+
optional_out=True,
42+
native_sep=True)
4043
return c
4144

4245

mypy/test/teststubgen.py

+20-19
Original file line numberDiff line numberDiff line change
@@ -108,34 +108,35 @@ def test_stubgen(testcase):
108108
sys.path.insert(0, 'stubgen-test-path')
109109
os.mkdir('stubgen-test-path')
110110
source = '\n'.join(testcase.input)
111-
handle = tempfile.NamedTemporaryFile(prefix='prog_', suffix='.py', dir='stubgen-test-path')
111+
handle = tempfile.NamedTemporaryFile(prefix='prog_', suffix='.py', dir='stubgen-test-path',
112+
delete=False)
112113
assert os.path.isabs(handle.name)
113114
path = os.path.basename(handle.name)
114115
name = path[:-3]
115116
path = os.path.join('stubgen-test-path', path)
116117
out_dir = '_out'
117118
os.mkdir(out_dir)
118119
try:
119-
with open(path, 'w') as file:
120-
file.write(source)
121-
file.close()
122-
# Without this we may sometimes be unable to import the module below, as importlib
123-
# caches os.listdir() results in Python 3.3+ (Guido explained this to me).
124-
reset_importlib_caches()
125-
try:
126-
if testcase.name.endswith('_import'):
127-
generate_stub_for_module(name, out_dir, quiet=True)
128-
else:
129-
generate_stub(path, out_dir)
130-
a = load_output(out_dir)
131-
except CompileError as e:
132-
a = e.messages
133-
assert_string_arrays_equal(testcase.output, a,
134-
'Invalid output ({}, line {})'.format(
135-
testcase.file, testcase.line))
120+
handle.write(bytes(source, 'ascii'))
121+
handle.close()
122+
# Without this we may sometimes be unable to import the module below, as importlib
123+
# caches os.listdir() results in Python 3.3+ (Guido explained this to me).
124+
reset_importlib_caches()
125+
try:
126+
if testcase.name.endswith('_import'):
127+
generate_stub_for_module(name, out_dir, quiet=True)
128+
else:
129+
generate_stub(path, out_dir)
130+
a = load_output(out_dir)
131+
except CompileError as e:
132+
a = e.messages
133+
assert_string_arrays_equal(testcase.output, a,
134+
'Invalid output ({}, line {})'.format(
135+
testcase.file, testcase.line))
136136
finally:
137-
shutil.rmtree(out_dir)
138137
handle.close()
138+
os.unlink(handle.name)
139+
shutil.rmtree(out_dir)
139140

140141

141142
def reset_importlib_caches():

mypy/test/testtransform.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ def cases(self):
3030
c = []
3131
for f in self.transform_files:
3232
c += parse_test_cases(os.path.join(test_data_prefix, f),
33-
test_transform, test_temp_dir)
33+
test_transform,
34+
base_path=test_temp_dir,
35+
native_sep=True)
3436
return c
3537

3638

mypy/waiter.py

+16-28
Original file line numberDiff line numberDiff line change
@@ -31,42 +31,23 @@ def __init__(self, name: str, args: List[str], *, cwd: str = None,
3131
self.end_time = None # type: float
3232

3333
def start(self) -> None:
34-
self.outfile = tempfile.NamedTemporaryFile()
34+
self.outfile = tempfile.TemporaryFile()
3535
self.start_time = time.time()
3636
self.process = Popen(self.args, cwd=self.cwd, env=self.env,
3737
stdout=self.outfile, stderr=STDOUT)
3838
self.pid = self.process.pid
3939

40-
def handle_exit_status(self, status: int) -> None:
41-
"""Update process exit status received via an external os.waitpid() call."""
42-
# Inlined subprocess._handle_exitstatus, it's not a public API.
43-
# TODO(jukka): I'm not quite sure why this is implemented like this.
44-
self.end_time = time.time()
45-
process = self.process
46-
assert process.returncode is None
47-
if os.WIFSIGNALED(status):
48-
process.returncode = -os.WTERMSIG(status)
49-
elif os.WIFEXITED(status):
50-
process.returncode = os.WEXITSTATUS(status)
51-
else:
52-
# Should never happen
53-
raise RuntimeError("Unknown child exit status!")
54-
assert process.returncode is not None
55-
5640
def wait(self) -> int:
5741
return self.process.wait()
5842

5943
def status(self) -> Optional[int]:
6044
return self.process.returncode
6145

6246
def read_output(self) -> str:
63-
with open(self.outfile.name, 'rb') as file:
64-
# Assume it's ascii to avoid unicode headaches (and portability issues).
65-
return file.read().decode('ascii')
66-
67-
def cleanup(self) -> None:
68-
self.outfile.close()
69-
assert not os.path.exists(self.outfile.name)
47+
file = self.outfile
48+
file.seek(0)
49+
# Assume it's ascii to avoid unicode headaches (and portability issues).
50+
return file.read().decode('ascii')
7051

7152
@property
7253
def elapsed_time(self) -> float:
@@ -178,17 +159,25 @@ def _record_time(self, name: str, elapsed_time: float) -> None:
178159
name2 = re.sub('( .*?) .*', r'\1', name) # First two words.
179160
self.times2[name2] = elapsed_time + self.times2.get(name2, 0)
180161

162+
def _poll_current(self) -> Tuple[int, int]:
163+
while True:
164+
time.sleep(.25)
165+
for pid in self.current:
166+
cmd = self.current[pid][1]
167+
code = cmd.process.poll()
168+
if code is not None:
169+
cmd.end_time = time.time()
170+
return pid, code
171+
181172
def _wait_next(self) -> Tuple[List[str], int, int]:
182173
"""Wait for a single task to finish.
183174
184175
Return tuple (list of failed tasks, number test cases, number of failed tests).
185176
"""
186-
pid, status = os.waitpid(-1, 0)
177+
pid, status = self._poll_current()
187178
num, cmd = self.current.pop(pid)
188179
name = cmd.name
189180

190-
cmd.handle_exit_status(status)
191-
192181
self._record_time(cmd.name, cmd.elapsed_time)
193182

194183
rc = cmd.wait()
@@ -223,7 +212,6 @@ def _wait_next(self) -> Tuple[List[str], int, int]:
223212

224213
# Get task output.
225214
output = cmd.read_output()
226-
cmd.cleanup()
227215
num_tests, num_tests_failed = parse_test_stats_from_output(output, fail_type)
228216

229217
if fail_type is not None or self.verbosity >= 1:

0 commit comments

Comments
 (0)