HEX
Server: Apache/2.4.46 (Win64) OpenSSL/1.1.1j PHP/8.4.25
System: Windows NT DESKTOP-4TAV2RJ 10.0 build 19045 (Windows 10) AMD64
User: fred (0)
PHP: 8.4.25
Disabled: NONE
Upload Files
File: C:/Users/fred/anaconda3/Lib/site-packages/anaconda_navigator/utils/telemetry/utilities.py
# -*- coding: utf8 -*-

"""Utility components for telemetry."""

from __future__ import annotations

__all__ = ['ApiClient', 'Stats']

import abc
import collections.abc
import functools
import locale
import platform
import sys
import typing
import urllib.parse
from contextlib import suppress

import psutil
import requests.cookies
from qtpy import QtCore
from qtpy import QtWidgets

from anaconda_navigator import __about__
from anaconda_navigator.utils import hardware

if typing.TYPE_CHECKING:
    from anaconda_navigator.api import anaconda_api

T = typing.TypeVar('T')


def normalize_root_url(value: str) -> str:
    """
    Make sure that path is normalized to be used as a root URL.

    It strips down all query params, fragment and ensures that URL ends with a :code:`/`. The latter is required for the
    :func:`~urllib.parse.urljoin`.
    """
    scheme: str
    netloc: str
    path: str
    scheme, netloc, path, _, _ = urllib.parse.urlsplit(value)

    if not path.endswith('/'):
        path += '/'

    if not netloc:
        if path[:1] in '/':
            raise ValueError(f'{value!r} is not a valid root url')
        netloc, path = path.split('/', 1)

    return urllib.parse.urlunsplit((scheme or 'https', netloc, path, '', ''))


class ApiClient(metaclass=abc.ABCMeta):  # pylint: disable=too-few-public-methods
    """
    Base for API client classes.

    :param root_url: Base URL for the API.
    """

    __slots__ = ('__session', '__urls')

    def __init__(self, root_url: str) -> None:
        """Initialize new instance of a :class:`ApiClient`."""
        self.__session: typing.Final[requests.Session] = requests.Session()
        self.__urls: typing.Final[typing.Dict[str, str]] = {'': normalize_root_url(root_url)}

    @property
    def _cookies(self) -> requests.cookies.RequestsCookieJar:  # noqa: D401
        """Cookies attached to each request."""
        return self.__session.cookies

    @property
    def _headers(self) -> typing.MutableMapping[str, typing.Union[bytes, str]]:  # noqa: D401
        """Headers attached to each request."""
        return self.__session.headers

    def _request(
            self,
            method: str,
            url: str,
            *args: typing.Any,
            ignore_status: bool = False,
            **kwargs: typing.Any,
    ) -> requests.Response:
        """
        Send request to the service.

        :param method: HTTP method for the request.
        :param url: Path to the API endpoint. It should be a URL relative to the :code:`root_url`.
        :param args: Extra positional arguments to :func:`requests.request`.
        :param ignore_status: Do not raise an error if server responds with non-200 status code.
        :param kwargs: Extra keyword arguments to :func:`requests.request`.
        :return: Server response.
        """
        target: typing.Optional[str] = self.__urls.get(url, None)
        if target is None:
            target = self.__urls[url] = urllib.parse.urljoin(self.__urls[''], url)

        response: requests.Response = self.__session.request(method, target, *args, **kwargs)
        if not ignore_status:
            response.raise_for_status()
        return response


MACHINE_CLARIFICATION: typing.Final[collections.abc.Mapping[str, str]] = {
    'amd64': '64',
    'x86': '32',
    'x86_64': '64',
}
PLATFORM_CLARIFICATION: typing.Final[tuple[tuple[str, str], ...]] = (
    ('darwin', 'osx'),
    ('win32', 'win'),
)

StatsDetails = typing.TypedDict('StatsDetails', {
    'channels-count': int,
    'conda-version': str,
    'cpu-model': str,
    'cpu-cores': int,
    'cpu-max-freq': int,
    'disk-total-space': int,
    'environments-count': int,
    'gpu-model': str,
    'locale-encoding': str,
    'locale-language': str,
    'navigator-version': str,
    'os-long': str,
    'os-short': str,
    'platform': str,
    'python-long': str,
    'python-short': str,
    'qt-version': str,
    'ram-total': str,
    'ram-available': str,
    'screen-height': int,
    'screen-width': int,
})


