Source code for sherpa

#
#  Copyright (C) 2007, 2014 - 2016, 2019 - 2024
#  Smithsonian Astrophysical Observatory
#
#
#  This program is free software; you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation; either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License along
#  with this program; if not, write to the Free Software Foundation, Inc.,
#  51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#

"""
Modeling and fitting package for scientific data analysis

Sherpa is a modeling and fitting package for scientific data analysis.
It includes general tools of interest to all users as well as
specialized components for particular disciplines (e.g. astronomy).

Note that the top level sherpa package does not import any
subpackages.  This allows the user to import a particular component
(e.g. `sherpa.optmethods`) without pulling in any others.  To import all
the standard subpackages, use ``import sherpa.all`` or
``from sherpa.all import *``.

"""

import datetime
import importlib
import logging
import os
import os.path
import subprocess
import sys
from typing import Any, Optional

from . import _version
__version__ = _version.get_versions()['version']


__all__ = ('citation', 'get_config', 'get_include', 'smoke')


class Formatter(logging.Formatter):
    def format(self, record):
        # Get the full message, #1688 shows we can not rely
        # in record.msg.
        #
        msg = record.getMessage()
        if record.levelno > logging.INFO:
            msg = f"{record.levelname}: {msg}"
        return msg


log = logging.getLogger('sherpa')
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(Formatter())
log.addHandler(handler)
log.setLevel(logging.INFO)

dbg = log.debug

del Formatter, log, handler


DEFAULT_CITATION = """Please review the Zenodo Sherpa page at
       https://doi.org/10.5281/zenodo.593753
to identify the latest release of Sherpa. The Zenodo page
https://help.zenodo.org/#versioning provides information on how to
cite a specific version.

If you want a general-purpose citation then please use either of the
following, kindly provided by the SAO/NASA Astrophysics Data System
service:

The most recent article will appear in the Astrophysical Journal Supplement Series and it is
available on the archives:

@ARTICLE{2024arXiv240910400S,
       author = {{Siemiginowska}, Aneta and {Burke}, Douglas and {G{\"u}nther}, Hans Moritz and {Lee}, Nicholas P. and {McLaughlin}, Warren and {Principe}, David A. and {Cheer}, Harlan and {Fruscione}, Antonella and {Laurino}, Omar and {McDowell}, Jonathan and {Terrell}, Marie},
        title = "{Sherpa: An Open Source Python Fitting Package}",
      journal = {arXiv e-prints},
     keywords = {Astrophysics - Instrumentation and Methods for Astrophysics, Astrophysics - High Energy Astrophysical Phenomena},
         year = 2024,
        month = sep,
          eid = {arXiv:2409.10400},
        pages = {arXiv:2409.10400},
          doi = {10.48550/arXiv.2409.10400},
archivePrefix = {arXiv},
       eprint = {2409.10400},
 primaryClass = {astro-ph.IM},
       adsurl = {https://ui.adsabs.harvard.edu/abs/2024arXiv240910400S},
      adsnote = {Provided by the SAO/NASA Astrophysics Data System}
}


@INPROCEEDINGS{2001SPIE.4477...76F,
       author = {{Freeman}, Peter and {Doe}, Stephen and {Siemiginowska}, Aneta},
        title = "{Sherpa: a mission-independent data analysis application}",
     keywords = {Astrophysics},
    booktitle = {Astronomical Data Analysis},
         year = 2001,
       editor = {{Starck}, Jean-Luc and {Murtagh}, Fionn D.},
       series = {Society of Photo-Optical Instrumentation Engineers (SPIE) Conference Series},
       volume = {4477},
        month = nov,
        pages = {76-87},
          doi = {10.1117/12.447161},
archivePrefix = {arXiv},
       eprint = {astro-ph/0108426},
 primaryClass = {astro-ph},
       adsurl = {https://ui.adsabs.harvard.edu/abs/2001SPIE.4477...76F},
      adsnote = {Provided by the SAO/NASA Astrophysics Data System}
}

@INPROCEEDINGS{2007ASPC..376..543D,
   author = {{Doe}, S. and {Nguyen}, D. and {Stawarz}, C. and {Refsdal}, B. and
        {Siemiginowska}, A. and {Burke}, D. and {Evans}, I. and {Evans}, J. and
        {McDowell}, J. and {Houck}, J. and {Nowak}, M.},
    title = "{Developing Sherpa with Python}",
booktitle = {Astronomical Data Analysis Software and Systems XVI},
     year = 2007,
   series = {Astronomical Society of the Pacific Conference Series},
   volume = 376,
   editor = {{Shaw}, R.~A. and {Hill}, F. and {Bell}, D.~J.},
    month = oct,
    pages = {543},
   adsurl = {http://adsabs.harvard.edu/abs/2007ASPC..376..543D},
  adsnote = {Provided by the SAO/NASA Astrophysics Data System}
}
"""


def _make_citation(version: str,
                   title: str,
                   date: datetime.datetime,
                   authors: list[str],
                   idval: str,
                   latest: bool = True) -> str:

    year = date.year
    month = date.strftime('%b').lower()
    nicedate = date.strftime('%B %d, %Y')

    authnames = ' and\n                  '.join(authors)

    if latest:
        out = f"The latest release of Sherpa is {title}\n"
        out += f"released on {nicedate}."
    else:
        out = f"Sherpa {version} was released on {nicedate}."

    out += """

@software{{sherpa_{year}_{idval},
  author       = {{{authnames}}},
  title        = {{{title}}},
  month        = {month},
  year         = {year},
  publisher    = {{Zenodo}},
  version      = {{{version}}},
  doi          = {{10.5281/zenodo.{idval}}},
  url          = {{https://doi.org/10.5281/zenodo.{idval}}}
}}

""".format(authnames=authnames, idval=idval, title=title,
           version=version, year=year, month=month)

    out += DEFAULT_CITATION
    return out


def _get_citation_hardcoded(version: str) -> Optional[str]:
    """Retrieve the citation information.

    Parameters
    ----------
    version : str
        The version to retrieve the citation before. It is expected
        to be '4.8.0' or higher.

    Returns
    -------
    citation : str or None
        Citation information if known, otherwise None.

    Notes
    -----
    The entries can be created with the script:
    scripts/make_zenodo_release.py

    """

    def todate(year, mnum, dnum):
        return datetime.datetime(year, mnum, dnum)

    cite = {}

    def add(version, **kwargs):
        assert version not in cite
        cite[version] = dict(**kwargs)
        cite[version]['version'] = version

    add(version='4.16.1', title='sherpa/sherpa: Sherpa 4.16.1',
        date=todate(2024, 5, 21),
        authors=['Doug Burke', 'Omar Laurino', 'wmclaugh', 'Hans Moritz Günther', 'Marie-Terrell', 'dtnguyen2', 'Aneta Siemiginowska', 'Harlan Cheer', 'Jamie Budynkiewicz', 'Tom Aldcroft', 'luzpaz', 'Christoph Deil', 'Brigitta Sipőcz', 'Johannes Buchner', 'nplee', 'Axel Donath', 'Iva Laginja', 'Katrin Leinweber', 'Todd'],
        idval='11236879')
    add(version='4.16.0', title='sherpa/sherpa: Sherpa 4.16.0',
        date=todate(2023, 10, 26),
        authors=['Doug Burke', 'Omar Laurino', 'wmclaugh', 'Hans Moritz Günther', 'Marie-Terrell', 'dtnguyen2', 'Aneta Siemiginowska', 'Harlan Cheer', 'Jamie Budynkiewicz', 'Tom Aldcroft', 'Christoph Deil', 'Brigitta Sipőcz', 'Johannes Buchner', 'nplee', 'Axel Donath', 'Iva Laginja', 'Katrin Leinweber', 'Todd'],
        idval='825839')

    add(version='4.15.1', title='sherpa/sherpa: Sherpa 4.15.1',
        date=todate(2023, 5, 18),
        authors=['Doug Burke', 'Omar Laurino', 'wmclaugh', 'Marie-Terrell',
                 'dtnguyen2', 'Hans Moritz Günther', 'Aneta Siemiginowska',
                 'Jamie Budynkiewicz', 'Harlan Cheer', 'Tom Aldcroft',
                 'Christoph Deil', 'Brigitta Sipőcz', 'Johannes Buchner',
                 'Axel Donath', 'Iva Laginja', 'Katrin Leinweber',
                 'nplee', 'Todd'],
        idval='7948720')
    add(version='4.15.0', title='sherpa/sherpa: Sherpa 4.15.0',
        date=todate(2022, 10, 11),
        authors=['Doug Burke', 'Omar Laurino', 'wmclaugh', 'dtnguyen2',
                 'Hans Moritz Günther', 'Marie-Terrell', 'Aneta Siemiginowska',
                 'Jamie Budynkiewicz', 'Harlan Cheer', 'Tom Aldcroft',
                 'Christoph Deil', 'Brigitta Sipőcz', 'Johannes Buchner',
                 'Axel Donath', 'Iva Laginja', 'Katrin Leinweber',
                 'nplee', 'Todd'],
        idval='7186379')
    add(version='4.14.1', title='sherpa/sherpa: Sherpa 4.14.1',
        date=todate(2022, 5, 20),
        authors=['Doug Burke', 'Omar Laurino', 'wmclaugh', 'dtnguyen2',
                 'Hans Moritz Günther', 'Marie-Terrell', 'Aneta Siemiginowska',
                 'Jamie Budynkiewicz', 'Tom Aldcroft', 'Christoph Deil',
                 'Harlan Cheer', 'Brigitta Sipőcz', 'Johannes Buchner',
                 'Axel Donath', 'Iva Laginja', 'Katrin Leinweber',
                 'nplee', 'Todd'],
        idval='6567264')
    add(version='4.14.0', title='sherpa/sherpa: Sherpa 4.14.0',
        date=todate(2021, 10, 7),
        authors=['Doug Burke', 'Omar Laurino', 'wmclaugh', 'dtnguyen2',
                 'Hans Moritz Günther', 'Marie-Terrell', 'Aneta Siemiginowska',
                 'Jamie Budynkiewicz', 'Tom Aldcroft', 'Christoph Deil',
                 'Brigitta Sipőcz', 'Johannes Buchner', 'Iva Laginja',
                 'Katrin Leinweber', 'nplee', 'Todd'],
        idval='5554957')

    add(version='4.13.1', title='sherpa/sherpa: Sherpa 4.13.1',
        date=todate(2021, 5, 18),
        authors=['Doug Burke', 'Omar Laurino', 'wmclaugh', 'dtnguyen2',
                 'Marie-Terrell', 'Hans Moritz Günther', 'Aneta Siemiginowska',
                 'Jamie Budynkiewicz', 'Tom Aldcroft', 'Christoph Deil',
                 'Brigitta Sipőcz', 'Johannes Buchner', 'Iva Laginja',
                 'Katrin Leinweber', 'nplee', 'Todd'],
        idval='4770623')
    add(version='4.13.0', title='sherpa/sherpa: Sherpa 4.13.0',
        date=todate(2021, 1, 8),
        authors=['Doug Burke', 'Omar Laurino', 'wmclaugh', 'dtnguyen2',
                 'Marie-Terrell', 'Hans Moritz Günther', 'Aneta Siemiginowska',
                 'Jamie Budynkiewicz', 'Tom Aldcroft', 'Christoph Deil',
                 'Brigitta Sipőcz', 'Johannes Buchner', 'Iva Laginja',
                 'Katrin Leinweber', 'nplee', 'Todd'],
        idval='4428938')

    add(version='4.12.2', title='sherpa/sherpa: Sherpa 4.12.2',
        date=todate(2020, 10, 27),
        authors=['Doug Burke', 'Omar Laurino', 'wmclaugh', 'dtnguyen2',
                 'Hans Moritz Günther', 'Marie-Terrell', 'Aneta Siemiginowska',
                 'Jamie Budynkiewicz', 'Tom Aldcroft', 'Christoph Deil',
                 'Brigitta Sipőcz', 'Johannes Buchner', 'Iva Laginja',
                 'Katrin Leinweber', 'nplee', 'Todd'],
        idval='4141888')
    add(version='4.12.1', title='sherpa/sherpa: Sherpa 4.12.1',
        date=todate(2020, 7, 14),
        authors=['Doug Burke', 'Omar Laurino', 'wmclaugh', 'dtnguyen2',
                 'Marie-Terrell', 'Hans Moritz Günther', 'Jamie Budynkiewicz',
                 'Aneta Siemiginowska', 'Tom Aldcroft', 'Christoph Deil',
                 'Brigitta Sipőcz', 'Katrin Leinweber', 'Todd'],
        idval='3944985')
    add(version='4.12.0', title='sherpa/sherpa: Sherpa 4.12.0',
        date=todate(2020, 1, 30),
        authors=['Doug Burke', 'Omar Laurino', 'dtnguyen2', 'wmclaugh',
                 'Jamie Budynkiewicz', 'Tom Aldcroft', 'Aneta Siemiginowska',
                 'Christoph Deil', 'Marie-Terrell', 'Brigitta Sipőcz',
                 'Hans Moritz Günther', 'Todd', 'Katrin Leinweber'],
        idval='3631574')

    add(version='4.11.1', title='sherpa/sherpa: Sherpa 4.11.1',
        date=todate(2019, 8, 1),
        authors=['Doug Burke', 'Omar Laurino', 'dtnguyen2',
                 'Jamie Budynkiewicz', 'Tom Aldcroft', 'Aneta Siemiginowska',
                 'Christoph Deil', 'wmclaugh', 'Brigitta Sipocz',
                 'Katrin Leinweber'],
        idval='3358134')
    add(version='4.11.0', title='sherpa/sherpa: Sherpa 4.11.0',
        date=todate(2019, 2, 20),
        authors=['Doug Burke', 'Omar Laurino', 'dtnguyen2',
                 'Jamie Budynkiewicz', 'Tom Aldcroft', 'Aneta Siemiginowska',
                 'Christoph Deil', 'wmclaugh', 'Brigitta Sipocz',
                 'Katrin Leinweber'],
        idval='2573885')

    add(version='4.10.2', title='sherpa/sherpa: Sherpa 4.10.2',
        date=todate(2018, 12, 14),
        authors=['Doug Burke', 'Omar Laurino', 'dtnguyen2',
                 'Jamie Budynkiewicz', 'Tom Aldcroft', 'Aneta Siemiginowska',
                 'Christoph Deil', 'wmclaugh', 'Brigitta Sipocz',
                 'Katrin Leinweber'],
        idval='2275738')
    add(version='4.10.1', title='sherpa/sherpa: Sherpa 4.10.1',
        date=todate(2018, 10, 16),
        authors=['Doug Burke', 'Omar Laurino', 'dtnguyen2',
                 'Jamie Budynkiewicz', 'Tom Aldcroft', 'Aneta Siemiginowska',
                 'wmclaugh', 'Brigitta Sipocz', 'Christoph Deil',
                 'Katrin Leinweber'],
        idval='1463962')
    add(version='4.10.0', title='sherpa/sherpa: Sherpa 4.10.0',
        date=todate(2018, 5, 11),
        authors=['Doug Burke', 'Omar Laurino', 'dtnguyen2',
                 'Jamie Budynkiewicz', 'Tom Aldcroft', 'Aneta Siemiginowska',
                 'wmclaugh', 'Christoph Deil', 'Brigitta Sipocz'],
        idval='1245678')

    add(version='4.9.1', title='sherpa/sherpa: Sherpa 4.9.1',
        date=todate(2017, 8, 3),
        authors=['Doug Burke', 'Omar Laurino', 'dtnguyen2',
                 'Jamie Budynkiewicz', 'Tom Aldcroft', 'Aneta Siemiginowska',
                 'wmclaugh', 'Christoph Deil', 'Brigitta Sipocz'],
        idval='838686')
    add(version='4.9.0', title='sherpa/sherpa: Sherpa 4.9.0',
        date=todate(2017, 1, 27),
        authors=['Doug Burke', 'Omar Laurino', 'dtnguyen2',
                 'Jamie Budynkiewicz', 'Tom Aldcroft', 'Aneta Siemiginowska',
                 'wmclaugh', 'Christoph Deil', 'Brigitta Sipocz'],
        idval='260416')

    add(version='4.8.2', title='sherpa/sherpa: Sherpa 4.8.2',
        date=todate(2016, 9, 23),
        authors=['Doug Burke', 'Omar Laurino', 'dtnguyen2',
                 'Jamie Budynkiewicz', 'Tom Aldcroft', 'Aneta Siemiginowska',
                 'Christoph Deil', 'wmclaugh', 'Brigitta Sipocz'],
        idval='154744')
    add(version='4.8.1', title='sherpa: Sherpa 4.8.1',
        date=todate(2016, 4, 15),
        authors=['Doug Burke', 'Omar Laurino', 'dtnguyen2', 'Tom Aldcroft',
                 'Jamie Budynkiewicz', 'Aneta Siemiginowska', 'Christoph Deil',
                 'Brigitta Sipocz'],
        idval='49832')
    add(version='4.8.0', title='sherpa: Sherpa 4.8.0',
        date=todate(2016, 1, 27),
        authors=['Doug Burke', 'Omar Laurino', 'dtnguyen2', 'Tom Aldcroft',
                 'Aneta Siemiginowska'],
        idval='45243')

    kwargs = cite.get(version, None)
    if kwargs is None:
        return None

    return _make_citation(**kwargs, latest=False)


