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/api/external_apps/base.py
# -*- coding: utf-8 -*-

"""Core structures for third-party application descriptions."""

from __future__ import annotations

__all__ = ['AppPatch', 'BaseApp', 'BaseWebApp', 'BaseInstallableApp']

import abc
import collections.abc
import os
import re
import types
import typing
import webbrowser

import attr

from anaconda_navigator.static.images import ANACONDA_ICON_256_PATH, APPLICATIONS_PATH
from anaconda_navigator.utils import constants

from . import detectors
from . import exceptions
from . import import_utils
from . import parsing_utils
from . import validation


if typing.TYPE_CHECKING:
    from anaconda_navigator.api import process
    from anaconda_navigator.api import types as api_types
    from anaconda_navigator.config import user as user_config


AppPatchField = typing.Literal['display_name', 'description', 'image_path', 'rank']


class AppPatchDelta(typing.TypedDict, total=False):
    """Change provided by :class:`~AppPatch`."""
    display_name: str
    description: str
    image_path: str
    rank: int


APP_PATCH_FIELD_KEYS: 'typing.Final[typing.Tuple[AppPatchField, ...]]' = AppPatchField.__args__  # type: ignore


@attr.s(auto_attribs=True, cache_hash=True, frozen=True, order=False, slots=True)
class AppPatch:
    """Patch for the application defined outside this component."""

    app_name: str = attr.ib(default='')
    display_name: typing.Optional[str] = attr.ib(default=None)
    description: typing.Optional[str] = attr.ib(default=None)
    image_path: typing.Optional[str] = attr.ib(default=None)
    is_available: bool = attr.ib(default=True)
    rank: typing.Optional[int] = attr.ib(default=None)

    @property
    def delta(self) -> 'AppPatchDelta':  # noqa: D401
        """Dict-like content of the patch."""
        result: 'AppPatchDelta' = {}

        # NOTE: should be replaced with py38+: fields = AppPatchField.__args__

        field: 'AppPatchField'
        for field in APP_PATCH_FIELD_KEYS:
            current: typing.Any = getattr(self, field)
            if current is not None:
                result[field] = current

        return result

    @typing.overload
    def apply_to(self, instance: AppPatch) -> AppPatch:
        """Modify other AppPatch."""

    @typing.overload
    def apply_to(self, instance: 'api_types.Application') -> 'api_types.Application':
        """Apply all values from a patch to an application description dictionary."""

    def apply_to(
            self,
            instance: typing.Union[AppPatch, 'api_types.Application'],
    ) -> typing.Union[AppPatch, 'api_types.Application']:
        """Apply patch to another instance."""
        if isinstance(instance, AppPatch):
            return attr.evolve(
                instance,
                is_available=self.is_available,
                **self.delta,
            )

        instance.update(typing.cast('api_types.Application', self.delta))
        return instance


def _call(function: str) -> typing.Any:
    """Execute imported function and return the execution result."""
    func: typing.Callable[[], typing.Any] = import_utils.import_callable(function)
    with validation.catch_exception():
        return func()


def _image_path(value: str) -> str:
    """Normalize `image_path` value."""
    result: str = os.path.join(APPLICATIONS_PATH, value)
    if os.path.isfile(result):
        return result
    return value


