1# Copyright 2017 The Abseil Authors.
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 app.py."""
16
17import contextlib
18import copy
19import enum
20import io
21import os
22import re
23import subprocess
24import sys
25import tempfile
26from unittest import mock
27
28from absl import app
29from absl import flags
30from absl.testing import _bazelize_command
31from absl.testing import absltest
32from absl.testing import flagsaver
33from absl.tests import app_test_helper
34
35
36FLAGS = flags.FLAGS
37
38
39_newline_regex = re.compile('(\r\n)|\r')
40
41
42@contextlib.contextmanager
43def patch_main_module_docstring(docstring):
44  old_doc = sys.modules['__main__'].__doc__
45  sys.modules['__main__'].__doc__ = docstring
46  yield
47  sys.modules['__main__'].__doc__ = old_doc
48
49
50def _normalize_newlines(s):
51  return re.sub('(\r\n)|\r', '\n', s)
52
53
54class UnitTests(absltest.TestCase):
55
56  def test_install_exception_handler(self):
57    with self.assertRaises(TypeError):
58      app.install_exception_handler(1)
59
60  def test_usage(self):
61    with mock.patch.object(
62        sys, 'stderr', new=io.StringIO()) as mock_stderr:
63      app.usage()
64    self.assertIn(__doc__, mock_stderr.getvalue())
65    # Assert that flags are written to stderr.
66    self.assertIn('\n  --[no]helpfull:', mock_stderr.getvalue())
67
68  def test_usage_shorthelp(self):
69    with mock.patch.object(
70        sys, 'stderr', new=io.StringIO()) as mock_stderr:
71      app.usage(shorthelp=True)
72    # Assert that flags are NOT written to stderr.
73    self.assertNotIn('  --', mock_stderr.getvalue())
74
75  def test_usage_writeto_stderr(self):
76    with mock.patch.object(
77        sys, 'stdout', new=io.StringIO()) as mock_stdout:
78      app.usage(writeto_stdout=True)
79    self.assertIn(__doc__, mock_stdout.getvalue())
80
81  def test_usage_detailed_error(self):
82    with mock.patch.object(
83        sys, 'stderr', new=io.StringIO()) as mock_stderr:
84      app.usage(detailed_error='BAZBAZ')
85    self.assertIn('BAZBAZ', mock_stderr.getvalue())
86
87  def test_usage_exitcode(self):
88    with mock.patch.object(sys, 'stderr', new=sys.stderr):
89      try:
90        app.usage(exitcode=2)
91        self.fail('app.usage(exitcode=1) should raise SystemExit')
92      except SystemExit as e:
93        self.assertEqual(2, e.code)
94
95  def test_usage_expands_docstring(self):
96    with patch_main_module_docstring('Name: %s, %%s'):
97      with mock.patch.object(
98          sys, 'stderr', new=io.StringIO()) as mock_stderr:
99        app.usage()
100    self.assertIn('Name: {}, %s'.format(sys.argv[0]),
101                  mock_stderr.getvalue())
102
103  def test_usage_does_not_expand_bad_docstring(self):
104    with patch_main_module_docstring('Name: %s, %%s, %@'):
105      with mock.patch.object(
106          sys, 'stderr', new=io.StringIO()) as mock_stderr:
107        app.usage()
108    self.assertIn('Name: %s, %%s, %@', mock_stderr.getvalue())
109
110  @flagsaver.flagsaver
111  def test_register_and_parse_flags_with_usage_exits_on_only_check_args(self):
112    done = app._register_and_parse_flags_with_usage.done
113    try:
114      app._register_and_parse_flags_with_usage.done = False
115      with self.assertRaises(SystemExit):
116        app._register_and_parse_flags_with_usage(
117            argv=['./program', '--only_check_args'])
118    finally:
119      app._register_and_parse_flags_with_usage.done = done
120
121  def test_register_and_parse_flags_with_usage_exits_on_second_run(self):
122    with self.assertRaises(SystemError):
123      app._register_and_parse_flags_with_usage()
124
125
126class FunctionalTests(absltest.TestCase):
127  """Functional tests that use runs app_test_helper."""
128
129  helper_type = 'pure_python'
130
131  def run_helper(self, expect_success,
132                 expected_stdout_substring=None, expected_stderr_substring=None,
133                 arguments=(),
134                 env_overrides=None):
135    env = os.environ.copy()
136    env['APP_TEST_HELPER_TYPE'] = self.helper_type
137    env['PYTHONIOENCODING'] = 'utf8'
138    if env_overrides:
139      env.update(env_overrides)
140
141    helper = 'absl/tests/app_test_helper_{}'.format(self.helper_type)
142    process = subprocess.Popen(
143        [_bazelize_command.get_executable_path(helper)] + list(arguments),
144        stdout=subprocess.PIPE,
145        stderr=subprocess.PIPE, env=env, universal_newlines=False)
146    stdout, stderr = process.communicate()
147    # In Python 2, we can't control the encoding used by universal_newline
148    # mode, which can cause UnicodeDecodeErrors when subprocess tries to
149    # convert the bytes to unicode, so we have to decode it manually.
150    stdout = _normalize_newlines(stdout.decode('utf8'))
151    stderr = _normalize_newlines(stderr.decode('utf8'))
152
153    message = (u'Command: {command}\n'
154               'Exit Code: {exitcode}\n'
155               '===== stdout =====\n{stdout}'
156               '===== stderr =====\n{stderr}'
157               '=================='.format(
158                   command=' '.join([helper] + list(arguments)),
159                   exitcode=process.returncode,
160                   stdout=stdout or '<no output>\n',
161                   stderr=stderr or '<no output>\n'))
162    if expect_success:
163      self.assertEqual(0, process.returncode, msg=message)
164    else:
165      self.assertNotEqual(0, process.returncode, msg=message)
166
167    if expected_stdout_substring:
168      self.assertIn(expected_stdout_substring, stdout, message)
169    if expected_stderr_substring:
170      self.assertIn(expected_stderr_substring, stderr, message)
171
172    return process.returncode, stdout, stderr
173
174  def test_help(self):
175    _, _, stderr = self.run_helper(
176        False,
177        arguments=['--help'],
178        expected_stdout_substring=app_test_helper.__doc__)
179    self.assertNotIn('--', stderr)
180
181  def test_helpfull_basic(self):
182    self.run_helper(
183        False,
184        arguments=['--helpfull'],
185        # --logtostderr is from absl.logging module.
186        expected_stdout_substring='--[no]logtostderr')
187
188  def test_helpfull_unicode_flag_help(self):
189    _, stdout, _ = self.run_helper(
190        False,
191        arguments=['--helpfull'],
192        expected_stdout_substring='str_flag_with_unicode_args')
193
194    self.assertIn(u'smile:\U0001F604', stdout)
195
196    self.assertIn(u'thumb:\U0001F44D', stdout)
197
198  def test_helpshort(self):
199    _, _, stderr = self.run_helper(
200        False,
201        arguments=['--helpshort'],
202        expected_stdout_substring=app_test_helper.__doc__)
203    self.assertNotIn('--', stderr)
204
205  def test_custom_main(self):
206    self.run_helper(
207        True,
208        env_overrides={'APP_TEST_CUSTOM_MAIN_FUNC': 'custom_main'},
209        expected_stdout_substring='Function called: custom_main.')
210
211  def test_custom_argv(self):
212    self.run_helper(
213        True,
214        expected_stdout_substring='argv: ./program pos_arg1',
215        env_overrides={
216            'APP_TEST_CUSTOM_ARGV': './program --noraise_exception pos_arg1',
217            'APP_TEST_PRINT_ARGV': '1',
218        })
219
220  def test_gwq_status_file_on_exception(self):
221    if self.helper_type == 'pure_python':
222      # Pure python binary does not write to GWQ Status.
223      return
224
225    tmpdir = tempfile.mkdtemp(dir=absltest.TEST_TMPDIR.value)
226    self.run_helper(
227        False,
228        arguments=['--raise_exception'],
229        env_overrides={'GOOGLE_STATUS_DIR': tmpdir})
230    with open(os.path.join(tmpdir, 'STATUS')) as status_file:
231      self.assertIn('MyException:', status_file.read())
232
233  def test_faulthandler_dumps_stack_on_sigsegv(self):
234    return_code, _, _ = self.run_helper(
235        False,
236        expected_stderr_substring='app_test_helper.py", line',
237        arguments=['--faulthandler_sigsegv'])
238    # sigsegv returns 3 on Windows, and -11 on LINUX/macOS.
239    expected_return_code = 3 if os.name == 'nt' else -11
240    self.assertEqual(expected_return_code, return_code)
241
242  def test_top_level_exception(self):
243    self.run_helper(
244        False,
245        arguments=['--raise_exception'],
246        expected_stderr_substring='MyException')
247
248  def test_only_check_args(self):
249    self.run_helper(
250        True,
251        arguments=['--only_check_args', '--raise_exception'])
252
253  def test_only_check_args_failure(self):
254    self.run_helper(
255        False,
256        arguments=['--only_check_args', '--banana'],
257        expected_stderr_substring='FATAL Flags parsing error')
258
259  def test_usage_error(self):
260    exitcode, _, _ = self.run_helper(
261        False,
262        arguments=['--raise_usage_error'],
263        expected_stderr_substring=app_test_helper.__doc__)
264    self.assertEqual(1, exitcode)
265
266  def test_usage_error_exitcode(self):
267    exitcode, _, _ = self.run_helper(
268        False,
269        arguments=['--raise_usage_error', '--usage_error_exitcode=88'],
270        expected_stderr_substring=app_test_helper.__doc__)
271    self.assertEqual(88, exitcode)
272
273  def test_exception_handler(self):
274    exception_handler_messages = (
275        'MyExceptionHandler: first\nMyExceptionHandler: second\n')
276    self.run_helper(
277        False,
278        arguments=['--raise_exception'],
279        expected_stdout_substring=exception_handler_messages)
280
281  def test_exception_handler_not_called(self):
282    _, _, stdout = self.run_helper(True)
283    self.assertNotIn('MyExceptionHandler', stdout)
284
285  def test_print_init_callbacks(self):
286    _, stdout, _ = self.run_helper(
287        expect_success=True, arguments=['--print_init_callbacks'])
288    self.assertIn('before app.run', stdout)
289    self.assertIn('during real_main', stdout)
290
291
292class FlagDeepCopyTest(absltest.TestCase):
293  """Make sure absl flags are copy.deepcopy() compatible."""
294
295  def test_deepcopyable(self):
296    copy.deepcopy(FLAGS)
297    # Nothing to assert
298
299
300class FlagValuesExternalizationTest(absltest.TestCase):
301  """Test to make sure FLAGS can be serialized out and parsed back in."""
302
303  @flagsaver.flagsaver
304  def test_nohelp_doesnt_show_help(self):
305    with self.assertRaisesWithPredicateMatch(SystemExit,
306                                             lambda e: e.code == 1):
307      app.run(
308          len,
309          argv=[
310              './program', '--nohelp', '--helpshort=false', '--helpfull=0',
311              '--helpxml=f'
312          ])
313
314  @flagsaver.flagsaver
315  def test_serialize_roundtrip(self):
316    # Use the global 'FLAGS' as the source, to ensure all the framework defined
317    # flags will go through the round trip process.
318    flags.DEFINE_string('testflag', 'testval', 'help', flag_values=FLAGS)
319
320    flags.DEFINE_multi_enum('test_multi_enum_flag',
321                            ['x', 'y'], ['x', 'y', 'z'],
322                            'Multi enum help.',
323                            flag_values=FLAGS)
324
325    class Fruit(enum.Enum):
326      APPLE = 1
327      ORANGE = 2
328      TOMATO = 3
329    flags.DEFINE_multi_enum_class('test_multi_enum_class_flag',
330                                  ['APPLE', 'TOMATO'], Fruit,
331                                  'Fruit help.',
332                                  flag_values=FLAGS)
333
334    new_flag_values = flags.FlagValues()
335    new_flag_values.append_flag_values(FLAGS)
336
337    FLAGS.testflag = 'roundtrip_me'
338    FLAGS.test_multi_enum_flag = ['y', 'z']
339    FLAGS.test_multi_enum_class_flag = [Fruit.ORANGE, Fruit.APPLE]
340    argv = ['binary_name'] + FLAGS.flags_into_string().splitlines()
341
342    self.assertNotEqual(new_flag_values['testflag'], FLAGS.testflag)
343    self.assertNotEqual(new_flag_values['test_multi_enum_flag'],
344                        FLAGS.test_multi_enum_flag)
345    self.assertNotEqual(new_flag_values['test_multi_enum_class_flag'],
346                        FLAGS.test_multi_enum_class_flag)
347    new_flag_values(argv)
348    self.assertEqual(new_flag_values.testflag, FLAGS.testflag)
349    self.assertEqual(new_flag_values.test_multi_enum_flag,
350                     FLAGS.test_multi_enum_flag)
351    self.assertEqual(new_flag_values.test_multi_enum_class_flag,
352                     FLAGS.test_multi_enum_class_flag)
353    del FLAGS.testflag
354    del FLAGS.test_multi_enum_flag
355    del FLAGS.test_multi_enum_class_flag
356
357
358if __name__ == '__main__':
359  absltest.main()
360