class Stats:
    """Helper class to collect basic details about the execution environment."""

    @functools.cached_property
    def channels(self) -> set[str]:  # noqa: D401
        """Channels that Navigator works with."""
        channels: set[str] = set()
        with suppress(KeyError, TypeError, IndexError):
            for config in self._api.conda_config_and_sources().communicate()[0].values():
                channels.update(config.get('channels', {}))

        return channels

    @functools.cached_property
    def conda_version(self) -> str:  # noqa: D401
        """Conda version that Navigator works with."""
        return self._api.conda_package_version(pkg='conda', name='root') or 'unknown'

    @functools.cached_property
    def cpu_max_frequency(self) -> int:  # noqa: D401
        """Get CPU maximum frequency with extra safety."""
        try:
            return int(psutil.cpu_freq().max or 0)
        except Exception:  # pylint: disable=broad-exception-caught
            # https://github.com/giampaolo/psutil/issues/1892
            return 0

    @functools.cached_property
    def disk_total_space(self) -> int:  # noqa: D401
        """Total disk space in bytes."""
        partitions = psutil.disk_partitions()
        total = 0
        for partition in partitions:
            try:
                partition_usage = psutil.disk_usage(partition.mountpoint)
            except Exception:  # nosec: B112  # pylint: disable=broad-exception-caught
                continue
            total += partition_usage.total

        return total

    @functools.cached_property
    def details(self) -> StatsDetails:  # noqa: D401
        """
        Collected details in a dictionary format.

        Can be used to send statistics to the server.
        """
        info: StatsDetails = {
            'channels-count': len(self.channels),
            'conda-version': self.conda_version,
            'cpu-model': hardware.get_cpu_model(),
            'cpu-cores': psutil.cpu_count() or 0,
            'cpu-max-freq': self.cpu_max_frequency,
            'disk-total-space': self.disk_total_space,
            'environments-count': len(self.environments),
            'gpu-model': hardware.get_gpu_model(),
            'locale-encoding': self.locale_encoding,
            'locale-language': self.locale_language,
            'navigator-version': self.navigator_version,
            'os-long': self.os_long,
            'os-short': self.os_short,
            'platform': self.platform,
            'python-long': self.python_long,
            'python-short': self.python_short,
            'qt-version': self.qt_version,
            'ram-total': self._virtual_memory.total,
            'ram-available': self._virtual_memory.available,
            'screen-height': self.screen_height,
            'screen-width': self.screen_width,
        }

        return info

    @functools.cached_property
    def environments(self) -> list[str]:  # noqa: D401
        """Environments that Navigator works with."""
        envs: list[str] = []
        with suppress(KeyError, TypeError, IndexError):
            info = self._api.conda_info().communicate()[0]
            envs = info.get('envs', [])

        return envs

    @functools.cached_property
    def locale_encoding(self) -> str:  # noqa: D401
        """Encoding used for the current locale."""
        return self._locale[1] or 'unknown'

    @functools.cached_property
    def locale_language(self) -> str:  # noqa: D401
        """Language of the current locale."""
        return self._locale[0] or 'unknown'

    @functools.cached_property
    def navigator_version(self) -> str:  # noqa: D401
        """Version of the Navigator."""
        return __about__.__version__

    @functools.cached_property
    def os_long(self) -> str:  # noqa: D401
        """Name of the OS in a long form (including version)."""
        return f'{self.os_short} {platform.release()}'

    @functools.cached_property
    def os_short(self) -> str:  # noqa: D401
        """Name of the OS in a short form (without version)."""
        return platform.system()

    @functools.cached_property
    def platform(self) -> str:  # noqa: D401
        """Platform that Navigator runs in."""
        architecture: str = platform.machine().lower()
        architecture = MACHINE_CLARIFICATION.get(architecture, architecture)

        left: str
        right: str
        system: str = sys.platform.lower()
        for left, right in PLATFORM_CLARIFICATION:
            if system.startswith(left):
                system = right
                break

        return f'{system}-{architecture}'

    @functools.cached_property
    def pyqt_version(self) -> str:  # noqa: D401
        """Version of the PyQt used to display Navigator."""
        return (
                self._api.conda_package_version(pkg='pyqt5', name='root') or
                self._api.conda_package_version(pkg='pyqt4', name='root') or
                self._api.conda_package_version(pkg='pyqt', name='root') or
                'unknown'
        )

    @functools.cached_property
    def python_long(self) -> str:  # noqa: D401
        """Current version of the python with extra details."""
        return f'{platform.python_version()} ({platform.python_implementation()})'

    @functools.cached_property
    def python_short(self) -> str:  # noqa: D401
        """Short version of the python (without patch and extra details)."""
        return '.'.join(platform.python_version_tuple()[:2])

    @functools.cached_property
    def qt_version(self) -> str:  # noqa: D401
        """Version of the Qt that Navigator is running in."""
        try:
            return QtCore.__version__
        except AttributeError:
            return 'unknown'

    @functools.cached_property
    def screen_height(self) -> int:  # noqa: D401
        """Height of the primary screen."""
        return self._screen[1]

    @functools.cached_property
    def screen_width(self) -> int:  # noqa: D401
        """Width of the primary screen."""
        return self._screen[0]

    @functools.cached_property
    def _api(self) -> anaconda_api._AnacondaAPI:  # noqa: D401
        """Shortcut to :code:`AnacondaAPI`."""
        from anaconda_navigator.api import anaconda_api  # pylint: disable=import-outside-toplevel
        return anaconda_api.AnacondaAPI()

    @functools.cached_property
    def _locale(self) -> tuple[str | None, str | None]:  # noqa: D401
        """Collected details on current locale."""
        return locale.getlocale()

    @functools.cached_property
    def _screen(self) -> tuple[int, int]:  # noqa: D401
        """Collected details on screen dimensions."""
        try:
            widget: QtWidgets.QDesktopWidget = QtWidgets.QDesktopWidget()
            geometry: QtCore.QRect = widget.screenGeometry(widget.primaryScreen())
            return geometry.width(), geometry.height()
        except Exception:  # pylint: disable=broad-exception-caught
            return 0, 0

    @functools.cached_property
    def _virtual_memory(self) -> psutil.svmem:  # noqa: D401
        """Total physical memory (exclusive swap)."""
        return psutil.virtual_memory()