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."""