Skip to content

Commit 0f81d98

Browse files
authored
Revert "Revert "[execute_process] emulate_tty configurable and defaults to true"" (#277)
* Revert "Revert "[execute_process] emulate_tty configurable and defaults to true (#265)" (#276)" This reverts commit 15af530. * add option to strip ansi escape sequences from output in launch_testing Signed-off-by: William Woodall <[email protected]> * move registration of event handlers before including test file Signed-off-by: William Woodall <[email protected]> * replace \r\n with \n too, because you get this in emulated tty mode Signed-off-by: William Woodall <[email protected]> * fix a new test failure due a change in how pytest represents exceptions See: pytest-dev/pytest#5579 Signed-off-by: William Woodall <[email protected]> * refactor to not use YAML in eval of emulate_tty option Signed-off-by: William Woodall <[email protected]> * fix typo Signed-off-by: William Woodall <[email protected]> * refactor emulate_tty tests and test constructor argument Signed-off-by: William Woodall <[email protected]> * change default for emulate_tty to be False and fix warnings Signed-off-by: William Woodall <[email protected]>
1 parent 415138d commit 0f81d98

File tree

9 files changed

+193
-14
lines changed

9 files changed

+193
-14
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
#!/usr/bin/env python3
2+
3+
# Copyright 2019 Open Source Robotics Foundation, Inc.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
"""
18+
Script that demonstrates disabling tty emulation.
19+
20+
This is most significant for python processes which, without tty
21+
emulation, will be buffered by default and have various other
22+
capabilities disabled."
23+
"""
24+
25+
import os
26+
import sys
27+
from typing import cast
28+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) # noqa
29+
30+
import launch
31+
32+
33+
def generate_launch_description():
34+
ld = launch.LaunchDescription()
35+
36+
# Disable tty emulation (on by default).
37+
ld.add_action(launch.actions.SetLaunchConfiguration('emulate_tty', 'false'))
38+
39+
# Wire up stdout from processes
40+
def on_output(event: launch.Event) -> None:
41+
for line in event.text.decode().splitlines():
42+
print('[{}] {}'.format(
43+
cast(launch.events.process.ProcessIO, event).process_name, line))
44+
45+
ld.add_action(launch.actions.RegisterEventHandler(launch.event_handlers.OnProcessIO(
46+
on_stdout=on_output,
47+
)))
48+
49+
# Execute
50+
ld.add_action(launch.actions.ExecuteProcess(
51+
cmd=[sys.executable, './counter.py']
52+
))
53+
return ld
54+
55+
56+
if __name__ == '__main__':
57+
# ls = LaunchService(argv=argv, debug=True) # Use this instead to get more debug messages.
58+
ls = launch.LaunchService(argv=sys.argv[1:])
59+
ls.include_launch_description(generate_launch_description())
60+
sys.exit(ls.run())

launch/launch/actions/execute_process.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from .timer_action import TimerAction
4444

4545
from ..action import Action
46+
from ..conditions import evaluate_condition_expression
4647
from ..event import Event
4748
from ..event_handler import EventHandler
4849
from ..event_handlers import OnProcessExit
@@ -95,6 +96,7 @@ def __init__(
9596
'sigterm_timeout', default=5),
9697
sigkill_timeout: SomeSubstitutionsType = LaunchConfiguration(
9798
'sigkill_timeout', default=5),
99+
emulate_tty: bool = False,
98100
prefix: Optional[SomeSubstitutionsType] = None,
99101
output: Text = 'log',
100102
output_format: Text = '[{this.name}] {line}',
@@ -173,6 +175,12 @@ def __init__(
173175
as a string or a list of strings and Substitutions to be resolved
174176
at runtime, defaults to the LaunchConfiguration called
175177
'sigkill_timeout'
178+
:param: emulate_tty emulate a tty (terminal), defaults to False, but can
179+
be overridden with the LaunchConfiguration called 'emulate_tty',
180+
the value of which is evaluated as true or false according to
181+
:py:func:`evaluate_condition_expression`.
182+
Throws :py:exception:`InvalidConditionExpressionError` if the
183+
'emulate_tty' configuration does not represent a boolean.
176184
:param: prefix a set of commands/arguments to preceed the cmd, used for
177185
things like gdb/valgrind and defaults to the LaunchConfiguration
178186
called 'launch-prefix'
@@ -211,6 +219,7 @@ def __init__(
211219
self.__shell = shell
212220
self.__sigterm_timeout = normalize_to_list_of_substitutions(sigterm_timeout)
213221
self.__sigkill_timeout = normalize_to_list_of_substitutions(sigkill_timeout)
222+
self.__emulate_tty = emulate_tty
214223
self.__prefix = normalize_to_list_of_substitutions(
215224
LaunchConfiguration('launch-prefix', default='') if prefix is None else prefix
216225
)
@@ -577,6 +586,16 @@ async def __execute_process(self, context: LaunchContext) -> None:
577586
self.__logger.info("process details: cmd=[{}], cwd='{}', custom_env?={}".format(
578587
', '.join(cmd), cwd, 'True' if env is not None else 'False'
579588
))
589+
590+
emulate_tty = self.__emulate_tty
591+
if 'emulate_tty' in context.launch_configurations:
592+
emulate_tty = evaluate_condition_expression(
593+
context,
594+
normalize_to_list_of_substitutions(
595+
context.launch_configurations['emulate_tty']
596+
),
597+
)
598+
580599
try:
581600
transport, self._subprocess_protocol = await async_execute_process(
582601
lambda **kwargs: self.__ProcessProtocol(
@@ -586,7 +605,7 @@ async def __execute_process(self, context: LaunchContext) -> None:
586605
cwd=cwd,
587606
env=env,
588607
shell=self.__shell,
589-
emulate_tty=False,
608+
emulate_tty=emulate_tty,
590609
stderr_to_stdout=False,
591610
)
592611
except Exception:
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Copyright 2019 Open Source Robotics Foundation, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tests for emulate_tty configuration of ExecuteProcess actions."""
16+
17+
import platform
18+
import sys
19+
20+
import launch
21+
import pytest
22+
23+
24+
class OnExit(object):
25+
26+
def __init__(self):
27+
self.returncode = None
28+
29+
def handle(self, event, context):
30+
self.returncode = event.returncode
31+
32+
33+
def tty_expected_unless_windows():
34+
return 1 if platform.system() != 'Windows' else 0
35+
36+
37+
@pytest.mark.parametrize('test_input,expected', [
38+
# use the default defined by ExecuteProcess
39+
(None, not tty_expected_unless_windows()),
40+
# redundantly override the default via LaunchConfiguration
41+
('true', tty_expected_unless_windows()),
42+
# override the default via LaunchConfiguration
43+
('false', 0),
44+
# redundantly override the default via constructor
45+
(True, tty_expected_unless_windows()),
46+
# override the default via constructor
47+
(False, 0),
48+
])
49+
def test_emulate_tty(test_input, expected):
50+
on_exit = OnExit()
51+
ld = launch.LaunchDescription()
52+
kwargs = {}
53+
if isinstance(test_input, bool):
54+
kwargs['emulate_tty'] = test_input
55+
elif isinstance(test_input, str):
56+
ld.add_action(
57+
launch.actions.SetLaunchConfiguration(
58+
'emulate_tty',
59+
test_input
60+
)
61+
)
62+
ld.add_action(
63+
launch.actions.RegisterEventHandler(
64+
launch.event_handlers.OnProcessExit(on_exit=on_exit.handle)
65+
)
66+
)
67+
ld.add_action(launch.actions.ExecuteProcess(
68+
cmd=[
69+
sys.executable,
70+
'-c',
71+
'import sys; sys.exit(sys.stdout.isatty())',
72+
],
73+
**kwargs
74+
))
75+
ls = launch.LaunchService()
76+
ls.include_launch_description(ld)
77+
ls.run()
78+
assert on_exit.returncode == expected

launch/test/launch/actions/test_include_launch_description.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ def test_include_launch_description_launch_arguments():
110110
lc2 = LaunchContext()
111111
with pytest.raises(RuntimeError) as excinfo2:
112112
action2.visit(lc2)
113-
assert 'Included launch description missing required argument' in str(excinfo2)
113+
assert 'Included launch description missing required argument' in str(excinfo2.value)
114114

115115
# test that a declared argument that is not provided raises an error, but with other args set
116116
ld2 = LaunchDescription([DeclareLaunchArgument('foo')])
@@ -121,8 +121,8 @@ def test_include_launch_description_launch_arguments():
121121
lc2 = LaunchContext()
122122
with pytest.raises(RuntimeError) as excinfo2:
123123
action2.visit(lc2)
124-
assert 'Included launch description missing required argument' in str(excinfo2)
125-
assert 'not_foo' in str(excinfo2)
124+
assert 'Included launch description missing required argument' in str(excinfo2.value)
125+
assert 'not_foo' in str(excinfo2.value)
126126

127127
# test that a declared argument with a default value that is not provided does not raise
128128
ld2 = LaunchDescription([DeclareLaunchArgument('foo', default_value='FOO')])

launch_testing/launch_testing/asserts/assert_output.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,20 @@
1414

1515
import os
1616

17+
from osrf_pycommon.terminal_color import remove_ansi_escape_senquences
18+
1719
from ..util import resolveProcesses
1820

1921

22+
def normalize_lineseps(lines):
23+
r"""Normalize and then return the given lines to all use '\n'."""
24+
lines = lines.replace(os.linesep, '\n')
25+
# This happens (even on Linux and macOS) when capturing I/O from an
26+
# emulated tty.
27+
lines = lines.replace('\r\n', '\n')
28+
return lines
29+
30+
2031
def get_matching_function(expected_output):
2132
if isinstance(expected_output, (list, tuple)):
2233
if len(expected_output) > 0:
@@ -33,7 +44,7 @@ def _match(expected, actual):
3344
if hasattr(expected_output[0], 'search'):
3445
def _match(expected, actual):
3546
start = 0
36-
actual = actual.replace(os.linesep, '\n')
47+
actual = normalize_lineseps(actual)
3748
for pattern in expected:
3849
match = pattern.search(actual, start)
3950
if match is None:
@@ -45,7 +56,7 @@ def _match(expected, actual):
4556
return lambda expected, actual: expected in actual
4657
elif hasattr(expected_output, 'search'):
4758
return lambda expected, actual: (
48-
expected.search(actual.replace(os.linesep, '\n')) is not None
59+
expected.search(normalize_lineseps(actual)) is not None
4960
)
5061
raise ValueError('Unknown format for expected output')
5162

@@ -56,7 +67,8 @@ def assertInStdout(proc_output,
5667
cmd_args=None,
5768
*,
5869
output_filter=None,
59-
strict_proc_matching=True):
70+
strict_proc_matching=True,
71+
strip_ansi_escape_sequences=True):
6072
"""
6173
Assert that 'output' was found in the standard out of a process.
6274
@@ -82,6 +94,11 @@ def assertInStdout(proc_output,
8294
of proc and cmd_args matches multiple processes, then strict_proc_matching=True will raise
8395
an error.
8496
:type strict_proc_matching: bool
97+
98+
:param strip_ansi_escape_sequences: If True (default), strip ansi escape
99+
sequences from actual output before comparing with the output filter or
100+
expected output.
101+
:type strip_ansi_escape_sequences: bool
85102
"""
86103
resolved_procs = resolveProcesses(
87104
info_obj=proc_output,
@@ -98,6 +115,8 @@ def assertInStdout(proc_output,
98115
full_output = ''.join(
99116
output.text.decode() for output in proc_output[proc] if output.from_stdout
100117
)
118+
if strip_ansi_escape_sequences:
119+
full_output = remove_ansi_escape_senquences(full_output)
101120
if output_filter is not None:
102121
full_output = output_filter(full_output)
103122
if output_match(expected_output, full_output):

launch_testing/launch_testing/io_handler.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ def waitFor(
134134
strict_proc_matching=True,
135135
output_filter=None,
136136
timeout=10,
137+
strip_ansi_escape_sequences=True
137138
):
138139
success = False
139140

@@ -145,7 +146,8 @@ def msg_found():
145146
process=process,
146147
cmd_args=cmd_args,
147148
output_filter=output_filter,
148-
strict_proc_matching=strict_proc_matching
149+
strict_proc_matching=strict_proc_matching,
150+
strip_ansi_escape_sequences=strip_ansi_escape_sequences,
149151
)
150152
return True
151153
except NoMatchingProcessException:

launch_testing/launch_testing/test_runner.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,6 @@ def run(self):
122122
# the test and add our own event handlers for process IO and process exit:
123123
launch_description = LaunchDescription([
124124
*self._test_run_preamble,
125-
launch.actions.IncludeLaunchDescription(
126-
launch.LaunchDescriptionSource(launch_description=test_ld),
127-
launch_arguments=parsed_launch_arguments
128-
),
129125
RegisterEventHandler(
130126
OnProcessExit(on_exit=lambda info, unused: proc_info.append(info))
131127
),
@@ -135,6 +131,10 @@ def run(self):
135131
on_stderr=proc_output.append,
136132
)
137133
),
134+
launch.actions.IncludeLaunchDescription(
135+
launch.LaunchDescriptionSource(launch_description=test_ld),
136+
launch_arguments=parsed_launch_arguments
137+
),
138138
])
139139

140140
self._launch_service.include_launch_description(

launch_testing/package.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010
<author>Esteve Fernandez</author>
1111
<license>Apache License 2.0</license>
1212

13-
<exec_depend>launch</exec_depend>
1413
<exec_depend>ament_index_python</exec_depend>
14+
<exec_depend>launch</exec_depend>
15+
<exec_depend>osrf_pycommon</exec_depend>
1516

1617
<test_depend>ament_copyright</test_depend>
1718
<test_depend>ament_flake8</test_depend>

launch_testing/test/launch_testing/test_resolve_process.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ def test_proc_string_lookup_no_args(self):
161161
assert found_proc
162162

163163
def test_strict_proc_matching(self):
164-
with self.assertRaisesRegexp(Exception, 'Found multiple processes'):
164+
with self.assertRaisesRegex(Exception, 'Found multiple processes'):
165165
launch_testing.util.resolveProcesses(
166166
self.proc_info,
167167
process=os.path.basename(sys.executable),

0 commit comments

Comments
 (0)