636 lines
18 KiB
Python
Executable File
636 lines
18 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
"""Runs ./ninja and checks if the output is correct.
|
|
|
|
In order to simulate a smart terminal it uses the 'script' command.
|
|
"""
|
|
|
|
import os
|
|
import platform
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from textwrap import dedent
|
|
import typing as T
|
|
|
|
default_env = dict(os.environ)
|
|
default_env.pop('NINJA_STATUS', None)
|
|
default_env.pop('CLICOLOR_FORCE', None)
|
|
default_env['TERM'] = ''
|
|
NINJA_PATH = os.path.abspath('./ninja')
|
|
|
|
def remove_non_visible_lines(raw_output: bytes) -> str:
|
|
# When running in a smart terminal, Ninja uses CR (\r) to
|
|
# return the cursor to the start of the current line, prints
|
|
# something, then uses `\x1b[K` to clear everything until
|
|
# the end of the line.
|
|
#
|
|
# Thus printing 'FOO', 'BAR', 'ZOO' on the same line, then
|
|
# jumping to the next one results in the following output
|
|
# on Posix:
|
|
#
|
|
# '\rFOO\x1b[K\rBAR\x1b[K\rZOO\x1b[K\r\n'
|
|
#
|
|
# The following splits the output at both \r, \n and \r\n
|
|
# boundaries, which gives:
|
|
#
|
|
# [ '\r', 'FOO\x1b[K\r', 'BAR\x1b[K\r', 'ZOO\x1b[K\r\n' ]
|
|
#
|
|
decoded_lines = raw_output.decode('utf-8').splitlines(True)
|
|
|
|
# Remove any item that ends with a '\r' as this means its
|
|
# content will be overwritten by the next item in the list.
|
|
# For the previous example, this gives:
|
|
#
|
|
# [ 'ZOO\x1b[K\r\n' ]
|
|
#
|
|
final_lines = [ l for l in decoded_lines if not l.endswith('\r') ]
|
|
|
|
# Return a single string that concatenates all filtered lines
|
|
# while removing any remaining \r in it. Needed to transform
|
|
# \r\n into \n.
|
|
#
|
|
# "ZOO\x1b[K\n'
|
|
#
|
|
return ''.join(final_lines).replace('\r', '')
|
|
|
|
class BuildDir:
|
|
def __init__(self, build_ninja: str):
|
|
self.build_ninja = dedent(build_ninja)
|
|
self.d = None
|
|
|
|
def __enter__(self):
|
|
self.d = tempfile.TemporaryDirectory()
|
|
with open(os.path.join(self.d.name, 'build.ninja'), 'w') as f:
|
|
f.write(self.build_ninja)
|
|
f.flush()
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
self.d.cleanup()
|
|
|
|
@property
|
|
def path(self) -> str:
|
|
return os.path.realpath(self.d.name)
|
|
|
|
|
|
def run(
|
|
self,
|
|
flags: T.Optional[str] = None,
|
|
pipe: bool = False,
|
|
raw_output: bool = False,
|
|
env: T.Dict[str, str] = default_env,
|
|
print_err_output = True,
|
|
) -> str:
|
|
"""Run Ninja command, and get filtered output.
|
|
|
|
Args:
|
|
flags: Extra arguments passed to Ninja.
|
|
|
|
pipe: set to True to run Ninja in a non-interactive terminal.
|
|
If False (the default), this runs Ninja in a pty to simulate
|
|
a smart terminal (this feature cannot work on Windows!).
|
|
|
|
raw_output: set to True to return the raw, unfiltered command
|
|
output.
|
|
|
|
env: Optional environment dictionary to run the command in.
|
|
|
|
print_err_output: set to False if the test expects ninja to print
|
|
something to stderr. (Otherwise, an error message from Ninja
|
|
probably represents a failed test.)
|
|
|
|
Returns:
|
|
A UTF-8 string corresponding to the output (stdout only) of the
|
|
Ninja command. By default, partial lines that were overwritten
|
|
are removed according to the rules described in the comments
|
|
below.
|
|
"""
|
|
ninja_cmd = '{} {}'.format(NINJA_PATH, flags if flags else '')
|
|
try:
|
|
if pipe:
|
|
output = subprocess.check_output(
|
|
[ninja_cmd], shell=True, cwd=self.d.name, env=env)
|
|
elif platform.system() == 'Darwin':
|
|
output = subprocess.check_output(['script', '-q', '/dev/null', 'bash', '-c', ninja_cmd],
|
|
cwd=self.d.name, env=env)
|
|
else:
|
|
output = subprocess.check_output(['script', '-qfec', ninja_cmd, '/dev/null'],
|
|
cwd=self.d.name, env=env)
|
|
except subprocess.CalledProcessError as err:
|
|
if print_err_output:
|
|
sys.stdout.buffer.write(err.output)
|
|
err.cooked_output = remove_non_visible_lines(err.output)
|
|
raise err
|
|
|
|
if raw_output:
|
|
return output.decode('utf-8')
|
|
return remove_non_visible_lines(output)
|
|
|
|
def run(
|
|
build_ninja: str,
|
|
flags: T.Optional[str] = None,
|
|
pipe: bool = False,
|
|
raw_output: bool = False,
|
|
env: T.Dict[str, str] = default_env,
|
|
print_err_output = True,
|
|
) -> str:
|
|
"""Run Ninja with a given build plan in a temporary directory.
|
|
"""
|
|
with BuildDir(build_ninja) as b:
|
|
return b.run(flags, pipe, raw_output, env, print_err_output)
|
|
|
|
@unittest.skipIf(platform.system() == 'Windows', 'These test methods do not work on Windows')
|
|
class Output(unittest.TestCase):
|
|
BUILD_SIMPLE_ECHO = '\n'.join((
|
|
'rule echo',
|
|
' command = printf "do thing"',
|
|
' description = echo $out',
|
|
'',
|
|
'build a: echo',
|
|
'',
|
|
))
|
|
|
|
def _test_expected_error(self, plan: str, flags: T.Optional[str],expected: str,
|
|
*args, exit_code: T.Optional[int]=None, **kwargs)->None:
|
|
"""Run Ninja with a given plan and flags, and verify its cooked output against an expected content.
|
|
All *args and **kwargs are passed to the `run` function
|
|
"""
|
|
actual = ''
|
|
kwargs['print_err_output'] = False
|
|
with self.assertRaises(subprocess.CalledProcessError) as cm:
|
|
run(plan, flags, *args, **kwargs)
|
|
actual = cm.exception.cooked_output
|
|
if exit_code is not None:
|
|
self.assertEqual(cm.exception.returncode, exit_code)
|
|
self.assertEqual(expected, actual)
|
|
|
|
def test_issue_1418(self) -> None:
|
|
self.assertEqual(run(
|
|
'''rule echo
|
|
command = sleep $delay && echo $out
|
|
description = echo $out
|
|
|
|
build a: echo
|
|
delay = 3
|
|
build b: echo
|
|
delay = 2
|
|
build c: echo
|
|
delay = 1
|
|
''', '-j3'),
|
|
'''[1/3] echo c\x1b[K
|
|
c
|
|
[2/3] echo b\x1b[K
|
|
b
|
|
[3/3] echo a\x1b[K
|
|
a
|
|
''')
|
|
|
|
def test_issue_1214(self) -> None:
|
|
print_red = '''rule echo
|
|
command = printf '\x1b[31mred\x1b[0m'
|
|
description = echo $out
|
|
|
|
build a: echo
|
|
'''
|
|
# Only strip color when ninja's output is piped.
|
|
self.assertEqual(run(print_red),
|
|
'''[1/1] echo a\x1b[K
|
|
\x1b[31mred\x1b[0m
|
|
''')
|
|
self.assertEqual(run(print_red, pipe=True),
|
|
'''[1/1] echo a
|
|
red
|
|
''')
|
|
# Even in verbose mode, colors should still only be stripped when piped.
|
|
self.assertEqual(run(print_red, flags='-v'),
|
|
'''[1/1] printf '\x1b[31mred\x1b[0m'
|
|
\x1b[31mred\x1b[0m
|
|
''')
|
|
self.assertEqual(run(print_red, flags='-v', pipe=True),
|
|
'''[1/1] printf '\x1b[31mred\x1b[0m'
|
|
red
|
|
''')
|
|
|
|
# CLICOLOR_FORCE=1 can be used to disable escape code stripping.
|
|
env = default_env.copy()
|
|
env['CLICOLOR_FORCE'] = '1'
|
|
self.assertEqual(run(print_red, pipe=True, env=env),
|
|
'''[1/1] echo a
|
|
\x1b[31mred\x1b[0m
|
|
''')
|
|
|
|
def test_issue_1966(self) -> None:
|
|
self.assertEqual(run(
|
|
'''rule cat
|
|
command = cat $rspfile $rspfile > $out
|
|
rspfile = cat.rsp
|
|
rspfile_content = a b c
|
|
|
|
build a: cat
|
|
''', '-j3'),
|
|
'''[1/1] cat cat.rsp cat.rsp > a\x1b[K
|
|
''')
|
|
|
|
def test_issue_2499(self) -> None:
|
|
# This verifies that Ninja prints its status line updates on a single
|
|
# line when running in a smart terminal, and when commands do not have
|
|
# any output. Get the raw command output which includes CR (\r) codes
|
|
# and all content that was printed by Ninja.
|
|
self.assertEqual(run(
|
|
'''rule touch
|
|
command = touch $out
|
|
|
|
build foo: touch
|
|
build bar: touch foo
|
|
build zoo: touch bar
|
|
''', flags='-j1 zoo', raw_output=True).split('\r'),
|
|
[
|
|
'',
|
|
'[0/3] touch foo\x1b[K',
|
|
'[1/3] touch foo\x1b[K',
|
|
'[1/3] touch bar\x1b[K',
|
|
'[2/3] touch bar\x1b[K',
|
|
'[2/3] touch zoo\x1b[K',
|
|
'[3/3] touch zoo\x1b[K',
|
|
'\n',
|
|
])
|
|
|
|
def test_pr_1685(self) -> None:
|
|
# Running those tools without .ninja_deps and .ninja_log shouldn't fail.
|
|
self.assertEqual(run('', flags='-t recompact'), '')
|
|
self.assertEqual(run('', flags='-t restat'), '')
|
|
|
|
def test_issue_2048(self) -> None:
|
|
with tempfile.TemporaryDirectory() as d:
|
|
with open(os.path.join(d, 'build.ninja'), 'w'):
|
|
pass
|
|
|
|
with open(os.path.join(d, '.ninja_log'), 'w') as f:
|
|
f.write('# ninja log v4\n')
|
|
|
|
try:
|
|
output = subprocess.check_output([NINJA_PATH, '-t', 'recompact'],
|
|
cwd=d,
|
|
env=default_env,
|
|
stderr=subprocess.STDOUT,
|
|
text=True
|
|
)
|
|
|
|
self.assertEqual(
|
|
output.strip(),
|
|
"ninja: warning: build log version is too old; starting over"
|
|
)
|
|
except subprocess.CalledProcessError as err:
|
|
self.fail("non-zero exit code with: " + err.output)
|
|
|
|
def test_pr_2540(self)->None:
|
|
py = sys.executable
|
|
plan = f'''\
|
|
rule CUSTOM_COMMAND
|
|
command = $COMMAND
|
|
|
|
build 124: CUSTOM_COMMAND
|
|
COMMAND = {py} -c 'exit(124)'
|
|
|
|
build 127: CUSTOM_COMMAND
|
|
COMMAND = {py} -c 'exit(127)'
|
|
|
|
build 130: CUSTOM_COMMAND
|
|
COMMAND = {py} -c 'exit(130)'
|
|
|
|
build 137: CUSTOM_COMMAND
|
|
COMMAND = {py} -c 'exit(137)'
|
|
|
|
build success: CUSTOM_COMMAND
|
|
COMMAND = sleep 0.3; echo success
|
|
'''
|
|
# Disable colors
|
|
env = default_env.copy()
|
|
env['TERM'] = 'dumb'
|
|
self._test_expected_error(
|
|
plan, '124',
|
|
f'''[1/1] {py} -c 'exit(124)'
|
|
FAILED: [code=124] 124 \n{py} -c 'exit(124)'
|
|
ninja: build stopped: subcommand failed.
|
|
''',
|
|
exit_code=124, env=env,
|
|
)
|
|
self._test_expected_error(
|
|
plan, '127',
|
|
f'''[1/1] {py} -c 'exit(127)'
|
|
FAILED: [code=127] 127 \n{py} -c 'exit(127)'
|
|
ninja: build stopped: subcommand failed.
|
|
''',
|
|
exit_code=127, env=env,
|
|
)
|
|
self._test_expected_error(
|
|
plan, '130',
|
|
'ninja: build stopped: interrupted by user.\n',
|
|
exit_code=130, env=env,
|
|
)
|
|
self._test_expected_error(
|
|
plan, '137',
|
|
f'''[1/1] {py} -c 'exit(137)'
|
|
FAILED: [code=137] 137 \n{py} -c 'exit(137)'
|
|
ninja: build stopped: subcommand failed.
|
|
''',
|
|
exit_code=137, env=env,
|
|
)
|
|
self._test_expected_error(
|
|
plan, 'non-existent-target',
|
|
"ninja: error: unknown target 'non-existent-target'\n",
|
|
exit_code=1, env=env,
|
|
)
|
|
self._test_expected_error(
|
|
plan, '-j2 success 127',
|
|
f'''[1/2] {py} -c 'exit(127)'
|
|
FAILED: [code=127] 127 \n{py} -c 'exit(127)'
|
|
[2/2] sleep 0.3; echo success
|
|
success
|
|
ninja: build stopped: subcommand failed.
|
|
''',
|
|
exit_code=127, env=env,
|
|
)
|
|
|
|
def test_depfile_directory_creation(self) -> None:
|
|
b = BuildDir('''\
|
|
rule touch
|
|
command = touch $out && echo "$out: extra" > $depfile
|
|
|
|
build somewhere/out: touch
|
|
depfile = somewhere_else/out.d
|
|
''')
|
|
with b:
|
|
self.assertEqual(b.run('', pipe=True), dedent('''\
|
|
[1/1] touch somewhere/out && echo "somewhere/out: extra" > somewhere_else/out.d
|
|
'''))
|
|
self.assertTrue(os.path.isfile(os.path.join(b.d.name, "somewhere", "out")))
|
|
self.assertTrue(os.path.isfile(os.path.join(b.d.name, "somewhere_else", "out.d")))
|
|
|
|
def test_status(self) -> None:
|
|
self.assertEqual(run(''), 'ninja: no work to do.\n')
|
|
self.assertEqual(run('', pipe=True), 'ninja: no work to do.\n')
|
|
self.assertEqual(run('', flags='--quiet'), '')
|
|
|
|
def test_ninja_status_default(self) -> None:
|
|
'Do we show the default status by default?'
|
|
self.assertEqual(run(Output.BUILD_SIMPLE_ECHO), '[1/1] echo a\x1b[K\ndo thing\n')
|
|
|
|
def test_ninja_status_quiet(self) -> None:
|
|
'Do we suppress the status information when --quiet is specified?'
|
|
output = run(Output.BUILD_SIMPLE_ECHO, flags='--quiet')
|
|
self.assertEqual(output, 'do thing\n')
|
|
|
|
def test_entering_directory_on_stdout(self) -> None:
|
|
output = run(Output.BUILD_SIMPLE_ECHO, flags='-C$PWD', pipe=True)
|
|
self.assertEqual(output.splitlines()[0][:25], "ninja: Entering directory")
|
|
|
|
def test_tool_inputs(self) -> None:
|
|
plan = '''
|
|
rule cat
|
|
command = cat $in $out
|
|
build out1 : cat in1
|
|
build out2 : cat in2 out1
|
|
build out3 : cat out2 out1 | implicit || order_only
|
|
'''
|
|
self.assertEqual(run(plan, flags='-t inputs out3'),
|
|
'''implicit
|
|
in1
|
|
in2
|
|
order_only
|
|
out1
|
|
out2
|
|
''')
|
|
|
|
self.assertEqual(run(plan, flags='-t inputs --dependency-order out3'),
|
|
'''in2
|
|
in1
|
|
out1
|
|
out2
|
|
implicit
|
|
order_only
|
|
''')
|
|
|
|
# Verify that results are shell-escaped by default, unless --no-shell-escape
|
|
# is used. Also verify that phony outputs are never part of the results.
|
|
quote = '"' if platform.system() == "Windows" else "'"
|
|
|
|
plan = '''
|
|
rule cat
|
|
command = cat $in $out
|
|
build out1 : cat in1
|
|
build out$ 2 : cat out1
|
|
build out$ 3 : phony out$ 2
|
|
build all: phony out$ 3
|
|
'''
|
|
|
|
# Quoting changes the order of results when sorting alphabetically.
|
|
self.assertEqual(run(plan, flags='-t inputs all'),
|
|
f'''{quote}out 2{quote}
|
|
in1
|
|
out1
|
|
''')
|
|
|
|
self.assertEqual(run(plan, flags='-t inputs --no-shell-escape all'),
|
|
'''in1
|
|
out 2
|
|
out1
|
|
''')
|
|
|
|
# But not when doing dependency order.
|
|
self.assertEqual(
|
|
run(
|
|
plan,
|
|
flags='-t inputs --dependency-order all'
|
|
),
|
|
f'''in1
|
|
out1
|
|
{quote}out 2{quote}
|
|
''')
|
|
|
|
self.assertEqual(
|
|
run(
|
|
plan,
|
|
flags='-t inputs --dependency-order --no-shell-escape all'
|
|
),
|
|
f'''in1
|
|
out1
|
|
out 2
|
|
''')
|
|
|
|
self.assertEqual(
|
|
run(
|
|
plan,
|
|
flags='-t inputs --dependency-order --no-shell-escape --print0 all'
|
|
),
|
|
f'''in1\0out1\0out 2\0'''
|
|
)
|
|
|
|
|
|
def test_tool_compdb_targets(self) -> None:
|
|
plan = '''
|
|
rule cat
|
|
command = cat $in $out
|
|
build out1 : cat in1
|
|
build out2 : cat in2 out1
|
|
build out3 : cat out2 out1
|
|
build out4 : cat in4
|
|
'''
|
|
|
|
|
|
self._test_expected_error(plan, '-t compdb-targets',
|
|
'''ninja: error: compdb-targets expects the name of at least one target
|
|
usage: ninja -t compdb [-hx] target [targets]
|
|
|
|
options:
|
|
-h display this help message
|
|
-x expand @rspfile style response file invocations
|
|
''')
|
|
|
|
self._test_expected_error(plan, '-t compdb-targets in1',
|
|
"ninja: fatal: 'in1' is not a target (i.e. it is not an output of any `build` statement)\n")
|
|
|
|
self._test_expected_error(plan, '-t compdb-targets nonexistent_target',
|
|
"ninja: fatal: unknown target 'nonexistent_target'\n")
|
|
|
|
|
|
with BuildDir(plan) as b:
|
|
actual = b.run(flags='-t compdb-targets out3')
|
|
expected = f'''[
|
|
{{
|
|
"directory": "{b.path}",
|
|
"command": "cat in1 out1",
|
|
"file": "in1",
|
|
"output": "out1"
|
|
}},
|
|
{{
|
|
"directory": "{b.path}",
|
|
"command": "cat in2 out1 out2",
|
|
"file": "in2",
|
|
"output": "out2"
|
|
}},
|
|
{{
|
|
"directory": "{b.path}",
|
|
"command": "cat out2 out1 out3",
|
|
"file": "out2",
|
|
"output": "out3"
|
|
}}
|
|
]
|
|
'''
|
|
self.assertEqual(expected, actual)
|
|
|
|
|
|
def test_tool_multi_inputs(self) -> None:
|
|
plan = '''
|
|
rule cat
|
|
command = cat $in $out
|
|
build out1 : cat in1
|
|
build out2 : cat in1 in2
|
|
build out3 : cat in1 in2 in3
|
|
'''
|
|
self.assertEqual(run(plan, flags='-t multi-inputs out1'),
|
|
'''out1<TAB>in1
|
|
'''.replace("<TAB>", "\t"))
|
|
|
|
self.assertEqual(run(plan, flags='-t multi-inputs out1 out2 out3'),
|
|
'''out1<TAB>in1
|
|
out2<TAB>in1
|
|
out2<TAB>in2
|
|
out3<TAB>in1
|
|
out3<TAB>in2
|
|
out3<TAB>in3
|
|
'''.replace("<TAB>", "\t"))
|
|
|
|
self.assertEqual(run(plan, flags='-t multi-inputs -d: out1'),
|
|
'''out1:in1
|
|
''')
|
|
|
|
self.assertEqual(
|
|
run(
|
|
plan,
|
|
flags='-t multi-inputs -d, --print0 out1 out2'
|
|
),
|
|
'''out1,in1\0out2,in1\0out2,in2\0'''
|
|
)
|
|
|
|
|
|
def test_explain_output(self):
|
|
b = BuildDir('''\
|
|
build .FORCE: phony
|
|
rule create_if_non_exist
|
|
command = [ -e $out ] || touch $out
|
|
restat = true
|
|
rule write
|
|
command = cp $in $out
|
|
build input : create_if_non_exist .FORCE
|
|
build mid : write input
|
|
build output : write mid
|
|
default output
|
|
''')
|
|
with b:
|
|
# The explain output is shown just before the relevant build:
|
|
self.assertEqual(b.run('-v -d explain'), dedent('''\
|
|
ninja explain: .FORCE is dirty
|
|
[1/3] [ -e input ] || touch input
|
|
ninja explain: input is dirty
|
|
[2/3] cp input mid
|
|
ninja explain: mid is dirty
|
|
[3/3] cp mid output
|
|
'''))
|
|
# Don't print "ninja explain: XXX is dirty" for inputs that are
|
|
# pruned from the graph by an earlier restat.
|
|
self.assertEqual(b.run('-v -d explain'), dedent('''\
|
|
ninja explain: .FORCE is dirty
|
|
[1/3] [ -e input ] || touch input
|
|
'''))
|
|
|
|
def test_issue_2586(self):
|
|
"""This shouldn't hang"""
|
|
plan = '''rule echo
|
|
command = echo echo
|
|
build dep: echo
|
|
build console1: echo dep
|
|
pool = console
|
|
build console2: echo
|
|
pool = console
|
|
build all: phony console1 console2
|
|
default all
|
|
'''
|
|
self.assertEqual(run(plan, flags='-j2', env={'NINJA_STATUS':''}), '''echo echo
|
|
echo
|
|
echo echo
|
|
echo
|
|
echo echo
|
|
echo
|
|
''')
|
|
|
|
def test_issue_2621(self):
|
|
"""Should result in "multiple rules generate" error"""
|
|
plan = r"""rule dd
|
|
command = printf 'ninja_dyndep_version = 1\nbuild stamp-$n | out: dyndep\n' > $out
|
|
rule touch
|
|
command = touch stamp-$n out
|
|
dyndep = dd-$n
|
|
build dd-1: dd
|
|
n = 1
|
|
build dd-2: dd
|
|
n = 2
|
|
build stamp-1: touch || dd-1
|
|
n = 1
|
|
build stamp-2: touch || dd-2
|
|
n = 2
|
|
"""
|
|
self._test_expected_error(
|
|
plan,
|
|
"-v",
|
|
r"""[1/4] printf 'ninja_dyndep_version = 1\nbuild stamp-1 | out: dyndep\n' > dd-1
|
|
[2/4] printf 'ninja_dyndep_version = 1\nbuild stamp-2 | out: dyndep\n' > dd-2
|
|
ninja: build stopped: multiple rules generate out.
|
|
""",
|
|
)
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|