@attr.s(auto_attribs=True, cache_hash=True, frozen=True, order=False)
class BaseApp(metaclass=abc.ABCMeta):
    """
    Root description of the application.

    :param app_type: Type of the application (web application, installable, etc.).
    :param app_name: Alias of the application in a package-naming format.
    :param display_name: Name of the application to show in home tile.
    :param description: Description of the application to show in home tile.
    :param image_path: Application icon to show in home tile.
    :param config: Application configuration.
    """

    __app_types__: typing.ClassVar[typing.MutableMapping[str, typing.Type['BaseApp']]] = {}

    app_type: constants.AppType
    app_name: str
    display_name: str
    description: str
    image_path: str
    non_conda: bool
    config: 'user_config.UserConfig'
    is_available: bool
    rank: int

    def __init_subclass__(
            cls,  # pylint: disable=unused-argument
            alias: typing.Optional[constants.AppType] = None,
            **kwargs: typing.Any,
    ) -> None:
        """
        Initialize new :class:`~BaseApp` subclass.

        Used to register new classes in registry.
        """
        if alias is None:
            return

        if alias in cls.__app_types__:
            raise ValueError(f'{alias!r} is already registered')

        cls.__app_types__[alias] = cls

    @property
    def tile_definition(self) -> 'api_types.Application':  # noqa: D401
        """Definition of the application tile."""
        return {
            'app_type': self.app_type,
            'description': self.description,
            'display_name': self.display_name,
            'image_path': self.image_path,
            'name': self.app_name,
            'non_conda': self.non_conda,
            'rank': self.rank,
        }

    @classmethod
    def parse_configuration(
            cls,
            context: parsing_utils.ParsingContext,
            configuration: typing.Mapping[str, typing.Any],
            app_name: str,
    ) -> typing.Union[None, 'BaseApp', 'AppPatch']:
        """Generate applications according to the `configuration`."""
        validation.OfType(typing.Mapping)(configuration)
        mirror: typing.Dict[str, typing.Any] = dict(configuration)

        app_type: str
        target_cls: typing.Optional[typing.Type[BaseApp]]
        display_name: typing.Optional[str]
        description: typing.Optional[str]
        image_path: typing.Optional[str]
        rank: typing.Optional[int]
        try:
            app_type = validation.ItemPopper('app_type')(mirror)
        except exceptions.ValidationError:
            target_cls = None
            display_name = validation.ItemPopper('display_name', default=None).convert(validation.IS_OPT_STR)(mirror)
            description = validation.ItemPopper('description', default=None).convert(validation.IS_OPT_STR)(mirror)
            image_path = validation.ItemPopper('image_path', default=None).convert(
                validation.IS_NONE | validation.IS_STR.convert(_image_path),
            )(mirror)
            rank = validation.ItemPopper('rank', default=None).convert(validation.IS_OPT_INT)(mirror)
        else:
            target_cls = cls.__app_types__[validation.OfChoices(*cls.__app_types__)(app_type, field='app_type')]
            display_name = validation.ItemPopper('display_name').convert(validation.IS_STR)(mirror)
            description = validation.ItemPopper('description', default='').convert(validation.IS_STR)(mirror)
            image_path = validation.ItemPopper('image_path', default=ANACONDA_ICON_256_PATH).convert(
                validation.IS_STR.convert(_image_path),
            )(mirror)
            rank = validation.ItemPopper('rank', default=0).convert(validation.IS_INT)(mirror)

        is_available: bool = validation.ItemPopper('is_available', default=True).convert(
            validation.sequence_of(str, bool),
            validation.FindValue(
                validation.OfType(bool) | validation.OfType(str).convert(_call).convert(validation.IS_BOOL),
            ),
        )(mirror)

        if target_cls is None:
            validation.IsEmptyMapping()(mirror)
            return AppPatch(
                app_name=app_name,
                display_name=display_name,
                description=description,
                image_path=image_path,
                is_available=is_available,
                rank=rank,
            )

        if not is_available:
            return None

        return target_cls._parse_configuration(  # pylint: disable=protected-access
            context=context,
            configuration=mirror,

            app_name=app_name,
            config=context.user_configuration,
            display_name=display_name,
            description=description,
            image_path=image_path,
            is_available=is_available,
            rank=rank,
        )

    @staticmethod
    @abc.abstractmethod
    def _parse_configuration(
            context: parsing_utils.ParsingContext,
            configuration: typing.MutableMapping[str, typing.Any],
            **kwargs: typing.Any,  # app_name, config, display_name, description, image_path, is_available, rank
    ) -> BaseApp:
        """Parse configuration for this particular :class:`~BaseApp`."""


