Home Assistant Git Exporter
This commit is contained in:
323
config/custom_components/pyscript/requirements.py
Normal file
323
config/custom_components/pyscript/requirements.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user