1# Licensed under a 3-clause BSD style license - see LICENSE.rst
2"""This module contains classes and functions to standardize access to
3configuration files for Astropy and affiliated packages.
4
5.. note::
6    The configuration system makes use of the 'configobj' package, which stores
7    configuration in a text format like that used in the standard library
8    `ConfigParser`. More information and documentation for configobj can be
9    found at https://configobj.readthedocs.io .
10"""
11
12import io
13import pkgutil
14import warnings
15import importlib
16import contextlib
17import os
18from os import path
19from textwrap import TextWrapper
20from warnings import warn
21from contextlib import contextmanager, nullcontext
22
23from astropy.extern.configobj import configobj, validate
24from astropy.utils import find_current_module, silence
25from astropy.utils.decorators import deprecated
26from astropy.utils.exceptions import AstropyDeprecationWarning, AstropyWarning
27from astropy.utils.introspection import resolve_name
28
29from .paths import get_config_dir
30
31__all__ = ('InvalidConfigurationItemWarning', 'ConfigurationMissingWarning',
32           'get_config', 'reload_config', 'ConfigNamespace', 'ConfigItem',
33           'generate_config', 'create_config_file')
34
35
36class InvalidConfigurationItemWarning(AstropyWarning):
37    """ A Warning that is issued when the configuration value specified in the
38    astropy configuration file does not match the type expected for that
39    configuration value.
40    """
41
42
43# This was raised with Astropy < 4.3 when the configuration file was not found.
44# It is kept for compatibility and should be removed at some point.
45@deprecated('5.0')
46class ConfigurationMissingWarning(AstropyWarning):
47    """ A Warning that is issued when the configuration directory cannot be
48    accessed (usually due to a permissions problem). If this warning appears,
49    configuration items will be set to their defaults rather than read from the
50    configuration file, and no configuration will persist across sessions.
51    """
52
53
54# these are not in __all__ because it's not intended that a user ever see them
55class ConfigurationDefaultMissingError(ValueError):
56    """ An exception that is raised when the configuration defaults (which
57    should be generated at build-time) are missing.
58    """
59
60
61# this is used in astropy/__init__.py
62class ConfigurationDefaultMissingWarning(AstropyWarning):
63    """ A warning that is issued when the configuration defaults (which
64    should be generated at build-time) are missing.
65    """
66
67
68class ConfigurationChangedWarning(AstropyWarning):
69    """
70    A warning that the configuration options have changed.
71    """
72
73
74class _ConfigNamespaceMeta(type):
75    def __init__(cls, name, bases, dict):
76        if cls.__bases__[0] is object:
77            return
78
79        for key, val in dict.items():
80            if isinstance(val, ConfigItem):
81                val.name = key
82
83
84class ConfigNamespace(metaclass=_ConfigNamespaceMeta):
85    """
86    A namespace of configuration items.  Each subpackage with
87    configuration items should define a subclass of this class,
88    containing `ConfigItem` instances as members.
89
90    For example::
91
92        class Conf(_config.ConfigNamespace):
93            unicode_output = _config.ConfigItem(
94                False,
95                'Use Unicode characters when outputting values, ...')
96            use_color = _config.ConfigItem(
97                sys.platform != 'win32',
98                'When True, use ANSI color escape sequences when ...',
99                aliases=['astropy.utils.console.USE_COLOR'])
100        conf = Conf()
101    """
102    def __iter__(self):
103        for key, val in self.__class__.__dict__.items():
104            if isinstance(val, ConfigItem):
105                yield key
106
107    keys = __iter__
108    """Iterate over configuration item names."""
109
110    def values(self):
111        """Iterate over configuration item values."""
112        for val in self.__class__.__dict__.values():
113            if isinstance(val, ConfigItem):
114                yield val
115
116    def items(self):
117        """Iterate over configuration item ``(name, value)`` pairs."""
118        for key, val in self.__class__.__dict__.items():
119            if isinstance(val, ConfigItem):
120                yield key, val
121
122    def set_temp(self, attr, value):
123        """
124        Temporarily set a configuration value.
125
126        Parameters
127        ----------
128        attr : str
129            Configuration item name
130
131        value : object
132            The value to set temporarily.
133
134        Examples
135        --------
136        >>> import astropy
137        >>> with astropy.conf.set_temp('use_color', False):
138        ...     pass
139        ...     # console output will not contain color
140        >>> # console output contains color again...
141        """
142        if hasattr(self, attr):
143            return self.__class__.__dict__[attr].set_temp(value)
144        raise AttributeError(f"No configuration parameter '{attr}'")
145
146    def reload(self, attr=None):
147        """
148        Reload a configuration item from the configuration file.
149
150        Parameters
151        ----------
152        attr : str, optional
153            The name of the configuration parameter to reload.  If not
154            provided, reload all configuration parameters.
155        """
156        if attr is not None:
157            if hasattr(self, attr):
158                return self.__class__.__dict__[attr].reload()
159            raise AttributeError(f"No configuration parameter '{attr}'")
160
161        for item in self.values():
162            item.reload()
163
164    def reset(self, attr=None):
165        """
166        Reset a configuration item to its default.
167
168        Parameters
169        ----------
170        attr : str, optional
171            The name of the configuration parameter to reload.  If not
172            provided, reset all configuration parameters.
173        """
174        if attr is not None:
175            if hasattr(self, attr):
176                prop = self.__class__.__dict__[attr]
177                prop.set(prop.defaultvalue)
178                return
179            raise AttributeError(f"No configuration parameter '{attr}'")
180
181        for item in self.values():
182            item.set(item.defaultvalue)
183
184
185class ConfigItem:
186    """
187    A setting and associated value stored in a configuration file.
188
189    These objects should be created as members of
190    `ConfigNamespace` subclasses, for example::
191
192        class _Conf(config.ConfigNamespace):
193            unicode_output = config.ConfigItem(
194                False,
195                'Use Unicode characters when outputting values, and writing widgets '
196                'to the console.')
197        conf = _Conf()
198
199    Parameters
200    ----------
201    defaultvalue : object, optional
202        The default value for this item. If this is a list of strings, this
203        item will be interpreted as an 'options' value - this item must be one
204        of those values, and the first in the list will be taken as the default
205        value.
206
207    description : str or None, optional
208        A description of this item (will be shown as a comment in the
209        configuration file)
210
211    cfgtype : str or None, optional
212        A type specifier like those used as the *values* of a particular key
213        in a ``configspec`` file of ``configobj``. If None, the type will be
214        inferred from the default value.
215
216    module : str or None, optional
217        The full module name that this item is associated with. The first
218        element (e.g. 'astropy' if this is 'astropy.config.configuration')
219        will be used to determine the name of the configuration file, while
220        the remaining items determine the section. If None, the package will be
221        inferred from the package within which this object's initializer is
222        called.
223
224    aliases : str, or list of str, optional
225        The deprecated location(s) of this configuration item.  If the
226        config item is not found at the new location, it will be
227        searched for at all of the old locations.
228
229    Raises
230    ------
231    RuntimeError
232        If ``module`` is `None`, but the module this item is created from
233        cannot be determined.
234    """
235
236    # this is used to make validation faster so a Validator object doesn't
237    # have to be created every time
238    _validator = validate.Validator()
239    cfgtype = None
240    """
241    A type specifier like those used as the *values* of a particular key in a
242    ``configspec`` file of ``configobj``.
243    """
244
245    rootname = 'astropy'
246    """
247    Rootname sets the base path for all config files.
248    """
249
250    def __init__(self, defaultvalue='', description=None, cfgtype=None,
251                 module=None, aliases=None):
252        from astropy.utils import isiterable
253
254        if module is None:
255            module = find_current_module(2)
256            if module is None:
257                msg1 = 'Cannot automatically determine get_config module, '
258                msg2 = 'because it is not called from inside a valid module'
259                raise RuntimeError(msg1 + msg2)
260            else:
261                module = module.__name__
262
263        self.module = module
264        self.description = description
265        self.__doc__ = description
266
267        # now determine cfgtype if it is not given
268        if cfgtype is None:
269            if (isiterable(defaultvalue) and not
270                    isinstance(defaultvalue, str)):
271                # it is an options list
272                dvstr = [str(v) for v in defaultvalue]
273                cfgtype = 'option(' + ', '.join(dvstr) + ')'
274                defaultvalue = dvstr[0]
275            elif isinstance(defaultvalue, bool):
276                cfgtype = 'boolean'
277            elif isinstance(defaultvalue, int):
278                cfgtype = 'integer'
279            elif isinstance(defaultvalue, float):
280                cfgtype = 'float'
281            elif isinstance(defaultvalue, str):
282                cfgtype = 'string'
283                defaultvalue = str(defaultvalue)
284
285        self.cfgtype = cfgtype
286
287        self._validate_val(defaultvalue)
288        self.defaultvalue = defaultvalue
289
290        if aliases is None:
291            self.aliases = []
292        elif isinstance(aliases, str):
293            self.aliases = [aliases]
294        else:
295            self.aliases = aliases
296
297    def __set__(self, obj, value):
298        return self.set(value)
299
300    def __get__(self, obj, objtype=None):
301        if obj is None:
302            return self
303        return self()
304
305    def set(self, value):
306        """
307        Sets the current value of this ``ConfigItem``.
308
309        This also updates the comments that give the description and type
310        information.
311
312        Parameters
313        ----------
314        value
315            The value this item should be set to.
316
317        Raises
318        ------
319        TypeError
320            If the provided ``value`` is not valid for this ``ConfigItem``.
321        """
322        try:
323            value = self._validate_val(value)
324        except validate.ValidateError as e:
325            msg = 'Provided value for configuration item {0} not valid: {1}'
326            raise TypeError(msg.format(self.name, e.args[0]))
327
328        sec = get_config(self.module, rootname=self.rootname)
329
330        sec[self.name] = value
331
332    @contextmanager
333    def set_temp(self, value):
334        """
335        Sets this item to a specified value only inside a with block.
336
337        Use as::
338
339            ITEM = ConfigItem('ITEM', 'default', 'description')
340
341            with ITEM.set_temp('newval'):
342                #... do something that wants ITEM's value to be 'newval' ...
343                print(ITEM)
344
345            # ITEM is now 'default' after the with block
346
347        Parameters
348        ----------
349        value
350            The value to set this item to inside the with block.
351
352        """
353        initval = self()
354        self.set(value)
355        try:
356            yield
357        finally:
358            self.set(initval)
359
360    def reload(self):
361        """ Reloads the value of this ``ConfigItem`` from the relevant
362        configuration file.
363
364        Returns
365        -------
366        val : object
367            The new value loaded from the configuration file.
368
369        """
370        self.set(self.defaultvalue)
371        baseobj = get_config(self.module, True, rootname=self.rootname)
372        secname = baseobj.name
373
374        cobj = baseobj
375        # a ConfigObj's parent is itself, so we look for the parent with that
376        while cobj.parent is not cobj:
377            cobj = cobj.parent
378
379        newobj = configobj.ConfigObj(cobj.filename, interpolation=False)
380        if secname is not None:
381            if secname not in newobj:
382                return baseobj.get(self.name)
383            newobj = newobj[secname]
384
385        if self.name in newobj:
386            baseobj[self.name] = newobj[self.name]
387        return baseobj.get(self.name)
388
389    def __repr__(self):
390        out = '<{}: name={!r} value={!r} at 0x{:x}>'.format(
391            self.__class__.__name__, self.name, self(), id(self))
392        return out
393
394    def __str__(self):
395        out = '\n'.join(('{0}: {1}',
396                         '  cfgtype={2!r}',
397                         '  defaultvalue={3!r}',
398                         '  description={4!r}',
399                         '  module={5}',
400                         '  value={6!r}'))
401        out = out.format(self.__class__.__name__, self.name, self.cfgtype,
402                         self.defaultvalue, self.description, self.module,
403                         self())
404        return out
405
406    def __call__(self):
407        """ Returns the value of this ``ConfigItem``
408
409        Returns
410        -------
411        val : object
412            This item's value, with a type determined by the ``cfgtype``
413            attribute.
414
415        Raises
416        ------
417        TypeError
418            If the configuration value as stored is not this item's type.
419
420        """
421        def section_name(section):
422            if section == '':
423                return 'at the top-level'
424            else:
425                return f'in section [{section}]'
426
427        options = []
428        sec = get_config(self.module, rootname=self.rootname)
429        if self.name in sec:
430            options.append((sec[self.name], self.module, self.name))
431
432        for alias in self.aliases:
433            module, name = alias.rsplit('.', 1)
434            sec = get_config(module, rootname=self.rootname)
435            if '.' in module:
436                filename, module = module.split('.', 1)
437            else:
438                filename = module
439                module = ''
440            if name in sec:
441                if '.' in self.module:
442                    new_module = self.module.split('.', 1)[1]
443                else:
444                    new_module = ''
445                warn(
446                    "Config parameter '{}' {} of the file '{}' "
447                    "is deprecated. Use '{}' {} instead.".format(
448                        name, section_name(module), get_config_filename(filename,
449                                                                        rootname=self.rootname),
450                        self.name, section_name(new_module)),
451                    AstropyDeprecationWarning)
452                options.append((sec[name], module, name))
453
454        if len(options) == 0:
455            self.set(self.defaultvalue)
456            options.append((self.defaultvalue, None, None))
457
458        if len(options) > 1:
459            filename, sec = self.module.split('.', 1)
460            warn(
461                "Config parameter '{}' {} of the file '{}' is "
462                "given by more than one alias ({}). Using the first.".format(
463                    self.name, section_name(sec), get_config_filename(filename,
464                                                                      rootname=self.rootname),
465                    ', '.join([
466                        '.'.join(x[1:3]) for x in options if x[1] is not None])),
467                AstropyDeprecationWarning)
468
469        val = options[0][0]
470
471        try:
472            return self._validate_val(val)
473        except validate.ValidateError as e:
474            raise TypeError('Configuration value not valid:' + e.args[0])
475
476    def _validate_val(self, val):
477        """ Validates the provided value based on cfgtype and returns the
478        type-cast value
479
480        throws the underlying configobj exception if it fails
481        """
482        # note that this will normally use the *class* attribute `_validator`,
483        # but if some arcane reason is needed for making a special one for an
484        # instance or sub-class, it will be used
485        return self._validator.check(self.cfgtype, val)
486
487
488# this dictionary stores the primary copy of the ConfigObj's for each
489# root package
490_cfgobjs = {}
491
492
493def get_config_filename(packageormod=None, rootname=None):
494    """
495    Get the filename of the config file associated with the given
496    package or module.
497    """
498    cfg = get_config(packageormod, rootname=rootname)
499    while cfg.parent is not cfg:
500        cfg = cfg.parent
501    return cfg.filename
502
503
504# This is used by testing to override the config file, so we can test
505# with various config files that exercise different features of the
506# config system.
507_override_config_file = None
508
509
510def get_config(packageormod=None, reload=False, rootname=None):
511    """ Gets the configuration object or section associated with a particular
512    package or module.
513
514    Parameters
515    ----------
516    packageormod : str or None
517        The package for which to retrieve the configuration object. If a
518        string, it must be a valid package name, or if ``None``, the package from
519        which this function is called will be used.
520
521    reload : bool, optional
522        Reload the file, even if we have it cached.
523
524    rootname : str or None
525        Name of the root configuration directory. If ``None`` and
526        ``packageormod`` is ``None``, this defaults to be the name of
527        the package from which this function is called. If ``None`` and
528        ``packageormod`` is not ``None``, this defaults to ``astropy``.
529
530    Returns
531    -------
532    cfgobj : ``configobj.ConfigObj`` or ``configobj.Section``
533        If the requested package is a base package, this will be the
534        ``configobj.ConfigObj`` for that package, or if it is a subpackage or
535        module, it will return the relevant ``configobj.Section`` object.
536
537    Raises
538    ------
539    RuntimeError
540        If ``packageormod`` is `None`, but the package this item is created
541        from cannot be determined.
542    """
543
544    if packageormod is None:
545        packageormod = find_current_module(2)
546        if packageormod is None:
547            msg1 = 'Cannot automatically determine get_config module, '
548            msg2 = 'because it is not called from inside a valid module'
549            raise RuntimeError(msg1 + msg2)
550        else:
551            packageormod = packageormod.__name__
552
553        _autopkg = True
554
555    else:
556        _autopkg = False
557
558    packageormodspl = packageormod.split('.')
559    pkgname = packageormodspl[0]
560    secname = '.'.join(packageormodspl[1:])
561
562    if rootname is None:
563        if _autopkg:
564            rootname = pkgname
565        else:
566            rootname = 'astropy'  # so we don't break affiliated packages
567
568    cobj = _cfgobjs.get(pkgname, None)
569
570    if cobj is None or reload:
571        cfgfn = None
572        try:
573            # This feature is intended only for use by the unit tests
574            if _override_config_file is not None:
575                cfgfn = _override_config_file
576            else:
577                cfgfn = path.join(get_config_dir(rootname=rootname), pkgname + '.cfg')
578            cobj = configobj.ConfigObj(cfgfn, interpolation=False)
579        except OSError:
580            # This can happen when HOME is not set
581            cobj = configobj.ConfigObj(interpolation=False)
582
583        # This caches the object, so if the file becomes accessible, this
584        # function won't see it unless the module is reloaded
585        _cfgobjs[pkgname] = cobj
586
587    if secname:  # not the root package
588        if secname not in cobj:
589            cobj[secname] = {}
590        return cobj[secname]
591    else:
592        return cobj
593
594
595def generate_config(pkgname='astropy', filename=None, verbose=False):
596    """Generates a configuration file, from the list of `ConfigItem`
597    objects for each subpackage.
598
599    .. versionadded:: 4.1
600
601    Parameters
602    ----------
603    pkgname : str or None
604        The package for which to retrieve the configuration object.
605    filename : str or file-like or None
606        If None, the default configuration path is taken from `get_config`.
607
608    """
609    if verbose:
610        verbosity = nullcontext
611        filter_warnings = AstropyDeprecationWarning
612    else:
613        verbosity = silence
614        filter_warnings = Warning
615
616    package = importlib.import_module(pkgname)
617    with verbosity(), warnings.catch_warnings():
618        warnings.simplefilter('ignore', category=filter_warnings)
619        for mod in pkgutil.walk_packages(path=package.__path__,
620                                         prefix=package.__name__ + '.'):
621
622            if (mod.module_finder.path.endswith(('test', 'tests')) or
623                    mod.name.endswith('setup_package')):
624                # Skip test and setup_package modules
625                continue
626            if mod.name.split('.')[-1].startswith('_'):
627                # Skip private modules
628                continue
629
630            with contextlib.suppress(ImportError):
631                importlib.import_module(mod.name)
632
633    wrapper = TextWrapper(initial_indent="## ", subsequent_indent='## ',
634                          width=78)
635
636    if filename is None:
637        filename = get_config_filename(pkgname)
638
639    with contextlib.ExitStack() as stack:
640        if isinstance(filename, (str, os.PathLike)):
641            fp = stack.enter_context(open(filename, 'w'))
642        else:
643            # assume it's a file object, or io.StringIO
644            fp = filename
645
646        # Parse the subclasses, ordered by their module name
647        subclasses = ConfigNamespace.__subclasses__()
648        processed = set()
649
650        for conf in sorted(subclasses, key=lambda x: x.__module__):
651            mod = conf.__module__
652
653            # Skip modules for other packages, e.g. astropy modules that
654            # would be imported when running the function for astroquery.
655            if mod.split('.')[0] != pkgname:
656                continue
657
658            # Check that modules are not processed twice, which can happen
659            # when they are imported in another module.
660            if mod in processed:
661                continue
662            else:
663                processed.add(mod)
664
665            print_module = True
666            for item in conf().values():
667                if print_module:
668                    # If this is the first item of the module, we print the
669                    # module name, but not if this is the root package...
670                    if item.module != pkgname:
671                        modname = item.module.replace(f'{pkgname}.', '')
672                        fp.write(f"[{modname}]\n\n")
673                    print_module = False
674
675                fp.write(wrapper.fill(item.description) + '\n')
676                if isinstance(item.defaultvalue, (tuple, list)):
677                    if len(item.defaultvalue) == 0:
678                        fp.write(f'# {item.name} = ,\n\n')
679                    elif len(item.defaultvalue) == 1:
680                        fp.write(f'# {item.name} = {item.defaultvalue[0]},\n\n')
681                    else:
682                        fp.write(f'# {item.name} = {",".join(map(str, item.defaultvalue))}\n\n')
683                else:
684                    fp.write(f'# {item.name} = {item.defaultvalue}\n\n')
685
686
687def reload_config(packageormod=None, rootname=None):
688    """ Reloads configuration settings from a configuration file for the root
689    package of the requested package/module.
690
691    This overwrites any changes that may have been made in `ConfigItem`
692    objects.  This applies for any items that are based on this file, which
693    is determined by the *root* package of ``packageormod``
694    (e.g. ``'astropy.cfg'`` for the ``'astropy.config.configuration'``
695    module).
696
697    Parameters
698    ----------
699    packageormod : str or None
700        The package or module name - see `get_config` for details.
701    rootname : str or None
702        Name of the root configuration directory - see `get_config`
703        for details.
704    """
705    sec = get_config(packageormod, True, rootname=rootname)
706    # look for the section that is its own parent - that's the base object
707    while sec.parent is not sec:
708        sec = sec.parent
709    sec.reload()
710
711
712def is_unedited_config_file(content, template_content=None):
713    """
714    Determines if a config file can be safely replaced because it doesn't
715    actually contain any meaningful content, i.e. if it contains only comments
716    or is completely empty.
717    """
718    buffer = io.StringIO(content)
719    raw_cfg = configobj.ConfigObj(buffer, interpolation=True)
720    # If any of the items is set, return False
721    return not any(len(v) > 0 for v in raw_cfg.values())
722
723
724# This function is no more used by astropy but it is kept for the other
725# packages that may use it (e.g. astroquery). It should be removed at some
726# point.
727# this is not in __all__ because it's not intended that a user uses it
728@deprecated('5.0')
729def update_default_config(pkg, default_cfg_dir_or_fn, version=None, rootname='astropy'):
730    """
731    Checks if the configuration file for the specified package exists,
732    and if not, copy over the default configuration.  If the
733    configuration file looks like it has already been edited, we do
734    not write over it, but instead write a file alongside it named
735    ``pkg.version.cfg`` as a "template" for the user.
736
737    Parameters
738    ----------
739    pkg : str
740        The package to be updated.
741    default_cfg_dir_or_fn : str
742        The filename or directory name where the default configuration file is.
743        If a directory name, ``'pkg.cfg'`` will be used in that directory.
744    version : str, optional
745        The current version of the given package.  If not provided, it will
746        be obtained from ``pkg.__version__``.
747    rootname : str
748        Name of the root configuration directory.
749
750    Returns
751    -------
752    updated : bool
753        If the profile was updated, `True`, otherwise `False`.
754
755    Raises
756    ------
757    AttributeError
758        If the version number of the package could not determined.
759
760    """
761
762    if path.isdir(default_cfg_dir_or_fn):
763        default_cfgfn = path.join(default_cfg_dir_or_fn, pkg + '.cfg')
764    else:
765        default_cfgfn = default_cfg_dir_or_fn
766
767    if not path.isfile(default_cfgfn):
768        # There is no template configuration file, which basically
769        # means the affiliated package is not using the configuration
770        # system, so just return.
771        return False
772
773    cfgfn = get_config(pkg, rootname=rootname).filename
774
775    with open(default_cfgfn, 'rt', encoding='latin-1') as fr:
776        template_content = fr.read()
777
778    doupdate = False
779    if cfgfn is not None:
780        if path.exists(cfgfn):
781            with open(cfgfn, 'rt', encoding='latin-1') as fd:
782                content = fd.read()
783
784            identical = (content == template_content)
785
786            if not identical:
787                doupdate = is_unedited_config_file(
788                    content, template_content)
789        elif path.exists(path.dirname(cfgfn)):
790            doupdate = True
791            identical = False
792
793    if version is None:
794        version = resolve_name(pkg, '__version__')
795
796    # Don't install template files for dev versions, or we'll end up
797    # spamming `~/.astropy/config`.
798    if version and 'dev' not in version and cfgfn is not None:
799        template_path = path.join(
800            get_config_dir(rootname=rootname), f'{pkg}.{version}.cfg')
801        needs_template = not path.exists(template_path)
802    else:
803        needs_template = False
804
805    if doupdate or needs_template:
806        if needs_template:
807            with open(template_path, 'wt', encoding='latin-1') as fw:
808                fw.write(template_content)
809            # If we just installed a new template file and we can't
810            # update the main configuration file because it has user
811            # changes, display a warning.
812            if not identical and not doupdate:
813                warn(
814                    "The configuration options in {} {} may have changed, "
815                    "your configuration file was not updated in order to "
816                    "preserve local changes.  A new configuration template "
817                    "has been saved to '{}'.".format(
818                        pkg, version, template_path),
819                    ConfigurationChangedWarning)
820
821        if doupdate and not identical:
822            with open(cfgfn, 'wt', encoding='latin-1') as fw:
823                fw.write(template_content)
824            return True
825
826    return False
827
828
829def create_config_file(pkg, rootname='astropy', overwrite=False):
830    """
831    Create the default configuration file for the specified package.
832    If the file already exists, it is updated only if it has not been
833    modified.  Otherwise the ``overwrite`` flag is needed to overwrite it.
834
835    Parameters
836    ----------
837    pkg : str
838        The package to be updated.
839    rootname : str
840        Name of the root configuration directory.
841    overwrite : bool
842        Force updating the file if it already exists.
843
844    Returns
845    -------
846    updated : bool
847        If the profile was updated, `True`, otherwise `False`.
848
849    """
850
851    # local import to prevent using the logger before it is configured
852    from astropy.logger import log
853
854    cfgfn = get_config_filename(pkg, rootname=rootname)
855
856    # generate the default config template
857    template_content = io.StringIO()
858    generate_config(pkg, template_content)
859    template_content.seek(0)
860    template_content = template_content.read()
861
862    doupdate = True
863
864    # if the file already exists, check that it has not been modified
865    if cfgfn is not None and path.exists(cfgfn):
866        with open(cfgfn, 'rt', encoding='latin-1') as fd:
867            content = fd.read()
868
869        doupdate = is_unedited_config_file(content, template_content)
870
871    if doupdate or overwrite:
872        with open(cfgfn, 'wt', encoding='latin-1') as fw:
873            fw.write(template_content)
874        log.info('The configuration file has been successfully written '
875                 f'to {cfgfn}')
876        return True
877    elif not doupdate:
878        log.warning('The configuration file already exists and seems to '
879                    'have been customized, so it has not been updated. '
880                    'Use overwrite=True if you really want to update it.')
881
882    return False
883