@attr.s(auto_attribs=True, cache_hash=True, collect_by_mro=True, frozen=True, order=False)
class BaseWebApp(BaseApp, alias=constants.AppType.WEB):
    """
    Common base for all external web applications.

    Most of the params reused from the :class:`~BaseApp`.

    :param url: Web page to open for the application.
    """

    app_type: constants.AppType = attr.ib(default=constants.AppType.WEB, init=False)
    non_conda: bool = attr.ib(default=True, init=False)

    url: str

    @property
    def tile_definition(self) -> 'api_types.Application':  # noqa: D401
        """Definition of the application tile."""
        result: 'api_types.Application' = super().tile_definition
        result['installed'] = True
        return result

    @classmethod
    def _parse_configuration(
            cls,
            context: parsing_utils.ParsingContext,
            configuration: typing.MutableMapping[str, typing.Any],
            **kwargs: typing.Any,  # app_name, config, display_name, description, image_path, is_available, rank
    ) -> BaseWebApp:
        """Parse configuration for this particular :class:`~BaseApp`."""
        url: str = validation.ItemPopper('url').convert(validation.IS_STR)(configuration)
        validation.IsEmptyMapping()(configuration)

        return BaseWebApp(url=url, **kwargs)


InstallFunction = typing.Callable[['BaseInstallableApp'], None]
InstallExtensionsFunction = typing.Callable[['BaseInstallableApp'], 'process.ProcessWorker']
UpdateConfigFunction = typing.Callable[['BaseInstallableApp', str], None]


def _install(value: str | collections.abc.Mapping[str, typing.Any]) -> InstallFunction | None:
    """Validate and resolve value of `install`."""
    if isinstance(value, collections.abc.Mapping):
        value = dict(value)

        name: str = validation.ItemPopper('func').convert(validation.IS_STR)(value)
        function: collections.abc.Callable[..., typing.Any] = import_utils.import_callable(name)

        arguments: collections.abc.Sequence[typing.Any] = validation.ItemPopper('args', default=()).convert(
            validation.IS_SEQ,
        )(value)

        def install(self: 'BaseInstallableApp') -> None:
            """Install application"""
            return function(self, *arguments, **value)

        return install

    if re.match(r'^[a-z][a-z0-9.+-]*://', value):
        def install(self: 'BaseInstallableApp') -> None:  # pylint: disable=function-redefined,unused-argument
            """Install application"""
            webbrowser.open(value)

        return install

    return import_utils.import_callable(value)


def to_string_tuple(value: typing.Iterable[typing.Any]) -> typing.Sequence[str]:
    """Convert any iterable value to a string tuple."""
    if isinstance(value, str):
        return (value,)
    return tuple(map(str, value))


