diff --git a/custom_components/pyscript/__init__.py b/custom_components/pyscript/__init__.py index 43c88d4..62db1ed 100644 --- a/custom_components/pyscript/__init__.py +++ b/custom_components/pyscript/__init__.py @@ -19,7 +19,14 @@ import homeassistant.helpers.config_validation as cv from homeassistant.loader import bind_hass -from .const import CONF_ALLOW_ALL_IMPORTS, DOMAIN, FOLDER, LOGGER_PATH, SERVICE_JUPYTER_KERNEL_START +from .const import ( + CONF_ALLOW_ALL_IMPORTS, + CONF_HASS_IS_GLOBAL, + DOMAIN, + FOLDER, + LOGGER_PATH, + SERVICE_JUPYTER_KERNEL_START, +) from .eval import AstEval from .event import Event from .function import Function @@ -31,7 +38,11 @@ _LOGGER = logging.getLogger(LOGGER_PATH) PYSCRIPT_SCHEMA = vol.Schema( - {vol.Optional(CONF_ALLOW_ALL_IMPORTS, default=False): cv.boolean}, extra=vol.ALLOW_EXTRA, + { + vol.Optional(CONF_ALLOW_ALL_IMPORTS, default=False): cv.boolean, + vol.Optional(CONF_HASS_IS_GLOBAL, default=False): cv.boolean, + }, + extra=vol.ALLOW_EXTRA, ) CONFIG_SCHEMA = vol.Schema({DOMAIN: PYSCRIPT_SCHEMA}, extra=vol.ALLOW_EXTRA) diff --git a/custom_components/pyscript/config_flow.py b/custom_components/pyscript/config_flow.py index 821f192..4b29bbe 100644 --- a/custom_components/pyscript/config_flow.py +++ b/custom_components/pyscript/config_flow.py @@ -8,10 +8,16 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.core import callback -from .const import CONF_ALLOW_ALL_IMPORTS, DOMAIN +from .const import CONF_ALLOW_ALL_IMPORTS, CONF_HASS_IS_GLOBAL, DOMAIN + +CONF_BOOL_ALL = {CONF_ALLOW_ALL_IMPORTS, CONF_HASS_IS_GLOBAL} PYSCRIPT_SCHEMA = vol.Schema( - {vol.Optional(CONF_ALLOW_ALL_IMPORTS, default=False): bool}, extra=vol.ALLOW_EXTRA, + { + vol.Optional(CONF_ALLOW_ALL_IMPORTS, default=False): bool, + vol.Optional(CONF_HASS_IS_GLOBAL, default=False): bool, + }, + extra=vol.ALLOW_EXTRA, ) @@ -34,15 +40,17 @@ async def async_step_init(self, user_input: Dict[str, Any] = None) -> Dict[str, step_id="init", data_schema=vol.Schema( { - vol.Optional( - CONF_ALLOW_ALL_IMPORTS, default=self.config_entry.data[CONF_ALLOW_ALL_IMPORTS], - ): bool + vol.Optional(name, default=self.config_entry.data.get(name, False)): bool + for name in CONF_BOOL_ALL }, extra=vol.ALLOW_EXTRA, ), ) - if user_input[CONF_ALLOW_ALL_IMPORTS] != self.config_entry.data[CONF_ALLOW_ALL_IMPORTS]: + if any( + name not in self.config_entry.data or user_input[name] != self.config_entry.data[name] + for name in CONF_BOOL_ALL + ): updated_data = self.config_entry.data.copy() updated_data.update(user_input) self.hass.config_entries.async_update_entry(entry=self.config_entry, data=updated_data) @@ -108,15 +116,13 @@ async def async_step_import(self, import_config: Dict[str, Any] = None) -> Dict[ # Update values for all keys, excluding `allow_all_imports` for entries # set up through the UI. for key, val in import_config.items(): - if entry.source == SOURCE_IMPORT or key != CONF_ALLOW_ALL_IMPORTS: + if entry.source == SOURCE_IMPORT or key not in CONF_BOOL_ALL: updated_data[key] = val # Remove values for all keys in entry.data that are not in the imported config, # excluding `allow_all_imports` for entries set up through the UI. for key in entry.data: - if ( - entry.source == SOURCE_IMPORT or key != CONF_ALLOW_ALL_IMPORTS - ) and key not in import_config: + if (entry.source == SOURCE_IMPORT or key not in CONF_BOOL_ALL) and key not in import_config: updated_data.pop(key) # Update and reload entry if data needs to be updated diff --git a/custom_components/pyscript/const.py b/custom_components/pyscript/const.py index cc9c7d7..78f0660 100644 --- a/custom_components/pyscript/const.py +++ b/custom_components/pyscript/const.py @@ -5,6 +5,7 @@ FOLDER = "pyscript" CONF_ALLOW_ALL_IMPORTS = "allow_all_imports" +CONF_HASS_IS_GLOBAL = "hass_is_global" SERVICE_JUPYTER_KERNEL_START = "jupyter_kernel_start" diff --git a/custom_components/pyscript/global_ctx.py b/custom_components/pyscript/global_ctx.py index 478fdba..4a70631 100644 --- a/custom_components/pyscript/global_ctx.py +++ b/custom_components/pyscript/global_ctx.py @@ -4,7 +4,7 @@ import os from types import ModuleType -from .const import FOLDER, LOGGER_PATH +from .const import CONF_HASS_IS_GLOBAL, DOMAIN, FOLDER, LOGGER_PATH from .eval import AstEval from .function import Function from .trigger import TrigInfo @@ -26,6 +26,12 @@ def __init__(self, name, global_sym_table=None, manager=None, rel_import_path=No self.auto_start = False self.module = None self.rel_import_path = rel_import_path + config_entry = Function.hass.data.get(DOMAIN, {}) + if config_entry.data.get(CONF_HASS_IS_GLOBAL, False): + # + # expose hass as a global variable if configured + # + self.global_sym_table["hass"] = Function.hass def trigger_register(self, func): """Register a trigger function; return True if start now.""" diff --git a/custom_components/pyscript/strings.json b/custom_components/pyscript/strings.json index dfcbd73..96a6966 100644 --- a/custom_components/pyscript/strings.json +++ b/custom_components/pyscript/strings.json @@ -5,7 +5,8 @@ "title": "pyscript", "description": "Once you have created an entry, refer to the [docs](https://hacs-pyscript.readthedocs.io/en/latest/) to learn how to create scripts and functions.", "data": { - "allow_all_imports": "Allow All Imports?" + "allow_all_imports": "Allow All Imports?", + "hass_is_global": "Access hass as a global variable?" } } }, @@ -20,7 +21,8 @@ "init": { "title": "Update pyscript configuration", "data": { - "allow_all_imports": "Allow All Imports?" + "allow_all_imports": "Allow All Imports?", + "hass_is_global": "Access hass as a global variable?" } }, "no_ui_configuration_allowed": { diff --git a/custom_components/pyscript/translations/en.json b/custom_components/pyscript/translations/en.json index dfcbd73..96a6966 100644 --- a/custom_components/pyscript/translations/en.json +++ b/custom_components/pyscript/translations/en.json @@ -5,7 +5,8 @@ "title": "pyscript", "description": "Once you have created an entry, refer to the [docs](https://hacs-pyscript.readthedocs.io/en/latest/) to learn how to create scripts and functions.", "data": { - "allow_all_imports": "Allow All Imports?" + "allow_all_imports": "Allow All Imports?", + "hass_is_global": "Access hass as a global variable?" } } }, @@ -20,7 +21,8 @@ "init": { "title": "Update pyscript configuration", "data": { - "allow_all_imports": "Allow All Imports?" + "allow_all_imports": "Allow All Imports?", + "hass_is_global": "Access hass as a global variable?" } }, "no_ui_configuration_allowed": { diff --git a/docs/configuration.rst b/docs/configuration.rst index f98154b..08a4e50 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -1,17 +1,22 @@ Configuration ============= -- Go to the Integrations menu in the Home Assistant Configuration UI and add - ``Pyscript Python scripting`` from there, or add ``pyscript:`` to - ``/configuration.yaml``; pyscript has one optional configuration - parameter that allows any python package to be imported if set, eg: +- Pyscript can be configured using the UI, or via yaml. To use the UI, go to the + Configuration -> Integrations page and selection "+" to add ``Pyscript Python scripting``. + After that, you can change the settings anytime by selecting Options under Pyscript + in the Configuration page. + + Alternatively, for yaml configuration, add ``pyscript:`` to ``/configuration.yaml``. + Pyscript has two optional configuration parameters that allow any python package to be + imported and exposes the ``hass`` variable as a global (both options default to ``false``): .. code:: yaml pyscript: allow_all_imports: true + hass_is_global: true - Add files with a suffix of ``.py`` in the folder ``/pyscript``. -- Restart HASS. -- Whenever you change a script file, make a ``reload`` service call to ``pyscript``. +- Restart HASS after installing pyscript. +- Whenever you change a script file or configuration, make a ``reload`` service call to ``pyscript``. - Watch the HASS log for ``pyscript`` errors and logger output from your scripts. diff --git a/docs/reference.rst b/docs/reference.rst index ccee75c..4486586 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -4,18 +4,34 @@ Reference Configuration ------------- -Pyscript has one optional configuration setting that allows any Python package to be imported -if set, eg: +Pyscript can be configured using the UI, or via yaml. To use the UI, go to the +Configuration -> Integrations page and selection "+" to add ``Pyscript Python scripting``. +After that, you can change the settings anytime by selecting Options under Pyscript +in the Configuration page. + +Alternatively, for yaml configuration, add ``pyscript:`` to ``/configuration.yaml``. +You can't mix these two methods - your initial choice determines how you should update +these settings later. If you want to switch configuration methods you will need to +uninstall and reinstall pyscript. + +Pyscript has two optional configuration parameters that allow any python package to be +imported and exposes the ``hass`` variable as a global (both options default to ``false``). +In `/configuration.yaml``: .. code:: yaml pyscript: allow_all_imports: true + hass_is_global: true + +The settings and behavior of your code can be controlled by additional user-defined yaml +configuration settings. If you configured pyscript using the UI flow, you can still +add additional configuration settings via yaml. Since they are free-form (no fixed +schema) there is no UI configuration available for these additional settings. -The settings and behavior of your code can be controlled by additional user-defined configuration -settings. All the pyscript configuration settings are available via the variable -``pyscript.config`` (see `this section <#accessing-yaml-configuration>`__). The recommended -structure is to have entries for each application you write stored under an ``apps`` entry. +All the pyscript configuration settings are available via the variable ``pyscript.config`` +(see `this section <#accessing-yaml-configuration>`__). The recommended structure is +to have entries for each application you write stored under an ``apps`` entry. For example, applications ``my_app1`` and ``my_app2`` would be configured as: .. code:: yaml @@ -1078,6 +1094,58 @@ triggers and application logic, eg: Validating the configuration can be done either manually or with the ``voluptuous`` package. +Access to Hass +^^^^^^^^^^^^^^ + +If the ``hass_is_global`` configuration setting is set (default is off), then the variable ``hass`` +is available as a global variable in all pyscript contexts. That provides significant flexiblity +in accessing HASS internals for cases where pyscript doesn't provide some binding or access. + +Ideally you should only use ``hass`` for read-only access. However, you do need a good understanding +of ``hass`` internals and objects if you try to call functions or update anything. With great power +comes great responsibility! + +For example, you can access configuration settings like ``hass.config.latitude`` or ``hass.config.time_zone``. + +You can use ``hass`` to compute sunrise and sunset times using the same method HASS does, eg: + +.. code:: python + + import homeassistant.helpers.sun as sun + import datetime + + location = sun.get_astral_location(hass) + sunrise = location.sunrise(datetime.datetime.today()).replace(tzinfo=None) + sunset = location.sunset(datetime.datetime.today()).replace(tzinfo=None) + print(f"today sunrise = {sunrise}, sunset = {sunset}") + +Here's another method that uses the installed version of ``astral`` directly, rather than the HASS +helper function. It's a bit more crytpic since it's a very old version of ``astral``, but you can +see how the HASS configuration values are used: + +.. code:: python + + import astral + import datetime + + here = astral.Location( + ( + "", + "", + hass.config.latitude, + hass.config.longitude, + str(hass.config.time_zone), + hass.config.elevation, + ) + ) + sunrise = here.sunrise(datetime.datetime.today()).replace(tzinfo=None) + sunset = here.sunset(datetime.datetime.today()).replace(tzinfo=None) + print(f"today sunrise = {sunrise}, sunset = {sunset}") + +If there are particular HASS internals that you think many pyscript users would find useful, +consider making a feature request or PR so it becomes a built-in feature in pyscript, rather +than requiring users to always have to delve into ``hass``. + Avoiding Event Loop I/O ^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index d8c4ecf..c2a0080 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -2,7 +2,7 @@ import logging from custom_components.pyscript import PYSCRIPT_SCHEMA -from custom_components.pyscript.const import CONF_ALLOW_ALL_IMPORTS, DOMAIN +from custom_components.pyscript.const import CONF_ALLOW_ALL_IMPORTS, CONF_HASS_IS_GLOBAL, DOMAIN import pytest from pytest_homeassistant_custom_component.async_mock import patch @@ -30,7 +30,9 @@ async def test_user_flow_minimum_fields(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert CONF_ALLOW_ALL_IMPORTS in result["data"] + assert CONF_HASS_IS_GLOBAL in result["data"] assert not result["data"][CONF_ALLOW_ALL_IMPORTS] + assert not result["data"][CONF_HASS_IS_GLOBAL] async def test_user_flow_all_fields(hass): @@ -42,24 +44,29 @@ async def test_user_flow_all_fields(hass): assert result["step_id"] == "user" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_ALLOW_ALL_IMPORTS: True} + result["flow_id"], user_input={CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True} ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert CONF_ALLOW_ALL_IMPORTS in result["data"] assert result["data"][CONF_ALLOW_ALL_IMPORTS] + assert result["data"][CONF_HASS_IS_GLOBAL] async def test_user_already_configured(hass): """Test service is already configured during user setup.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_ALLOW_ALL_IMPORTS: True} + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_ALLOW_ALL_IMPORTS: True} + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -84,7 +91,9 @@ async def test_import_flow_update_allow_all_imports(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_ALLOW_ALL_IMPORTS: True} + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -140,7 +149,9 @@ async def test_import_flow_no_update(hass): async def test_import_flow_update_user(hass): """Test import config flow update excludes `allow_all_imports` from being updated when updated entry was a user entry.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True}) + DOMAIN, + context={"source": SOURCE_USER}, + data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True}), ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -154,6 +165,7 @@ async def test_import_flow_update_user(hass): assert hass.config_entries.async_entries(DOMAIN)[0].data == { CONF_ALLOW_ALL_IMPORTS: True, + CONF_HASS_IS_GLOBAL: True, "apps": {"test_app": {"param": 1}}, } @@ -161,7 +173,9 @@ async def test_import_flow_update_user(hass): async def test_import_flow_update_import(hass): """Test import config flow update includes `allow_all_imports` in update when updated entry was imported entry.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True}) + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True}), ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -179,7 +193,9 @@ async def test_import_flow_update_import(hass): async def test_options_flow_import(hass): """Test options flow aborts because configuration needs to be managed via configuration.yaml.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True}) + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True}), ) await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -199,7 +215,9 @@ async def test_options_flow_import(hass): async def test_options_flow_user_change(hass): """Test options flow updates config entry when options change.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True}) + DOMAIN, + context={"source": SOURCE_USER}, + data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True}), ) await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -211,7 +229,7 @@ async def test_options_flow_user_change(hass): assert result["step_id"] == "init" result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_ALLOW_ALL_IMPORTS: False} + result["flow_id"], user_input={CONF_ALLOW_ALL_IMPORTS: False, CONF_HASS_IS_GLOBAL: False} ) await hass.async_block_till_done() @@ -219,12 +237,15 @@ async def test_options_flow_user_change(hass): assert result["title"] == "" assert entry.data[CONF_ALLOW_ALL_IMPORTS] is False + assert entry.data[CONF_HASS_IS_GLOBAL] is False async def test_options_flow_user_no_change(hass): """Test options flow aborts when options don't change.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True}) + DOMAIN, + context={"source": SOURCE_USER}, + data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True}), ) await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -236,7 +257,7 @@ async def test_options_flow_user_no_change(hass): assert result["step_id"] == "init" result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_ALLOW_ALL_IMPORTS: True} + result["flow_id"], user_input={CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True} ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM