test_application.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905
  1. """
  2. Tests for traitlets.config.application.Application
  3. """
  4. # Copyright (c) IPython Development Team.
  5. # Distributed under the terms of the Modified BSD License.
  6. from __future__ import annotations
  7. import contextlib
  8. import io
  9. import json
  10. import logging
  11. import os
  12. import sys
  13. import typing as t
  14. from io import StringIO
  15. from tempfile import TemporaryDirectory
  16. from unittest import TestCase, mock
  17. import pytest
  18. from traitlets import Bool, Bytes, Dict, HasTraits, Integer, List, Set, Tuple, Unicode
  19. from traitlets.config.application import Application
  20. from traitlets.config.configurable import Configurable
  21. from traitlets.config.loader import Config, KVArgParseConfigLoader
  22. from traitlets.tests.utils import check_help_all_output, check_help_output, get_output_error_code
  23. pjoin = os.path.join
  24. class Foo(Configurable):
  25. i = Integer(
  26. 0,
  27. help="""
  28. The integer i.
  29. Details about i.
  30. """,
  31. ).tag(config=True)
  32. j = Integer(1, help="The integer j.").tag(config=True)
  33. name = Unicode("Brian", help="First name.").tag(config=True)
  34. la = List([]).tag(config=True)
  35. li = List(Integer()).tag(config=True)
  36. fdict = Dict().tag(config=True, multiplicity="+")
  37. class Bar(Configurable):
  38. b = Integer(0, help="The integer b.").tag(config=True)
  39. enabled = Bool(True, help="Enable bar.").tag(config=True)
  40. tb = Tuple(()).tag(config=True, multiplicity="*")
  41. aset = Set().tag(config=True, multiplicity="+")
  42. bdict = Dict().tag(config=True)
  43. idict = Dict(value_trait=Integer()).tag(config=True)
  44. key_dict = Dict(per_key_traits={"i": Integer(), "b": Bytes()}).tag(config=True)
  45. class MyApp(Application):
  46. name = Unicode("myapp")
  47. running = Bool(False, help="Is the app running?").tag(config=True)
  48. classes = List([Bar, Foo]) # type:ignore
  49. config_file = Unicode("", help="Load this config file").tag(config=True)
  50. warn_tpyo = Unicode(
  51. "yes the name is wrong on purpose",
  52. config=True,
  53. help="Should print a warning if `MyApp.warn-typo=...` command is passed",
  54. )
  55. aliases: t.Dict[t.Any, t.Any] = {}
  56. aliases.update(Application.aliases)
  57. aliases.update(
  58. {
  59. ("fooi", "i"): "Foo.i",
  60. ("j", "fooj"): ("Foo.j", "`j` terse help msg"),
  61. "name": "Foo.name",
  62. "la": "Foo.la",
  63. "li": "Foo.li",
  64. "tb": "Bar.tb",
  65. "D": "Bar.bdict",
  66. "enabled": "Bar.enabled",
  67. "enable": "Bar.enabled",
  68. "log-level": "Application.log_level",
  69. }
  70. )
  71. flags: t.Dict[t.Any, t.Any] = {}
  72. flags.update(Application.flags)
  73. flags.update(
  74. {
  75. ("enable", "e"): ({"Bar": {"enabled": True}}, "Set Bar.enabled to True"),
  76. ("d", "disable"): ({"Bar": {"enabled": False}}, "Set Bar.enabled to False"),
  77. "crit": ({"Application": {"log_level": logging.CRITICAL}}, "set level=CRITICAL"),
  78. }
  79. )
  80. def init_foo(self):
  81. self.foo = Foo(parent=self)
  82. def init_bar(self):
  83. self.bar = Bar(parent=self)
  84. def class_to_names(classes):
  85. return [klass.__name__ for klass in classes]
  86. class TestApplication(TestCase):
  87. def test_log(self):
  88. stream = StringIO()
  89. app = MyApp(log_level=logging.INFO)
  90. handler = logging.StreamHandler(stream)
  91. # trigger reconstruction of the log formatter
  92. app.log_format = "%(message)s"
  93. app.log_datefmt = "%Y-%m-%d %H:%M"
  94. app.log.handlers = [handler]
  95. app.log.info("hello")
  96. assert "hello" in stream.getvalue()
  97. def test_no_eval_cli_text(self):
  98. app = MyApp()
  99. app.initialize(["--Foo.name=1"])
  100. app.init_foo()
  101. assert app.foo.name == "1"
  102. def test_basic(self):
  103. app = MyApp()
  104. self.assertEqual(app.name, "myapp")
  105. self.assertEqual(app.running, False)
  106. self.assertEqual(app.classes, [MyApp, Bar, Foo]) # type:ignore
  107. self.assertEqual(app.config_file, "")
  108. def test_app_name_set_via_constructor(self):
  109. app = MyApp(name="set_via_constructor")
  110. assert app.name == "set_via_constructor"
  111. def test_mro_discovery(self):
  112. app = MyApp()
  113. self.assertSequenceEqual(
  114. class_to_names(app._classes_with_config_traits()),
  115. ["Application", "MyApp", "Bar", "Foo"],
  116. )
  117. self.assertSequenceEqual(
  118. class_to_names(app._classes_inc_parents()),
  119. [
  120. "Configurable",
  121. "LoggingConfigurable",
  122. "SingletonConfigurable",
  123. "Application",
  124. "MyApp",
  125. "Bar",
  126. "Foo",
  127. ],
  128. )
  129. self.assertSequenceEqual(
  130. class_to_names(app._classes_with_config_traits([Application])), ["Application"]
  131. )
  132. self.assertSequenceEqual(
  133. class_to_names(app._classes_inc_parents([Application])),
  134. ["Configurable", "LoggingConfigurable", "SingletonConfigurable", "Application"],
  135. )
  136. self.assertSequenceEqual(class_to_names(app._classes_with_config_traits([Foo])), ["Foo"])
  137. self.assertSequenceEqual(
  138. class_to_names(app._classes_inc_parents([Bar])), ["Configurable", "Bar"]
  139. )
  140. class MyApp2(Application): # no defined `classes` attr
  141. pass
  142. self.assertSequenceEqual(class_to_names(app._classes_with_config_traits([Foo])), ["Foo"])
  143. self.assertSequenceEqual(
  144. class_to_names(app._classes_inc_parents([Bar])), ["Configurable", "Bar"]
  145. )
  146. def test_config(self):
  147. app = MyApp()
  148. app.parse_command_line(
  149. [
  150. "--i=10",
  151. "--Foo.j=10",
  152. "--enable=False",
  153. "--log-level=50",
  154. ]
  155. )
  156. config = app.config
  157. print(config)
  158. self.assertEqual(config.Foo.i, 10)
  159. self.assertEqual(config.Foo.j, 10)
  160. self.assertEqual(config.Bar.enabled, False)
  161. self.assertEqual(config.MyApp.log_level, 50)
  162. def test_config_seq_args(self):
  163. app = MyApp()
  164. app.parse_command_line(
  165. "--li 1 --li 3 --la 1 --tb AB 2 --Foo.la=ab --Bar.aset S1 --Bar.aset S2 --Bar.aset S1".split()
  166. )
  167. assert app.extra_args == ["2"]
  168. config = app.config
  169. assert config.Foo.li == [1, 3]
  170. assert config.Foo.la == ["1", "ab"]
  171. assert config.Bar.tb == ("AB",)
  172. self.assertEqual(config.Bar.aset, {"S1", "S2"})
  173. app.init_foo()
  174. assert app.foo.li == [1, 3]
  175. assert app.foo.la == ["1", "ab"]
  176. app.init_bar()
  177. self.assertEqual(app.bar.aset, {"S1", "S2"})
  178. assert app.bar.tb == ("AB",)
  179. def test_config_dict_args(self):
  180. app = MyApp()
  181. app.parse_command_line(
  182. "--Foo.fdict a=1 --Foo.fdict b=b --Foo.fdict c=3 "
  183. "--Bar.bdict k=1 -D=a=b -D 22=33 "
  184. "--Bar.idict k=1 --Bar.idict b=2 --Bar.idict c=3 ".split()
  185. )
  186. fdict = {"a": "1", "b": "b", "c": "3"}
  187. bdict = {"k": "1", "a": "b", "22": "33"}
  188. idict = {"k": 1, "b": 2, "c": 3}
  189. config = app.config
  190. assert config.Bar.idict == idict
  191. self.assertDictEqual(config.Foo.fdict, fdict)
  192. self.assertDictEqual(config.Bar.bdict, bdict)
  193. app.init_foo()
  194. self.assertEqual(app.foo.fdict, fdict)
  195. app.init_bar()
  196. assert app.bar.idict == idict
  197. self.assertEqual(app.bar.bdict, bdict)
  198. def test_config_propagation(self):
  199. app = MyApp()
  200. app.parse_command_line(["--i=10", "--Foo.j=10", "--enable=False", "--log-level=50"])
  201. app.init_foo()
  202. app.init_bar()
  203. self.assertEqual(app.foo.i, 10)
  204. self.assertEqual(app.foo.j, 10)
  205. self.assertEqual(app.bar.enabled, False)
  206. def test_cli_priority(self):
  207. """Test that loading config files does not override CLI options"""
  208. name = "config.py"
  209. class TestApp(Application):
  210. value = Unicode().tag(config=True)
  211. config_file_loaded = Bool().tag(config=True)
  212. aliases = {"v": "TestApp.value"}
  213. app = TestApp()
  214. with TemporaryDirectory() as td:
  215. config_file = pjoin(td, name)
  216. with open(config_file, "w") as f:
  217. f.writelines(
  218. ["c.TestApp.value = 'config file'\n", "c.TestApp.config_file_loaded = True\n"]
  219. )
  220. app.parse_command_line(["--v=cli"])
  221. assert "value" in app.config.TestApp
  222. assert app.config.TestApp.value == "cli"
  223. assert app.value == "cli"
  224. app.load_config_file(name, path=[td])
  225. assert app.config_file_loaded
  226. assert app.config.TestApp.value == "cli"
  227. assert app.value == "cli"
  228. def test_ipython_cli_priority(self):
  229. # this test is almost entirely redundant with above,
  230. # but we can keep it around in case of subtle issues creeping into
  231. # the exact sequence IPython follows.
  232. name = "config.py"
  233. class TestApp(Application):
  234. value = Unicode().tag(config=True)
  235. config_file_loaded = Bool().tag(config=True)
  236. aliases = {"v": ("TestApp.value", "some help")}
  237. app = TestApp()
  238. with TemporaryDirectory() as td:
  239. config_file = pjoin(td, name)
  240. with open(config_file, "w") as f:
  241. f.writelines(
  242. ["c.TestApp.value = 'config file'\n", "c.TestApp.config_file_loaded = True\n"]
  243. )
  244. # follow IPython's config-loading sequence to ensure CLI priority is preserved
  245. app.parse_command_line(["--v=cli"])
  246. # this is where IPython makes a mistake:
  247. # it assumes app.config will not be modified,
  248. # and storing a reference is storing a copy
  249. cli_config = app.config
  250. assert "value" in app.config.TestApp
  251. assert app.config.TestApp.value == "cli"
  252. assert app.value == "cli"
  253. app.load_config_file(name, path=[td])
  254. assert app.config_file_loaded
  255. # enforce cl-opts override config file opts:
  256. # this is where IPython makes a mistake: it assumes
  257. # that cl_config is a different object, but it isn't.
  258. app.update_config(cli_config)
  259. assert app.config.TestApp.value == "cli"
  260. assert app.value == "cli"
  261. def test_cli_allow_none(self):
  262. class App(Application):
  263. aliases = {"opt": "App.opt"}
  264. opt = Unicode(allow_none=True, config=True)
  265. app = App()
  266. app.parse_command_line(["--opt=None"])
  267. assert app.opt is None
  268. def test_flags(self):
  269. app = MyApp()
  270. app.parse_command_line(["--disable"])
  271. app.init_bar()
  272. self.assertEqual(app.bar.enabled, False)
  273. app = MyApp()
  274. app.parse_command_line(["-d"])
  275. app.init_bar()
  276. self.assertEqual(app.bar.enabled, False)
  277. app = MyApp()
  278. app.parse_command_line(["--enable"])
  279. app.init_bar()
  280. self.assertEqual(app.bar.enabled, True)
  281. app = MyApp()
  282. app.parse_command_line(["-e"])
  283. app.init_bar()
  284. self.assertEqual(app.bar.enabled, True)
  285. def test_flags_help_msg(self):
  286. app = MyApp()
  287. stdout = io.StringIO()
  288. with contextlib.redirect_stdout(stdout):
  289. app.print_flag_help()
  290. hmsg = stdout.getvalue()
  291. self.assertRegex(hmsg, "(?<!-)-e, --enable\\b")
  292. self.assertRegex(hmsg, "(?<!-)-d, --disable\\b")
  293. self.assertIn("Equivalent to: [--Bar.enabled=True]", hmsg)
  294. self.assertIn("Equivalent to: [--Bar.enabled=False]", hmsg)
  295. def test_aliases(self):
  296. app = MyApp()
  297. app.parse_command_line(["--i=5", "--j=10"])
  298. app.init_foo()
  299. self.assertEqual(app.foo.i, 5)
  300. app.init_foo()
  301. self.assertEqual(app.foo.j, 10)
  302. app = MyApp()
  303. app.parse_command_line(["-i=5", "-j=10"])
  304. app.init_foo()
  305. self.assertEqual(app.foo.i, 5)
  306. app.init_foo()
  307. self.assertEqual(app.foo.j, 10)
  308. app = MyApp()
  309. app.parse_command_line(["--fooi=5", "--fooj=10"])
  310. app.init_foo()
  311. self.assertEqual(app.foo.i, 5)
  312. app.init_foo()
  313. self.assertEqual(app.foo.j, 10)
  314. def test_aliases_multiple(self):
  315. # Test multiple > 2 aliases for the same argument
  316. class TestMultiAliasApp(Application):
  317. foo = Integer(config=True)
  318. aliases = {("f", "bar", "qux"): "TestMultiAliasApp.foo"}
  319. app = TestMultiAliasApp()
  320. app.parse_command_line(["-f", "3"])
  321. self.assertEqual(app.foo, 3)
  322. app = TestMultiAliasApp()
  323. app.parse_command_line(["--bar", "4"])
  324. self.assertEqual(app.foo, 4)
  325. app = TestMultiAliasApp()
  326. app.parse_command_line(["--qux", "5"])
  327. self.assertEqual(app.foo, 5)
  328. def test_aliases_help_msg(self):
  329. app = MyApp()
  330. stdout = io.StringIO()
  331. with contextlib.redirect_stdout(stdout):
  332. app.print_alias_help()
  333. hmsg = stdout.getvalue()
  334. self.assertRegex(hmsg, "(?<!-)-i, --fooi\\b")
  335. self.assertRegex(hmsg, "(?<!-)-j, --fooj\\b")
  336. self.assertIn("Equivalent to: [--Foo.i]", hmsg)
  337. self.assertIn("Equivalent to: [--Foo.j]", hmsg)
  338. self.assertIn("Equivalent to: [--Foo.name]", hmsg)
  339. def test_alias_unrecognized(self):
  340. """Check ability to override handling for unrecognized aliases"""
  341. class StrictLoader(KVArgParseConfigLoader):
  342. def _handle_unrecognized_alias(self, arg):
  343. self.parser.error("Unrecognized alias: %s" % arg)
  344. class StrictApplication(Application):
  345. def _create_loader(self, argv, aliases, flags, classes):
  346. return StrictLoader(argv, aliases, flags, classes=classes, log=self.log)
  347. app = StrictApplication()
  348. app.initialize(["--log-level=20"]) # recognized alias
  349. assert app.log_level == 20
  350. app = StrictApplication()
  351. with pytest.raises(SystemExit, match="2"):
  352. app.initialize(["--unrecognized=20"])
  353. # Ideally we would use pytest capsys fixture, but fixtures are incompatible
  354. # with unittest.TestCase-style classes :(
  355. # stderr = capsys.readouterr().err
  356. # assert "Unrecognized alias: unrecognized" in stderr
  357. def test_flag_clobber(self):
  358. """test that setting flags doesn't clobber existing settings"""
  359. app = MyApp()
  360. app.parse_command_line(["--Bar.b=5", "--disable"])
  361. app.init_bar()
  362. self.assertEqual(app.bar.enabled, False)
  363. self.assertEqual(app.bar.b, 5)
  364. app.parse_command_line(["--enable", "--Bar.b=10"])
  365. app.init_bar()
  366. self.assertEqual(app.bar.enabled, True)
  367. self.assertEqual(app.bar.b, 10)
  368. def test_warn_autocorrect(self):
  369. stream = StringIO()
  370. app = MyApp(log_level=logging.INFO)
  371. app.log.handlers = [logging.StreamHandler(stream)]
  372. cfg = Config()
  373. cfg.MyApp.warn_typo = "WOOOO"
  374. app.config = cfg
  375. self.assertIn("warn_typo", stream.getvalue())
  376. self.assertIn("warn_tpyo", stream.getvalue())
  377. def test_flatten_flags(self):
  378. cfg = Config()
  379. cfg.MyApp.log_level = logging.WARN
  380. app = MyApp()
  381. app.update_config(cfg)
  382. self.assertEqual(app.log_level, logging.WARN)
  383. self.assertEqual(app.config.MyApp.log_level, logging.WARN)
  384. app.initialize(["--crit"])
  385. self.assertEqual(app.log_level, logging.CRITICAL)
  386. # this would be app.config.Application.log_level if it failed:
  387. self.assertEqual(app.config.MyApp.log_level, logging.CRITICAL)
  388. def test_flatten_aliases(self):
  389. cfg = Config()
  390. cfg.MyApp.log_level = logging.WARN
  391. app = MyApp()
  392. app.update_config(cfg)
  393. self.assertEqual(app.log_level, logging.WARN)
  394. self.assertEqual(app.config.MyApp.log_level, logging.WARN)
  395. app.initialize(["--log-level", "CRITICAL"])
  396. self.assertEqual(app.log_level, logging.CRITICAL)
  397. # this would be app.config.Application.log_level if it failed:
  398. self.assertEqual(app.config.MyApp.log_level, "CRITICAL")
  399. def test_extra_args(self):
  400. app = MyApp()
  401. app.parse_command_line(["--Bar.b=5", "extra", "args", "--disable"])
  402. app.init_bar()
  403. self.assertEqual(app.bar.enabled, False)
  404. self.assertEqual(app.bar.b, 5)
  405. self.assertEqual(app.extra_args, ["extra", "args"])
  406. app = MyApp()
  407. app.parse_command_line(["--Bar.b=5", "--", "extra", "--disable", "args"])
  408. app.init_bar()
  409. self.assertEqual(app.bar.enabled, True)
  410. self.assertEqual(app.bar.b, 5)
  411. self.assertEqual(app.extra_args, ["extra", "--disable", "args"])
  412. app = MyApp()
  413. app.parse_command_line(["--disable", "--la", "-", "-", "--Bar.b=1", "--", "-", "extra"])
  414. self.assertEqual(app.extra_args, ["-", "-", "extra"])
  415. def test_unicode_argv(self):
  416. app = MyApp()
  417. app.parse_command_line(["ünîcødé"])
  418. def test_document_config_option(self):
  419. app = MyApp()
  420. app.document_config_options()
  421. def test_generate_config_file(self):
  422. app = MyApp()
  423. assert "The integer b." in app.generate_config_file()
  424. def test_generate_config_file_classes_to_include(self):
  425. class NotInConfig(HasTraits):
  426. from_hidden = Unicode(
  427. "x",
  428. help="""From hidden class
  429. Details about from_hidden.
  430. """,
  431. ).tag(config=True)
  432. class NoTraits(Foo, Bar, NotInConfig):
  433. pass
  434. app = MyApp()
  435. app.classes.append(NoTraits) # type:ignore
  436. conf_txt = app.generate_config_file()
  437. print(conf_txt)
  438. self.assertIn("The integer b.", conf_txt)
  439. self.assertIn("# Foo(Configurable)", conf_txt)
  440. self.assertNotIn("# Configurable", conf_txt)
  441. self.assertIn("# NoTraits(Foo, Bar)", conf_txt)
  442. # inherited traits, parent in class list:
  443. self.assertIn("# c.NoTraits.i", conf_txt)
  444. self.assertIn("# c.NoTraits.j", conf_txt)
  445. self.assertIn("# c.NoTraits.n", conf_txt)
  446. self.assertIn("# See also: Foo.j", conf_txt)
  447. self.assertIn("# See also: Bar.b", conf_txt)
  448. self.assertEqual(conf_txt.count("Details about i."), 1)
  449. # inherited traits, parent not in class list:
  450. self.assertIn("# c.NoTraits.from_hidden", conf_txt)
  451. self.assertNotIn("# See also: NotInConfig.", conf_txt)
  452. self.assertEqual(conf_txt.count("Details about from_hidden."), 1)
  453. self.assertNotIn("NotInConfig", conf_txt)
  454. def test_multi_file(self):
  455. app = MyApp()
  456. app.log = logging.getLogger()
  457. name = "config.py"
  458. with TemporaryDirectory("_1") as td1:
  459. with open(pjoin(td1, name), "w") as f1:
  460. f1.write("get_config().MyApp.Bar.b = 1")
  461. with TemporaryDirectory("_2") as td2:
  462. with open(pjoin(td2, name), "w") as f2:
  463. f2.write("get_config().MyApp.Bar.b = 2")
  464. app.load_config_file(name, path=[td2, td1])
  465. app.init_bar()
  466. self.assertEqual(app.bar.b, 2)
  467. app.load_config_file(name, path=[td1, td2])
  468. app.init_bar()
  469. self.assertEqual(app.bar.b, 1)
  470. @pytest.mark.skipif(not hasattr(TestCase, "assertLogs"), reason="requires TestCase.assertLogs")
  471. def test_log_collisions(self):
  472. app = MyApp()
  473. app.log = logging.getLogger()
  474. app.log.setLevel(logging.INFO)
  475. name = "config"
  476. with TemporaryDirectory("_1") as td:
  477. with open(pjoin(td, name + ".py"), "w") as f:
  478. f.write("get_config().Bar.b = 1")
  479. with open(pjoin(td, name + ".json"), "w") as f:
  480. json.dump({"Bar": {"b": 2}}, f)
  481. with self.assertLogs(app.log, logging.WARNING) as captured:
  482. app.load_config_file(name, path=[td])
  483. app.init_bar()
  484. assert app.bar.b == 2
  485. output = "\n".join(captured.output)
  486. assert "Collision" in output
  487. assert "1 ignored, using 2" in output
  488. assert pjoin(td, name + ".py") in output
  489. assert pjoin(td, name + ".json") in output
  490. @pytest.mark.skipif(not hasattr(TestCase, "assertLogs"), reason="requires TestCase.assertLogs")
  491. def test_log_bad_config(self):
  492. app = MyApp()
  493. app.log = logging.getLogger()
  494. name = "config.py"
  495. with TemporaryDirectory() as td:
  496. with open(pjoin(td, name), "w") as f:
  497. f.write("syntax error()")
  498. with self.assertLogs(app.log, logging.ERROR) as captured:
  499. app.load_config_file(name, path=[td])
  500. output = "\n".join(captured.output)
  501. self.assertIn("SyntaxError", output)
  502. def test_raise_on_bad_config(self):
  503. app = MyApp()
  504. app.raise_config_file_errors = True
  505. app.log = logging.getLogger()
  506. name = "config.py"
  507. with TemporaryDirectory() as td:
  508. with open(pjoin(td, name), "w") as f:
  509. f.write("syntax error()")
  510. with self.assertRaises(SyntaxError):
  511. app.load_config_file(name, path=[td])
  512. def test_subcommands_instantiation(self):
  513. """Try all ways to specify how to create sub-apps."""
  514. app = Root.instance()
  515. app.parse_command_line(["sub1"])
  516. self.assertIsInstance(app.subapp, Sub1)
  517. # Check parent hierarchy.
  518. self.assertIs(app.subapp.parent, app)
  519. Root.clear_instance()
  520. Sub1.clear_instance() # Otherwise, replaced spuriously and hierarchy check fails.
  521. app = Root.instance()
  522. app.parse_command_line(["sub1", "sub2"])
  523. self.assertIsInstance(app.subapp, Sub1)
  524. self.assertIsInstance(app.subapp.subapp, Sub2)
  525. # Check parent hierarchy.
  526. self.assertIs(app.subapp.parent, app)
  527. self.assertIs(app.subapp.subapp.parent, app.subapp)
  528. Root.clear_instance()
  529. Sub1.clear_instance() # Otherwise, replaced spuriously and hierarchy check fails.
  530. app = Root.instance()
  531. app.parse_command_line(["sub1", "sub3"])
  532. self.assertIsInstance(app.subapp, Sub1)
  533. self.assertIsInstance(app.subapp.subapp, Sub3)
  534. self.assertTrue(app.subapp.subapp.flag) # Set by factory.
  535. # Check parent hierarchy.
  536. self.assertIs(app.subapp.parent, app)
  537. self.assertIs(app.subapp.subapp.parent, app.subapp) # Set by factory.
  538. Root.clear_instance()
  539. Sub1.clear_instance()
  540. def test_loaded_config_files(self):
  541. app = MyApp()
  542. app.log = logging.getLogger()
  543. name = "config.py"
  544. with TemporaryDirectory("_1") as td1:
  545. config_file = pjoin(td1, name)
  546. with open(config_file, "w") as f:
  547. f.writelines(["c.MyApp.running = True\n"])
  548. app.load_config_file(name, path=[td1])
  549. self.assertEqual(len(app.loaded_config_files), 1)
  550. self.assertEqual(app.loaded_config_files[0], config_file)
  551. app.start()
  552. self.assertEqual(app.running, True)
  553. # emulate an app that allows dynamic updates and update config file
  554. with open(config_file, "w") as f:
  555. f.writelines(["c.MyApp.running = False\n"])
  556. # reload and verify update, and that loaded_configs was not increased
  557. app.load_config_file(name, path=[td1])
  558. self.assertEqual(len(app.loaded_config_files), 1)
  559. self.assertEqual(app.running, False)
  560. # Attempt to update, ensure error...
  561. with self.assertRaises(AttributeError):
  562. app.loaded_config_files = "/foo" # type:ignore
  563. # ensure it can't be updated via append
  564. app.loaded_config_files.append("/bar")
  565. self.assertEqual(len(app.loaded_config_files), 1)
  566. # repeat to ensure no unexpected changes occurred
  567. app.load_config_file(name, path=[td1])
  568. self.assertEqual(len(app.loaded_config_files), 1)
  569. self.assertEqual(app.running, False)
  570. @pytest.mark.skip
  571. def test_cli_multi_scalar(caplog):
  572. class App(Application):
  573. aliases = {"opt": "App.opt"}
  574. opt = Unicode(config=True)
  575. app = App(log=logging.getLogger())
  576. with pytest.raises(SystemExit):
  577. app.parse_command_line(["--opt", "1", "--opt", "2"])
  578. record = caplog.get_records("call")[-1]
  579. message = record.message
  580. assert "Error loading argument" in message
  581. assert "App.opt=['1', '2']" in message
  582. assert "opt only accepts one value" in message
  583. assert record.levelno == logging.CRITICAL
  584. class Root(Application):
  585. subcommands = {
  586. "sub1": ("__tests__.config.test_application.Sub1", "import string"),
  587. }
  588. class Sub3(Application):
  589. flag = Bool(False)
  590. class Sub2(Application):
  591. pass
  592. class Sub1(Application):
  593. subcommands: dict = { # type:ignore
  594. "sub2": (Sub2, "Application class"),
  595. "sub3": (lambda root: Sub3(parent=root, flag=True), "factory"),
  596. }
  597. class DeprecatedApp(Application):
  598. override_called = False
  599. parent_called = False
  600. def _config_changed(self, name, old, new):
  601. self.override_called = True
  602. def _capture(*args):
  603. self.parent_called = True
  604. with mock.patch.object(self.log, "debug", _capture):
  605. super()._config_changed(name, old, new)
  606. def test_deprecated_notifier():
  607. app = DeprecatedApp()
  608. assert not app.override_called
  609. assert not app.parent_called
  610. app.config = Config({"A": {"b": "c"}})
  611. assert app.override_called
  612. assert app.parent_called
  613. def test_help_output():
  614. check_help_output(__name__)
  615. def test_help_all_output():
  616. check_help_all_output(__name__)
  617. def test_show_config_cli():
  618. out, err, ec = get_output_error_code([sys.executable, "-m", __name__, "--show-config"])
  619. assert ec == 0
  620. assert "show_config" not in out
  621. def test_show_config_json_cli():
  622. out, err, ec = get_output_error_code([sys.executable, "-m", __name__, "--show-config-json"])
  623. assert ec == 0
  624. assert "show_config" not in out
  625. def test_show_config(capsys):
  626. cfg = Config()
  627. cfg.MyApp.i = 5
  628. # don't show empty
  629. cfg.OtherApp
  630. app = MyApp(config=cfg, show_config=True)
  631. app.start()
  632. out, err = capsys.readouterr()
  633. assert "MyApp" in out
  634. assert "i = 5" in out
  635. assert "OtherApp" not in out
  636. def test_show_config_json(capsys):
  637. cfg = Config()
  638. cfg.MyApp.i = 5
  639. cfg.OtherApp
  640. app = MyApp(config=cfg, show_config_json=True)
  641. app.start()
  642. out, err = capsys.readouterr()
  643. displayed = json.loads(out)
  644. assert Config(displayed) == cfg
  645. def test_deep_alias():
  646. from traitlets import Int
  647. from traitlets.config import Application, Configurable
  648. class Foo(Configurable):
  649. val = Int(default_value=5).tag(config=True)
  650. class Bar(Configurable):
  651. def __init__(self, *args, **kwargs):
  652. super().__init__(*args, **kwargs)
  653. self.foo = Foo(parent=self)
  654. class TestApp(Application):
  655. name = "test"
  656. aliases = {"val": "Bar.Foo.val"}
  657. classes = [Foo, Bar]
  658. def initialize(self, *args, **kwargs):
  659. super().initialize(*args, **kwargs)
  660. self.bar = Bar(parent=self)
  661. app = TestApp()
  662. app.initialize(["--val=10"])
  663. assert app.bar.foo.val == 10
  664. assert len(list(app.emit_alias_help())) > 0
  665. def test_logging_config(tmp_path, capsys):
  666. """We should be able to configure additional log handlers."""
  667. log_file = tmp_path / "log_file"
  668. app = Application(
  669. logging_config={
  670. "version": 1,
  671. "handlers": {
  672. "file": {
  673. "class": "logging.FileHandler",
  674. "level": "DEBUG",
  675. "filename": str(log_file),
  676. },
  677. },
  678. "loggers": {
  679. "Application": {
  680. "level": "DEBUG",
  681. "handlers": ["console", "file"],
  682. },
  683. },
  684. }
  685. )
  686. # the default "console" handler + our new "file" handler
  687. assert len(app.log.handlers) == 2
  688. # log a couple of messages
  689. app.log.info("info")
  690. app.log.warning("warn")
  691. # test that log messages get written to the file
  692. with open(log_file) as log_handle:
  693. assert log_handle.read() == "info\nwarn\n"
  694. # test that log messages get written to stderr (default console handler)
  695. assert capsys.readouterr().err == "[Application] WARNING | warn\n"
  696. def test_get_default_logging_config_pythonw(monkeypatch):
  697. """Ensure logging is correctly disabled for pythonw usage."""
  698. monkeypatch.setattr("traitlets.config.application.IS_PYTHONW", True)
  699. config = Application().get_default_logging_config()
  700. assert "handlers" not in config
  701. assert "loggers" not in config
  702. monkeypatch.setattr("traitlets.config.application.IS_PYTHONW", False)
  703. config = Application().get_default_logging_config()
  704. assert "handlers" in config
  705. assert "loggers" in config
  706. @pytest.fixture()
  707. def caplogconfig(monkeypatch):
  708. """Capture logging config events for DictConfigurator objects.
  709. This suppresses the event (so the configuration doesn't happen).
  710. Returns a list of (args, kwargs).
  711. """
  712. calls = []
  713. def _configure(*args, **kwargs):
  714. nonlocal calls
  715. calls.append((args, kwargs))
  716. monkeypatch.setattr(
  717. "logging.config.DictConfigurator.configure",
  718. _configure,
  719. )
  720. return calls
  721. @pytest.mark.skipif(sys.implementation.name == "pypy", reason="Test does not work on pypy")
  722. def test_logging_teardown_on_error(capsys, caplogconfig):
  723. """Ensure we don't try to open logs in order to close them (See #722).
  724. If you try to configure logging handlers whilst Python is shutting down
  725. you may get traceback.
  726. """
  727. # create and destroy an app (without configuring logging)
  728. # (ensure that the logs were not configured)
  729. app = Application()
  730. del app
  731. assert len(caplogconfig) == 0 # logging was not configured
  732. out, err = capsys.readouterr()
  733. assert "Traceback" not in err
  734. # ensure that the logs would have been configured otherwise
  735. # (to prevent this test from yielding false-negatives)
  736. app = Application()
  737. app._logging_configured = True # make it look like logging was configured
  738. del app
  739. assert len(caplogconfig) == 1 # logging was configured
  740. if __name__ == "__main__":
  741. # for test_help_output:
  742. MyApp.launch_instance()