def _download_json(url: str) -> dict[str, Any]:
    """Return the JSON data or None.

    Parameters
    ----------
    url : str
        The URL to process.

    Returns
    -------
    response : dict
        The data in JSON as 'success' or an error message as 'failed'.

    """

    import json
    import ssl
    from urllib.request import Request, urlopen
    from urllib.error import HTTPError

    # Need to set the Content-Type so it looks like I need
    # a request. Darn it. Even though https://developers.zenodo.org/#list36
    # claims you can set the content-type, it just seems to
    # return JSON.
    #
    # req = Request(url,
    #               headers={'Content-Type': 'application/x-bibtex'})
    #
    req = Request(url)

    # This is not ideal, but given the way that we package
    # CIAO we can end up with issues evaluating SSL certificates,
    # so as we are not sending secure information we drop
    # the SSL checks.
    #
    context = ssl._create_unverified_context()

    try:
        res = urlopen(req, context=context)
    except HTTPError as he:
        dbg(f"Unable to access {url}: {he}")
        return {'failed': 'Unable to access the Zenodo site.'}

    try:
        jsdata = json.load(res)
    except UnicodeDecodeError as ue:
        dbg(f"Unable to decode JSON from Zenodo: {ue}")
        return {'failed': 'Unable to understand the response from Zenodo.'}

    return {'success': jsdata}


def _make_zenodo_citation(jsdata,
                          latest: bool = True) -> dict[str, str]:
    """Convert Zenodo record to a citation.

    Parameters
    ----------
    jsdata : object
        A Zenodo record.

    Returns
    -------
    response : dict
        Success in 'success' (dict) or failure message in 'failed'.

    """

    try:
        created = jsdata['created']
        mdata = jsdata['metadata']
        idval = jsdata['id']
    except KeyError as ke:
        dbg(f"Unable to find metadata: {ke}")
        return {'failed': 'Unable to parse the Zenodo response.'}

    created = created.split('T')[0]
    isoformat = '%Y-%m-%d'
    try:
        date = datetime.datetime.strptime(created, isoformat)
    except ValueError:
        dbg(f"Unable to convert created: '{created}'")
        return {'failed': 'Unable to parse the Zenodo response.'}

    try:
        version = mdata['version']
        title = mdata['title']
        creators = mdata['creators']
    except KeyError as ke:
        dbg(f"Unable to find metadata: {ke}")
        return {'failed': 'Unable to parse the Zenodo response.'}

    authors = [c['name'] for c in creators]
    out = _make_citation(version=version, title=title, date=date,
                         authors=authors, idval=idval,
                         latest=latest)
    return {'success': out}


def _get_citation_version() -> str:
    """What version of Sherpa are we using?

    Returns
    -------
    version : str
        The Sherpa version.
    """

    out = f'You are using Sherpa {__version__}'
    if '+' in __version__:
        out += " (it is not a released version)"

    out += ".\n\n"
    return out


def _get_citation_zenodo_failure(failed: str) -> str:
    """Standard response when there's a problem.

    Returns
    -------
    text : str
        The failure message.

    """
    out = 'There was a problem retrieving the data from Zenodo:\n'
    out += failed
    out += '\n\n'
    out += DEFAULT_CITATION
    return out


def _get_citation_zenodo_latest() -> str:
    """Query Zenodo for the latest release.

    Returns
    -------
    citation : str
        Citation information. It will include information
        on any failure.
    """

    # Can we retrieve the information from Zenodo?
    #
    # This used to access the information via
    #     https://zenodo.org/api/records/593753
    # but this no-longer works and I do not trust Zenodo not
    # to change things again, so this is no a simplified
    # version of _download_zenodo_data().
    #
    from urllib.parse import urlencode

    params = {"q": "parent.id:593753",
              "all_versions": 0,
              "sort": "mostrecent"}
    paramstr = urlencode(params)
    url = f'https://zenodo.org/api/records?{paramstr}'

    dbg(f"Zenodo query: {url}")
    jsdata = _download_json(url)
    if 'failed' in jsdata:
        return _get_citation_zenodo_failure(jsdata['failed'])

    try:
        hit = jsdata['success']['hits']['hits'][0]
    except (KeyError, IndexError):
        dbg("Unable to find hits/hits[0]")
        return _get_citation_zenodo_failure('Unable to parse the Zenodo response')

    out = _make_zenodo_citation(hit)
    if 'failed' in out:
        return _get_citation_zenodo_failure(out['failed'])

    return out['success']


def _zenodo_missing(version: str) -> str:
    """

    Parameters
    ----------
    version : str
        The version we are looking for.

    Returns
    -------
    message : str
        The "unable to find" message.
    """

    return f'Zenodo has no information for version {version}.'


def _parse_zenodo_data(jsdata, version: str) -> dict[str, Any]:
    """Extract data for the given version from a Zenodo query.

    Parameters
    ----------
    jsdata : json
        The response from Zenodo.
    version : str
        The version we are looking for.

    Returns
    -------
    response : dict
        Success in 'success' (dict) or failure message in 'failed'.
    """

    try:
        hits = jsdata['hits']['hits']
    except KeyError:
        dbg("Unable to find hits/hits")
        return {'failed': 'Unable to parse the Zenodo response.'}

    data = None
    try:
        for hit in hits:
            if hit['metadata']['version'] == version:
                data = hit
                break

    except KeyError:
        dbg("Record missing version")
        return {'failed': 'Unable to parse the Zenodo response.'}

    except TypeError:
        dbg('hits/hits is not iterable!')
        return {'failed': 'Unable to parse the Zenodo response.'}

    if data is None:
        dbg(f'Version {version} not found')
        return {'failed': _zenodo_missing(version)}

    return {'success': data}


def _download_zenodo_data(version: str) -> dict[str, Any]:
    """Query Zenodo for the specific release.

    We have to deal with pagination in the Zenodo response.

    Parameters
    ----------
    version : str
        The release number (e.g. '4.12.2').

    Returns
    -------
    response : dict
        The data in JSON as 'success' or an error message as 'failed'.

    """

    # We could set the size parameter to something very large, to
    # get all responses with one call, but instead we use pagination.
    # Zenodo helpfully provides a links/next record with the
    # next URL, but it seems to be missing the all_versions=True
    # option, which makes it less-than-useful, hence the addition
    # of it below. The alternative would be to manually track the
    # page counter and add '&page=n' to the call.
    #
    from urllib.parse import urlencode

    params = {"q": "parent.id:593753",
              "all_versions": 1,
              "sort": "mostrecent"}
    paramstr = urlencode(params)
    url = f'https://zenodo.org/api/records?{paramstr}'

    missing = _zenodo_missing(version)

    while True:
        dbg(f"Zenodo query: {url}")
        jsdata = _download_json(url)

        # If the query fails then we error out
        #
        if 'failed' in jsdata:
            return jsdata

        jsdata = jsdata['success']
        data = _parse_zenodo_data(jsdata, version)
        if 'success' in data:
            return data

        # There are two failures we care about:
        #  - we can parse the information but have not been able to
        #    find the version
        #  - any other reason
        #
        # If the former then we look for the links/next entry to
        # look at the next page of the response. If it doesn't
        # exist we assume we are on the last page and so can error
        # out.
        #
        # If the latter then we error out rather than trying anything
        # else.
        #
        if data['failed'] != missing:
            return data

        try:
            url = jsdata['links']['next']

            # Add in the necessary all_versions tag: see
            # https://github.com/zenodo/zenodo/issues/1662
            #
            if 'all_versions=True' not in url:
                url += '&all_versions=True'

        except KeyError:
            # There is no links/next field so assume we've checked all
            # pages
            return data


def _get_citation_zenodo_version(version: str) -> str:
    """Query Zenodo for the specific release.

    As this has to return all Sherpa records it is slow.

    Parameters
    ----------
    version : str
        The release number (e.g. '4.12.2').

    Returns
    -------
    citation : str
        Citation information.
    """

    jsdata = _download_zenodo_data(version)
    if 'failed' in jsdata:
        return _get_citation_zenodo_failure(jsdata['failed'])

    out = _make_zenodo_citation(jsdata['success'], latest=False)
    if 'failed' in out:
        return _get_citation_zenodo_failure(out['failed'])

    return out['success']


def _get_citation(version: str = 'current') -> str:
    """Retrieve the citation information.

    Parameters
    ----------
    version : str, optional
        The version to retrieve the citation for. The supported values
        are limited to 'current', to return the citation for the
        installed version of Sherpa, 'latest' which will return the
        latest release, and the current set of releases available on
        Zenodo (this goes back to '4.8.0').

    Returns
    -------
    citation : str
        Citation information.

    """

    vstr = _get_citation_version()

    if version == 'latest':
        return vstr + _get_citation_zenodo_latest()

    if version == 'current':
        # Replace with the version (excluding any extraneous
        # information). Note that development versions close to a
        # release will have a version which has no release, but
        # I believe it is okay to say "Hey, Zenodo has no info
        # on this", rather than to try and fall back to the
        # previous version (since there's no good Zenodo reference
        # in this case).
        #
        version = __version__.split('+')[0]

    # We could include a check on the version number (e.g. a.b.c
    # where all elements are an integer), but do we want to
    # require this naming scheme?

    # If we know this version there's no need to call Zenodo
    #
    out = _get_citation_hardcoded(version)
    if out is not None:
        return vstr + out

    # In case the hardcoded list hasn't been updated.
    #
    return vstr + _get_citation_zenodo_version(version)


