Repository: astropy/astropy

Issue:
Consider providing template config file in docs instead of writing to disk
If no config file exists in the user's home directory, we currently create one. We also have logic to check if the config file is still the default one and update it if astropy is updated. However, having a config file, even set to defaults, can cause issues with concurrency, e.g. https://github.com/astropy/astropy/issues/7290

One solution would be to never write a default config file to the user's home directory but instead show the default config file in the docs, like Matplotlib does for the matplotlibrc:

https://matplotlib.org/users/customizing.html

This would make the use of config files opt-in. Changing to do this wouldn't affect any existing user, so if we switched to simply showing the default config in the docs, nothing would break, it just would prevent config files from being re-created or created in future.

Discussion:
Cadair: So this seems to be working in DKISTDC/dkist#3
Cadair: @eteq I think this is almost ready to roll. The one bit I don't understand is how it interacts with this:

https://github.com/astropy/package-template/blob/cookiecutter/%7B%7B%20cookiecutter.package_name%20%7D%7D/%7B%7B%20cookiecutter.module_name%20%7D%7D/_%7B%7B%20cookiecutter._parent_project%20%7D%7D_init.py#L43-L60

bit of the package template. That code seems obnoxiously complex and if we have to ask affiliated package authors to modify it like I have here: https://github.com/DKISTDC/dkist/pull/3/files#diff-03100dba6855f116c1325d9e7258e54e then it might need a re-think.
astrofrog: Can a template version of that code not exist in astropy.config instead of in affiliated packages?
codecov[bot]: # [Codecov](https://codecov.io/gh/astropy/astropy/pull/8237?src=pr&el=h1) Report
> Merging [#8237](https://codecov.io/gh/astropy/astropy/pull/8237?src=pr&el=desc) into [master](https://codecov.io/gh/astropy/astropy/commit/d871698a015a198162e35033a1b8f15016ef8797?src=pr&el=desc) will **decrease** coverage by `0.02%`.
> The diff coverage is `81.66%`.

[![Impacted file tree graph](https://codecov.io/gh/astropy/astropy/pull/8237/graphs/tree.svg?width=650&token=eEhbJxwU0j&height=150&src=pr)](https://codecov.io/gh/astropy/astropy/pull/8237?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master    #8237      +/-   ##
==========================================
- Coverage   86.81%   86.79%   -0.03%     
==========================================
  Files         386      386              
  Lines       58171    58176       +5     
  Branches     1060     1060              
==========================================
- Hits        50503    50495       -8     
- Misses       7053     7066      +13     
  Partials      615      615
```


| [Impacted Files](https://codecov.io/gh/astropy/astropy/pull/8237?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [astropy/logger.py](https://codecov.io/gh/astropy/astropy/pull/8237/diff?src=pr&el=tree#diff-YXN0cm9weS9sb2dnZXIucHk=) | `79.32% <ø> (ø)` | :arrow_up: |
| [astropy/utils/data.py](https://codecov.io/gh/astropy/astropy/pull/8237/diff?src=pr&el=tree#diff-YXN0cm9weS91dGlscy9kYXRhLnB5) | `80.93% <100%> (ø)` | :arrow_up: |
| [astropy/config/configuration.py](https://codecov.io/gh/astropy/astropy/pull/8237/diff?src=pr&el=tree#diff-YXN0cm9weS9jb25maWcvY29uZmlndXJhdGlvbi5weQ==) | `79.38% <100%> (-4.01%)` | :arrow_down: |
| [astropy/config/paths.py](https://codecov.io/gh/astropy/astropy/pull/8237/diff?src=pr&el=tree#diff-YXN0cm9weS9jb25maWcvcGF0aHMucHk=) | `63.49% <60.71%> (-1.03%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/astropy/astropy/pull/8237?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/astropy/astropy/pull/8237?src=pr&el=footer). Last update [d871698...90f8161](https://codecov.io/gh/astropy/astropy/pull/8237?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

Cadair: Oh good, the tests seem to write the config to a different path on Python 3.5 :woman_facepalming: 
Cadair: Ok I have now added docs to this, (I also removed the old 0.4 migration docs) and addressed all the comments. I think this is ready for review again.
pllim: For item 3 you mentioned above, are there unwanted side effects? Like, we added a new item or changed default of existing item but template no longer gets copied over, but user has an older version from last install, so user now accidentally using an outdated config?
Cadair: > For item 3 you mentioned above, are there unwanted side effects? Like, we added a new item or changed default of existing item but template no longer gets copied over, but user has an older version from last install, so user now accidentally using an outdated config?

This behaviour is the same as before, currently if the user has changed their config a new template is written with the version number appended. This is the same behaviour as used here, just it happens explicitly when called rather than on import.


Cadair: @pllim I think I have addressed all your review comments now.
Cadair: I think this is ready for final review (assuming my rebase hasn't broken the CI)
bsipocz: Once we have https://github.com/astropy/astropy/pull/8955, this can be rebased and finished.
astrofrog: #8955 is now merged, so @Cadair feel free to finish this up! :)
Cadair: I am a bit stumped by what those votable tests are trying to do?!
pllim: They are to test that setting pedantic in `astropy.cfg` would work as expected. I think @astrofrog wrote them.
bsipocz: So, how do we stand with this? Would be nice to get it in before Friday.
pllim: Unless @Cadair got time to rebase and fix the failing tests, this might have to slip again.
Cadair: If it's only a rebase and fixing the test I can probably manage it.
Cadair: Is there general approval on it other than that?
pllim: > Is there general approval on it other than that?

I'd say getting this in for 4.0 is a good thing but I'd rather let @astrofrog approve it since he is shown to have requested changes. Can you hop over to his place and make him look at this? :wink: 
Cadair: This is getting bumped...
astrofrog: @Cadair has confirmed that he'll rebase this tomorrow morning and I can merge it then.
Cadair: rebased, tests passed locally... :crossed_fingers: 

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 461ccecacd..1c699a1bd4 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -7,6 +7,10 @@ New Features
 astropy.config
 ^^^^^^^^^^^^^^
 
+- The config and cache directories and the name of the config file are now
+  customizable. This allows affiliated packages to put their configuration
+  files in locations other than ``CONFIG_DIR/.astropy/``. [#8237]
+
 astropy.constants
 ^^^^^^^^^^^^^^^^^
 
diff --git a/astropy/config/configuration.py b/astropy/config/configuration.py
index af3ae3eaa4..1cf178b11c 100644
--- a/astropy/config/configuration.py
+++ b/astropy/config/configuration.py
@@ -91,6 +91,7 @@ class ConfigNamespace(metaclass=_ConfigNamespaceMeta):
                 aliases=['astropy.utils.console.USE_COLOR'])
         conf = Conf()
     """
+
     def set_temp(self, attr, value):
         """
         Temporarily set a configuration value.
@@ -216,6 +217,11 @@ class ConfigItem:
     ``configspec`` file of ``configobj``.
     """
 
+    rootname = 'astropy'
+    """
+    Rootname sets the base path for all config files.
+    """
+
     def __init__(self, defaultvalue='', description=None, cfgtype=None,
                  module=None, aliases=None):
         from astropy.utils import isiterable
@@ -294,7 +300,7 @@ class ConfigItem:
             msg = 'Provided value for configuration item {0} not valid: {1}'
             raise TypeError(msg.format(self.name, e.args[0]))
 
-        sec = get_config(self.module)
+        sec = get_config(self.module, rootname=self.rootname)
 
         sec[self.name] = value
 
@@ -336,7 +342,7 @@ class ConfigItem:
             The new value loaded from the configuration file.
         """
         self.set(self.defaultvalue)
-        baseobj = get_config(self.module, True)
+        baseobj = get_config(self.module, True, rootname=self.rootname)
         secname = baseobj.name
 
         cobj = baseobj
@@ -392,13 +398,13 @@ class ConfigItem:
                 return f'in section [{section}]'
 
         options = []
-        sec = get_config(self.module)
+        sec = get_config(self.module, rootname=self.rootname)
         if self.name in sec:
             options.append((sec[self.name], self.module, self.name))
 
         for alias in self.aliases:
             module, name = alias.rsplit('.', 1)
-            sec = get_config(module)
+            sec = get_config(module, rootname=self.rootname)
             if '.' in module:
                 filename, module = module.split('.', 1)
             else:
@@ -412,7 +418,8 @@ class ConfigItem:
                 warn(
                     "Config parameter '{}' {} of the file '{}' "
                     "is deprecated. Use '{}' {} instead.".format(
-                        name, section_name(module), get_config_filename(filename),
+                        name, section_name(module), get_config_filename(filename,
+                                                                        rootname=self.rootname),
                         self.name, section_name(new_module)),
                     AstropyDeprecationWarning)
                 options.append((sec[name], module, name))
@@ -426,7 +433,8 @@ class ConfigItem:
             warn(
                 "Config parameter '{}' {} of the file '{}' is "
                 "given by more than one alias ({}). Using the first.".format(
-                    self.name, section_name(sec), get_config_filename(filename),
+                    self.name, section_name(sec), get_config_filename(filename,
+                                                                      rootname=self.rootname),
                     ', '.join([
                         '.'.join(x[1:3]) for x in options if x[1] is not None])),
                 AstropyDeprecationWarning)
@@ -455,12 +463,12 @@ class ConfigItem:
 _cfgobjs = {}
 
 
-def get_config_filename(packageormod=None):
+def get_config_filename(packageormod=None, rootname=None):
     """
     Get the filename of the config file associated with the given
     package or module.
     """
-    cfg = get_config(packageormod)
+    cfg = get_config(packageormod, rootname=rootname)
     while cfg.parent is not cfg:
         cfg = cfg.parent
     return cfg.filename
@@ -472,7 +480,7 @@ def get_config_filename(packageormod=None):
 _override_config_file = None
 
 
-def get_config(packageormod=None, reload=False):
+def get_config(packageormod=None, reload=False, rootname=None):
     """ Gets the configuration object or section associated with a particular
     package or module.
 
@@ -480,12 +488,18 @@ def get_config(packageormod=None, reload=False):
     -----------
     packageormod : str or None
         The package for which to retrieve the configuration object. If a
-        string, it must be a valid package name, or if `None`, the package from
+        string, it must be a valid package name, or if ``None``, the package from
         which this function is called will be used.
 
     reload : bool, optional
         Reload the file, even if we have it cached.
 
+    rootname : str or None
+        Name of the root configuration directory. If ``None`` and
+        ``packageormod`` is ``None``, this defaults to be the name of
+        the package from which this function is called. If ``None`` and
+        ``packageormod`` is not ``None``, this defaults to ``astropy``.
+
     Returns
     -------
     cfgobj : ``configobj.ConfigObj`` or ``configobj.Section``
@@ -499,6 +513,7 @@ def get_config(packageormod=None, reload=False):
         If ``packageormod`` is `None`, but the package this item is created
         from cannot be determined.
     """
+
     if packageormod is None:
         packageormod = find_current_module(2)
         if packageormod is None:
@@ -508,11 +523,22 @@ def get_config(packageormod=None, reload=False):
         else:
             packageormod = packageormod.__name__
 
+        _autopkg = True
+
+    else:
+        _autopkg = False
+
     packageormodspl = packageormod.split('.')
-    rootname = packageormodspl[0]
+    pkgname = packageormodspl[0]
     secname = '.'.join(packageormodspl[1:])
 
-    cobj = _cfgobjs.get(rootname, None)
+    if rootname is None:
+        if _autopkg:
+            rootname = pkgname
+        else:
+            rootname = 'astropy'  # so we don't break affiliated packages
+
+    cobj = _cfgobjs.get(pkgname, None)
 
     if cobj is None or reload:
         cfgfn = None
@@ -521,7 +547,7 @@ def get_config(packageormod=None, reload=False):
             if _override_config_file is not None:
                 cfgfn = _override_config_file
             else:
-                cfgfn = path.join(get_config_dir(), rootname + '.cfg')
+                cfgfn = path.join(get_config_dir(rootname=rootname), pkgname + '.cfg')
             cobj = configobj.ConfigObj(cfgfn, interpolation=False)
         except OSError as e:
             msg = ('Configuration defaults will be used due to ')
@@ -534,7 +560,7 @@ def get_config(packageormod=None, reload=False):
             # function won't see it unless the module is reloaded
             cobj = configobj.ConfigObj(interpolation=False)
 
-        _cfgobjs[rootname] = cobj
+        _cfgobjs[pkgname] = cobj
 
     if secname:  # not the root package
         if secname not in cobj:
@@ -544,7 +570,7 @@ def get_config(packageormod=None, reload=False):
         return cobj
 
 
-def reload_config(packageormod=None):
+def reload_config(packageormod=None, rootname=None):
     """ Reloads configuration settings from a configuration file for the root
     package of the requested package/module.
 
@@ -558,8 +584,11 @@ def reload_config(packageormod=None):
     ----------
     packageormod : str or None
         The package or module name - see `get_config` for details.
+    rootname : str or None
+        Name of the root configuration directory - see `get_config`
+        for details.
     """
-    sec = get_config(packageormod, True)
+    sec = get_config(packageormod, True, rootname=rootname)
     # look for the section that is its own parent - that's the base object
     while sec.parent is not sec:
         sec = sec.parent
@@ -616,7 +645,7 @@ def is_unedited_config_file(content, template_content=None):
 
 
 # this is not in __all__ because it's not intended that a user uses it
-def update_default_config(pkg, default_cfg_dir_or_fn, version=None):
+def update_default_config(pkg, default_cfg_dir_or_fn, version=None, rootname='astropy'):
     """
     Checks if the configuration file for the specified package exists,
     and if not, copy over the default configuration.  If the
@@ -634,6 +663,8 @@ def update_default_config(pkg, default_cfg_dir_or_fn, version=None):
     version : str, optional
         The current version of the given package.  If not provided, it will
         be obtained from ``pkg.__version__``.
+    rootname : str
+        Name of the root configuration directory.
 
     Returns
     -------
@@ -658,7 +689,7 @@ def update_default_config(pkg, default_cfg_dir_or_fn, version=None):
         # system, so just return.
         return False
 
-    cfgfn = get_config(pkg).filename
+    cfgfn = get_config(pkg, rootname=rootname).filename
 
     with open(default_cfgfn, 'rt', encoding='latin-1') as fr:
         template_content = fr.read()
@@ -685,7 +716,7 @@ def update_default_config(pkg, default_cfg_dir_or_fn, version=None):
     # spamming `~/.astropy/config`.
     if 'dev' not in version and cfgfn is not None:
         template_path = path.join(
-            get_config_dir(), f'{pkg}.{version}.cfg')
+            get_config_dir(rootname=rootname), f'{pkg}.{version}.cfg')
         needs_template = not path.exists(template_path)
     else:
         needs_template = False
diff --git a/astropy/config/paths.py b/astropy/config/paths.py
index 48799a8053..1d407921c8 100644
--- a/astropy/config/paths.py
+++ b/astropy/config/paths.py
@@ -71,14 +71,14 @@ def _find_home():
             homedir = os.environ['HOME']
         else:
             raise OSError('Could not find a home directory to search for '
-                          'astropy config dir - are you on an unspported '
+                          'astropy config dir - are you on an unsupported '
                           'platform?')
     return homedir
 
 
-def get_config_dir(create=True):
+def get_config_dir(rootname='astropy'):
     """
-    Determines the Astropy configuration directory name and creates the
+    Determines the package configuration directory name and creates the
     directory if it doesn't exist.
 
     This directory is typically ``$HOME/.astropy/config``, but if the
@@ -86,6 +86,13 @@ def get_config_dir(create=True):
     ``$XDG_CONFIG_HOME/astropy`` directory exists, it will be that directory.
     If neither exists, the former will be created and symlinked to the latter.
 
+    Parameters
+    ----------
+    rootname : str
+        Name of the root configuration directory. For example, if ``rootname =
+        'pkgname'``, the configuration directory would be ``<home>/.pkgname/``
+        rather than ``<home>/.astropy`` (depending on platform).
+
     Returns
     -------
     configdir : str
@@ -99,7 +106,7 @@ def get_config_dir(create=True):
     # If using set_temp_config, that overrides all
     if set_temp_config._temp_path is not None:
         xch = set_temp_config._temp_path
-        config_path = os.path.join(xch, 'astropy')
+        config_path = os.path.join(xch, rootname)
         if not os.path.exists(config_path):
             os.mkdir(config_path)
         return os.path.abspath(config_path)
@@ -108,16 +115,16 @@ def get_config_dir(create=True):
     xch = os.environ.get('XDG_CONFIG_HOME')
 
     if xch is not None and os.path.exists(xch):
-        xchpth = os.path.join(xch, 'astropy')
+        xchpth = os.path.join(xch, rootname)
         if not os.path.islink(xchpth):
             if os.path.exists(xchpth):
                 return os.path.abspath(xchpth)
             else:
                 linkto = xchpth
-    return os.path.abspath(_find_or_create_astropy_dir('config', linkto))
+    return os.path.abspath(_find_or_create_root_dir('config', linkto, rootname))
 
 
-def get_cache_dir():
+def get_cache_dir(rootname="astropy"):
     """
     Determines the Astropy cache directory name and creates the directory if it
     doesn't exist.
@@ -127,6 +134,13 @@ def get_cache_dir():
     ``$XDG_CACHE_HOME/astropy`` directory exists, it will be that directory.
     If neither exists, the former will be created and symlinked to the latter.
 
+    Parameters
+    ----------
+    rootname : str
+        Name of the root cache directory. For example, if
+        ``rootname = 'pkgname'``, the cache directory will be
+        ``<cache>/.pkgname/``.
+
     Returns
     -------
     cachedir : str
@@ -140,7 +154,7 @@ def get_cache_dir():
     # If using set_temp_cache, that overrides all
     if set_temp_cache._temp_path is not None:
         xch = set_temp_cache._temp_path
-        cache_path = os.path.join(xch, 'astropy')
+        cache_path = os.path.join(xch, rootname)
         if not os.path.exists(cache_path):
             os.mkdir(cache_path)
         return os.path.abspath(cache_path)
@@ -149,14 +163,14 @@ def get_cache_dir():
     xch = os.environ.get('XDG_CACHE_HOME')
 
     if xch is not None and os.path.exists(xch):
-        xchpth = os.path.join(xch, 'astropy')
+        xchpth = os.path.join(xch, rootname)
         if not os.path.islink(xchpth):
             if os.path.exists(xchpth):
                 return os.path.abspath(xchpth)
             else:
                 linkto = xchpth
 
-    return os.path.abspath(_find_or_create_astropy_dir('cache', linkto))
+    return os.path.abspath(_find_or_create_root_dir('cache', linkto, rootname))
 
 
 class _SetTempPath:
@@ -173,7 +187,7 @@ class _SetTempPath:
 
     def __enter__(self):
         self.__class__._temp_path = self._path
-        return self._default_path_getter()
+        return self._default_path_getter('astropy')
 
     def __exit__(self, *args):
         self.__class__._temp_path = self._prev_path
@@ -269,9 +283,9 @@ class set_temp_cache(_SetTempPath):
     _default_path_getter = staticmethod(get_cache_dir)
 
 
-def _find_or_create_astropy_dir(dirnm, linkto):
-    innerdir = os.path.join(_find_home(), '.astropy')
-    maindir = os.path.join(_find_home(), '.astropy', dirnm)
+def _find_or_create_root_dir(dirnm, linkto, pkgname='astropy'):
+    innerdir = os.path.join(_find_home(), '.{}'.format(pkgname))
+    maindir = os.path.join(_find_home(), '.{}'.format(pkgname), dirnm)
 
     if not os.path.exists(maindir):
         # first create .astropy dir if needed
@@ -282,8 +296,8 @@ def _find_or_create_astropy_dir(dirnm, linkto):
                 if not os.path.isdir(innerdir):
                     raise
         elif not os.path.isdir(innerdir):
-            msg = 'Intended Astropy directory {0} is actually a file.'
-            raise OSError(msg.format(innerdir))
+            msg = 'Intended {0} {1} directory {1} is actually a file.'
+            raise OSError(msg.format(pkgname, dirnm, maindir))
 
         try:
             os.mkdir(maindir)
@@ -297,7 +311,7 @@ def _find_or_create_astropy_dir(dirnm, linkto):
             os.symlink(maindir, linkto)
 
     elif not os.path.isdir(maindir):
-        msg = 'Intended Astropy {0} directory {1} is actually a file.'
-        raise OSError(msg.format(dirnm, maindir))
+        msg = 'Intended {0} {1} directory {1} is actually a file.'
+        raise OSError(msg.format(pkgname, dirnm, maindir))
 
     return os.path.abspath(maindir)
diff --git a/astropy/logger.py b/astropy/logger.py
index bd74c3e3b8..64829e2160 100644
--- a/astropy/logger.py
+++ b/astropy/logger.py
@@ -495,7 +495,7 @@ class AstropyLogger(Logger):
             try:
                 if log_file_path == '' or testing_mode:
                     log_file_path = os.path.join(
-                        _config.get_config_dir(), "astropy.log")
+                        _config.get_config_dir('astropy'), "astropy.log")
                 else:
                     log_file_path = os.path.expanduser(log_file_path)
 
diff --git a/astropy/utils/data.py b/astropy/utils/data.py
index 05786c5d7f..b09eb8a946 100644
--- a/astropy/utils/data.py
+++ b/astropy/utils/data.py
@@ -944,7 +944,7 @@ def check_free_space_in_dir(path, size):
                 path, human_file_size(size)))
 
 
-def download_file(remote_url, cache=False, show_progress=True, timeout=None):
+def download_file(remote_url, cache=False, show_progress=True, timeout=None, pkgname='astropy'):
     """
     Accepts a URL, downloads and optionally caches the result
     returning the filename, with a name determined by the file's MD5
@@ -968,6 +968,11 @@ def download_file(remote_url, cache=False, show_progress=True, timeout=None):
         The timeout, in seconds.  Otherwise, use
         `astropy.utils.data.Conf.remote_timeout`.
 
+    pkgname : `str`, optional
+        The package name to use to locate the download cache. i.e. for
+        ``pkgname='astropy'`` the default cache location is
+        ``~/.astropy/cache``.
+
     Returns
     -------
     local_path : str
@@ -988,7 +993,7 @@ def download_file(remote_url, cache=False, show_progress=True, timeout=None):
 
     if cache:
         try:
-            dldir, urlmapfn = _get_download_cache_locs()
+            dldir, urlmapfn = _get_download_cache_locs(pkgname)
         except OSError as e:
             msg = 'Remote data cache could not be accessed due to '
             estr = '' if len(e.args) < 1 else (': ' + str(e))
@@ -1113,7 +1118,7 @@ def download_file(remote_url, cache=False, show_progress=True, timeout=None):
     return local_path
 
 
-def is_url_in_cache(url_key):
+def is_url_in_cache(url_key, pkgname='astropy'):
     """
     Check if a download from ``url_key`` is in the cache.
 
@@ -1129,7 +1134,7 @@ def is_url_in_cache(url_key):
     """
     # The code below is modified from astropy.utils.data.download_file()
     try:
-        dldir, urlmapfn = _get_download_cache_locs()
+        dldir, urlmapfn = _get_download_cache_locs(pkgname)
     except OSError as e:
         msg = 'Remote data cache could not be accessed due to '
         estr = '' if len(e.args) < 1 else (': ' + str(e))
@@ -1229,7 +1234,7 @@ def _deltemps():
                 os.remove(fn)
 
 
-def clear_download_cache(hashorurl=None):
+def clear_download_cache(hashorurl=None, pkgname='astropy'):
     """ Clears the data file cache by deleting the local file(s).
 
     Parameters
@@ -1241,14 +1246,14 @@ def clear_download_cache(hashorurl=None):
     """
 
     try:
-        dldir, urlmapfn = _get_download_cache_locs()
+        dldir, urlmapfn = _get_download_cache_locs(pkgname)
     except OSError as e:
         msg = 'Not clearing data cache - cache inacessable due to '
         estr = '' if len(e.args) < 1 else (': ' + str(e))
         warn(CacheMissingWarning(msg + e.__class__.__name__ + estr))
         return
 
-    _acquire_download_cache_lock()
+    _acquire_download_cache_lock(pkgname)
     try:
         if hashorurl is None:
             # dldir includes both the download files and the urlmapfn.  This structure
@@ -1283,10 +1288,10 @@ def clear_download_cache(hashorurl=None):
     finally:
         # the lock will be gone if rmtree was used above, but release otherwise
         if os.path.exists(os.path.join(dldir, 'lock')):
-            _release_download_cache_lock()
+            _release_download_cache_lock(pkgname)
 
 
-def _get_download_cache_locs():
+def _get_download_cache_locs(pkgname='astropy'):
     """ Finds the path to the data cache directory and makes them if
     they don't exist.
 
@@ -1305,7 +1310,7 @@ def _get_download_cache_locs():
     # do whatever it wants with the filename.  Filename munging can and does happen
     # in practice).
     py_version = 'py' + str(sys.version_info.major)
-    datadir = os.path.join(get_cache_dir(), 'download', py_version)
+    datadir = os.path.join(get_cache_dir(pkgname), 'download', py_version)
     shelveloc = os.path.join(datadir, 'urlmap')
 
     if not os.path.exists(datadir):
@@ -1327,13 +1332,13 @@ def _get_download_cache_locs():
 
 # the cache directory must be locked before any writes are performed.  Same for
 # the hash shelve, so this should be used for both.
-def _acquire_download_cache_lock():
+def _acquire_download_cache_lock(pkgname='astropy'):
     """
     Uses the lock directory method.  This is good because `mkdir` is
     atomic at the system call level, so it's thread-safe.
     """
 
-    lockdir = os.path.join(_get_download_cache_locs()[0], 'lock')
+    lockdir = os.path.join(_get_download_cache_locs(pkgname)[0], 'lock')
     for i in range(conf.download_cache_lock_attempts):
         try:
             os.mkdir(lockdir)
@@ -1351,8 +1356,8 @@ def _acquire_download_cache_lock():
     raise RuntimeError(msg.format(lockdir))
 
 
-def _release_download_cache_lock():
-    lockdir = os.path.join(_get_download_cache_locs()[0], 'lock')
+def _release_download_cache_lock(pkgname='astropy'):
+    lockdir = os.path.join(_get_download_cache_locs(pkgname)[0], 'lock')
 
     if os.path.isdir(lockdir):
         # if the pid file is present, be sure to remove it
diff --git a/docs/config/index.rst b/docs/config/index.rst
index 5dd1e3e622..fb74794ccc 100644
--- a/docs/config/index.rst
+++ b/docs/config/index.rst
@@ -353,6 +353,34 @@ Or, if the option needs to be available as a function parameter::
         return (conf.some_setting if val is None else val) + 2
 
 
+
+Customising Config in Affiliated Packages
+=========================================
+
+The `astropy.config` package can be used by other pacakges. By default creating
+a config object in another package will lead to a configuration file taking the
+name of that package in the astropy config directory. i.e.
+``<astropy_config>/packagename.cfg``.
+
+
+It is possible to configure this behavior so that the a custom configuration
+directory is created for your package, for example
+``~/.packagename/packagename.cfg``. To do this create a ``packagename.config``
+subpackage and put the following into the ``__init__.py`` file::
+
+  import astropy.config as astropyconfig
+
+
+  class ConfigNamespace(astropyconfig.ConfigNamespace):
+      rootname = 'packagename'
+
+
+  class ConfigItem(astropyconfig.ConfigItem):
+      rootname = 'packagename'
+
+Then replace all imports of `astropy.config` with ``packagename.config``.
+
+
 See Also
 ========
 


Test patch:
diff --git a/astropy/config/tests/test_configs.py b/astropy/config/tests/test_configs.py
index 80124a0567..4096bf0dbd 100644
--- a/astropy/config/tests/test_configs.py
+++ b/astropy/config/tests/test_configs.py
@@ -20,28 +20,31 @@ def test_paths():
     assert 'astropy' in paths.get_config_dir()
     assert 'astropy' in paths.get_cache_dir()
 
+    assert 'testpkg' in paths.get_config_dir(rootname='testpkg')
+    assert 'testpkg' in paths.get_cache_dir(rootname='testpkg')
+
 
 def test_set_temp_config(tmpdir, monkeypatch):
     monkeypatch.setattr(paths.set_temp_config, '_temp_path', None)
 
-    orig_config_dir = paths.get_config_dir()
+    orig_config_dir = paths.get_config_dir(rootname='astropy')
     temp_config_dir = str(tmpdir.mkdir('config'))
     temp_astropy_config = os.path.join(temp_config_dir, 'astropy')
 
     # Test decorator mode
     @paths.set_temp_config(temp_config_dir)
     def test_func():
-        assert paths.get_config_dir() == temp_astropy_config
+        assert paths.get_config_dir(rootname='astropy') == temp_astropy_config
 
         # Test temporary restoration of original default
         with paths.set_temp_config() as d:
-            assert d == orig_config_dir == paths.get_config_dir()
+            assert d == orig_config_dir == paths.get_config_dir(rootname='astropy')
 
     test_func()
 
     # Test context manager mode (with cleanup)
     with paths.set_temp_config(temp_config_dir, delete=True):
-        assert paths.get_config_dir() == temp_astropy_config
+        assert paths.get_config_dir(rootname='astropy') == temp_astropy_config
 
     assert not os.path.exists(temp_config_dir)
 
@@ -49,24 +52,24 @@ def test_set_temp_config(tmpdir, monkeypatch):
 def test_set_temp_cache(tmpdir, monkeypatch):
     monkeypatch.setattr(paths.set_temp_cache, '_temp_path', None)
 
-    orig_cache_dir = paths.get_cache_dir()
+    orig_cache_dir = paths.get_cache_dir(rootname='astropy')
     temp_cache_dir = str(tmpdir.mkdir('cache'))
     temp_astropy_cache = os.path.join(temp_cache_dir, 'astropy')
 
     # Test decorator mode
     @paths.set_temp_cache(temp_cache_dir)
     def test_func():
-        assert paths.get_cache_dir() == temp_astropy_cache
+        assert paths.get_cache_dir(rootname='astropy') == temp_astropy_cache
 
         # Test temporary restoration of original default
         with paths.set_temp_cache() as d:
-            assert d == orig_cache_dir == paths.get_cache_dir()
+            assert d == orig_cache_dir == paths.get_cache_dir(rootname='astropy')
 
     test_func()
 
     # Test context manager mode (with cleanup)
     with paths.set_temp_cache(temp_cache_dir, delete=True):
-        assert paths.get_cache_dir() == temp_astropy_cache
+        assert paths.get_cache_dir(rootname='astropy') == temp_astropy_cache
 
     assert not os.path.exists(temp_cache_dir)
 
@@ -82,6 +85,35 @@ def test_config_file():
     assert cfgsec.name == 'config'
     assert cfgsec.parent.filename.endswith('astropy.cfg')
 
+    # try with a different package name, still inside astropy config dir:
+    testcfg = get_config('testpkg', rootname='astropy')
+    parts = os.path.normpath(testcfg.filename).split(os.sep)
+    assert '.astropy' in parts or 'astropy' in parts
+    assert parts[-1] == 'testpkg.cfg'
+    configuration._cfgobjs['testpkg'] = None # HACK
+
+    # try with a different package name, no specified root name (should
+    #   default to astropy):
+    testcfg = get_config('testpkg')
+    parts = os.path.normpath(testcfg.filename).split(os.sep)
+    assert '.astropy' in parts or 'astropy' in parts
+    assert parts[-1] == 'testpkg.cfg'
+    configuration._cfgobjs['testpkg'] = None # HACK
+
+    # try with a different package name, specified root name:
+    testcfg = get_config('testpkg', rootname='testpkg')
+    parts = os.path.normpath(testcfg.filename).split(os.sep)
+    assert '.testpkg' in parts or 'testpkg' in  parts
+    assert parts[-1] == 'testpkg.cfg'
+    configuration._cfgobjs['testpkg'] = None # HACK
+
+    # try with a subpackage with specified root name:
+    testcfg_sec = get_config('testpkg.somemodule', rootname='testpkg')
+    parts = os.path.normpath(testcfg_sec.parent.filename).split(os.sep)
+    assert '.testpkg' in parts or 'testpkg' in  parts
+    assert parts[-1] == 'testpkg.cfg'
+    configuration._cfgobjs['testpkg'] = None # HACK
+
     reload_config('astropy')
 
 
@@ -190,18 +222,18 @@ def test_config_noastropy_fallback(monkeypatch):
     monkeypatch.delenv('XDG_CONFIG_HOME')
     monkeypatch.setattr(paths.set_temp_config, '_temp_path', None)
 
-    # make sure the _find_or_create_astropy_dir function fails as though the
+    # make sure the _find_or_create_root_dir function fails as though the
     # astropy dir could not be accessed
-    def osraiser(dirnm, linkto):
+    def osraiser(dirnm, linkto, pkgname=None):
         raise OSError
-    monkeypatch.setattr(paths, '_find_or_create_astropy_dir', osraiser)
+    monkeypatch.setattr(paths, '_find_or_create_root_dir', osraiser)
 
     # also have to make sure the stored configuration objects are cleared
     monkeypatch.setattr(configuration, '_cfgobjs', {})
 
     with pytest.raises(OSError):
         # make sure the config dir search fails
-        paths.get_config_dir()
+        paths.get_config_dir(rootname='astropy')
 
     # now run the basic tests, and make sure the warning about no astropy
     # is present
diff --git a/astropy/utils/tests/test_data.py b/astropy/utils/tests/test_data.py
index 1cade33d49..0e07a524a6 100644
--- a/astropy/utils/tests/test_data.py
+++ b/astropy/utils/tests/test_data.py
@@ -320,7 +320,7 @@ def test_data_noastropy_fallback(monkeypatch):
     from astropy.config import paths
 
     # needed for testing the *real* lock at the end
-    lockdir = os.path.join(_get_download_cache_locs()[0], 'lock')
+    lockdir = os.path.join(_get_download_cache_locs('astropy')[0], 'lock')
 
     # better yet, set the configuration to make sure the temp files are deleted
     conf.delete_temporary_downloads_at_exit = True
@@ -336,13 +336,13 @@ def test_data_noastropy_fallback(monkeypatch):
 
     # make sure the _find_or_create_astropy_dir function fails as though the
     # astropy dir could not be accessed
-    def osraiser(dirnm, linkto):
+    def osraiser(dirnm, linkto, pkgname=None):
         raise OSError
-    monkeypatch.setattr(paths, '_find_or_create_astropy_dir', osraiser)
+    monkeypatch.setattr(paths, '_find_or_create_root_dir', osraiser)
 
     with pytest.raises(OSError):
         # make sure the config dir search fails
-        paths.get_cache_dir()
+        paths.get_cache_dir(rootname='astropy')
 
     # first try with cache
     with catch_warnings(CacheMissingWarning) as w: