From 737ae333a344cf5fb76931adfc37436e591e22db Mon Sep 17 00:00:00 2001 From: Konstantin Kharlamov Date: Mon, 23 Mar 2026 18:11:40 +0300 Subject: [PATCH 1/2] Fail gracefully if config lacks the mandatory main section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, if a user didn't put `[general]` section in config file, bugwarrior fails like this: Traceback (most recent call last): File "/usr/bin/bugwarrior", line 8, in sys.exit(cli()) ~~~^^ File "/usr/lib/python3.14/site-packages/click/core.py", line 1485, in __call__ return self.main(*args, **kwargs) ~~~~~~~~~^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.14/site-packages/click/core.py", line 1406, in main rv = self.invoke(ctx) File "/usr/lib/python3.14/site-packages/click/core.py", line 1873, in invoke return _process_result(sub_ctx.command.invoke(sub_ctx)) ~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^ File "/usr/lib/python3.14/site-packages/click/core.py", line 1269, in invoke return ctx.invoke(self.callback, **ctx.params) ~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.14/site-packages/click/core.py", line 824, in invoke return callback(*args, **kwargs) File "/usr/lib/python3.14/site-packages/click/decorators.py", line 34, in new_func return f(get_current_context(), *args, **kwargs) File "/usr/lib/python3.14/site-packages/bugwarrior/command.py", line 62, in wrapped_subcommand_callback return ctx.invoke(subcommand_callback, *args, **kwargs) ~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.14/site-packages/click/core.py", line 824, in invoke return callback(*args, **kwargs) File "/usr/lib/python3.14/site-packages/bugwarrior/command.py", line 105, in pull config = _try_load_config(main_section, interactive, quiet) File "/usr/lib/python3.14/site-packages/bugwarrior/command.py", line 35, in _try_load_config return load_config(main_section, interactive, quiet) File "/usr/lib/python3.14/site-packages/bugwarrior/config/load.py", line 122, in load_config rawconfig['flavor'][main_section]['interactive'] = interactive ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^ KeyError: 'general' This is confusing — it looks more like bugwarrior broke due to some API change in Python, rather than because of a user mistake. So handle this case with explicit check. Now it will fail instead like this: Validation error found in /home/constantine/.config/bugwarrior/bugwarriorrc See https://bugwarrior.readthedocs.io No section: 'general' --- bugwarrior/config/load.py | 3 ++- tests/config/test_load.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/bugwarrior/config/load.py b/bugwarrior/config/load.py index 97bcbc76..e7454c1d 100644 --- a/bugwarrior/config/load.py +++ b/bugwarrior/config/load.py @@ -120,7 +120,8 @@ def parse_file(configpath: str) -> dict: def load_config(main_section, interactive, quiet) -> Config: configpath = get_config_path() rawconfig = parse_file(configpath) - rawconfig['flavor'][main_section]['interactive'] = interactive + for flavor in rawconfig['flavor'].values(): + flavor['interactive'] = interactive config = validate_config(rawconfig, main_section, configpath) configure_logging( config.main.log_file, 'WARNING' if quiet else config.main.log_level diff --git a/tests/config/test_load.py b/tests/config/test_load.py index 986f3590..2e15c940 100644 --- a/tests/config/test_load.py +++ b/tests/config/test_load.py @@ -263,3 +263,31 @@ def test_ini_wrong_prefix(self): with self.assertRaises(SystemExit): load.parse_file(config_path) + + +class TestLoadConfig(LoadTest): + def setUp(self): + self.basedir = Path(__file__).parent + super().setUp() + + def test_main_section_does_not_exist(self): + config_path = self.create(".bugwarriorrc") + with open(config_path, 'w') as fout: + fout.write( + textwrap.dedent(""" + [redmine] + service = redmine + redmine.url = example.com + """) + ) + + with self.assertRaises(SystemExit): + load.load_config("general", False, False) + + self.assertEqual(len(self.caplog.records), 1) + self.assertIn("No section: 'general'", self.caplog.records[0].message) + + def test_interactive_flag_propagated(self): + os.environ['BUGWARRIORRC'] = str(self.basedir / 'example-bugwarriorrc') + config = load.load_config('general', interactive=True, quiet=False) + self.assertTrue(config.main.interactive) From 924990bba18dffa980c7ec565ec687679b2fb22c Mon Sep 17 00:00:00 2001 From: Konstantin Kharlamov Date: Sun, 5 Apr 2026 16:41:57 +0300 Subject: [PATCH 2/2] config: move default `interactive` value to validate_config() Per @ryneeverett's request, the value should be removed from the schema.py. Doing so required changing some of the tests. --- bugwarrior/config/schema.py | 2 +- bugwarrior/config/validation.py | 2 +- tests/config/test_validation.py | 8 +++++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/bugwarrior/config/schema.py b/bugwarrior/config/schema.py index b92f6765..4f9f20f8 100644 --- a/bugwarrior/config/schema.py +++ b/bugwarrior/config/schema.py @@ -138,7 +138,7 @@ class MainSectionConfig(BaseConfig): # added during configuration loading #: Interactive status. - interactive: bool = False + interactive: bool @computed_field @property diff --git a/bugwarrior/config/validation.py b/bugwarrior/config/validation.py index dc8ea7f1..781121ea 100644 --- a/bugwarrior/config/validation.py +++ b/bugwarrior/config/validation.py @@ -189,7 +189,7 @@ def validate_config(config: dict, main_section: str, config_path: str) -> "Confi f"[{flavor_name}]\ntargets = {flavor.targets} <- No [{target}] section found\n" ) - main = flavors.get(main_section, MainSectionConfig(targets=[])) + main = flavors.get(main_section, MainSectionConfig(targets=[], interactive=False)) filtered_service_configs = [ service_config for service_config in service_configs diff --git a/tests/config/test_validation.py b/tests/config/test_validation.py index 60820e2b..0c60a325 100644 --- a/tests/config/test_validation.py +++ b/tests/config/test_validation.py @@ -141,7 +141,9 @@ def test_deprecated_project_name(self): self.validate() def test_flavors(self): - self.config['flavor'] = {'myflavor': {'targets': ['my_service', 'my_gitlab']}} + self.config['flavor'] = { + 'myflavor': {'targets': ['my_service', 'my_gitlab'], 'interactive': False} + } self.validate() def test_quoted_flavor_key_error(self): @@ -175,6 +177,10 @@ def test_load_and_validate_example_files(self): for main_section, expected_configs in expected_by_flavor.items(): with self.subTest(config=config_path.name, main_section=main_section): formatted_config = parse_file(str(config_path)) + + for flavor in formatted_config['flavor'].values(): + flavor['interactive'] = False + config = validation.validate_config( formatted_config, main_section, str(config_path) )