[docs] def citation(version: str = 'current', filename=None, clobber: bool = False) -> None: """Return citatation information for Sherpa. The citation information is taken from Zenodo [1]_, using the Sherpa "latest release" identifier [2]_, and so requires an internet connection. The message is displayed on screen, using pagination, or can be written to a file. Parameters ---------- version : str, optional The version to retrieve the citation for. The supported values are limited to 'current', to return the citation for the installed version of Sherpa, 'latest' which will return the latest release, and the current set of releases available on Zenodo (this goes back to '4.8.0'). filename : str or StringIO or None, optional If not None, write the output to the given file or filelike object. clobber : bool, optional If filename is a string, then - when clobber is set - refuse to overwrite the file if it already exists. Notes ----- If there is no internet connection, or there was a problem in downloading the data, or the Zenodo API has started to return different informatioon than expected, then the code will return information on why the call failed and other citation options. Zenodo only lets you perform a limited number of calls in a set time span, so if you call this routine too many times then it may start to fail. If a specific version is given then a hard-coded list of versions is checked, and if it matches then this information is used, rather than requiring a call to Zenodo. References ---------- .. [1] https://zenodo.org/ .. [2] https://doi.org/10.5281/zenodo.593753 Examples -------- Display the citation information for the current release on Zenodo. The information is paged to the display: >>> import sherpa >>> sherpa.citation() Write out the citation information for Sherpa 4.12.1 to the file ``cite.txt``: >>> sherpa.citation('4.12.1', outfile='cite.txt') Display the information for the latest release: >>> sherpa.citation('latest') """ from sherpa.utils import send_to_pager cite = _get_citation(version=version) send_to_pager(cite, filename=filename, clobber=clobber)
[docs] def get_include() -> str: "Get the root path for installed Sherpa header files" return os.path.join(os.path.dirname(__file__), 'include')
[docs] def get_config() -> str: "Get the path for the installed Sherpa configuration file" filename = "sherpa-standalone.rc" # The behavior depends on whether the NOSHERPARC # environment variable is set. # if 'NOSHERPARC' not in os.environ: # If SHERPARC is set, try that # config = os.environ.get('SHERPARC') if config is not None: if os.path.isfile(config): return config # Can we use the HOME directory? # home_dir = os.environ.get('HOME') if home_dir is not None: config = os.path.join(home_dir, f'.{filename}') if os.path.isfile(config): return config # Fall back to the system config file return os.path.join(os.path.dirname(__file__), filename)
[docs] def smoke(verbosity: int = 0, require_failure: bool = False, fits: Optional[str] = None, xspec: bool = False, ds9: bool = False) -> None: """Run Sherpa's "smoke" test. The smoke test is a simple test that ensures the Sherpa installation is functioning. It is not a complete test suite, but it fails if obvious issues are found. Parameters ---------- verbosity : int, optional The level of verbosity of this test require_failure : boolean, optional For debugging purposes, the smoke test may be required to always fail. Defaults to False. fits : str or None, optional Require a fits module with this name to be present before running the smoke test. This option makes sure that when the smoke test is run the required modules are present. Note that tests requiring fits may still run if any fits backend is available, and they might still fail on their own. xspec : boolean, optional Require xspec module when running tests. Tests requiring xspec may still run if the xspec module is present. ds9 : boolean, optional Requires DS9 when running tests. Raises ------ SystemExit Raised if any errors are found during the tests. """ from sherpa.astro.utils import smoke smoke.run(verbosity=verbosity, require_failure=require_failure, fits=fits, xspec=xspec, ds9=ds9)
def _smoke_cli(verbosity=0, require_failure=False, fits=None, xspec=False, ds9=False): from optparse import OptionParser parser = OptionParser() parser.add_option("-v", "--verbosity", dest="verbosity", help="verbosity level") parser.add_option("-0", "--require-failure", dest="require_failure", action="store_true", help="require smoke test to fail (for debugging)") parser.add_option("-f", "--fits-module", dest="fits", action="store", help="require a specific fits module to be present") parser.add_option("-x", "--require-xspec", dest="xspec", action="store_true", help="require xspec module") parser.add_option("-d", "--require-ds9", dest="ds9", action="store_true", help="require DS9") options, _ = parser.parse_args() xspec = options.xspec or xspec verbosity = options.verbosity or verbosity require_failure = options.require_failure or require_failure fits = options.fits or fits ds9 = options.ds9 or ds9 smoke(verbosity=verbosity, require_failure=require_failure, fits=fits, xspec=xspec, ds9=ds9) def _install_test_deps() -> list[str]: """Check the needed modules for running the tests are installed. Since pytest has historically had issues with being able to use packages installed during this check, the list of such modules is returned so it can be passed to pytest.main(). Returns ------- plugins : list of module The pytest modules that were added by this call (may be empty). """ def install(package_name): try: subprocess.call([sys.executable, '-m', 'pip', 'install', package_name], stdout=sys.stdout, stderr=sys.stderr) except: print("""Cannot import pip or install packages with it. You need pytest in order to run the tests. If you downloaded the source code, please run 'pip install -r test_requirements.txt' from the source directory first. """) raise # packages are stored as a dictionary with keys # name: package name, as used by pip # check: module to check it is installed # (defaults to name if not given) # constraint: the constraint to use with pip # (defaults to name if not given) # # So we can have # # {'name': 'pycheck'} # {'name': 'pycheck', 'constraint': 'pytest>=5.0,!=5.2.3'} # {'name': 'pytest-doctestplus', # 'check': 'pytest_doctestplus.output_checker'} # # Note that for pytest plugins, the module name is assumed to be # the name field with hyphens replaced by underscores. If necessary # this could be updated to be another field in the package # dictionary. # # The reason for the "check" field is that mid 2023 it was found # that # # importlib.import_module("pytest_doctestplus") # # would work even when not installed, hence the need to import a # module within the package as a check. This has been left in even # though the current list of required plugins is empty. # deps: list[dict[str, str]] = [{'name': 'pytest', 'constraint': 'pytest>=8.0'}] pytest_plugins: list[dict[str, str]] = [] def get(dep: dict[str, str]) -> tuple[str, str, str]: name = dep['name'] constraint = dep.get('constraint', name) check = dep.get('check', name) return name, constraint, check installed_plugins = [] for dep in deps: name, constraint, check = get(dep) try: importlib.import_module(check) except ImportError: install(constraint) for dep in pytest_plugins: name, constraint, check = get(dep) try: importlib.import_module(check) except ImportError: install(constraint) installed_plugins.append(name.replace('-', '_')) return installed_plugins def clitest() -> None: """The sherpa_test endpoint.""" plugins = _install_test_deps() import pytest # Add in command-line arguments to allow configuring the Sherpa tests args = [os.path.dirname(__file__), '-rs'] + sys.argv[1:] # passing the plugins that have been installed "now". errno = pytest.main(args, plugins=plugins) sys.exit(errno)