Repository: qutebrowser/qutebrowser

Issue:
Using :set with a config.py overwrites old autoconfig.yml
From IRC:

- Have a autoconfig with user changes.
- Not be using a config.py
- Qutebrowser is open

- Create config.py with infomation from theme repo (+clone repo into config folder)- https://github.com/evannagle/qutebrowser-dracula-theme
  - *Does not contain*:  config.load_autoconfig()

- Restart Qutebrowser
  - Theme is 'active'
  - autoconfig is not read, user settings are not loaded

- Use the :set command - (tabs.position) 
  - autoconfig is overwritten with blank user settings + the setting for tabs.position


Discussion:
what kind of solution would you like here?
Good question... Let's first check what possible solutions we can think of! I can think of a couple which would also prevent confusion about `autoconfig.yml` files not being loaded, which is still something that occurs from time to time despite all the changes we (well, mostly you!) have made to prevent that.

- Require a `config.py` to always explicitly say `config.load_autoconfig(False)` if it doesn't want to load `autoconfig.yml`. If neither `config.load_autoconfig()` (with an argument defaulting to `True`) nor `config.load_autoconfig(False)` is called, *don't* load the autoconfig (for backwards compatibility) but issue a config error (which will not prevent qutebrowser from starting, but be annoying enough so people fix it and take a conscious decision). It doesn't actually prevent this from happening (the user can still ignore the error and do things via `:set`), but it might be sufficient.
- Show an error when `:set` and friends are used (without `-t`) and `autoconfig.yml` is not loaded. Might be the most "natural" one, but is a bit cumbersome for people who don't use `autoconfig.yml` and just use `:set` for temporary settings. Also there's no solution for `:bind` because no `:bind -t` exists.

Alternatively, if we'd really just want to prevent this issue, we'd somehow need to load the `autoconfig.yml` values but not apply them, just so they can be saved properly later. But this makes things even more intricate than they already are (with `config.py` settings, `autoconfig.yml` settings and temporary settings), so I'd really rather not do that.

Any opinions/comments?
Ah, of course there's another one:

- Don't write to `autoconfig.yml` if it hasn't been loaded

but then you can get into scenarios where people use `:set` and such (and possibly spend a lot of time configuring things) only to discover their changes didn't actually get saved because they happened to have a `config.py` lying around - so I don't think this is a good idea.
How about combining that las suggest with a new config setting:
- If `autoconfig.yml` hasn't been loaded, then don't write to it. Additionally, show a warning to the user unless `warn_no_autoconfig` is set to `False` - e.g. `Warning: autoconfig.yml has not been loaded, so this setting change will not be saved. To disable these warnings, set warn_no_autoconfig=False`.
@andrewcarlotti the warning could also be disabled by loading the autoconfig, right?
Yes - the "if `autoconfig.yml` hasn't been loaded" was intended to apply to the second sentence too.
Yes, that sounds good, and I presume the default for that config option is `True`
I'll open a PR for this
Sorry for the somewhat late answer, but I don't understand the benefit of having a config option instead of my initially proposed `config.load_autoconfig(False)` for this - it seems to me that it'd only be confusing, as you can't set it via `:set` because it'll be temporary, so it won't actually help with getting rid of the error message. Also it adds a new config option rather than using an (IMHO) very natural extension of a function we already have.
I think I read past your initial proposal, but that one does seem better, I'll rewrite and open a pr for `config.load_autoconfig(False)`

Resolving patch:
diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py
--- a/qutebrowser/config/config.py
+++ b/qutebrowser/config/config.py
@@ -283,6 +283,7 @@ def __init__(self,
         self._init_values()
         self.yaml_loaded = False
         self.config_py_loaded = False
+        self.warn_autoconfig = True
 
     def _init_values(self) -> None:
         """Populate the self._values dict."""
diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py
--- a/qutebrowser/config/configfiles.py
+++ b/qutebrowser/config/configfiles.py
@@ -566,12 +566,21 @@ def _handle_error(self, action: str, name: str) -> typing.Iterator[None]:
 
     def finalize(self) -> None:
         """Do work which needs to be done after reading config.py."""
+        if self._config.warn_autoconfig:
+            desc = configexc.ConfigErrorDesc(
+                "autoconfig loading not specified",
+                ("Your config.py should call either `config.load_autoconfig()`"
+                 " (to load settings configured via the GUI) or "
+                 "`config.load_autoconfig(False)` (to not do so)"))
+            self.errors.append(desc)
         self._config.update_mutables()
 
-    def load_autoconfig(self) -> None:
+    def load_autoconfig(self, load_config: bool = True) -> None:
         """Load the autoconfig.yml file which is used for :set/:bind/etc."""
-        with self._handle_error('reading', 'autoconfig.yml'):
-            read_autoconfig()
+        self._config.warn_autoconfig = False
+        if load_config:
+            with self._handle_error('reading', 'autoconfig.yml'):
+                read_autoconfig()
 
     def get(self, name: str, pattern: str = None) -> typing.Any:
         """Get a setting value from the config, optionally with a pattern."""
@@ -689,12 +698,12 @@ def _gen_header(self) -> typing.Iterator[str]:
                              "still loaded.")
             yield self._line("# Remove it to not load settings done via the "
                              "GUI.")
