324 lines
14 KiB
Python
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)
|