Repository: yt-project/yt

Issue:
test_config is not parallel-safe
### Bug report

**Bug summary**

During the execution of `yt/utilities/tests/test_config.py` we modify the actual `<XDG_CONFIG_HOME>/yt/yt.toml`. This is problematic since tests run in parallel as separate processes and can pick up whatever garbage is being dumped into temporary yt configuration. This can manifest as e.g.:

```
THIS IS THE REAL CAUSE OF THE FAILURE:
Traceback (most recent call last):
  File "doc/source/cookbook/custom_transfer_function_volume_rendering.py", line 6, in <module>
    ds = yt.load("Enzo_64/DD0043/data0043")
  File "/home/fido/workspace/yt_py38_latest_deps/yt/loaders.py", line 86, in load
    raise FileNotFoundError(msg)
FileNotFoundError: No such file or directory: 'Enzo_64/DD0043/data0043'.
```

Entire thing went unnoticed cause until yesterday test suite didn't rely on config file. Instead I was doing the following:

```
 sed -i -e '/test_data_dir/ s:/does/not/exist:/mnt/yt:g' \
    -e '/xray_data_dir/ s:/does/not/exist:/mnt/yt/xray_data:g' \
    -e '/supp_data_dir/ s:/does/not/exist:/mnt/yt/supp_data:g' \
    -e '/__strict_requires/ s/False/True/g' \
    -e '/suppressstreamlogging/ s/False/True/g' yt/config.py
```

prior to test execution to set whatever is necessary, which is now restored back.

**Code for reproduction**

```bash
$ sudo chown root:root -R $HOME/.config/yt 
$ nosetests -s -vv yt/utilities/tests/test_config.py
```

**Actual outcome**

```
======================================================================
ERROR: test suite for <module 'yt.utilities.tests.test_config' from '/home/xarth/codes/xarthisius/yt/yt/utilities/tests/test_config.py'>
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/xarth/codes/xarthisius/yt/pytest/lib/python3.8/site-packages/nose/suite.py", line 210, in run
    self.setUp()
  File "/home/xarth/codes/xarthisius/yt/pytest/lib/python3.8/site-packages/nose/suite.py", line 293, in setUp
    self.setupContext(ancestor)
  File "/home/xarth/codes/xarthisius/yt/pytest/lib/python3.8/site-packages/nose/suite.py", line 316, in setupContext
    try_run(context, names)
  File "/home/xarth/codes/xarthisius/yt/pytest/lib/python3.8/site-packages/nose/util.py", line 471, in try_run
    return func()
  File "/home/xarth/codes/xarthisius/yt/yt/utilities/tests/test_config.py", line 54, in setUpModule
    os.rename(cfgfile, cfgfile + ".bak_test")
PermissionError: [Errno 13] Permission denied: '/home/xarth/.config/yt/yt.toml' -> '/home/xarth/.config/yt/yt.toml.bak_test'
```

**Expected outcome**

Config not modified.


Discussion:

Resolving patch:
diff --git a/yt/config.py b/yt/config.py
--- a/yt/config.py
+++ b/yt/config.py
@@ -66,14 +66,31 @@
 )
 
 
-CONFIG_DIR = os.environ.get(
-    "XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config", "yt")
-)
-if not os.path.exists(CONFIG_DIR):
-    try:
-        os.makedirs(CONFIG_DIR)
-    except OSError:
-        warnings.warn("unable to create yt config directory")
+def config_dir():
+    config_root = os.environ.get(
+        "XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")
+    )
+    conf_dir = os.path.join(config_root, "yt")
+
+    if not os.path.exists(conf_dir):
+        try:
+            os.makedirs(conf_dir)
+        except OSError:
+            warnings.warn("unable to create yt config directory")
+    return conf_dir
+
+
+def old_config_file():
+    return os.path.join(config_dir(), "ytrc")
+
+
+def old_config_dir():
+    return os.path.join(os.path.expanduser("~"), ".yt")
+
+
+# For backward compatibility, do not use these vars internally in yt
+CONFIG_DIR = config_dir()
+_OLD_CONFIG_FILE = old_config_file()
 
 
 class YTConfig:
