Skip to content

Commit b8f52e3

Browse files
authored
Merge pull request #68 from raman325/install_packages
Allow users to create a requirements.txt to install missing packages
2 parents 07542be + c1c9345 commit b8f52e3

File tree

8 files changed

+174
-0
lines changed

8 files changed

+174
-0
lines changed

custom_components/pyscript/__init__.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
import json
55
import logging
66
import os
7+
import sys
78

9+
import pkg_resources
810
import voluptuous as vol
911

1012
from homeassistant.config import async_hass_config_yaml
@@ -19,6 +21,7 @@
1921
import homeassistant.helpers.config_validation as cv
2022
from homeassistant.helpers.restore_state import RestoreStateData
2123
from homeassistant.loader import bind_hass
24+
from homeassistant.requirements import async_process_requirements
2225

2326
from .const import (
2427
CONF_ALLOW_ALL_IMPORTS,
@@ -27,6 +30,8 @@
2730
DOMAIN,
2831
FOLDER,
2932
LOGGER_PATH,
33+
REQUIREMENTS_FILE,
34+
REQUIREMENTS_PATHS,
3035
SERVICE_JUPYTER_KERNEL_START,
3136
UNSUB_LISTENERS,
3237
)
@@ -38,6 +43,17 @@
3843
from .state import State
3944
from .trigger import TrigTime
4045

46+
if sys.version_info[:2] >= (3, 8):
47+
from importlib.metadata import ( # pylint: disable=no-name-in-module,import-error
48+
PackageNotFoundError,
49+
version as installed_version,
50+
)
51+
else:
52+
from importlib_metadata import ( # pylint: disable=import-error
53+
PackageNotFoundError,
54+
version as installed_version,
55+
)
56+
4157
_LOGGER = logging.getLogger(LOGGER_PATH)
4258

