1# -*- coding: utf-8 -*-
2# Licensed under a 3-clause BSD style license - see LICENSE.rst
3"""
4A "grab bag" of relatively small general-purpose utilities that don't have
5a clear module/package to live in.
6"""
7
8import abc
9import contextlib
10import difflib
11import inspect
12import json
13import os
14import signal
15import sys
16import traceback
17import unicodedata
18import locale
19import threading
20import re
21
22from contextlib import contextmanager
23from collections import defaultdict, OrderedDict
24
25from astropy.utils.decorators import deprecated
26
27
28__all__ = ['isiterable', 'silence', 'format_exception', 'NumpyRNGContext',
29           'find_api_page', 'is_path_hidden', 'walk_skip_hidden',
30           'JsonCustomEncoder', 'indent', 'dtype_bytes_or_chars',
31           'OrderedDescriptor', 'OrderedDescriptorContainer']
32
33
34# Because they are deprecated.
35__doctest_skip__ = ['OrderedDescriptor', 'OrderedDescriptorContainer']
36
37
38NOT_OVERWRITING_MSG = ('File {} already exists. If you mean to replace it '
39                       'then use the argument "overwrite=True".')
40# A useful regex for tests.
41_NOT_OVERWRITING_MSG_MATCH = ('File .* already exists\. If you mean to '
42                              'replace it then use the argument '
43                              '"overwrite=True"\.')
44
45
46def isiterable(obj):
47    """Returns `True` if the given object is iterable."""
48
49    try:
50        iter(obj)
51        return True
52    except TypeError:
53        return False
54
55
56def indent(s, shift=1, width=4):
57    """Indent a block of text.  The indentation is applied to each line."""
58
59    indented = '\n'.join(' ' * (width * shift) + l if l else ''
60                         for l in s.splitlines())
61    if s[-1] == '\n':
62        indented += '\n'
63
64    return indented
65
66
67class _DummyFile:
68    """A noop writeable object."""
69
70    def write(self, s):
71        pass
72
73
74@contextlib.contextmanager
75def silence():
76    """A context manager that silences sys.stdout and sys.stderr."""
77
78    old_stdout = sys.stdout
79    old_stderr = sys.stderr
80    sys.stdout = _DummyFile()
81    sys.stderr = _DummyFile()
82    yield
83    sys.stdout = old_stdout
84    sys.stderr = old_stderr
85
86
87def format_exception(msg, *args, **kwargs):
88    """
89    Given an exception message string, uses new-style formatting arguments
90    ``{filename}``, ``{lineno}``, ``{func}`` and/or ``{text}`` to fill in
91    information about the exception that occurred.  For example:
92
93        try:
94            1/0
95        except:
96            raise ZeroDivisionError(
97                format_except('A divide by zero occurred in {filename} at '
98                              'line {lineno} of function {func}.'))
99
100    Any additional positional or keyword arguments passed to this function are
101    also used to format the message.
102
103    .. note::
104        This uses `sys.exc_info` to gather up the information needed to fill
105        in the formatting arguments. Since `sys.exc_info` is not carried
106        outside a handled exception, it's not wise to use this
107        outside of an ``except`` clause - if it is, this will substitute
108        '<unknown>' for the 4 formatting arguments.
109    """
110
111    tb = traceback.extract_tb(sys.exc_info()[2], limit=1)
112    if len(tb) > 0:
113        filename, lineno, func, text = tb[0]
114    else:
115        filename = lineno = func = text = '<unknown>'
116
117    return msg.format(*args, filename=filename, lineno=lineno, func=func,
118                      text=text, **kwargs)
119
120
121class NumpyRNGContext:
122    """
123    A context manager (for use with the ``with`` statement) that will seed the
124    numpy random number generator (RNG) to a specific value, and then restore
125    the RNG state back to whatever it was before.
126
127    This is primarily intended for use in the astropy testing suit, but it
128    may be useful in ensuring reproducibility of Monte Carlo simulations in a
129    science context.
130
131    Parameters
132    ----------
133    seed : int
134        The value to use to seed the numpy RNG
135
136    Examples
137    --------
138    A typical use case might be::
139
140        with NumpyRNGContext(<some seed value you pick>):
141            from numpy import random
142
143            randarr = random.randn(100)
144            ... run your test using `randarr` ...
145
146        #Any code using numpy.random at this indent level will act just as it
147        #would have if it had been before the with statement - e.g. whatever
148        #the default seed is.
149
150
151    """
152
153    def __init__(self, seed):
154        self.seed = seed
155
156    def __enter__(self):
157        from numpy import random
158
159        self.startstate = random.get_state()
160        random.seed(self.seed)
161
162    def __exit__(self, exc_type, exc_value, traceback):
163        from numpy import random
164
165        random.set_state(self.startstate)
166
167
168def find_api_page(obj, version=None, openinbrowser=True, timeout=None):
169    """
170    Determines the URL of the API page for the specified object, and
171    optionally open that page in a web browser.
172
173    .. note::
174        You must be connected to the internet for this to function even if
175        ``openinbrowser`` is `False`, unless you provide a local version of
176        the documentation to ``version`` (e.g., ``file:///path/to/docs``).
177
178    Parameters
179    ----------
180    obj
181        The object to open the docs for or its fully-qualified name
182        (as a str).
183    version : str
184        The doc version - either a version number like '0.1', 'dev' for
185        the development/latest docs, or a URL to point to a specific
186        location that should be the *base* of the documentation. Defaults to
187        latest if you are on aren't on a release, otherwise, the version you
188        are on.
189    openinbrowser : bool
190        If `True`, the `webbrowser` package will be used to open the doc
191        page in a new web browser window.
192    timeout : number, optional
193        The number of seconds to wait before timing-out the query to
194        the astropy documentation.  If not given, the default python
195        stdlib timeout will be used.
196
197    Returns
198    -------
199    url : str
200        The loaded URL
201
202    Raises
203    ------
204    ValueError
205        If the documentation can't be found
206
207    """
208    import webbrowser
209    from zlib import decompress
210    from astropy.utils.data import get_readable_fileobj
211
212    if (not isinstance(obj, str) and
213            hasattr(obj, '__module__') and
214            hasattr(obj, '__name__')):
215        obj = obj.__module__ + '.' + obj.__name__
216    elif inspect.ismodule(obj):
217        obj = obj.__name__
218
219    if version is None:
220        from astropy import version
221
222        if version.release:
223            version = 'v' + version.version
224        else:
225            version = 'dev'
226
227    if '://' in version:
228        if version.endswith('index.html'):
229            baseurl = version[:-10]
230        elif version.endswith('/'):
231            baseurl = version
232        else:
233            baseurl = version + '/'
234    elif version == 'dev' or version == 'latest':
235        baseurl = 'http://devdocs.astropy.org/'
236    else:
237        baseurl = f'https://docs.astropy.org/en/{version}/'
238
239    # Custom request headers; see
240    # https://github.com/astropy/astropy/issues/8990
241    url = baseurl + 'objects.inv'
242    headers = {'User-Agent': f'Astropy/{version}'}
243    with get_readable_fileobj(url, encoding='binary', remote_timeout=timeout,
244                              http_headers=headers) as uf:
245        oiread = uf.read()
246
247        # need to first read/remove the first four lines, which have info before
248        # the compressed section with the actual object inventory
249        idx = -1
250        headerlines = []
251        for _ in range(4):
252            oldidx = idx
253            idx = oiread.index(b'\n', oldidx + 1)
254            headerlines.append(oiread[(oldidx+1):idx].decode('utf-8'))
255
256        # intersphinx version line, project name, and project version
257        ivers, proj, vers, compr = headerlines
258        if 'The remainder of this file is compressed using zlib' not in compr:
259            raise ValueError('The file downloaded from {} does not seem to be'
260                             'the usual Sphinx objects.inv format.  Maybe it '
261                             'has changed?'.format(baseurl + 'objects.inv'))
262
263        compressed = oiread[(idx+1):]
264
265    decompressed = decompress(compressed).decode('utf-8')
266
267    resurl = None
268
269    for l in decompressed.strip().splitlines():
270        ls = l.split()
271        name = ls[0]
272        loc = ls[3]
273        if loc.endswith('$'):
274            loc = loc[:-1] + name
275
276        if name == obj:
277            resurl = baseurl + loc
278            break
279
280    if resurl is None:
281        raise ValueError(f'Could not find the docs for the object {obj}')
282    elif openinbrowser:
283        webbrowser.open(resurl)
284
285    return resurl
286
287
288def signal_number_to_name(signum):
289    """
290    Given an OS signal number, returns a signal name.  If the signal
291    number is unknown, returns ``'UNKNOWN'``.
292    """
293    # Since these numbers and names are platform specific, we use the
294    # builtin signal module and build a reverse mapping.
295
296    signal_to_name_map = dict((k, v) for v, k in signal.__dict__.items()
297                              if v.startswith('SIG'))
298
299    return signal_to_name_map.get(signum, 'UNKNOWN')
300
301
302if sys.platform == 'win32':
303    import ctypes
304
305    def _has_hidden_attribute(filepath):
306        """
307        Returns True if the given filepath has the hidden attribute on
308        MS-Windows.  Based on a post here:
309        https://stackoverflow.com/questions/284115/cross-platform-hidden-file-detection
310        """
311        if isinstance(filepath, bytes):
312            filepath = filepath.decode(sys.getfilesystemencoding())
313        try:
314            attrs = ctypes.windll.kernel32.GetFileAttributesW(filepath)
315            result = bool(attrs & 2) and attrs != -1
316        except AttributeError:
317            result = False
318        return result
319else:
320    def _has_hidden_attribute(filepath):
321        return False
322
323
324def is_path_hidden(filepath):
325    """
326    Determines if a given file or directory is hidden.
327
328    Parameters
329    ----------
330    filepath : str
331        The path to a file or directory
332
333    Returns
334    -------
335    hidden : bool
336        Returns `True` if the file is hidden
337    """
338    name = os.path.basename(os.path.abspath(filepath))
339    if isinstance(name, bytes):
340        is_dotted = name.startswith(b'.')
341    else:
342        is_dotted = name.startswith('.')
343    return is_dotted or _has_hidden_attribute(filepath)
344
345
346def walk_skip_hidden(top, onerror=None, followlinks=False):
347    """
348    A wrapper for `os.walk` that skips hidden files and directories.
349
350    This function does not have the parameter ``topdown`` from
351    `os.walk`: the directories must always be recursed top-down when
352    using this function.
353
354    See also
355    --------
356    os.walk : For a description of the parameters
357    """
358    for root, dirs, files in os.walk(
359            top, topdown=True, onerror=onerror,
360            followlinks=followlinks):
361        # These lists must be updated in-place so os.walk will skip
362        # hidden directories
363        dirs[:] = [d for d in dirs if not is_path_hidden(d)]
364        files[:] = [f for f in files if not is_path_hidden(f)]
365        yield root, dirs, files
366
367
368class JsonCustomEncoder(json.JSONEncoder):
369    """Support for data types that JSON default encoder
370    does not do.
371
372    This includes:
373
374        * Numpy array or number
375        * Complex number
376        * Set
377        * Bytes
378        * astropy.UnitBase
379        * astropy.Quantity
380
381    Examples
382    --------
383    >>> import json
384    >>> import numpy as np
385    >>> from astropy.utils.misc import JsonCustomEncoder
386    >>> json.dumps(np.arange(3), cls=JsonCustomEncoder)
387    '[0, 1, 2]'
388
389    """
390
391    def default(self, obj):
392        from astropy import units as u
393        import numpy as np
394        if isinstance(obj, u.Quantity):
395            return dict(value=obj.value, unit=obj.unit.to_string())
396        if isinstance(obj, (np.number, np.ndarray)):
397            return obj.tolist()
398        elif isinstance(obj, complex):
399            return [obj.real, obj.imag]
400        elif isinstance(obj, set):
401            return list(obj)
402        elif isinstance(obj, bytes):  # pragma: py3
403            return obj.decode()
404        elif isinstance(obj, (u.UnitBase, u.FunctionUnitBase)):
405            if obj == u.dimensionless_unscaled:
406                obj = 'dimensionless_unit'
407            else:
408                return obj.to_string()
409
410        return json.JSONEncoder.default(self, obj)
411
412
413def strip_accents(s):
414    """
415    Remove accents from a Unicode string.
416
417    This helps with matching "ångström" to "angstrom", for example.
418    """
419    return ''.join(
420        c for c in unicodedata.normalize('NFD', s)
421        if unicodedata.category(c) != 'Mn')
422
423
424def did_you_mean(s, candidates, n=3, cutoff=0.8, fix=None):
425    """
426    When a string isn't found in a set of candidates, we can be nice
427    to provide a list of alternatives in the exception.  This
428    convenience function helps to format that part of the exception.
429
430    Parameters
431    ----------
432    s : str
433
434    candidates : sequence of str or dict of str keys
435
436    n : int
437        The maximum number of results to include.  See
438        `difflib.get_close_matches`.
439
440    cutoff : float
441        In the range [0, 1]. Possibilities that don't score at least
442        that similar to word are ignored.  See
443        `difflib.get_close_matches`.
444
445    fix : callable
446        A callable to modify the results after matching.  It should
447        take a single string and return a sequence of strings
448        containing the fixed matches.
449
450    Returns
451    -------
452    message : str
453        Returns the string "Did you mean X, Y, or Z?", or the empty
454        string if no alternatives were found.
455    """
456    if isinstance(s, str):
457        s = strip_accents(s)
458    s_lower = s.lower()
459
460    # Create a mapping from the lower case name to all capitalization
461    # variants of that name.
462    candidates_lower = {}
463    for candidate in candidates:
464        candidate_lower = candidate.lower()
465        candidates_lower.setdefault(candidate_lower, [])
466        candidates_lower[candidate_lower].append(candidate)
467
468    # The heuristic here is to first try "singularizing" the word.  If
469    # that doesn't match anything use difflib to find close matches in
470    # original, lower and upper case.
471    if s_lower.endswith('s') and s_lower[:-1] in candidates_lower:
472        matches = [s_lower[:-1]]
473    else:
474        matches = difflib.get_close_matches(
475            s_lower, candidates_lower, n=n, cutoff=cutoff)
476
477    if len(matches):
478        capitalized_matches = set()
479        for match in matches:
480            capitalized_matches.update(candidates_lower[match])
481        matches = capitalized_matches
482
483        if fix is not None:
484            mapped_matches = []
485            for match in matches:
486                mapped_matches.extend(fix(match))
487            matches = mapped_matches
488
489        matches = list(set(matches))
490        matches = sorted(matches)
491
492        if len(matches) == 1:
493            matches = matches[0]
494        else:
495            matches = (', '.join(matches[:-1]) + ' or ' +
496                       matches[-1])
497        return f'Did you mean {matches}?'
498
499    return ''
500
501
502_ordered_descriptor_deprecation_message = """\
503The {func} {obj_type} is deprecated and may be removed in a future version.
504
505    You can replace its functionality with a combination of the
506    __init_subclass__ and __set_name__ magic methods introduced in Python 3.6.
507    See https://github.com/astropy/astropy/issues/11094 for recipes on how to
508    replicate their functionality.
509"""
510
511
512@deprecated('4.3', _ordered_descriptor_deprecation_message)
513class OrderedDescriptor(metaclass=abc.ABCMeta):
514    """
515    Base class for descriptors whose order in the class body should be
516    preserved.  Intended for use in concert with the
517    `OrderedDescriptorContainer` metaclass.
518
519    Subclasses of `OrderedDescriptor` must define a value for a class attribute
520    called ``_class_attribute_``.  This is the name of a class attribute on the
521    *container* class for these descriptors, which will be set to an
522    `~collections.OrderedDict` at class creation time.  This
523    `~collections.OrderedDict` will contain a mapping of all class attributes
524    that were assigned instances of the `OrderedDescriptor` subclass, to the
525    instances themselves.  See the documentation for
526    `OrderedDescriptorContainer` for a concrete example.
527
528    Optionally, subclasses of `OrderedDescriptor` may define a value for a
529    class attribute called ``_name_attribute_``.  This should be the name of
530    an attribute on instances of the subclass.  When specified, during
531    creation of a class containing these descriptors, the name attribute on
532    each instance will be set to the name of the class attribute it was
533    assigned to on the class.
534
535    .. note::
536
537        Although this class is intended for use with *descriptors* (i.e.
538        classes that define any of the ``__get__``, ``__set__``, or
539        ``__delete__`` magic methods), this base class is not itself a
540        descriptor, and technically this could be used for classes that are
541        not descriptors too.  However, use with descriptors is the original
542        intended purpose.
543    """
544
545    # This id increments for each OrderedDescriptor instance created, so they
546    # are always ordered in the order they were created.  Class bodies are
547    # guaranteed to be executed from top to bottom.  Not sure if this is
548    # thread-safe though.
549    _nextid = 1
550
551    @property
552    @abc.abstractmethod
553    def _class_attribute_(self):
554        """
555        Subclasses should define this attribute to the name of an attribute on
556        classes containing this subclass.  That attribute will contain the mapping
557        of all instances of that `OrderedDescriptor` subclass defined in the class
558        body.  If the same descriptor needs to be used with different classes,
559        each with different names of this attribute, multiple subclasses will be
560        needed.
561        """
562
563    _name_attribute_ = None
564    """
565    Subclasses may optionally define this attribute to specify the name of an
566    attribute on instances of the class that should be filled with the
567    instance's attribute name at class creation time.
568    """
569
570    def __init__(self, *args, **kwargs):
571        # The _nextid attribute is shared across all subclasses so that
572        # different subclasses of OrderedDescriptors can be sorted correctly
573        # between themselves
574        self.__order = OrderedDescriptor._nextid
575        OrderedDescriptor._nextid += 1
576        super().__init__()
577
578    def __lt__(self, other):
579        """
580        Defined for convenient sorting of `OrderedDescriptor` instances, which
581        are defined to sort in their creation order.
582        """
583
584        if (isinstance(self, OrderedDescriptor) and
585                isinstance(other, OrderedDescriptor)):
586            try:
587                return self.__order < other.__order
588            except AttributeError:
589                raise RuntimeError(
590                    'Could not determine ordering for {} and {}; at least '
591                    'one of them is not calling super().__init__ in its '
592                    '__init__.'.format(self, other))
593        else:
594            return NotImplemented
595
596
597@deprecated('4.3', _ordered_descriptor_deprecation_message)
598class OrderedDescriptorContainer(type):
599    """
600    Classes should use this metaclass if they wish to use `OrderedDescriptor`
601    attributes, which are class attributes that "remember" the order in which
602    they were defined in the class body.
603
604    Every subclass of `OrderedDescriptor` has an attribute called
605    ``_class_attribute_``.  For example, if we have
606
607    .. code:: python
608
609        class ExampleDecorator(OrderedDescriptor):
610            _class_attribute_ = '_examples_'
611
612    Then when a class with the `OrderedDescriptorContainer` metaclass is
613    created, it will automatically be assigned a class attribute ``_examples_``
614    referencing an `~collections.OrderedDict` containing all instances of
615    ``ExampleDecorator`` defined in the class body, mapped to by the names of
616    the attributes they were assigned to.
617
618    When subclassing a class with this metaclass, the descriptor dict (i.e.
619    ``_examples_`` in the above example) will *not* contain descriptors
620    inherited from the base class.  That is, this only works by default with
621    decorators explicitly defined in the class body.  However, the subclass
622    *may* define an attribute ``_inherit_decorators_`` which lists
623    `OrderedDescriptor` classes that *should* be added from base classes.
624    See the examples section below for an example of this.
625
626    Examples
627    --------
628
629    >>> from astropy.utils import OrderedDescriptor, OrderedDescriptorContainer
630    >>> class TypedAttribute(OrderedDescriptor):
631    ...     \"\"\"
632    ...     Attributes that may only be assigned objects of a specific type,
633    ...     or subclasses thereof.  For some reason we care about their order.
634    ...     \"\"\"
635    ...
636    ...     _class_attribute_ = 'typed_attributes'
637    ...     _name_attribute_ = 'name'
638    ...     # A default name so that instances not attached to a class can
639    ...     # still be repr'd; useful for debugging
640    ...     name = '<unbound>'
641    ...
642    ...     def __init__(self, type):
643    ...         # Make sure not to forget to call the super __init__
644    ...         super().__init__()
645    ...         self.type = type
646    ...
647    ...     def __get__(self, obj, objtype=None):
648    ...         if obj is None:
649    ...             return self
650    ...         if self.name in obj.__dict__:
651    ...             return obj.__dict__[self.name]
652    ...         else:
653    ...             raise AttributeError(self.name)
654    ...
655    ...     def __set__(self, obj, value):
656    ...         if not isinstance(value, self.type):
657    ...             raise ValueError('{0}.{1} must be of type {2!r}'.format(
658    ...                 obj.__class__.__name__, self.name, self.type))
659    ...         obj.__dict__[self.name] = value
660    ...
661    ...     def __delete__(self, obj):
662    ...         if self.name in obj.__dict__:
663    ...             del obj.__dict__[self.name]
664    ...         else:
665    ...             raise AttributeError(self.name)
666    ...
667    ...     def __repr__(self):
668    ...         if isinstance(self.type, tuple) and len(self.type) > 1:
669    ...             typestr = '({0})'.format(
670    ...                 ', '.join(t.__name__ for t in self.type))
671    ...         else:
672    ...             typestr = self.type.__name__
673    ...         return '<{0}(name={1}, type={2})>'.format(
674    ...                 self.__class__.__name__, self.name, typestr)
675    ...
676
677    Now let's create an example class that uses this ``TypedAttribute``::
678
679        >>> class Point2D(metaclass=OrderedDescriptorContainer):
680        ...     x = TypedAttribute((float, int))
681        ...     y = TypedAttribute((float, int))
682        ...
683        ...     def __init__(self, x, y):
684        ...         self.x, self.y = x, y
685        ...
686        >>> p1 = Point2D(1.0, 2.0)
687        >>> p1.x
688        1.0
689        >>> p1.y
690        2.0
691        >>> p2 = Point2D('a', 'b')  # doctest: +IGNORE_EXCEPTION_DETAIL
692        Traceback (most recent call last):
693            ...
694        ValueError: Point2D.x must be of type (float, int>)
695
696    We see that ``TypedAttribute`` works more or less as advertised, but
697    there's nothing special about that.  Let's see what
698    `OrderedDescriptorContainer` did for us::
699
700        >>> Point2D.typed_attributes
701        OrderedDict([('x', <TypedAttribute(name=x, type=(float, int))>),
702        ('y', <TypedAttribute(name=y, type=(float, int))>)])
703
704    If we create a subclass, it does *not* by default add inherited descriptors
705    to ``typed_attributes``::
706
707        >>> class Point3D(Point2D):
708        ...     z = TypedAttribute((float, int))
709        ...
710        >>> Point3D.typed_attributes
711        OrderedDict([('z', <TypedAttribute(name=z, type=(float, int))>)])
712
713    However, if we specify ``_inherit_descriptors_`` from ``Point2D`` then
714    it will do so::
715
716        >>> class Point3D(Point2D):
717        ...     _inherit_descriptors_ = (TypedAttribute,)
718        ...     z = TypedAttribute((float, int))
719        ...
720        >>> Point3D.typed_attributes
721        OrderedDict([('x', <TypedAttribute(name=x, type=(float, int))>),
722        ('y', <TypedAttribute(name=y, type=(float, int))>),
723        ('z', <TypedAttribute(name=z, type=(float, int))>)])
724
725    .. note::
726
727        Hopefully it is clear from these examples that this construction
728        also allows a class of type `OrderedDescriptorContainer` to use
729        multiple different `OrderedDescriptor` classes simultaneously.
730    """
731
732    _inherit_descriptors_ = ()
733
734    def __init__(cls, cls_name, bases, members):
735        descriptors = defaultdict(list)
736        seen = set()
737        inherit_descriptors = ()
738        descr_bases = {}
739
740        for mro_cls in cls.__mro__:
741            for name, obj in mro_cls.__dict__.items():
742                if name in seen:
743                    # Checks if we've already seen an attribute of the given
744                    # name (if so it will override anything of the same name in
745                    # any base class)
746                    continue
747
748                seen.add(name)
749
750                if (not isinstance(obj, OrderedDescriptor) or
751                        (inherit_descriptors and
752                            not isinstance(obj, inherit_descriptors))):
753                    # The second condition applies when checking any
754                    # subclasses, to see if we can inherit any descriptors of
755                    # the given type from subclasses (by default inheritance is
756                    # disabled unless the class has _inherit_descriptors_
757                    # defined)
758                    continue
759
760                if obj._name_attribute_ is not None:
761                    setattr(obj, obj._name_attribute_, name)
762
763                # Don't just use the descriptor's class directly; instead go
764                # through its MRO and find the class on which _class_attribute_
765                # is defined directly.  This way subclasses of some
766                # OrderedDescriptor *may* override _class_attribute_ and have
767                # its own _class_attribute_, but by default all subclasses of
768                # some OrderedDescriptor are still grouped together
769                # TODO: It might be worth clarifying this in the docs
770                if obj.__class__ not in descr_bases:
771                    for obj_cls_base in obj.__class__.__mro__:
772                        if '_class_attribute_' in obj_cls_base.__dict__:
773                            descr_bases[obj.__class__] = obj_cls_base
774                            descriptors[obj_cls_base].append((obj, name))
775                            break
776                else:
777                    # Make sure to put obj first for sorting purposes
778                    obj_cls_base = descr_bases[obj.__class__]
779                    descriptors[obj_cls_base].append((obj, name))
780
781            if not getattr(mro_cls, '_inherit_descriptors_', False):
782                # If _inherit_descriptors_ is undefined then we don't inherit
783                # any OrderedDescriptors from any of the base classes, and
784                # there's no reason to continue through the MRO
785                break
786            else:
787                inherit_descriptors = mro_cls._inherit_descriptors_
788
789        for descriptor_cls, instances in descriptors.items():
790            instances.sort()
791            instances = OrderedDict((key, value) for value, key in instances)
792            setattr(cls, descriptor_cls._class_attribute_, instances)
793
794        super(OrderedDescriptorContainer, cls).__init__(cls_name, bases,
795                                                        members)
796
797
798LOCALE_LOCK = threading.Lock()
799
800
801@contextmanager
802def _set_locale(name):
803    """
804    Context manager to temporarily set the locale to ``name``.
805
806    An example is setting locale to "C" so that the C strtod()
807    function will use "." as the decimal point to enable consistent
808    numerical string parsing.
809
810    Note that one cannot nest multiple _set_locale() context manager
811    statements as this causes a threading lock.
812
813    This code taken from https://stackoverflow.com/questions/18593661/how-do-i-strftime-a-date-object-in-a-different-locale.
814
815    Parameters
816    ==========
817    name : str
818        Locale name, e.g. "C" or "fr_FR".
819    """
820    name = str(name)
821
822    with LOCALE_LOCK:
823        saved = locale.setlocale(locale.LC_ALL)
824        if saved == name:
825            # Don't do anything if locale is already the requested locale
826            yield
827        else:
828            try:
829                locale.setlocale(locale.LC_ALL, name)
830                yield
831            finally:
832                locale.setlocale(locale.LC_ALL, saved)
833
834
835set_locale = deprecated('4.0')(_set_locale)
836set_locale.__doc__ = """Deprecated version of :func:`_set_locale` above.
837See https://github.com/astropy/astropy/issues/9196
838"""
839
840
841def dtype_bytes_or_chars(dtype):
842    """
843    Parse the number out of a dtype.str value like '<U5' or '<f8'.
844
845    See #5819 for discussion on the need for this function for getting
846    the number of characters corresponding to a string dtype.
847
848    Parameters
849    ----------
850    dtype : numpy dtype object
851        Input dtype
852
853    Returns
854    -------
855    bytes_or_chars : int or None
856        Bits (for numeric types) or characters (for string types)
857    """
858    match = re.search(r'(\d+)$', dtype.str)
859    out = int(match.group(1)) if match else None
860    return out
861
862
863def _hungry_for(option):  # pragma: no cover
864    """
865    Open browser loaded with ``option`` options near you.
866
867    *Disclaimers: Payments not included. Astropy is not
868    responsible for any liability from using this function.*
869
870    .. note:: Accuracy depends on your browser settings.
871
872    """
873    import webbrowser
874    webbrowser.open(f'https://www.google.com/search?q={option}+near+me')
875
876
877def pizza():  # pragma: no cover
878    """``/pizza``"""
879    _hungry_for('pizza')
880
881
882def coffee(is_adam=False, is_brigitta=False):  # pragma: no cover
883    """``/coffee``"""
884    if is_adam and is_brigitta:
885        raise ValueError('There can be only one!')
886    if is_adam:
887        option = 'fresh+third+wave+coffee'
888    elif is_brigitta:
889        option = 'decent+espresso'
890    else:
891        option = 'coffee'
892    _hungry_for(option)
893