@@ -167,21 +184,20 @@ def write(self, file_handler):
 
     @staticmethod
     def get_global_config_file():
-        return os.path.join(CONFIG_DIR, "yt.toml")
+        return os.path.join(config_dir(), "yt.toml")
 
     @staticmethod
     def get_local_config_file():
         return os.path.join(os.path.abspath(os.curdir), "yt.toml")
 
 
-OLD_CONFIG_FILE = os.path.join(CONFIG_DIR, "ytrc")
 _global_config_file = YTConfig.get_global_config_file()
 _local_config_file = YTConfig.get_local_config_file()
 
-if os.path.exists(OLD_CONFIG_FILE):
+if os.path.exists(old_config_file()):
     if os.path.exists(_global_config_file):
         issue_deprecation_warning(
-            f"The configuration file {OLD_CONFIG_FILE} is deprecated in "
+            f"The configuration file {old_config_file()} is deprecated in "
             f"favor of {_global_config_file}. Currently, both are present. "
             "Please manually remove the deprecated one to silence "
             "this warning.",
@@ -190,7 +206,7 @@ def get_local_config_file():
         )
     else:
         issue_deprecation_warning(
-            f"The configuration file {OLD_CONFIG_FILE} is deprecated. "
+            f"The configuration file {_OLD_CONFIG_FILE} is deprecated. "
             f"Please migrate your config to {_global_config_file} by running: "
             "'yt config migrate'",
             since="4.0.0",
diff --git a/yt/funcs.py b/yt/funcs.py
--- a/yt/funcs.py
+++ b/yt/funcs.py
@@ -935,7 +935,7 @@ def enable_plugins(plugin_filename=None):
     file is shared with it.
     """
     import yt
-    from yt.config import CONFIG_DIR, ytcfg
+    from yt.config import config_dir, old_config_dir, ytcfg
     from yt.fields.my_plugin_fields import my_plugins_fields
 
     if plugin_filename is not None:
@@ -948,20 +948,19 @@ def enable_plugins(plugin_filename=None):
         # - CONFIG_DIR
         # - obsolete config dir.
         my_plugin_name = ytcfg.get("yt", "plugin_filename")
-        old_config_dir = os.path.join(os.path.expanduser("~"), ".yt")
-        for base_prefix in ("", CONFIG_DIR, old_config_dir):
+        for base_prefix in ("", config_dir(), old_config_dir()):
             if os.path.isfile(os.path.join(base_prefix, my_plugin_name)):
                 _fn = os.path.join(base_prefix, my_plugin_name)
                 break
         else:
             raise FileNotFoundError("Could not find a global system plugin file.")
 
-        if _fn.startswith(old_config_dir):
+        if _fn.startswith(old_config_dir()):
             mylog.warning(
                 "Your plugin file is located in a deprecated directory. "
                 "Please move it from %s to %s",
-                os.path.join(old_config_dir, my_plugin_name),
-                os.path.join(CONFIG_DIR, my_plugin_name),
+                os.path.join(old_config_dir(), my_plugin_name),
+                os.path.join(config_dir(), my_plugin_name),
             )
 
     mylog.info("Loading plugins from %s", _fn)
diff --git a/yt/utilities/configure.py b/yt/utilities/configure.py
--- a/yt/utilities/configure.py
+++ b/yt/utilities/configure.py
@@ -2,7 +2,7 @@
 import os
 import sys
 
-from yt.config import OLD_CONFIG_FILE, YTConfig, ytcfg_defaults
+from yt.config import YTConfig, old_config_file, ytcfg_defaults
 
 CONFIG = YTConfig()
 
@@ -48,7 +48,7 @@ def write_config(config_file):
 
 
 def migrate_config():
-    if not os.path.exists(OLD_CONFIG_FILE):
+    if not os.path.exists(old_config_file()):
         print("Old config not found.")
         sys.exit(1)
 
@@ -56,7 +56,7 @@ def migrate_config():
     # Preserve case:
     # See https://stackoverflow.com/questions/1611799/preserve-case-in-configparser
     old_config.optionxform = str
-    old_config.read(OLD_CONFIG_FILE)
+    old_config.read(old_config_file())
 
     # In order to migrate, we'll convert everything to lowercase, and map that
     # to the new snake_case convention
@@ -93,8 +93,8 @@ def usesCamelCase(key):
     global_config_file = YTConfig.get_global_config_file()
     print(f"Writing a new config file to: {global_config_file}")
     write_config(global_config_file)
-    print(f"Backing up the old config file: {OLD_CONFIG_FILE}.bak")
-    os.rename(OLD_CONFIG_FILE, OLD_CONFIG_FILE + ".bak")
+    print(f"Backing up the old config file: {old_config_file()}.bak")
+    os.rename(old_config_file(), old_config_file() + ".bak")
 
 
 def rm_config(section, option, config_file):


Test patch:
diff --git a/yt/fields/tests/test_fields_plugins.py b/yt/fields/tests/test_fields_plugins.py
--- a/yt/fields/tests/test_fields_plugins.py
+++ b/yt/fields/tests/test_fields_plugins.py
@@ -1,10 +1,21 @@
 import os
+import shutil
+import tempfile
 import unittest
 
 import yt
-from yt.config import CONFIG_DIR, ytcfg
+from yt.config import YTConfig, config_dir, ytcfg
 from yt.testing import assert_raises, fake_random_ds
 
+_TEST_PLUGIN = "_test_plugin.py"
+
+_DUMMY_CFG_TOML = f"""[yt]
+log_level = 49
+plugin_filename = "{_TEST_PLUGIN}"
+boolean_stuff = true
+chunk_size = 3
+"""
+
 TEST_PLUGIN_FILE = """
 import numpy as np
 
@@ -19,63 +30,28 @@ def myfunc():
 """
 
 
-def setUpModule():
-    my_plugin_name = ytcfg.get("yt", "plugin_filename")
-    # In the following order if plugin_filename is: an absolute path, located in
-    # the CONFIG_DIR, located in an obsolete config dir.
-    old_config_dir = os.path.join(os.path.expanduser("~"), ".yt")
-    for base_prefix in ("", CONFIG_DIR, old_config_dir):
-        potential_plugin_file = os.path.join(base_prefix, my_plugin_name)
-        if os.path.isfile(potential_plugin_file):
-            os.rename(potential_plugin_file, potential_plugin_file + ".bak_test")
-
-    plugin_file = os.path.join(CONFIG_DIR, my_plugin_name)
-    with open(plugin_file, "w") as fh:
-        fh.write(TEST_PLUGIN_FILE)
-
-
-def tearDownModule():
-    from yt.fields.my_plugin_fields import my_plugins_fields
-
-    my_plugins_fields.clear()
-    my_plugin_name = ytcfg.get("yt", "plugin_filename")
-    plugin_file = os.path.join(CONFIG_DIR, my_plugin_name)
-    os.remove(plugin_file)
-
-
 class TestPluginFile(unittest.TestCase):
     @classmethod
     def setUpClass(cls):
-        my_plugin_name = ytcfg.get("yt", "plugin_filename")
-        # In the following order if plugin_filename is: an absolute path, located in
-        # the CONFIG_DIR, located in an obsolete config dir.
-        old_config_dir = os.path.join(os.path.expanduser("~"), ".yt")
-        for base_prefix in ("", CONFIG_DIR, old_config_dir):
-            potential_plugin_file = os.path.join(base_prefix, my_plugin_name)
-            if os.path.isfile(potential_plugin_file):
-                os.rename(potential_plugin_file, potential_plugin_file + ".bak_test")
-
-        plugin_file = os.path.join(CONFIG_DIR, my_plugin_name)
-        with open(plugin_file, "w") as fh:
+        cls.xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
+        cls.tmpdir = tempfile.mkdtemp()
+        os.environ["XDG_CONFIG_HOME"] = cls.tmpdir
+        with open(YTConfig.get_global_config_file(), mode="w") as fh:
+            fh.write(_DUMMY_CFG_TOML)
+        cls.plugin_path = os.path.join(config_dir(), ytcfg.get("yt", "plugin_filename"))
+        with open(cls.plugin_path, mode="w") as fh:
             fh.write(TEST_PLUGIN_FILE)
 
     @classmethod
     def tearDownClass(cls):
-        my_plugin_name = ytcfg.get("yt", "plugin_filename")
-        plugin_file = os.path.join(CONFIG_DIR, my_plugin_name)
-        os.remove(plugin_file)
-
-        old_config_dir = os.path.join(os.path.expanduser("~"), ".yt")
-        for base_prefix in ("", CONFIG_DIR, old_config_dir):
-            potential_plugin_file = os.path.join(base_prefix, my_plugin_name)
-            if os.path.isfile(potential_plugin_file + ".bak_test"):
-                os.rename(potential_plugin_file + ".bak_test", potential_plugin_file)
-        del yt.myfunc
+        shutil.rmtree(cls.tmpdir)
+        if cls.xdg_config_home:
+            os.environ["XDG_CONFIG_HOME"] = cls.xdg_config_home
+        else:
+            os.environ.pop("XDG_CONFIG_HOME")
 
     def testCustomField(self):
-        plugin_file = os.path.join(CONFIG_DIR, ytcfg.get("yt", "plugin_filename"))
-        msg = f"INFO:yt:Loading plugins from {plugin_file}"
-
+        msg = f"INFO:yt:Loading plugins from {self.plugin_path}"
         with self.assertLogs("yt", level="INFO") as cm:
             yt.enable_plugins()
             self.assertEqual(cm.output, [msg])
diff --git a/yt/utilities/tests/test_config.py b/yt/utilities/tests/test_config.py
--- a/yt/utilities/tests/test_config.py
+++ b/yt/utilities/tests/test_config.py
@@ -1,16 +1,15 @@
 import contextlib
 import os
+import shutil
 import sys
+import tempfile
 import unittest
 import unittest.mock as mock
 from io import StringIO
 
 import yt.config
 import yt.utilities.command_line
-from yt.config import OLD_CONFIG_FILE, YTConfig
-
-GLOBAL_CONFIG_FILE = YTConfig.get_global_config_file()
-LOCAL_CONFIG_FILE = YTConfig.get_local_config_file()
+from yt.config import YTConfig, old_config_file
 
 _TEST_PLUGIN = "_test_plugin.py"
 # NOTE: the normalization of the crazy camel-case will be checked
@@ -46,24 +45,19 @@ class SysExitException(Exception):
     pass
 
 
-def setUpModule():
-    for cfgfile in (GLOBAL_CONFIG_FILE, OLD_CONFIG_FILE, LOCAL_CONFIG_FILE):
-        if os.path.exists(cfgfile):
-            os.rename(cfgfile, cfgfile + ".bak_test")
-
-            if cfgfile == GLOBAL_CONFIG_FILE:
-                yt.utilities.configure.CONFIG = YTConfig()
-                if not yt.utilities.configure.CONFIG.has_section("yt"):
-                    yt.utilities.configure.CONFIG.add_section("yt")
-
-
-def tearDownModule():
-    for cfgfile in (GLOBAL_CONFIG_FILE, OLD_CONFIG_FILE, LOCAL_CONFIG_FILE):
-        if os.path.exists(cfgfile + ".bak_test"):
-            os.rename(cfgfile + ".bak_test", cfgfile)
+class TestYTConfig(unittest.TestCase):
+    def setUp(self):
+        self.xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
+        self.tmpdir = tempfile.mkdtemp()
+        os.environ["XDG_CONFIG_HOME"] = self.tmpdir
 
+    def tearDown(self):
+        shutil.rmtree(self.tmpdir)
+        if self.xdg_config_home:
+            os.environ["XDG_CONFIG_HOME"] = self.xdg_config_home
+        else:
+            os.environ.pop("XDG_CONFIG_HOME")
 
-class TestYTConfig(unittest.TestCase):
     def _runYTConfig(self, args):
         args = ["yt", "config"] + args
         retcode = 0
@@ -108,7 +102,7 @@ def testConfigCommands(self):
         def remove_spaces_and_breaks(s):
             return "".join(s.split())
 
-        self.assertFalse(os.path.exists(GLOBAL_CONFIG_FILE))
+        self.assertFalse(os.path.exists(YTConfig.get_global_config_file()))
 
         info = self._runYTConfig(["--help"])
         self.assertEqual(info["rc"], 0)
@@ -142,12 +136,13 @@ def remove_spaces_and_breaks(s):
         self._testKeyTypeError("foo.bar", "foo", "bar", expect_error=False)
 
     def tearDown(self):
-        if os.path.exists(GLOBAL_CONFIG_FILE):
-            os.remove(GLOBAL_CONFIG_FILE)
+        if os.path.exists(YTConfig.get_global_config_file()):
+            os.remove(YTConfig.get_global_config_file())
 
 
 class TestYTConfigGlobalLocal(TestYTConfig):
     def setUp(self):
+        super().setUp()
         with open(YTConfig.get_local_config_file(), mode="w") as f:
             f.writelines("[yt]\n")
         with open(YTConfig.get_global_config_file(), mode="w") as f:
@@ -164,33 +159,35 @@ def testAmbiguousConfig(self):
 
 class TestYTConfigMigration(TestYTConfig):
     def setUp(self):
-        if not os.path.exists(os.path.dirname(OLD_CONFIG_FILE)):
-            os.makedirs(os.path.dirname(OLD_CONFIG_FILE))
+        super().setUp()
+        if not os.path.exists(os.path.dirname(old_config_file())):
+            os.makedirs(os.path.dirname(old_config_file()))
 
-        with open(OLD_CONFIG_FILE, "w") as fh:
+        with open(old_config_file(), "w") as fh:
             fh.write(_DUMMY_CFG_INI)
 
-        if os.path.exists(GLOBAL_CONFIG_FILE):
-            os.remove(GLOBAL_CONFIG_FILE)
+        if os.path.exists(YTConfig.get_global_config_file()):
+            os.remove(YTConfig.get_global_config_file())
 
     def tearDown(self):
-        if os.path.exists(GLOBAL_CONFIG_FILE):
-            os.remove(GLOBAL_CONFIG_FILE)
-        if os.path.exists(OLD_CONFIG_FILE + ".bak"):
-            os.remove(OLD_CONFIG_FILE + ".bak")
+        if os.path.exists(YTConfig.get_global_config_file()):
+            os.remove(YTConfig.get_global_config_file())
+        if os.path.exists(old_config_file() + ".bak"):
+            os.remove(old_config_file() + ".bak")
+        super().tearDown()
 
     def testConfigMigration(self):
-        self.assertFalse(os.path.exists(GLOBAL_CONFIG_FILE))
-        self.assertTrue(os.path.exists(OLD_CONFIG_FILE))
+        self.assertFalse(os.path.exists(YTConfig.get_global_config_file()))
+        self.assertTrue(os.path.exists(old_config_file()))
 
         info = self._runYTConfig(["migrate"])
         self.assertEqual(info["rc"], 0)
 
-        self.assertTrue(os.path.exists(GLOBAL_CONFIG_FILE))
-        self.assertFalse(os.path.exists(OLD_CONFIG_FILE))
-        self.assertTrue(os.path.exists(OLD_CONFIG_FILE + ".bak"))
+        self.assertTrue(os.path.exists(YTConfig.get_global_config_file()))
+        self.assertFalse(os.path.exists(old_config_file()))
+        self.assertTrue(os.path.exists(old_config_file() + ".bak"))
 
-        with open(GLOBAL_CONFIG_FILE, mode="r") as fh:
+        with open(YTConfig.get_global_config_file(), mode="r") as fh:
             new_cfg = fh.read()
 
         self.assertEqual(new_cfg, _DUMMY_CFG_TOML)