Source code for spey.system.webutils

import json
import logging
import pathlib
from datetime import datetime, timezone
from platform import python_version
from typing import Literal

import requests
from semantic_version import SimpleSpec, Version

from spey._version import __version__

log = logging.getLogger("Spey")

__all__ = ["get_bibtex", "check_updates", "ConnectionError"]


def __dir__():
    return __all__


class ConnectionError(Exception):
    """No internet connection"""


def get_bibtex(
    home: Literal["inspire/arxiv", "inspire/doi", "doi", "zenodo"],
    identifier: str,
    timeout: int = 5,
) -> str:
    """
    Retreive BibTex information from InspireHEP, Zenodo or DOI.org

    Args:
        home (``Text``): Home location for the identifier.

            * ``"inspire/arxiv"``: retreive information from inspire using
                arXiv number.
            * ``"inspire/doi"``: retreive information from inspore using
                doi number.
            * ``"doi"``: retreive information from doi.org
            * ``"zenodo"``: retreive information from zenodo using zenodo
                identifier.

        identifier (``Text``): web identifier
        timeout (``int``, default ``5``): time out.

    Returns:
        ``Text``:
        Bibtex entry.
    """
    # pylint: disable=W1203

    home_loc = {
        "inspire/arxiv": "https://inspirehep.net/api/arxiv/%s",
        "inspire/doi": "https://inspirehep.net/api/doi/%s",
        "doi": "https://doi.org/%s",
        "zenodo": "https://zenodo.org/api/records/%s",
    }

    try:
        response = requests.get(
            home_loc[home] % identifier,
            headers={"accept": "application/x-bibtex"},
            timeout=timeout,
        )
        if not 200 <= response.status_code <= 299:
            log.debug(f"HTML Status Code: {response.status_code}")
            return ""
        response.encoding = "utf-8"
        return response.text
    except (
        requests.ConnectionError,
        requests.ConnectTimeout,
        requests.exceptions.ReadTimeout,
    ) as err:
        raise ConnectionError(
            "Can not retreive BibTeX information: No internet connection."
        ) from err
    except Exception as err:  # pylint: disable=W0718
        log.debug(str(err))
        return ""


_SPEY_DIR = pathlib.Path.home() / ".spey"
_CACHE_MAX_AGE_SECONDS = 86_400  # 24 hours


def _load_cached_pypi_info() -> dict:
    """
    Return cached PyPI JSON from ``~/.spey/pypi_latest_*.json`` if a file
    written within the last 24 hours exists, otherwise return ``None``.
    All filesystem errors are silently swallowed.
    """
    try:
        if not _SPEY_DIR.is_dir():
            return None
        now = datetime.now(tz=timezone.utc).timestamp()
        for cache_file in sorted(_SPEY_DIR.glob("pypi_latest_*.json"), reverse=True):
            try:
                age = now - cache_file.stat().st_mtime
                if age <= _CACHE_MAX_AGE_SECONDS:
                    data = json.loads(cache_file.read_text(encoding="utf-8"))
                    log.debug(
                        "Using cached PyPI info from %s (age %.0fs).",
                        cache_file.name,
                        age,
                    )
                    return data
                # File is older than 24 h — stop looking (files are sorted newest-first).
                break
            except Exception as err:  # noqa: BLE001
                log.debug("Could not read cache file %s: %s", cache_file.name, err)
    except Exception as err:  # noqa: BLE001
        log.debug("Cache directory scan failed: %s", err)
    return None


def _save_pypi_info(pypi_info: dict) -> None:
    """
    Save *pypi_info* to ``~/.spey/pypi_latest_{datetime}.json``.
    Old cache files (more than 24 h) are removed opportunistically.
    Silently skips if the directory cannot be created or written to.
    """
    try:
        _SPEY_DIR.mkdir(parents=False, exist_ok=True)
    except PermissionError as err:
        log.debug("Cannot create ~/.spey directory: %s", err)
        return
    except Exception as err:  # noqa: BLE001
        log.debug("Unexpected error creating ~/.spey: %s", err)
        return

    timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%d_%H%M%S")
    cache_file = _SPEY_DIR / f"pypi_latest_{timestamp}.json"
    try:
        cache_file.write_text(json.dumps(pypi_info, indent=4), encoding="utf-8")
        log.debug("Saved PyPI info to %s.", cache_file.name)
    except Exception as err:  # noqa: BLE001
        log.debug("Could not write cache file %s: %s", cache_file.name, err)

    # Remove every other cache file so only the latest one is kept.
    try:
        for old_file in _SPEY_DIR.glob("pypi_latest_*.json"):
            if old_file != cache_file:
                try:
                    old_file.unlink(missing_ok=True)
                except Exception:  # noqa: BLE001
                    pass
    except Exception:  # noqa: BLE001
        pass


def _get_pypi_info() -> dict:
    """
    Return PyPI JSON for spey, using a 24-hour on-disk cache.
    Returns ``None`` if the information cannot be obtained for any reason.
    """
    cached = _load_cached_pypi_info()
    if cached is not None:
        return cached

    try:
        response = requests.get(
            "https://pypi.org/pypi/spey/json",
            timeout=1,
            stream=False,
        )
        response.encoding = "utf-8"
        pypi_info = response.json()
    except Exception as err:  # noqa: BLE001
        log.debug("PyPI request failed: %s", err)
        return None

    _save_pypi_info(pypi_info)
    return pypi_info


[docs] def check_updates() -> None: """ Check Spey Updates. .. versionadded:: 0.1.6 Spey always checks updates when initialised. To disable this set the following Option if using terminal: .. code-block:: bash export SPEY_CHECKUPDATE=OFF Option if using python interface: .. code-block:: python import os os.environ["SPEY_CHECKUPDATE"]="OFF" PyPI is queried at most once per 24 hours; the response is cached in ``~/.spey/pypi_latest_{datetime}.json``. If the cache directory cannot be created (e.g. no write permission) the function always attempts a live request. Any network or filesystem error is logged at DEBUG level and the function returns silently without raising. """ # pylint: disable=W1203 try: pypi_info = _get_pypi_info() if pypi_info is None: return py_version = Version(python_version()) pypi_version = pypi_info.get("info", {}).get("version", False) version = __version__ if pypi_version: python_requires = SimpleSpec(pypi_info["info"]["requires_python"]) log.debug(f"Curernt version {version}, latest version {pypi_version}.") if "beta" in Version(version).prerelease: log.warning( f"A prerelease version of Spey is in use: {version}. " f"Latest stable version is {pypi_version}." ) elif Version(version) < Version(pypi_version): log.warning( f"A newer version ({pypi_version}) of Spey is available. " f"Current version is {version}." ) elif Version(version) > Version(pypi_version): log.warning( f"An unstable version of Spey ({version}) is being used." f" Latest stable version is {pypi_version}." ) if py_version not in python_requires: log.warning( f"The latest version of Spey requires python{python_requires}," f" however local python version is {py_version}." ) except Exception as err: # pylint: disable=W0718 log.debug(str(err))