Files
homeassistant_config/config/custom_components/pyscript/requirements.py
2024-08-09 06:45:02 +02:00

324 lines
14 KiB
Python

"""Requirements helpers for pyscript."""
import glob
import logging
import os
import sys
from homeassistant.loader import bind_hass
from homeassistant.requirements import async_process_requirements
from .const import (
ATTR_INSTALLED_VERSION,
ATTR_SOURCES,
ATTR_VERSION,
CONF_ALLOW_ALL_IMPORTS,
CONF_INSTALLED_PACKAGES,
DOMAIN,
LOGGER_PATH,
REQUIREMENTS_FILE,
REQUIREMENTS_PATHS,
UNPINNED_VERSION,
)
if sys.version_info[:2] >= (3, 8):
from importlib.metadata import ( # pylint: disable=no-name-in-module,import-error
PackageNotFoundError,
version as installed_version,
)
else:
from importlib_metadata import ( # pylint: disable=import-error
PackageNotFoundError,
version as installed_version,
)
_LOGGER = logging.getLogger(LOGGER_PATH)
def get_installed_version(pkg_name):
"""Get installed version of package. Returns None if not found."""
try:
return installed_version(pkg_name)
except PackageNotFoundError:
return None
def update_unpinned_versions(package_dict):
"""Check for current installed version of each unpinned package."""
requirements_to_pop = []
for package in package_dict:
if package_dict[package] != UNPINNED_VERSION:
continue
package_dict[package] = get_installed_version(package)
if not package_dict[package]:
_LOGGER.error("%s wasn't able to be installed", package)
requirements_to_pop.append(package)
for package in requirements_to_pop:
package_dict.pop(package)
return package_dict
@bind_hass
def process_all_requirements(pyscript_folder, requirements_paths, requirements_file):
"""
Load all lines from requirements_file located in requirements_paths.
Returns files and a list of packages, if any, that need to be installed.
"""
# Re-import Version to avoid dealing with multiple flake and pylint errors
from packaging.version import Version # pylint: disable=import-outside-toplevel
all_requirements_to_process = {}
for root in requirements_paths:
for requirements_path in glob.glob(os.path.join(pyscript_folder, root, requirements_file)):
with open(requirements_path, "r", encoding="utf-8") as requirements_fp:
all_requirements_to_process[requirements_path] = requirements_fp.readlines()
all_requirements_to_install = {}
for requirements_path, pkg_lines in all_requirements_to_process.items():
for pkg in pkg_lines:
# Remove inline comments which are accepted by pip but not by Home
# Assistant's installation method.
# https://rosettacode.org/wiki/Strip_comments_from_a_string#Python
i = pkg.find("#")
if i >= 0:
pkg = pkg[:i]
pkg = pkg.strip()
if not pkg or len(pkg) == 0:
continue
try:
# Attempt to get version of package. Do nothing if it's found since
# we want to use the version that's already installed to be safe
parts = pkg.split("==")
if len(parts) > 2 or "," in pkg or ">" in pkg or "<" in pkg:
_LOGGER.error(
(
"Ignoring invalid requirement '%s' specified in '%s'; if a specific version"
"is required, the requirement must use the format 'pkg==version'"
),
requirements_path,
pkg,
)
continue
if len(parts) == 1:
new_version = UNPINNED_VERSION
else:
new_version = parts[1]
pkg_name = parts[0]
current_pinned_version = all_requirements_to_install.get(pkg_name, {}).get(ATTR_VERSION)
current_sources = all_requirements_to_install.get(pkg_name, {}).get(ATTR_SOURCES, [])
# If a version hasn't already been recorded, record this one
if not current_pinned_version:
all_requirements_to_install[pkg_name] = {
ATTR_VERSION: new_version,
ATTR_SOURCES: [requirements_path],
ATTR_INSTALLED_VERSION: get_installed_version(pkg_name),
}
# If the new version is unpinned and there is an existing pinned version, use existing
# pinned version
elif new_version == UNPINNED_VERSION and current_pinned_version != UNPINNED_VERSION:
_LOGGER.warning(
(
"Unpinned requirement for package '%s' detected in '%s' will be ignored in "
"favor of the pinned version '%s' detected in '%s'"
),
pkg_name,
requirements_path,
current_pinned_version,
str(current_sources),
)
# If the new version is pinned and the existing version is unpinned, use the new pinned
# version
elif new_version != UNPINNED_VERSION and current_pinned_version == UNPINNED_VERSION:
_LOGGER.warning(
(
"Unpinned requirement for package '%s' detected in '%s will be ignored in "
"favor of the pinned version '%s' detected in '%s'"
),
pkg_name,
str(current_sources),
new_version,
requirements_path,
)
all_requirements_to_install[pkg_name] = {
ATTR_VERSION: new_version,
ATTR_SOURCES: [requirements_path],
ATTR_INSTALLED_VERSION: get_installed_version(pkg_name),
}
# If the already recorded version is the same as the new version, append the current
# path so we can show sources
elif (
new_version == UNPINNED_VERSION and current_pinned_version == UNPINNED_VERSION
) or Version(current_pinned_version) == Version(new_version):
all_requirements_to_install[pkg_name][ATTR_SOURCES].append(requirements_path)
# If the already recorded version is lower than the new version, use the new one
elif Version(current_pinned_version) < Version(new_version):
_LOGGER.warning(
(
"Version '%s' for package '%s' detected in '%s' will be ignored in "
"favor of the higher version '%s' detected in '%s'"
),
current_pinned_version,
pkg_name,
str(current_sources),
new_version,
requirements_path,
)
all_requirements_to_install[pkg_name].update(
{ATTR_VERSION: new_version, ATTR_SOURCES: [requirements_path]}
)
# If the already recorded version is higher than the new version, ignore the new one
elif Version(current_pinned_version) > Version(new_version):
_LOGGER.warning(
(
"Version '%s' for package '%s' detected in '%s' will be ignored in "
"favor of the higher version '%s' detected in '%s'"
),
new_version,
pkg_name,
requirements_path,
current_pinned_version,
str(current_sources),
)
except ValueError:
# Not valid requirements line so it can be skipped
_LOGGER.debug("Ignoring '%s' because it is not a valid package", pkg)
return all_requirements_to_install
@bind_hass
async def install_requirements(hass, config_entry, pyscript_folder):
"""Install missing requirements from requirements.txt."""
pyscript_installed_packages = config_entry.data.get(CONF_INSTALLED_PACKAGES, {}).copy()
# Import packaging inside install_requirements so that we can use Home Assistant to install it
# if it can't been found
try:
from packaging.version import Version # pylint: disable=import-outside-toplevel
except ModuleNotFoundError:
await async_process_requirements(hass, DOMAIN, ["packaging"])
from packaging.version import Version # pylint: disable=import-outside-toplevel
all_requirements = await hass.async_add_executor_job(
process_all_requirements, pyscript_folder, REQUIREMENTS_PATHS, REQUIREMENTS_FILE
)
requirements_to_install = {}
if all_requirements and not config_entry.data.get(CONF_ALLOW_ALL_IMPORTS, False):
_LOGGER.error(
(
"Requirements detected but 'allow_all_imports' is set to False, set "
"'allow_all_imports' to True if you want packages to be installed"
)
)
return
for package in all_requirements:
pkg_installed_version = all_requirements[package].get(ATTR_INSTALLED_VERSION)
version_to_install = all_requirements[package][ATTR_VERSION]
sources = all_requirements[package][ATTR_SOURCES]
# If package is already installed, we need to run some checks
if pkg_installed_version:
# If the version to install is unpinned and there is already something installed,
# defer to what is installed
if version_to_install == UNPINNED_VERSION:
_LOGGER.debug(
(
"Skipping unpinned version of package '%s' because version '%s' is "
"already installed"
),
package,
pkg_installed_version,
)
# If installed package is not the same version as the one we last installed,
# that means that the package is externally managed now so we shouldn't touch it
# and should remove it from our internal tracker
if (
package in pyscript_installed_packages
and pyscript_installed_packages[package] != pkg_installed_version
):
pyscript_installed_packages.pop(package)
continue
# If installed package is not the same version as the one we last installed,
# that means that the package is externally managed now so we shouldn't touch it
# and should remove it from our internal tracker
if package in pyscript_installed_packages and Version(
pyscript_installed_packages[package]
) != Version(pkg_installed_version):
_LOGGER.warning(
(
"Version '%s' for package '%s' detected in '%s' will be ignored in favor of"
" the version '%s' which was installed outside of pyscript"
),
version_to_install,
package,
str(sources),
pkg_installed_version,
)
pyscript_installed_packages.pop(package)
# If there is a version mismatch between what we want and what is installed, we
# can overwrite it since we know it was last installed by us
elif package in pyscript_installed_packages and Version(version_to_install) != Version(
pkg_installed_version
):
requirements_to_install[package] = all_requirements[package]
# If there is an installed version that we have not previously installed, we
# should not install it
else:
_LOGGER.debug(
(
"Version '%s' for package '%s' detected in '%s' will be ignored because it"
" is already installed"
),
version_to_install,
package,
str(sources),
)
# Anything not already installed in the environment can be installed
else:
requirements_to_install[package] = all_requirements[package]
if requirements_to_install:
_LOGGER.info(
"Installing the following packages: %s",
str(requirements_to_install),
)
await async_process_requirements(
hass,
DOMAIN,
[
f"{package}=={pkg_info[ATTR_VERSION]}"
if pkg_info[ATTR_VERSION] != UNPINNED_VERSION
else package
for package, pkg_info in requirements_to_install.items()
],
)
else:
_LOGGER.debug("No new packages to install")
# Update package tracker in config entry for next time
pyscript_installed_packages.update(
{package: pkg_info[ATTR_VERSION] for package, pkg_info in requirements_to_install.items()}
)
# If any requirements were unpinned, get their version now so they can be pinned later
if any(version == UNPINNED_VERSION for version in pyscript_installed_packages.values()):
pyscript_installed_packages = await hass.async_add_executor_job(
update_unpinned_versions, pyscript_installed_packages
)
if pyscript_installed_packages != config_entry.data.get(CONF_INSTALLED_PACKAGES, {}):
new_data = config_entry.data.copy()
new_data[CONF_INSTALLED_PACKAGES] = pyscript_installed_packages
hass.config_entries.async_update_entry(entry=config_entry, data=new_data)