-            yield self._line("config.load_autoconfig()")
+            yield self._line("config.load_autoconfig(True)")
             yield ''
         else:
-            yield self._line("# Uncomment this to still load settings "
+            yield self._line("# Change the argument to True to still load settings "
                              "configured via autoconfig.yml")
-            yield self._line("# config.load_autoconfig()")
+            yield self._line("config.load_autoconfig(False)")
             yield ''
 
     def _gen_options(self) -> typing.Iterator[str]:


Test patch:
diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py
--- a/tests/unit/config/test_configcommands.py
+++ b/tests/unit/config/test_configcommands.py
@@ -499,7 +499,8 @@ def test_config_source(self, tmpdir, commands, config_stub, config_tmpdir,
         else:
             assert False, location
 
-        pyfile.write_text('c.content.javascript.enabled = False\n',
+        pyfile.write_text('\n'.join(['config.load_autoconfig(False)',
+                                     'c.content.javascript.enabled = False']),
                           encoding='utf-8')
 
         commands.config_source(arg, clear=clear)
@@ -511,14 +512,16 @@ def test_config_source(self, tmpdir, commands, config_stub, config_tmpdir,
 
     def test_config_py_arg_source(self, commands, config_py_arg, config_stub):
         assert config_stub.val.content.javascript.enabled
-        config_py_arg.write_text('c.content.javascript.enabled = False\n',
+        config_py_arg.write_text('\n'.join(['config.load_autoconfig(False)',
+                                            'c.content.javascript.enabled = False']),
                                  encoding='utf-8')
         commands.config_source()
         assert not config_stub.val.content.javascript.enabled
 
     def test_errors(self, commands, config_tmpdir):
         pyfile = config_tmpdir / 'config.py'
-        pyfile.write_text('c.foo = 42', encoding='utf-8')
+        pyfile.write_text('\n'.join(['config.load_autoconfig(False)',
+                                     'c.foo = 42']), encoding='utf-8')
 
         with pytest.raises(cmdutils.CommandError) as excinfo:
             commands.config_source()
@@ -529,7 +532,8 @@ def test_errors(self, commands, config_tmpdir):
 
     def test_invalid_source(self, commands, config_tmpdir):
         pyfile = config_tmpdir / 'config.py'
-        pyfile.write_text('1/0', encoding='utf-8')
+        pyfile.write_text('\n'.join(['config.load_autoconfig(False)',
+                                     '1/0']), encoding='utf-8')
 
         with pytest.raises(cmdutils.CommandError) as excinfo:
             commands.config_source()
@@ -571,7 +575,8 @@ def _write_file(editor_self):
 
     def test_with_sourcing(self, commands, config_stub, patch_editor):
         assert config_stub.val.content.javascript.enabled
-        mock = patch_editor('c.content.javascript.enabled = False')
+        mock = patch_editor('\n'.join(['config.load_autoconfig(False)',
+                                       'c.content.javascript.enabled = False']))
 
         commands.config_edit()
 
@@ -580,16 +585,16 @@ def test_with_sourcing(self, commands, config_stub, patch_editor):
 
     def test_config_py_with_sourcing(self, commands, config_stub, patch_editor, config_py_arg):
         assert config_stub.val.content.javascript.enabled
-        conf = 'c.content.javascript.enabled = False'
-        mock = patch_editor(conf)
+        conf = ['config.load_autoconfig(False)', 'c.content.javascript.enabled = False']
+        mock = patch_editor("\n".join(conf))
         commands.config_edit()
         mock.assert_called_once_with(unittest.mock.ANY)
         assert not config_stub.val.content.javascript.enabled
-        assert config_py_arg.read_text('utf-8').splitlines() == [conf]
+        assert config_py_arg.read_text('utf-8').splitlines() == conf
 
     def test_error(self, commands, config_stub, patch_editor, message_mock,
                    caplog):
-        patch_editor('c.foo = 42')
+        patch_editor('\n'.join(['config.load_autoconfig(False)', 'c.foo = 42']))
 
         with caplog.at_level(logging.ERROR):
             commands.config_edit()
diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py
--- a/tests/unit/config/test_configfiles.py
+++ b/tests/unit/config/test_configfiles.py
@@ -659,6 +659,7 @@ class ConfPy:
     def __init__(self, tmpdir, filename: str = "config.py"):
         self._file = tmpdir / filename
         self.filename = str(self._file)
+        config.instance.warn_autoconfig = False
 
     def write(self, *lines):
         text = '\n'.join(lines)
@@ -954,6 +955,19 @@ def test_syntax_error(self, confpy):
         # points at the location *after* the +
         assert "    ^" in tblines or "     ^" in tblines
 
+    def test_load_autoconfig_warning(self, confpy):
+        confpy.write('')
+        config.instance.warn_autoconfig = True
+        with pytest.raises(configexc.ConfigFileErrors) as excinfo:
+            configfiles.read_config_py(confpy.filename)
+        assert len(excinfo.value.errors) == 1
+        error = excinfo.value.errors[0]
+        assert error.text == "autoconfig loading not specified"
+        exception_text = ('Your config.py should call either `config.load_autoconfig()`'
+                          ' (to load settings configured via the GUI) or '
+                          '`config.load_autoconfig(False)` (to not do so)')
+        assert str(error.exception) == exception_text
+
     def test_unhandled_exception(self, confpy):
         confpy.write("1/0")
         error = confpy.read(error=True)
@@ -1144,8 +1158,8 @@ def test_output(self):
             #   qute://help/configuring.html
             #   qute://help/settings.html
 
-            # Uncomment this to still load settings configured via autoconfig.yml
-            # config.load_autoconfig()
+            # Change the argument to True to still load settings configured via autoconfig.yml
+            config.load_autoconfig(False)
 
             # This is an option description.  Nullam eu ante vel est convallis
             # dignissim. Fusce suscipit, wisi nec facilisis facilisis, est dui
@@ -1197,7 +1211,7 @@ def test_commented(self):
         lines = list(writer._gen_lines())
 
         assert "## Autogenerated config.py" in lines
-        assert "# config.load_autoconfig()" in lines
+        assert "# config.load_autoconfig(True)" in lines
         assert "# c.opt = 'val'" in lines
         assert "## Bindings for normal mode" in lines
         assert "# config.bind(',x', 'message-info normal')" in lines
@@ -1246,7 +1260,7 @@ def test_empty(self):
                                             commented=False)
         lines = list(writer._gen_lines())
         assert lines[0] == '# Autogenerated config.py'
-        assert lines[-2] == '# config.load_autoconfig()'
+        assert lines[-2] == 'config.load_autoconfig(False)'
         assert not lines[-1]
 
     def test_pattern(self):
diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py
--- a/tests/unit/config/test_configinit.py
+++ b/tests/unit/config/test_configinit.py
@@ -63,7 +63,8 @@ def configdata_init(monkeypatch):
 class TestEarlyInit:
 
     def test_config_py_path(self, args, init_patch, config_py_arg):
-        config_py_arg.write('c.colors.hints.bg = "red"\n')
+        config_py_arg.write('\n'.join(['config.load_autoconfig()',
+                                       'c.colors.hints.bg = "red"']))
         configinit.early_init(args)
         expected = 'colors.hints.bg = red'
         assert config.instance.dump_userconfig() == expected
@@ -75,7 +76,8 @@ def test_config_py(self, init_patch, config_tmpdir, caplog, args,
         config_py_file = config_tmpdir / 'config.py'
 
         if config_py:
-            config_py_lines = ['c.colors.hints.bg = "red"']
+            config_py_lines = ['c.colors.hints.bg = "red"',
+                               'config.load_autoconfig(False)']
             if config_py == 'error':
                 config_py_lines.append('c.foo = 42')
             config_py_file.write_text('\n'.join(config_py_lines),
@@ -147,8 +149,7 @@ def test_autoconfig_yml(self, init_patch, config_tmpdir,  # noqa: C901
 
         if config_py:
             config_py_lines = ['c.colors.hints.bg = "red"']
-            if load_autoconfig:
-                config_py_lines.append('config.load_autoconfig()')
+            config_py_lines.append('config.load_autoconfig({})'.format(load_autoconfig))
             if config_py == 'error':
                 config_py_lines.append('c.foo = 42')
             config_py_file.write_text('\n'.join(config_py_lines),
@@ -310,6 +311,7 @@ def test_fonts_defaults_init(self, init_patch, args, config_tmpdir,
         elif method == 'py':
             config_py_file = config_tmpdir / 'config.py'
             lines = ["c.{} = '{}'".format(k, v) for k, v in settings]
+            lines.append("config.load_autoconfig(False)")
             config_py_file.write_text('\n'.join(lines), 'utf-8', ensure=True)
 
         configinit.early_init(args)