4359
PYSCRIPT_SCHEMA = vol.Schema(
@@ -133,6 +149,7 @@ async def async_setup_entry(hass, config_entry):
133149

134150
State.set_pyscript_config(config_entry.data)
135151

152+
await install_requirements(hass)
136153
await load_scripts(hass, config_entry.data)
137154

138155
async def reload_scripts_handler(call):
@@ -152,6 +169,7 @@ async def reload_scripts_handler(call):
152169

153170
await unload_scripts(global_ctx_only=global_ctx_only)
154171

172+
await install_requirements(hass)
155173
await load_scripts(hass, config_entry.data, global_ctx_only=global_ctx_only)
156174

157175
start_global_contexts(global_ctx_only=global_ctx_only)
@@ -253,6 +271,73 @@ async def unload_scripts(global_ctx_only=None, unload_all=False):
253271
await GlobalContextMgr.delete(global_ctx_name)
254272

255273

274+
@bind_hass
275+
def load_all_requirement_lines(hass, requirements_paths, requirements_file):
276+
"""Load all lines from requirements_file located in requirements_paths."""
277+
all_requirements = {}
278+
for root in requirements_paths:
279+
for requirements_path in glob.glob(os.path.join(hass.config.path(FOLDER), root, requirements_file)):
280+
with open(requirements_path, "r") as requirements_fp:
281+
all_requirements[requirements_path] = requirements_fp.readlines()
282+
283+
return all_requirements
284+
285+
286+
@bind_hass
287+
async def install_requirements(hass):
288+
"""Install missing requirements from requirements.txt."""
289+
all_requirements = await hass.async_add_executor_job(
290+
load_all_requirement_lines, hass, REQUIREMENTS_PATHS, REQUIREMENTS_FILE
291+
)
292+
requirements_to_install = []
293+
for requirements_path, pkg_lines in all_requirements.items():
294+
for pkg in pkg_lines:
295+
# Remove inline comments which are accepted by pip but not by Home
296+
# Assistant's installation method.
297+
# https://rosettacode.org/wiki/Strip_comments_from_a_string#Python
298+
i = pkg.find("#")
299+
if i >= 0:
300+
pkg = pkg[:i]
301+
pkg = pkg.strip()
302+
303+
if not pkg:
304+
continue
305+
306+
try:
307+
# Attempt to get version of package. Do nothing if it's found since
308+
# we want to use the version that's already installed to be safe
309+
requirement = pkg_resources.Requirement.parse(pkg)
310+
requirement_installed_version = installed_version(requirement.project_name)
311+
312+
if requirement_installed_version in requirement:
313+
_LOGGER.debug("`%s` already found", requirement.project_name)
314+
else:
315+
_LOGGER.warning(
316+
(
317+
"`%s` already found but found version `%s` does not"
318+
" match requirement. Keeping found version."
319+
),
320+
requirement.project_name,
321+
requirement_installed_version,
322+
)
323+
except PackageNotFoundError:
324+
# Since package wasn't found, add it to installation list
325+
_LOGGER.debug("%s not found, adding it to package installation list", pkg)
326+
requirements_to_install.append(pkg)
327+
except ValueError:
328+
# Not valid requirements line so it can be skipped
329+
_LOGGER.debug("Ignoring `%s` because it is not a valid package", pkg)
330+
if requirements_to_install:
331+
_LOGGER.info(
332+
"Installing the following packages from %s: %s",
333+
requirements_path,
334+
", ".join(requirements_to_install),
335+
)
336+
await async_process_requirements(hass, DOMAIN, requirements_to_install)
337+
else:
338+
_LOGGER.debug("All packages in %s are already available", requirements_path)
339+
340+
256341
@bind_hass
257342
async def load_scripts(hass, data, global_ctx_only=None):
258343
"""Load all python scripts in FOLDER."""

custom_components/pyscript/const.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414

1515
LOGGER_PATH = "custom_components.pyscript"
1616

17+
REQUIREMENTS_FILE = "requirements.txt"
18+
REQUIREMENTS_PATHS = ("", "apps/*", "modules/*")
19+
1720
ALLOWED_IMPORTS = {
1821
"black",
1922
"cmath",

tests/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Test configuration for pyscript."""
2+
from pytest import fixture
3+
from pytest_homeassistant_custom_component.async_mock import patch
4+
5+
6+
@fixture(autouse=True)
7+
def bypass_package_install_fixture():
8+
"""Bypass package installation."""
9+
with patch("custom_components.pyscript.async_process_requirements"):
10+
yield

tests/test_decorator_errors.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ async def setup_script(hass, notify_q, now, source):
2323
"custom_components.pyscript.trigger.dt_now", return_value=now
2424
), patch(
2525
"homeassistant.config.load_yaml_config_file", return_value={}
26+
), patch(
27+
"custom_components.pyscript.load_all_requirement_lines",
28+
return_value={
29+
"/some/config/dir/pyscript/requirements.txt": [
30+
"pytube==9.7.0\n",
31+
"# another test comment\n",
32+
"pykakasi==2.0.1 # test comment\n",
33+
"\n",
34+
]
35+
},
2636
):
2737
assert await async_setup_component(hass, "pyscript", {DOMAIN: {}})
2838

tests/test_function.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,16 @@ async def setup_script(hass, notify_q, now, source):
110110
"custom_components.pyscript.trigger.dt_now", return_value=now
111111
), patch(
112112
"homeassistant.config.load_yaml_config_file", return_value={DOMAIN: {CONF_ALLOW_ALL_IMPORTS: True}}
113+
), patch(
114+
"custom_components.pyscript.load_all_requirement_lines",
115+
return_value={
116+
"/some/config/dir/pyscript/requirements.txt": [
117+
"pytube==9.7.0\n",
118+
"# another test comment\n",
119+
"pykakasi==2.0.1 # test comment\n",
120+
"\n",
121+
]
122+
},
113123
):
114124
assert await async_setup_component(hass, "pyscript", {DOMAIN: {CONF_ALLOW_ALL_IMPORTS: True}})
115125

tests/test_init.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ async def setup_script(hass, notify_q, now, source):
3232
"custom_components.pyscript.trigger.dt_now", return_value=now
3333
), patch(
3434
"homeassistant.config.load_yaml_config_file", return_value={}
35+
), patch(
36+
"custom_components.pyscript.load_all_requirement_lines",
37+
return_value={
38+
"/some/config/dir/pyscript/requirements.txt": [
39+
"pytube==9.7.0\n",
40+
"# another test comment\n",
41+
"pykakasi==2.0.1 # test comment\n",
42+
"\n",
43+
]
44+
},
3545
):
3646
assert await async_setup_component(hass, "pyscript", {DOMAIN: {}})
3747

@@ -446,6 +456,16 @@ def func5(var_name=None, value=None):
446456
"custom_components.pyscript.trigger.dt_now", return_value=now
447457
), patch(
448458
"homeassistant.config.load_yaml_config_file", return_value={}
459+
), patch(
460+
"custom_components.pyscript.load_all_requirement_lines",
461+
return_value={
462+
"/some/config/dir/pyscript/requirements.txt": [
463+
"pytube==9.7.0\n",
464+
"# another test comment\n",
465+
"pykakasi==2.0.1 # test comment\n",
466+
"\n",
467+
]
468+
},
449469
):
450470
reload_param = {}
451471
if i % 2 == 1:
@@ -482,3 +502,19 @@ async def test_misc_errors(hass, caplog):
482502
assert "State class is not meant to be instantiated" in caplog.text
483503
assert "Event class is not meant to be instantiated" in caplog.text
484504
assert "TrigTime class is not meant to be instantiated" in caplog.text
505+
506+
507+
async def test_install_requirements(hass):
508+
"""Test install_requirements function."""
509+
with patch("custom_components.pyscript.async_hass_config_yaml", return_value={}), patch(
510+
"custom_components.pyscript.async_process_requirements"
511+
) as install_requirements:
512+
await setup_script(hass, None, dt(2020, 7, 1, 11, 59, 59, 999999), "")
513+
assert install_requirements.called
514+
assert install_requirements.call_args[0][2] == ["pytube==9.7.0", "pykakasi==2.0.1"]
515+
install_requirements.reset_mock()
516+
# Because in tests, packages are not installed, we fake that they are
517+
# installed so we can test that we don't attempt to install them
518+
with patch("custom_components.pyscript.installed_version", return_value="2.0.1"):
519+
await hass.services.async_call("pyscript", "reload", {}, blocking=True)
520+
assert not install_requirements.called

tests/test_jupyter.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,16 @@ async def setup_script(hass, now, source, no_connect=False):
117117
"custom_components.pyscript.trigger.dt_now", return_value=now
118118
), patch(
119119
"homeassistant.config.load_yaml_config_file", return_value={}
120+
), patch(
121+
"custom_components.pyscript.load_all_requirement_lines",
122+
return_value={
123+
"/some/config/dir/pyscript/requirements.txt": [
124+
"pytube==9.7.0\n",
125+
"# another test comment\n",
126+
"pykakasi==2.0.1 # test comment\n",
127+
"\n",
128+
]
129+
},
120130
):
121131
assert await async_setup_component(hass, "pyscript", {DOMAIN: {}})
122132

tests/test_unique.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ async def setup_script(hass, notify_q, now, source):
2323
"custom_components.pyscript.trigger.dt_now", return_value=now
2424
), patch(
2525
"homeassistant.config.load_yaml_config_file", return_value={}
26+
), patch(
27+
"custom_components.pyscript.load_all_requirement_lines",
28+
return_value={
29+
"/some/config/dir/pyscript/requirements.txt": [
30+
"pytube==9.7.0\n",
31+
"# another test comment\n",
32+
"pykakasi==2.0.1 # test comment\n",
33+
"\n",
34+
]
35+
},
2636
):
2737
assert await async_setup_component(hass, "pyscript", {DOMAIN: {}})
2838

0 commit comments

Comments
 (0)