@attr.s(auto_attribs=True, cache_hash=True, collect_by_mro=True, frozen=True, order=False)
class BaseInstallableApp(BaseApp, alias=constants.AppType.INSTALLABLE):
    """
    Common base for all external installable applications.

    Most of the params reused from the :class:`~BaseApp`.

    :param detector: Detector of the application location.
    :param extra_arguments: Additional arguments to pass to the application.
    :param process_api: API to spawn new processes (for extensions installation etc.).
    """

    install: typing.ClassVar[InstallFunction]

    app_type: constants.AppType = attr.ib(default=constants.AppType.INSTALLABLE, init=False)
    non_conda: bool = attr.ib(default=True, init=False)

    extra_arguments: typing.Sequence[typing.Any] = attr.ib(converter=to_string_tuple)
    _detector: 'detectors.Source'
    _process_api: process.WorkerManager
    _location: typing.Optional[detectors.DetectedApplication] = attr.ib(default=None, init=False)

    def __attrs_post_init__(self) -> None:
        """Additional initialization of the class."""
        context: detectors.DetectorContext = detectors.DetectorContext(
            app_name=self.app_name,
            user_configuration=self.config,
        )
        primary_location: 'typing.Optional[detectors.DetectedApplication]'
        secondary_location: 'typing.Optional[detectors.DetectedApplication]' = None
        for primary_location in self._detector(context=context):
            if primary_location.complete:
                break
            if secondary_location is not None:
                continue
            primary_location = primary_location.replace(version='')
            if primary_location.complete:
                secondary_location = primary_location
        else:
            primary_location = secondary_location
        object.__setattr__(self, '_location', primary_location)

        self.config.set('applications', f'{self.app_name}_path', self.root or '')

    @property
    def tile_definition(self) -> 'api_types.Application':  # noqa: D401
        """Definition of the application tile."""
        result: 'api_types.Application' = super().tile_definition
        result['command'] = self.executable or ''
        result['extra_arguments'] = self.extra_arguments
        result['installed'] = self.is_installed
        result['versions'] = self.versions
        return result

    @property
    def executable(self) -> typing.Optional[str]:  # noqa: D401
        """Command to execute the application."""
        if self._location is None:
            return None

        from anaconda_navigator.utils import launch  # pylint: disable=import-outside-toplevel

        return launch.safe_argument(self._location.executable)

    @property
    def is_installation_enabled(self) -> bool:  # noqa: D401
        """Application can be installed."""
        return hasattr(self, 'install')

    @property
    def is_installed(self) -> bool:  # noqa: D401
        """Application is installed in local system."""
        return bool(self.executable)

    @property
    def root(self) -> typing.Optional[str]:  # noqa: D401
        """Directory, in which application is installed."""
        if self._location is None:
            return None
        return self._location.root

    @property
    def version(self) -> typing.Optional[str]:  # noqa: D401
        """Version of the installed application."""
        if self._location is None:
            return None
        return self._location.version

    @property
    def versions(self) -> typing.Sequence[str]:  # noqa: D401
        """List of available application versions."""
        if self._location is None:
            return []
        return [self._location.version]

    @classmethod
    def _parse_configuration(
            cls,
            context: parsing_utils.ParsingContext,
            configuration: typing.MutableMapping[str, typing.Any],
            **kwargs: typing.Any,  # app_name, config, display_name, description, image_path, is_available, rank
    ) -> BaseInstallableApp:
        """Parse configuration for this particular :class:`~BaseApp`."""
        try:
            detector: detectors.Source = validation.ItemPopper('detector').convert(
                validation.IS_STR.convert(_call) | validation.IS_MAP.convert(detectors.Detector.parse_configuration),
                validation.OfType(detectors.Source),
            )(configuration)
        except exceptions.ValidationError:
            # show "install" option even if the application itself is unavailable
            # otherwise - remove `except` section
            detector = detectors.ZeroSource()

        extra_arguments: typing.Tuple[str, ...] = validation.ItemPopper('extra_arguments', default=None).convert(
            validation.IS_NONE.convert(lambda _: ()) | validation.IS_STR.convert(_call) | validation.IS_SEQ,
            validation.IS_SEQ,
            validation.EachValue(validation.IsAny().convert(str)).as_converter(tuple),
        )(configuration)

        result: BaseInstallableApp = BaseInstallableApp(
            detector=detector,
            process_api=context.process_api,
            extra_arguments=extra_arguments,
            **kwargs,
        )

        method: typing.Optional[typing.Callable[..., typing.Any]]
        method = validation.ItemPopper('install', default=None).convert(
            validation.sequence_of(str, bool, collections.abc.Mapping, None),
            validation.FindValue((validation.IS_STR | validation.IS_MAP).convert(_install), default=None),
        )(configuration)
        if method is not None:
            object.__setattr__(result, 'install', types.MethodType(method, result))

        method_name: str
        for method_name in ('install_extensions', 'update_config'):
            with validation.validation_to_warning(silent=True):
                method = validation.ItemPopper(method_name, default=None).convert(
                    validation.IS_NONE | validation.IS_STR.convert(import_utils.import_callable),
                )(configuration)
                if method is not None:
                    object.__setattr__(result, method_name, types.MethodType(method, result))

        validation.IsEmptyMapping()(configuration)

        return result

    def install_extensions(self) -> 'process.ProcessWorker':
        """Install app extensions."""
        return self._process_api.create_process_worker(['python', '--version'])

    def update_config(self, prefix: str) -> None:
        """Update user config to use selected Python prefix interpreter."""