├── docs ├── requirements.txt ├── contributing.rst ├── Makefile ├── make.bat ├── useful_links.rst ├── configuration.rst ├── new_features.rst ├── index.rst ├── installation.rst ├── conf.py ├── overview.rst └── tutorial.rst ├── tests ├── __init__.py ├── test_data │ └── test_requirements │ │ ├── apps │ │ └── app1 │ │ │ └── requirements.txt │ │ └── requirements.txt ├── conftest.py ├── bandit.yaml ├── requirements_test.txt ├── README.md ├── test_state.py ├── test_stubs.py ├── test_tasks.py ├── test_apps_modules.py ├── test_decorators.py ├── test_unique.py ├── test_reload.py ├── test_config_flow.py ├── test_decorator_errors.py └── test_requirements.py ├── custom_components ├── __init__.py └── pyscript │ ├── manifest.json │ ├── entity.py │ ├── strings.json │ ├── translations │ ├── en.json │ ├── sk.json │ ├── tr.json │ └── de.json │ ├── logbook.py │ ├── const.py │ ├── event.py │ ├── mqtt.py │ ├── services.yaml │ ├── webhook.py │ ├── config_flow.py │ ├── global_ctx.py │ └── requirements.py ├── .gitignore ├── pyproject.toml ├── hacs.json ├── .readthedocs.yaml ├── .yamllint ├── .github └── workflows │ └── validate.yml ├── .pre-commit-config.yaml ├── pylintrc ├── setup.cfg ├── info.md ├── README.md └── LICENSE /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx_rtd_theme 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the Pyscript Python scripting integration.""" 2 | -------------------------------------------------------------------------------- /tests/test_data/test_requirements/apps/app1/requirements.txt: -------------------------------------------------------------------------------- 1 | my-package-name==2.0.1 2 | my-package-name-alternate==0.0.1 -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """Dummy __init__.py to make imports with pytest-homeassistant-custom-component reliable.""" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | icon 3 | venv 4 | hass-custom-pyscript.zip 5 | docs/_build 6 | .coverage 7 | .vscode 8 | .*.swp 9 | .idea 10 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 109 3 | 4 | #[tool.pytest.ini_options] 5 | #asyncio_mode = "auto" 6 | #asyncio_default_fixture_loop_scope = "function" 7 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pyscript", 3 | "content_in_root": false, 4 | "domains": ["automation", "script", "timer"], 5 | "zip_release": true, 6 | "filename": "hass-custom-pyscript.zip" 7 | } 8 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Test: Global configuration for pytest.""" 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture(autouse=True) 7 | def auto_enable_custom_integrations(enable_custom_integrations): 8 | """Enable custom integrations in all tests.""" 9 | yield 10 | -------------------------------------------------------------------------------- /tests/bandit.yaml: -------------------------------------------------------------------------------- 1 | # https://bandit.readthedocs.io/en/latest/config.html 2 | 3 | tests: 4 | - B108 5 | - B306 6 | - B307 7 | - B313 8 | - B314 9 | - B315 10 | - B316 11 | - B317 12 | - B318 13 | - B319 14 | - B320 15 | - B325 16 | - B602 17 | - B604 18 | -------------------------------------------------------------------------------- /tests/requirements_test.txt: -------------------------------------------------------------------------------- 1 | coverage==7.10.6 2 | croniter==6.0.0 3 | watchdog==6.0.0 4 | mock-open==1.4.0 5 | mypy==1.10.1 6 | pycares<5.0.0 7 | pre-commit==3.7.1 8 | pytest==9.0.0 9 | pytest-cov==7.0.0 10 | pytest-homeassistant-custom-component==0.13.299 11 | pylint==3.3.4 12 | pylint-strict-informational==0.1 13 | -------------------------------------------------------------------------------- /tests/test_data/test_requirements/requirements.txt: -------------------------------------------------------------------------------- 1 | # Comment 2 | my-package-name==2.0.0 3 | my-package-name==0.2.2 4 | my-package-name==2.0.1 5 | > 6 | my-package-name-alternate==2.0.1 # Comment 7 | my-package-name-alternate 8 | 9 | my-package-name-alternate-1 10 | my-package-name-alternate-1==0.0.1 11 | 12 | my-package-name-test-fail>=0 13 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 2 | 3 | # Required 4 | version: 2 5 | 6 | # Set the OS, Python version and other tools you might need 7 | build: 8 | os: ubuntu-22.04 9 | tools: 10 | python: "3.12" 11 | 12 | # Build documentation in the "docs/" directory with Sphinx 13 | sphinx: 14 | configuration: docs/conf.py 15 | 16 | python: 17 | install: 18 | - requirements: docs/requirements.txt 19 | -------------------------------------------------------------------------------- /custom_components/pyscript/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "pyscript", 3 | "name": "Pyscript Python scripting", 4 | "codeowners": [ 5 | "@craigbarratt" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [], 9 | "documentation": "https://github.com/custom-components/pyscript", 10 | "homekit": {}, 11 | "iot_class": "local_push", 12 | "issue_tracker": "https://github.com/custom-components/pyscript/issues", 13 | "requirements": ["croniter==6.0.0", "watchdog==6.0.0"], 14 | "ssdp": [], 15 | "version": "1.7.0", 16 | "zeroconf": [] 17 | } 18 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Contributions are welcome! You are encouraged to submit PRs, bug 5 | reports, feature requests or add to the Wiki with examples and 6 | tutorials. It would be fun to hear about unique and clever applications 7 | you develop. Please see this 8 | `README `__ 9 | for setting up a development environment and running tests. 10 | 11 | Even if you aren't a developer, please participate in our 12 | `discussions community `__. 13 | Helping other users is another great way to contribute to pyscript! 14 | -------------------------------------------------------------------------------- /custom_components/pyscript/entity.py: -------------------------------------------------------------------------------- 1 | """Entity Classes.""" 2 | 3 | from homeassistant.const import STATE_UNKNOWN 4 | from homeassistant.helpers.restore_state import RestoreEntity 5 | from homeassistant.helpers.typing import StateType 6 | 7 | 8 | class PyscriptEntity(RestoreEntity): 9 | """Generic Pyscript Entity.""" 10 | 11 | _attr_extra_state_attributes: dict 12 | _attr_state: StateType = STATE_UNKNOWN 13 | 14 | def set_state(self, state): 15 | """Set the state.""" 16 | self._attr_state = state 17 | 18 | def set_attributes(self, attributes): 19 | """Set Attributes.""" 20 | self._attr_extra_state_attributes = attributes 21 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/useful_links.rst: -------------------------------------------------------------------------------- 1 | Useful Links 2 | ============ 3 | 4 | - `Documentation stable `__: latest release 5 | - `Documentation latest `__: current master in GitHub 6 | - `Discussion and help `__: community support and discussion 7 | - `GitHub repository `__ (please add a star if you like pyscript!) 8 | - `Release notes `__: see what's changed 9 | - `Issues `__: report bugs or propose new features 10 | - `Wiki `__: share your pyscript apps and scripts 11 | - `Using Jupyter `__ 12 | - `Jupyter notebook tutorial `__ 13 | -------------------------------------------------------------------------------- /docs/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | - Pyscript can be configured using the UI, or via yaml. To use the UI, go to the 5 | Configuration -> Integrations page and selection "+" to add ``Pyscript Python scripting``. 6 | After that, you can change the settings anytime by selecting Options under Pyscript 7 | in the Configuration page. 8 | 9 | Alternatively, for yaml configuration, add ``pyscript:`` to ``/configuration.yaml``. 10 | Pyscript has two optional configuration parameters that allow any python package to be 11 | imported and exposes the ``hass`` variable as a global (both options default to ``false``): 12 | 13 | .. code:: yaml 14 | 15 | pyscript: 16 | allow_all_imports: true 17 | hass_is_global: true 18 | 19 | - Add files with a suffix of ``.py`` in the folder ``/pyscript``. 20 | - Restart HASS after installing pyscript. 21 | - Whenever you change a script file or app, pyscript will automatically reload the changed files. 22 | To reload all files and apps, call the ``pyscript.reload`` service with the optional 23 | ``global_ctx`` parameter to ``*``. 24 | - Watch the HASS log for ``pyscript`` errors and logger output from your scripts. 25 | -------------------------------------------------------------------------------- /docs/new_features.rst: -------------------------------------------------------------------------------- 1 | Releases and New Features 2 | ========================= 3 | 4 | The releases and release notes are available on `GitHub `__. 5 | Use HACS to install different versions of pyscript. 6 | 7 | You can also install the master (head of tree) version from GitHub, either using HACS or manually. 8 | Because pyscript has quite a few unit tests, generally the master version should work ok. But it's not 9 | guaranteed to work at any random time, and newly-added features might change. 10 | 11 | This is 1.7.0, released on December 08, 2025. Here is the `documentation 12 | `__ for that release. Here is the 13 | `stable documentation `__ for 14 | the latest release. 15 | 16 | Over time, the master (head of tree) version in GitHub will include new features and bug fixes. 17 | Here is the `latest documentation `__ if you want 18 | to see the development version of the documentation. 19 | 20 | If you want to see development progress since 1.7.0, look at the 21 | `GitHub repository `__. 22 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | ignore: | 2 | azure-*.yml 3 | rules: 4 | braces: 5 | level: error 6 | min-spaces-inside: 0 7 | max-spaces-inside: 1 8 | min-spaces-inside-empty: -1 9 | max-spaces-inside-empty: -1 10 | brackets: 11 | level: error 12 | min-spaces-inside: 0 13 | max-spaces-inside: 0 14 | min-spaces-inside-empty: -1 15 | max-spaces-inside-empty: -1 16 | colons: 17 | level: error 18 | max-spaces-before: 0 19 | max-spaces-after: 1 20 | commas: 21 | level: error 22 | max-spaces-before: 0 23 | min-spaces-after: 1 24 | max-spaces-after: 1 25 | comments: 26 | level: error 27 | require-starting-space: true 28 | min-spaces-from-content: 2 29 | comments-indentation: 30 | level: error 31 | document-end: 32 | level: error 33 | present: false 34 | document-start: 35 | level: error 36 | present: false 37 | empty-lines: 38 | level: error 39 | max: 1 40 | max-start: 0 41 | max-end: 1 42 | hyphens: 43 | level: error 44 | max-spaces-after: 1 45 | indentation: 46 | level: error 47 | spaces: 2 48 | indent-sequences: true 49 | check-multi-line-strings: false 50 | key-duplicates: 51 | level: error 52 | line-length: disable 53 | new-line-at-end-of-file: 54 | level: error 55 | new-lines: 56 | level: error 57 | type: unix 58 | trailing-spaces: 59 | level: error 60 | truthy: disable 61 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Pyscript: Python Scripting for Home Assistant 2 | ============================================= 3 | 4 | |GitHub Release| |License| |hacs| |Project Maintenance| 5 | 6 | This HACS custom integration for Home Assistant allows you to write Python functions 7 | and scripts that can implement a wide range of automation, logic and triggers. 8 | State variables are bound to Python variables and services are callable as Python 9 | functions, so it's easy and concise to implement logic. 10 | 11 | Contents 12 | ~~~~~~~~ 13 | 14 | .. toctree:: 15 | :maxdepth: 3 16 | 17 | overview 18 | installation 19 | configuration 20 | tutorial 21 | reference 22 | contributing 23 | new_features 24 | useful_links 25 | 26 | .. |GitHub Release| image:: https://img.shields.io/github/release/custom-components/pyscript.svg?style=for-the-badge 27 | :target: https://github.com/custom-components/pyscript/releases 28 | .. |License| image:: https://img.shields.io/github/license/custom-components/pyscript.svg?style=for-the-badge 29 | :target: https://github.com/custom-components/pyscript/blob/master/LICENSE 30 | .. |hacs| image:: https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge 31 | :target: https://github.com/custom-components/hacs 32 | .. |Project Maintenance| image:: https://img.shields.io/badge/maintainer-%40craigbarratt-blue.svg?style=for-the-badge 33 | :target: https://github.com/craigbarratt 34 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Pyscript Development Setup 2 | 3 | These setup commands only need to be executed once. First, clone the repository: 4 | ```bash 5 | git clone https://github.com/custom-components/pyscript.git 6 | cd pyscript 7 | ``` 8 | 9 | Next, create a virtual environment (make sure `python` is at least `v3.7`; you might need to use 10 | `python3` instead): 11 | ```bash 12 | python -m venv venv 13 | source venv/bin/activate 14 | ``` 15 | 16 | Finally, install the requirements and the pre-commit hooks: 17 | ```bash 18 | python -m pip install -r tests/requirements_test.txt 19 | pre-commit install 20 | ``` 21 | 22 | To submit PRs you will want to fork the repository, and follow the steps above on your 23 | forked repository. Once you have pushed your changes to the fork (which should run the 24 | pre-commit hooks), you can go ahead and submit a PR. 25 | 26 | # Pyscript Tests 27 | 28 | This directory contains various tests for pyscript. 29 | 30 | After completing the above setup steps, you need to activate the virtual environment 31 | every time you start a new shell: 32 | ```bash 33 | source venv/bin/activate 34 | ``` 35 | 36 | Now you can run the tests using this command: 37 | ```bash 38 | pytest 39 | ``` 40 | or run a specific test file with: 41 | ```bash 42 | pytest tests/test_function.py 43 | ``` 44 | 45 | You can check coverage and list specific missing lines with: 46 | ``` 47 | pytest --cov=custom_components/pyscript --cov-report term-missing 48 | ``` 49 | -------------------------------------------------------------------------------- /custom_components/pyscript/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "pyscript", 6 | "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.", 7 | "data": { 8 | "allow_all_imports": "Allow All Imports?", 9 | "hass_is_global": "Access hass as a global variable?" 10 | } 11 | } 12 | }, 13 | "abort": { 14 | "already_configured": "Already configured.", 15 | "single_instance_allowed": "Already configured. Only a single configuration possible.", 16 | "updated_entry": "This entry has already been setup but the configuration has been updated." 17 | } 18 | }, 19 | "options": { 20 | "step": { 21 | "init": { 22 | "title": "Update pyscript configuration", 23 | "data": { 24 | "allow_all_imports": "Allow All Imports?", 25 | "hass_is_global": "Access hass as a global variable?" 26 | } 27 | }, 28 | "no_ui_configuration_allowed": { 29 | "title": "No UI configuration allowed", 30 | "description": "This entry was created via `configuration.yaml`, so all configuration parameters must be updated there. The [`pyscript.reload`](developer-tools/service) service will allow you to apply the changes you make to `configuration.yaml` without restarting your Home Assistant instance." 31 | }, 32 | "no_update": { 33 | "title": "No update needed", 34 | "description": "There is nothing to update." 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /custom_components/pyscript/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "pyscript", 6 | "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.", 7 | "data": { 8 | "allow_all_imports": "Allow All Imports?", 9 | "hass_is_global": "Access hass as a global variable?" 10 | } 11 | } 12 | }, 13 | "abort": { 14 | "already_configured": "Already configured.", 15 | "single_instance_allowed": "Already configured. Only a single configuration possible.", 16 | "updated_entry": "This entry has already been setup but the configuration has been updated." 17 | } 18 | }, 19 | "options": { 20 | "step": { 21 | "init": { 22 | "title": "Update pyscript configuration", 23 | "data": { 24 | "allow_all_imports": "Allow All Imports?", 25 | "hass_is_global": "Access hass as a global variable?" 26 | } 27 | }, 28 | "no_ui_configuration_allowed": { 29 | "title": "No UI configuration allowed", 30 | "description": "This entry was created via `configuration.yaml`, so all configuration parameters must be updated there. The [`pyscript.reload`](developer-tools/service) service will allow you to apply the changes you make to `configuration.yaml` without restarting your Home Assistant instance." 31 | }, 32 | "no_update": { 33 | "title": "No update needed", 34 | "description": "There is nothing to update." 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /custom_components/pyscript/translations/sk.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "pyscript", 6 | "description": "Akonáhle ste vytvorili položku, pozrite si [docs](https://hacs-pyscript.readthedocs.io/en/latest/) naučiť sa, ako vytvárať skripty a funkcie.", 7 | "data": { 8 | "allow_all_imports": "Povoliť všetky importy?", 9 | "hass_is_global": "Prístup k globálnej premennej?" 10 | } 11 | } 12 | }, 13 | "abort": { 14 | "already_configured": "Už konfigurované.", 15 | "single_instance_allowed": "Už nakonfigurované. Iba jedna možná konfigurácia.", 16 | "updated_entry": "Táto položka už bola nastavená, ale konfigurácia bola aktualizovaná." 17 | } 18 | }, 19 | "options": { 20 | "step": { 21 | "init": { 22 | "title": "Aktualizovať pyscript konfiguráciu", 23 | "data": { 24 | "allow_all_imports": "povoliť všetky importy?", 25 | "hass_is_global": "Prístup k globálnej premennej?" 26 | } 27 | }, 28 | "no_ui_configuration_allowed": { 29 | "title": "Nie je povolená konfigurácia používateľského rozhrania", 30 | "description": "Tento záznam bol vytvorený cez `configuration.yaml`, Takže všetky konfiguračné parametre sa musia aktualizovať. [`pyscript.reload`](developer-tools/service) Služba vám umožní uplatniť zmeny, ktoré vykonáte `configuration.yaml` bez reštartovania inštancie Home Assistant." 31 | }, 32 | "no_update": { 33 | "title": "Nie je potrebná aktualizácia", 34 | "description": "Nie je nič na aktualizáciu." 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /custom_components/pyscript/logbook.py: -------------------------------------------------------------------------------- 1 | """Describe logbook events.""" 2 | 3 | import logging 4 | 5 | from homeassistant.core import callback 6 | 7 | from .const import DOMAIN 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | 12 | @callback 13 | def async_describe_events(hass, async_describe_event): # type: ignore 14 | """Describe logbook events.""" 15 | 16 | @callback 17 | def async_describe_logbook_event(event): # type: ignore 18 | """Describe a logbook event.""" 19 | data = event.data 20 | func_args = data.get("func_args", {}) 21 | ev_name = data.get("name", "unknown") 22 | ev_entity_id = data.get("entity_id", "pyscript.unknown") 23 | 24 | ev_trigger_type = func_args.get("trigger_type", "unknown") 25 | if ev_trigger_type == "event": 26 | ev_source = f"event {func_args.get('event_type', 'unknown event')}" 27 | elif ev_trigger_type == "state": 28 | ev_source = f"state change {func_args.get('var_name', 'unknown entity')} == {func_args.get('value', 'unknown value')}" 29 | elif ev_trigger_type == "time": 30 | ev_trigger_time = func_args.get("trigger_time", "unknown") 31 | if ev_trigger_time is None: 32 | ev_trigger_time = "startup" 33 | ev_source = f"time {ev_trigger_time}" 34 | else: 35 | ev_source = ev_trigger_type 36 | 37 | message = f"has been triggered by {ev_source}" 38 | 39 | return { 40 | "name": ev_name, 41 | "message": message, 42 | "source": ev_source, 43 | "entity_id": ev_entity_id, 44 | } 45 | 46 | async_describe_event(DOMAIN, "pyscript_running", async_describe_logbook_event) 47 | -------------------------------------------------------------------------------- /custom_components/pyscript/translations/tr.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "pyscript", 6 | "description": "Bir girdi oluşturduktan sonra, betik ve fonksiyon oluşturmayı öğrenmek için [dokümantasyona](https://hacs-pyscript.readthedocs.io/en/latest/) bakabilirsiniz.", 7 | "data": { 8 | "allow_all_imports": "Tüm içe aktarmalara izin verilsin mi?", 9 | "hass_is_global": "hass'a global değişken olarak erişilsin mi?" 10 | } 11 | } 12 | }, 13 | "abort": { 14 | "already_configured": "Zaten yapılandırılmış.", 15 | "single_instance_allowed": "Zaten yapılandırılmış. Yalnızca tek bir yapılandırma mümkündür.", 16 | "updated_entry": "Bu girdi zaten kurulmuş ancak yapılandırma güncellendi." 17 | } 18 | }, 19 | "options": { 20 | "step": { 21 | "init": { 22 | "title": "pyscript yapılandırmasını güncelle", 23 | "data": { 24 | "allow_all_imports": "Tüm içe aktarmalara izin verilsin mi?", 25 | "hass_is_global": "hass'a global değişken olarak erişilsin mi?" 26 | } 27 | }, 28 | "no_ui_configuration_allowed": { 29 | "title": "Arayüz yapılandırmasına izin verilmiyor", 30 | "description": "Bu girdi `configuration.yaml` dosyası aracılığıyla oluşturulmuştur, bu nedenle tüm yapılandırma parametreleri orada güncellenmelidir. [`pyscript.reload`](developer-tools/service) servisi, Home Assistant örneğinizi yeniden başlatmadan `configuration.yaml` dosyasında yaptığınız değişiklikleri uygulamanıza olanak tanır." 31 | }, 32 | "no_update": { 33 | "title": "Güncelleme gerekmiyor", 34 | "description": "Güncellenecek bir şey yok." 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /custom_components/pyscript/const.py: -------------------------------------------------------------------------------- 1 | """Define pyscript-wide constants.""" 2 | 3 | # 4 | # 2023.7 supports service response; handle older versions by defaulting enum 5 | # Should eventually deprecate this and just use SupportsResponse import 6 | # 7 | try: 8 | from homeassistant.core import SupportsResponse 9 | 10 | SERVICE_RESPONSE_NONE = SupportsResponse.NONE 11 | SERVICE_RESPONSE_OPTIONAL = SupportsResponse.OPTIONAL 12 | SERVICE_RESPONSE_ONLY = SupportsResponse.ONLY 13 | except ImportError: 14 | SERVICE_RESPONSE_NONE = None 15 | SERVICE_RESPONSE_OPTIONAL = None 16 | SERVICE_RESPONSE_ONLY = None 17 | 18 | DOMAIN = "pyscript" 19 | 20 | CONFIG_ENTRY = "config_entry" 21 | CONFIG_ENTRY_OLD = "config_entry_old" 22 | UNSUB_LISTENERS = "unsub_listeners" 23 | 24 | FOLDER = "pyscript" 25 | 26 | UNPINNED_VERSION = "_unpinned_version" 27 | 28 | ATTR_INSTALLED_VERSION = "installed_version" 29 | ATTR_SOURCES = "sources" 30 | ATTR_VERSION = "version" 31 | 32 | CONF_ALLOW_ALL_IMPORTS = "allow_all_imports" 33 | CONF_HASS_IS_GLOBAL = "hass_is_global" 34 | CONF_INSTALLED_PACKAGES = "_installed_packages" 35 | 36 | SERVICE_JUPYTER_KERNEL_START = "jupyter_kernel_start" 37 | SERVICE_GENERATE_STUBS = "generate_stubs" 38 | 39 | LOGGER_PATH = "custom_components.pyscript" 40 | 41 | REQUIREMENTS_FILE = "requirements.txt" 42 | REQUIREMENTS_PATHS = ("", "apps/*", "modules/*", "scripts/**") 43 | 44 | WATCHDOG_TASK = "watch_dog_task" 45 | 46 | ALLOWED_IMPORTS = { 47 | "black", 48 | "cmath", 49 | "datetime", 50 | "decimal", 51 | "fractions", 52 | "functools", 53 | "homeassistant.const", 54 | "isort", 55 | "json", 56 | "math", 57 | "number", 58 | "random", 59 | "re", 60 | "statistics", 61 | "string", 62 | "time", 63 | "voluptuous", 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Push and PR actions 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | validate: 9 | runs-on: "ubuntu-latest" 10 | steps: 11 | - uses: "actions/checkout@v2" 12 | - uses: home-assistant/actions/hassfest@master 13 | 14 | style: 15 | runs-on: "ubuntu-latest" 16 | name: Check style formatting 17 | steps: 18 | - uses: "actions/checkout@v2" 19 | - uses: "actions/setup-python@v1" 20 | with: 21 | python-version: "3.13" 22 | allow-prereleases: true 23 | - run: python3 -m pip install black 24 | - run: black . 25 | 26 | pytest: 27 | runs-on: "ubuntu-latest" 28 | name: Run tests 29 | steps: 30 | - uses: "actions/checkout@v2" 31 | - uses: "actions/setup-python@v1" 32 | with: 33 | python-version: "3.13" 34 | allow-prereleases: true 35 | - run: python3 -m pip install -r tests/requirements_test.txt 36 | - run: pytest --cov=custom_components 37 | 38 | pylint: 39 | runs-on: "ubuntu-latest" 40 | name: Run pylint 41 | steps: 42 | - uses: "actions/checkout@v2" 43 | - uses: "actions/setup-python@v1" 44 | with: 45 | python-version: "3.13" 46 | allow-prereleases: true 47 | - run: python3 -m pip install -r tests/requirements_test.txt 48 | - run: pylint custom_components/pyscript/*.py tests/*.py 49 | 50 | mypy: 51 | runs-on: "ubuntu-latest" 52 | name: Run mypy 53 | steps: 54 | - uses: "actions/checkout@v2" 55 | - uses: "actions/setup-python@v1" 56 | with: 57 | python-version: "3.13" 58 | allow-prereleases: true 59 | - run: python3 -m pip install -r tests/requirements_test.txt 60 | - run: mypy custom_components/pyscript/*.py tests/*.py 61 | -------------------------------------------------------------------------------- /custom_components/pyscript/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "pyscript", 6 | "description": "Wenn Sie einen Eintrag angelegt haben, können Sie sich die [Doku (Englisch)](https://hacs-pyscript.readthedocs.io/en/latest/) ansehen, um zu lernen wie Sie Scripts und Funktionen erstellen können.", 7 | "data": { 8 | "allow_all_imports": "Alle Importe erlauben?", 9 | "hass_is_global": "Home Assistant als globale Variable verwenden?" 10 | } 11 | } 12 | }, 13 | "abort": { 14 | "already_configured": "Bereits konfiguriert.", 15 | "single_instance_allowed": "Bereits konfiguriert. Es ist nur eine Konfiguration gleichzeitig möglich", 16 | "updated_entry": "Der Eintrag wurde bereits erstellt, aber die Konfiguration wurde aktualisiert." 17 | } 18 | }, 19 | "options": { 20 | "step": { 21 | "init": { 22 | "title": "Pyscript configuration aktualisieren", 23 | "data": { 24 | "allow_all_imports": "Alle Importe erlauben??", 25 | "hass_is_global": "Home Assistant als globale Variable verwenden?" 26 | } 27 | }, 28 | "no_ui_configuration_allowed": { 29 | "title": "Die Konfiguartion der graphischen Nutzeroberfläche ist deaktiviert", 30 | "description": "Der Eintrag wurde über die Datei `configuration.yaml` erstellt. Alle Konfigurationsparameter müssen desshalb dort eingestellt werden. Der [`pyscript.reload`](developer-tools/service) Service übernimmt alle Änderungen aus `configuration.yaml`, ohne dass Home Assistant neu gestartet werden muss." 31 | }, 32 | "no_update": { 33 | "title": "Keine Aktualisierung notwendig", 34 | "description": "Es gibt nichts zu aktualisieren." 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 25.1.0 4 | hooks: 5 | - id: black 6 | args: 7 | - --safe 8 | - --quiet 9 | files: ^((custom_components|tests)/.+)?[^/]+\.py$ 10 | - repo: https://github.com/codespell-project/codespell 11 | rev: v2.4.1 12 | hooks: 13 | - id: codespell 14 | args: 15 | - --ignore-words-list=hass 16 | - --skip="./.*,*.csv,*.json" 17 | - --quiet-level=2 18 | exclude_types: [csv, json] 19 | - repo: https://github.com/pycqa/flake8 20 | rev: 7.3.0 21 | hooks: 22 | - id: flake8 23 | additional_dependencies: 24 | - flake8-docstrings==1.6.0 25 | - pydocstyle==6.1.1 26 | files: ^(custom_components|tests)/.+\.py$ 27 | - repo: https://github.com/PyCQA/bandit 28 | rev: 1.8.6 29 | hooks: 30 | - id: bandit 31 | args: 32 | - --quiet 33 | - --format=custom 34 | - --configfile=tests/bandit.yaml 35 | files: ^(custom_components|tests)/.+\.py$ 36 | - repo: https://github.com/PyCQA/isort 37 | rev: 6.0.1 38 | hooks: 39 | - id: isort 40 | - repo: https://github.com/pre-commit/pre-commit-hooks 41 | rev: v6.0.0 42 | hooks: 43 | - id: check-executables-have-shebangs 44 | stages: [manual] 45 | - id: check-json 46 | - repo: https://github.com/adrienverge/yamllint.git 47 | rev: v1.37.1 48 | hooks: 49 | - id: yamllint 50 | - repo: https://github.com/pycqa/pylint 51 | rev: v3.3.4 52 | hooks: 53 | - id: pylint 54 | args: [-j, "0", --disable=import-error] 55 | files: ^(custom_components|tests)/.+\.py$ 56 | additional_dependencies: 57 | - pylint_strict_informational 58 | - repo: https://github.com/pre-commit/mirrors-mypy 59 | rev: v1.17.1 60 | hooks: 61 | - id: mypy 62 | name: mypy 63 | files: ^(custom_components|tests)/.+\.py$ 64 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Option 1: Home Assistant Community Store (HACS) 5 | ----------------------------------------------- 6 | 7 | HACS is an integration in Home Assistant that allows you to 8 | install custom integrations, frontend elements, and add-ons 9 | developed by the Home Assistant community without the need 10 | to manually download and copy files. To install HACS, follow 11 | the instructions on the 12 | `HACS website `__. 13 | 14 | With HACS installed, under HACS -> Integrations, select “+”, 15 | search for ``pyscript``, and install it. 16 | 17 | During installation you will be asked to identify whether to 18 | allow all imports and whether to allow access to HASS as a 19 | global variable. These settings are documented on the 20 | `overview `__ 21 | page and can be changed after installation in the integration 22 | configuration. 23 | 24 | Option 2: Manual 25 | ---------------- 26 | 27 | From the `latest release `__ 28 | download the zip file ``hass-custom-pyscript.zip`` 29 | 30 | .. code:: bash 31 | 32 | cd YOUR_HASS_CONFIG_DIRECTORY # same place as configuration.yaml 33 | mkdir -p custom_components/pyscript 34 | cd custom_components/pyscript 35 | unzip hass-custom-pyscript.zip 36 | 37 | Alternatively, you can install the current GitHub master version by 38 | cloning and copying: 39 | 40 | .. code:: bash 41 | 42 | mkdir SOME_LOCAL_WORKSPACE 43 | cd SOME_LOCAL_WORKSPACE 44 | git clone https://github.com/custom-components/pyscript.git 45 | mkdir -p YOUR_HASS_CONFIG_DIRECTORY/custom_components 46 | cp -pr pyscript/custom_components/pyscript YOUR_HASS_CONFIG_DIRECTORY/custom_components 47 | 48 | Install Jupyter Kernel 49 | ---------------------- 50 | 51 | Installing the Pyscript Jupyter kernel is optional but highly recommended. 52 | The steps to install and use it are in this 53 | `README `__. 54 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | ignore=tests 3 | # Use a conservative default here; 2 should speed up most setups and not hurt 4 | # any too bad. Override on command line as appropriate. 5 | jobs=2 6 | load-plugins=pylint_strict_informational 7 | persistent=no 8 | extension-pkg-whitelist=ciso8601 9 | 10 | [BASIC] 11 | good-names=id,i,j,k,ex,Run,_,fp,T 12 | 13 | [MESSAGES CONTROL] 14 | # Reasons disabled: 15 | # format - handled by black 16 | # locally-disabled - it spams too much 17 | # duplicate-code - unavoidable 18 | # cyclic-import - doesn't test if both import on load 19 | # abstract-class-little-used - prevents from setting right foundation 20 | # unused-argument - generic callbacks and setup methods create a lot of warnings 21 | # too-many-* - are not enforced for the sake of readability 22 | # too-few-* - same as too-many-* 23 | # abstract-method - with intro of async there are always methods missing 24 | # inconsistent-return-statements - doesn't handle raise 25 | # too-many-ancestors - it's too strict. 26 | # wrong-import-order - isort guards this 27 | disable= 28 | format, 29 | abstract-method, 30 | broad-except, 31 | cyclic-import, 32 | duplicate-code, 33 | inconsistent-return-statements, 34 | locally-disabled, 35 | no-name-in-module, 36 | not-context-manager, 37 | raising-bad-type, 38 | too-few-public-methods, 39 | too-many-ancestors, 40 | too-many-arguments, 41 | too-many-branches, 42 | too-many-instance-attributes, 43 | too-many-lines, 44 | too-many-locals, 45 | too-many-positional-arguments, 46 | too-many-public-methods, 47 | too-many-return-statements, 48 | too-many-statements, 49 | too-many-nested-blocks, 50 | too-many-boolean-expressions, 51 | try-except-raise, 52 | unused-argument, 53 | no-value-for-parameter, 54 | unsubscriptable-object, 55 | wrong-import-order, 56 | unidiomatic-typecheck 57 | enable= 58 | use-symbolic-message-instead 59 | 60 | [REPORTS] 61 | score=no 62 | 63 | [TYPECHECK] 64 | # For attrs 65 | ignored-classes=_CountingAttr 66 | 67 | [FORMAT] 68 | expected-line-ending-format=LF 69 | 70 | [EXCEPTIONS] 71 | overgeneral-exceptions=builtins.BaseException,builtins.Exception,builtins.HomeAssistantError 72 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license = Apache License 2.0 3 | license_file = LICENSE 4 | platforms = any 5 | description = HASS custom-compontents integration for Python scripting 6 | long_description = file: README.md 7 | keywords = Home Automation, HASS, HACS, automation 8 | classifier = 9 | Development Status :: 4 - Beta 10 | Intended Audience :: End Users/Desktop 11 | Intended Audience :: Developers 12 | License :: OSI Approved :: Apache Software License 13 | Operating System :: OS Independent 14 | Programming Language :: Python :: 3.13 15 | Topic :: Home Automation 16 | 17 | [tool:pytest] 18 | testpaths = tests 19 | norecursedirs = .git 20 | log_level=INFO 21 | addopts = 22 | --strict-markers 23 | --asyncio-mode=auto 24 | 25 | [flake8] 26 | exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build 27 | doctests = True 28 | # To work with Black 29 | max-line-length = 109 30 | # E501: line too long 31 | # W503: Line break occurred before a binary operator 32 | # E203: Whitespace before ':' 33 | # D202 No blank lines allowed after function docstring 34 | # W504 line break after binary operator 35 | # E231 missing whitespace after ':' 36 | ignore = 37 | E501, 38 | W503, 39 | E203, 40 | D202, 41 | W504 42 | E231 43 | 44 | [isort] 45 | # https://github.com/timothycrosley/isort 46 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 47 | # splits long import on multiple lines indented by 4 spaces 48 | multi_line_output = 3 49 | include_trailing_comma=True 50 | force_grid_wrap=0 51 | use_parentheses=True 52 | line_length=109 53 | indent = " " 54 | # will group `import x` and `from x import` of the same module. 55 | force_sort_within_sections = true 56 | sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 57 | default_section = THIRDPARTY 58 | known_first_party = homeassistant,tests 59 | forced_separate = tests 60 | combine_as_imports = true 61 | 62 | [mypy] 63 | python_version = 3.13 64 | ignore_errors = true 65 | follow_imports = silent 66 | ignore_missing_imports = true 67 | warn_incomplete_stub = true 68 | warn_redundant_casts = true 69 | warn_unused_configs = true 70 | 71 | [codespell] 72 | ignore-words-list = thirdparty 73 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | import sphinx_rtd_theme 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = 'hacs-pyscript' 22 | copyright = '2020-2025, Craig Barratt' 23 | author = 'Craig Barratt' 24 | 25 | # The full version, including alpha/beta/rc tags 26 | release = '1.7.0' 27 | 28 | master_doc = 'index' 29 | 30 | # -- General configuration --------------------------------------------------- 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | 'sphinx_rtd_theme', 37 | ] 38 | 39 | # Add any paths that contain templates here, relative to this directory. 40 | templates_path = ['_templates'] 41 | 42 | # List of patterns, relative to source directory, that match files and 43 | # directories to ignore when looking for source files. 44 | # This pattern also affects html_static_path and html_extra_path. 45 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 46 | 47 | 48 | # -- Options for HTML output ------------------------------------------------- 49 | 50 | # The theme to use for HTML and HTML Help pages. See the documentation for 51 | # a list of builtin themes. 52 | # 53 | html_theme = "sphinx_rtd_theme" 54 | 55 | # Add any paths that contain custom static files (such as style sheets) here, 56 | # relative to this directory. They are copied after the builtin static files, 57 | # so a file named "default.css" will overwrite the builtin "default.css". 58 | html_static_path = ['_static'] 59 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | # Pyscript: Python scripting integration 2 | 3 | This is a custom component that adds rich Python scripting to Home Assistant. 4 | 5 | You can write Python functions and scripts that implement a wide range of automation, logic 6 | and triggers. State variables are bound to Python variables and services are callable as 7 | Python functions, so it's easy and concise to implement high-level logic. 8 | 9 | Functions you write can be configured to be called as a service or run upon time, state-change 10 | or event triggers. Functions can also call any service, fire events and set state variables. 11 | Functions can sleep or wait for additional changes in state variables or events, without slowing 12 | or affecting other operations. You can think of these functions as small programs that run in 13 | parallel, independently of each other, and they could be active for extended periods of time. 14 | 15 | Pyscript also interfaces with the Jupyter front-ends (eg, notebook, console, lab and VSCode). That 16 | allows you to develop and test pyscript functions, triggers, automation and logic interactively. 17 | 18 | ## Installation 19 | 20 | Under HACS -> Integrations, select "+", search for `pyscript` and install it. 21 | 22 | See the documentation if you want to install pyscript manually. 23 | 24 | ## Configuration 25 | 26 | * Go to the Integrations menu in the Home Assistant Configuration UI and add `Pyscript Python scripting` from there, or add `pyscript:` to `/configuration.yaml`; see docs for optional parameters. 27 | * Add files with a suffix of `.py` in the folder `/pyscript`. 28 | * Whenever you change a script file it will be auto-reloaded. 29 | * Watch the HASS log for `pyscript` errors and logger output from your scripts. 30 | * Consider installing the optional Jupyter kernel, so you can use pyscript interactively. 31 | 32 | ## Useful Links 33 | 34 | * [Documentation](https://hacs-pyscript.readthedocs.io/en/stable) 35 | * [Discussion and help](https://github.com/custom-components/pyscript/discussions) 36 | * [GitHub repository](https://github.com/custom-components/pyscript) (please add a star if you like pyscript!) 37 | * [Release notes](https://github.com/custom-components/pyscript/releases) 38 | * [Issues](https://github.com/custom-components/pyscript/issues) 39 | * [Wiki](https://github.com/custom-components/pyscript/wiki) 40 | * [Using Jupyter](https://github.com/craigbarratt/hass-pyscript-jupyter) 41 | * [Jupyter notebook tutorial](https://nbviewer.jupyter.org/github/craigbarratt/hass-pyscript-jupyter/blob/master/pyscript_tutorial.ipynb) 42 | -------------------------------------------------------------------------------- /custom_components/pyscript/event.py: -------------------------------------------------------------------------------- 1 | """Handles event firing and notification.""" 2 | 3 | import logging 4 | 5 | from .const import LOGGER_PATH 6 | 7 | _LOGGER = logging.getLogger(LOGGER_PATH + ".event") 8 | 9 | 10 | class Event: 11 | """Define event functions.""" 12 | 13 | # 14 | # Global hass instance 15 | # 16 | hass = None 17 | 18 | # 19 | # notify message queues by event type 20 | # 21 | notify = {} 22 | notify_remove = {} 23 | 24 | def __init__(self): 25 | """Warn on Event instantiation.""" 26 | _LOGGER.error("Event class is not meant to be instantiated") 27 | 28 | @classmethod 29 | def init(cls, hass): 30 | """Initialize Event.""" 31 | 32 | cls.hass = hass 33 | 34 | @classmethod 35 | async def event_listener(cls, event): 36 | """Listen callback for given event which updates any notifications.""" 37 | 38 | func_args = { 39 | "trigger_type": "event", 40 | "event_type": event.event_type, 41 | "context": event.context, 42 | } 43 | func_args.update(event.data) 44 | await cls.update(event.event_type, func_args) 45 | 46 | @classmethod 47 | def notify_add(cls, event_type, queue): 48 | """Register to notify for events of given type to be sent to queue.""" 49 | 50 | if event_type not in cls.notify: 51 | cls.notify[event_type] = set() 52 | _LOGGER.debug("event.notify_add(%s) -> adding event listener", event_type) 53 | cls.notify_remove[event_type] = cls.hass.bus.async_listen(event_type, cls.event_listener) 54 | cls.notify[event_type].add(queue) 55 | 56 | @classmethod 57 | def notify_del(cls, event_type, queue): 58 | """Unregister to notify for events of given type for given queue.""" 59 | 60 | if event_type not in cls.notify or queue not in cls.notify[event_type]: 61 | return 62 | cls.notify[event_type].discard(queue) 63 | if len(cls.notify[event_type]) == 0: 64 | cls.notify_remove[event_type]() 65 | _LOGGER.debug("event.notify_del(%s) -> removing event listener", event_type) 66 | del cls.notify[event_type] 67 | del cls.notify_remove[event_type] 68 | 69 | @classmethod 70 | async def update(cls, event_type, func_args): 71 | """Deliver all notifications for an event of the given type.""" 72 | 73 | _LOGGER.debug("event.update(%s, %s)", event_type, func_args) 74 | if event_type in cls.notify: 75 | for queue in cls.notify[event_type]: 76 | await queue.put(["event", func_args.copy()]) 77 | -------------------------------------------------------------------------------- /custom_components/pyscript/mqtt.py: -------------------------------------------------------------------------------- 1 | """Handles mqtt messages and notification.""" 2 | 3 | import json 4 | import logging 5 | 6 | from homeassistant.components import mqtt 7 | 8 | from .const import LOGGER_PATH 9 | 10 | _LOGGER = logging.getLogger(LOGGER_PATH + ".mqtt") 11 | 12 | 13 | class Mqtt: 14 | """Define mqtt functions.""" 15 | 16 | # 17 | # Global hass instance 18 | # 19 | hass = None 20 | 21 | # 22 | # notify message queues by mqtt message topic 23 | # 24 | notify = {} 25 | notify_remove = {} 26 | 27 | def __init__(self): 28 | """Warn on Mqtt instantiation.""" 29 | _LOGGER.error("Mqtt class is not meant to be instantiated") 30 | 31 | @classmethod 32 | def init(cls, hass): 33 | """Initialize Mqtt.""" 34 | 35 | cls.hass = hass 36 | 37 | @classmethod 38 | def mqtt_message_handler_maker(cls, subscribed_topic): 39 | """Closure for mqtt_message_handler.""" 40 | 41 | async def mqtt_message_handler(mqttmsg): 42 | """Listen for MQTT messages.""" 43 | func_args = { 44 | "trigger_type": "mqtt", 45 | "topic": mqttmsg.topic, 46 | "payload": mqttmsg.payload, 47 | "qos": mqttmsg.qos, 48 | "retain": mqttmsg.retain, 49 | } 50 | 51 | try: 52 | func_args["payload_obj"] = json.loads(mqttmsg.payload) 53 | except ValueError: 54 | pass 55 | 56 | await cls.update(subscribed_topic, func_args) 57 | 58 | return mqtt_message_handler 59 | 60 | @classmethod 61 | async def notify_add(cls, topic, queue, encoding=None): 62 | """Register to notify for mqtt messages of given topic to be sent to queue.""" 63 | 64 | if topic not in cls.notify: 65 | cls.notify[topic] = set() 66 | _LOGGER.debug("mqtt.notify_add(%s) -> adding mqtt subscription", topic) 67 | cls.notify_remove[topic] = await mqtt.async_subscribe( 68 | cls.hass, topic, cls.mqtt_message_handler_maker(topic), encoding=encoding or "utf-8", qos=0 69 | ) 70 | cls.notify[topic].add(queue) 71 | 72 | @classmethod 73 | def notify_del(cls, topic, queue): 74 | """Unregister to notify for mqtt messages of given topic for given queue.""" 75 | 76 | if topic not in cls.notify or queue not in cls.notify[topic]: 77 | return 78 | cls.notify[topic].discard(queue) 79 | if len(cls.notify[topic]) == 0: 80 | cls.notify_remove[topic]() 81 | _LOGGER.debug("mqtt.notify_del(%s) -> removing mqtt subscription", topic) 82 | del cls.notify[topic] 83 | del cls.notify_remove[topic] 84 | 85 | @classmethod 86 | async def update(cls, topic, func_args): 87 | """Deliver all notifications for an mqtt message on the given topic.""" 88 | 89 | _LOGGER.debug("mqtt.update(%s, %s, %s)", topic, vars, func_args) 90 | if topic in cls.notify: 91 | for queue in cls.notify[topic]: 92 | await queue.put(["mqtt", func_args.copy()]) 93 | -------------------------------------------------------------------------------- /custom_components/pyscript/services.yaml: -------------------------------------------------------------------------------- 1 | # Describes the format for available pyscript services 2 | 3 | reload: 4 | name: Reload pyscript 5 | description: Reloads all available pyscripts and restart triggers 6 | fields: 7 | global_ctx: 8 | name: Global Context 9 | description: Only reload this specific global context (file or app) 10 | example: file.example 11 | required: false 12 | selector: 13 | text: 14 | 15 | jupyter_kernel_start: 16 | name: Start Jupyter kernel 17 | description: Starts a jupyter kernel for interactive use; Called by Jupyter front end and should generally not be used by users 18 | fields: 19 | shell_port: 20 | name: Shell Port Number 21 | description: Shell port number 22 | example: 63599 23 | required: false 24 | selector: 25 | number: 26 | min: 10240 27 | max: 65535 28 | iopub_port: 29 | name: IOPub Port Number 30 | description: IOPub port number 31 | example: 63598 32 | required: false 33 | selector: 34 | number: 35 | min: 10240 36 | max: 65535 37 | stdin_port: 38 | name: Stdin Port Number 39 | description: Stdin port number 40 | example: 63597 41 | required: false 42 | selector: 43 | number: 44 | min: 10240 45 | max: 65535 46 | control_port: 47 | name: Control Port Number 48 | description: Control port number 49 | example: 63596 50 | required: false 51 | selector: 52 | number: 53 | min: 10240 54 | max: 65535 55 | hb_port: 56 | name: Heartbeat Port Number 57 | description: Heartbeat port number 58 | example: 63595 59 | required: false 60 | selector: 61 | number: 62 | min: 10240 63 | max: 65535 64 | ip: 65 | name: IP Address 66 | description: IP address to connect to Jupyter front end 67 | example: 127.0.0.1 68 | default: 127.0.0.1 69 | required: false 70 | selector: 71 | text: 72 | key: 73 | name: Security Key 74 | description: Used for signing 75 | example: 012345678-9abcdef023456789abcdef 76 | required: true 77 | selector: 78 | text: 79 | transport: 80 | name: Transport Type 81 | description: Transport type 82 | example: tcp 83 | default: tcp 84 | required: false 85 | selector: 86 | select: 87 | options: 88 | - tcp 89 | - udp 90 | signature_scheme: 91 | name: Signing Algorithm 92 | description: Signing algorithm 93 | example: hmac-sha256 94 | required: false 95 | default: hmac-sha256 96 | selector: 97 | select: 98 | options: 99 | - hmac-sha256 100 | kernel_name: 101 | name: Name of Kernel 102 | description: Kernel name 103 | example: pyscript 104 | required: true 105 | default: pyscript 106 | selector: 107 | text: 108 | 109 | generate_stubs: 110 | name: Generate pyscript stubs 111 | description: Build a stub files combining builtin helpers with discovered entities and services. 112 | -------------------------------------------------------------------------------- /custom_components/pyscript/webhook.py: -------------------------------------------------------------------------------- 1 | """Handles webhooks and notification.""" 2 | 3 | import logging 4 | 5 | from aiohttp import hdrs 6 | 7 | from homeassistant.components import webhook 8 | 9 | from .const import LOGGER_PATH 10 | 11 | _LOGGER = logging.getLogger(LOGGER_PATH + ".webhook") 12 | 13 | 14 | class Webhook: 15 | """Define webhook functions.""" 16 | 17 | # 18 | # Global hass instance 19 | # 20 | hass = None 21 | 22 | # 23 | # notify message queues by webhook type 24 | # 25 | notify = {} 26 | notify_remove = {} 27 | 28 | def __init__(self): 29 | """Warn on Webhook instantiation.""" 30 | _LOGGER.error("Webhook class is not meant to be instantiated") 31 | 32 | @classmethod 33 | def init(cls, hass): 34 | """Initialize Webhook.""" 35 | 36 | cls.hass = hass 37 | 38 | @classmethod 39 | async def webhook_handler(cls, hass, webhook_id, request): 40 | """Listen callback for given webhook which updates any notifications.""" 41 | 42 | func_args = { 43 | "trigger_type": "webhook", 44 | "webhook_id": webhook_id, 45 | } 46 | 47 | if "json" in request.headers.get(hdrs.CONTENT_TYPE, ""): 48 | func_args["payload"] = await request.json() 49 | else: 50 | # Could potentially return multiples of a key - only take the first 51 | payload_multidict = await request.post() 52 | func_args["payload"] = {k: payload_multidict.getone(k) for k in payload_multidict.keys()} 53 | 54 | await cls.update(webhook_id, func_args) 55 | 56 | @classmethod 57 | def notify_add(cls, webhook_id, local_only, methods, queue): 58 | """Register to notify for webhooks of given type to be sent to queue.""" 59 | if webhook_id not in cls.notify: 60 | cls.notify[webhook_id] = set() 61 | _LOGGER.debug("webhook.notify_add(%s) -> adding webhook listener", webhook_id) 62 | webhook.async_register( 63 | cls.hass, 64 | "pyscript", # DOMAIN 65 | "pyscript", # NAME 66 | webhook_id, 67 | cls.webhook_handler, 68 | local_only=local_only, 69 | allowed_methods=methods, 70 | ) 71 | cls.notify_remove[webhook_id] = lambda: webhook.async_unregister(cls.hass, webhook_id) 72 | 73 | cls.notify[webhook_id].add(queue) 74 | 75 | @classmethod 76 | def notify_del(cls, webhook_id, queue): 77 | """Unregister to notify for webhooks of given type for given queue.""" 78 | 79 | if webhook_id not in cls.notify or queue not in cls.notify[webhook_id]: 80 | return 81 | cls.notify[webhook_id].discard(queue) 82 | if len(cls.notify[webhook_id]) == 0: 83 | cls.notify_remove[webhook_id]() 84 | _LOGGER.debug("webhook.notify_del(%s) -> removing webhook listener", webhook_id) 85 | del cls.notify[webhook_id] 86 | del cls.notify_remove[webhook_id] 87 | 88 | @classmethod 89 | async def update(cls, webhook_id, func_args): 90 | """Deliver all notifications for an webhook of the given type.""" 91 | 92 | _LOGGER.debug("webhook.update(%s, %s)", webhook_id, func_args) 93 | if webhook_id in cls.notify: 94 | for queue in cls.notify[webhook_id]: 95 | await queue.put(["webhook", func_args.copy()]) 96 | -------------------------------------------------------------------------------- /docs/overview.rst: -------------------------------------------------------------------------------- 1 | Overview 2 | -------- 3 | 4 | This HACS custom integration allows you to write Python functions and 5 | scripts that can implement a wide range of automation, logic and 6 | triggers. State variables are bound to Python variables and services are 7 | callable as Python functions, so it's easy and concise to implement 8 | logic. 9 | 10 | Functions you write can be configured to be called as a service or run 11 | upon time, state-change, or event triggers. Functions can also call any 12 | service, fire events, and set state variables. Functions can sleep or 13 | wait for additional changes in state variables or events, without 14 | slowing or affecting other operations. You can think of these functions 15 | as small programs that run in parallel, independently of each other, and 16 | they could be active for extended periods of time. 17 | 18 | State, event, and time triggers are specified by Python function 19 | decorators (the "@" lines immediately before each function definition). 20 | A state trigger can be any Python expression using state variables - the 21 | trigger is evaluated only when a state variable it references changes, 22 | and the trigger occurs when the expression is true or non-zero. A time 23 | trigger could be a single event (e.g., date and time), a repetitive event 24 | (e.g., at a particular time each day or weekday, daily relative to sunrise 25 | or sunset, or any regular time period within an optional range), or using 26 | cron syntax (where events occur periodically based on a concise 27 | specification of ranges of minutes, hours, days of week, days of month, 28 | and months). An event trigger specifies the event type, and an optional 29 | Python trigger test based on the event data that runs the Python 30 | function if true. 31 | 32 | Pyscript implements a Python interpreter using the AST parser output, in 33 | a fully async manner. That allows several of the "magic" features to be 34 | implemented in a seamless Pythonic manner, such as binding of variables 35 | to states and functions to services. Pyscript supports imports, although 36 | by default the valid import list is restricted for security reasons 37 | (there is a configuration option ``allow_all_imports`` to allow all 38 | imports). Pyscript supports almost all Python language features except 39 | generators, ``yield``, and defining special class methods. 40 | (see `language limitations `__). 41 | Pyscript provides a handful of additional built-in functions that connect 42 | to HASS features, like logging, accessing state variables as strings 43 | (if you need to compute their names dynamically), running and managing 44 | tasks, sleeping, and waiting for triggers. 45 | 46 | Pyscript also provides a kernel that interfaces with the Jupyter 47 | front-ends (eg, notebook, console, lab, and VSC). That allows you to develop 48 | and test pyscript code interactively. Plus you can interact with much of 49 | HASS by looking at state variables, calling services etc, in a similar 50 | way to `HASS 51 | CLI `__, 52 | although the CLI provides status on many other parts of HASS. 53 | 54 | For more information about the Jupyter kernel, see the 55 | `README `__. 56 | There is also a `Jupyter notebook 57 | tutorial `__, 58 | which can be downloaded and run interactively in Jupyter notebook or VSC 59 | connected to your live HASS with pyscript. 60 | 61 | Pyscript provides functionality that complements the existing 62 | automations, templates, and triggers. Pyscript is most similar to 63 | `AppDaemon `__, and some 64 | similarities and differences are discussed in this `Wiki 65 | page `__. 66 | Pyscript with Jupyter makes it extremely easy to learn, use, and debug. 67 | Pyscripts presents a simplified and more integrated binding for Python 68 | scripting than `Python 69 | Scripts `__, 70 | which requires a lot more expertise and scaffolding using direct access 71 | to Home Assistant internals. 72 | -------------------------------------------------------------------------------- /tests/test_state.py: -------------------------------------------------------------------------------- 1 | """Test pyscripts test module.""" 2 | 3 | from datetime import datetime, timezone 4 | from unittest.mock import patch 5 | 6 | import pytest 7 | 8 | from custom_components.pyscript.function import Function 9 | from custom_components.pyscript.state import State, StateVal 10 | from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN 11 | from homeassistant.core import Context, ServiceRegistry, StateMachine 12 | from homeassistant.helpers.state import State as HassState 13 | 14 | 15 | @pytest.mark.asyncio 16 | async def test_service_call(hass): 17 | """Test calling a service using the entity_id as a property.""" 18 | with patch( 19 | "custom_components.pyscript.state.async_get_all_descriptions", 20 | return_value={ 21 | "test": { 22 | "test": {"description": None, "fields": {"entity_id": "blah", "other_service_data": "blah"}} 23 | } 24 | }, 25 | ), patch.object(StateMachine, "get", return_value=HassState("test.entity", "True")), patch.object( 26 | ServiceRegistry, "async_call" 27 | ) as call: 28 | State.init(hass) 29 | Function.init(hass) 30 | await State.get_service_params() 31 | 32 | func = State.get("test.entity.test") 33 | await func(context=Context(id="test"), blocking=True, limit=1, other_service_data="test") 34 | assert call.called 35 | assert call.call_args[0] == ( 36 | "test", 37 | "test", 38 | {"other_service_data": "test", "entity_id": "test.entity"}, 39 | ) 40 | assert call.call_args[1] == {"context": Context(id="test"), "blocking": True, "limit": 1} 41 | call.reset_mock() 42 | 43 | func = State.get("test.entity.test") 44 | await func(context=Context(id="test"), blocking=False, other_service_data="test") 45 | assert call.called 46 | assert call.call_args[0] == ( 47 | "test", 48 | "test", 49 | {"other_service_data": "test", "entity_id": "test.entity"}, 50 | ) 51 | assert call.call_args[1] == {"context": Context(id="test"), "blocking": False} 52 | 53 | # Stop all tasks to avoid conflicts with other tests 54 | await Function.waiter_stop() 55 | await Function.reaper_stop() 56 | 57 | 58 | def test_state_val_conversions(): 59 | """Test helper conversion methods exposed on StateVal.""" 60 | float_state = StateVal(HassState("test.float", "123.45")) 61 | assert float_state.as_float() == pytest.approx(123.45) 62 | 63 | int_state = StateVal(HassState("test.int", "42")) 64 | assert int_state.as_int() == 42 65 | 66 | hex_state = StateVal(HassState("test.hex", "FF")) 67 | assert hex_state.as_int(base=16) == 255 68 | 69 | bool_state = StateVal(HassState("test.bool", "on")) 70 | assert bool_state.as_bool() is True 71 | 72 | round_state = StateVal(HassState("test.round", "3.1415")) 73 | assert round_state.as_round(precision=2) == pytest.approx(3.14) 74 | 75 | datetime_state = StateVal(HassState("test.datetime", "2024-03-05T06:07:08+00:00")) 76 | assert datetime_state.as_datetime() == datetime(2024, 3, 5, 6, 7, 8, tzinfo=timezone.utc) 77 | 78 | invalid_state = StateVal(HassState("test.invalid", "invalid")) 79 | with pytest.raises(ValueError): 80 | invalid_state.as_float() 81 | with pytest.raises(ValueError): 82 | invalid_state.as_int() 83 | with pytest.raises(ValueError): 84 | invalid_state.as_bool() 85 | with pytest.raises(ValueError): 86 | invalid_state.as_round() 87 | with pytest.raises(ValueError): 88 | invalid_state.as_datetime() 89 | 90 | assert invalid_state.as_bool(default=False) is False 91 | 92 | assert invalid_state.as_float(default=1.23) == pytest.approx(1.23) 93 | 94 | assert invalid_state.as_round(default=0) == 0 95 | 96 | fallback_datetime = datetime(1999, 1, 2, 3, 4, 5, tzinfo=timezone.utc) 97 | assert invalid_state.as_datetime(default=fallback_datetime) == fallback_datetime 98 | 99 | unknown_state = StateVal(HassState("test.unknown", STATE_UNKNOWN)) 100 | assert unknown_state.is_unknown() is True 101 | assert unknown_state.is_unavailable() is False 102 | assert unknown_state.has_value() is False 103 | 104 | unavailable_state = StateVal(HassState("test.unavailable", STATE_UNAVAILABLE)) 105 | assert unavailable_state.is_unavailable() is True 106 | assert unavailable_state.is_unknown() is False 107 | assert unavailable_state.has_value() is False 108 | 109 | standard_state = StateVal(HassState("test.standard", "ready")) 110 | assert standard_state.has_value() is True 111 | -------------------------------------------------------------------------------- /tests/test_stubs.py: -------------------------------------------------------------------------------- 1 | """Tests for pyscript stub generation and stub imports.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import datetime as dt 6 | from pathlib import Path 7 | from types import SimpleNamespace 8 | from typing import Any 9 | 10 | import pytest 11 | 12 | from custom_components.pyscript.const import DOMAIN, FOLDER, SERVICE_GENERATE_STUBS 13 | 14 | from tests.test_init import setup_script 15 | 16 | 17 | @pytest.mark.asyncio 18 | async def test_generate_stubs_service_writes_files(hass, caplog, monkeypatch): 19 | """Ensure the generate_stubs service writes expected files into modules/stubs.""" 20 | 21 | # Set up pyscript so the service is registered. 22 | await setup_script( 23 | hass, 24 | notify_q=None, 25 | now=dt(2024, 1, 1, 0, 0, 0), 26 | source=""" 27 | @service 28 | def ready(): 29 | pass 30 | """, 31 | script_name="/stub_service.py", 32 | ) 33 | 34 | hass.states.async_set( 35 | "light.lamp", 36 | "on", 37 | { 38 | "valid_attr": 42, 39 | "invalid attr": "ignored", 40 | }, 41 | ) 42 | 43 | dummy_registry = SimpleNamespace( 44 | entities={ 45 | "light.lamp": SimpleNamespace(entity_id="light.lamp", disabled=False), 46 | } 47 | ) 48 | monkeypatch.setattr("custom_components.pyscript.stubs.generator.er.async_get", lambda _: dummy_registry) 49 | 50 | async def fake_service_descriptions(_hass) -> dict[str, dict[str, dict[str, Any]]]: 51 | return { 52 | "light": { 53 | "blink": { 54 | "description": "Blink the light once.", 55 | "target": {"entity": {"domain": "light"}}, 56 | "fields": { 57 | "brightness": { 58 | "required": True, 59 | "selector": {"number": {}}, 60 | "description": "Brightness.", 61 | }, 62 | "speed": { 63 | "required": False, 64 | "selector": {"select": {"options": ["slow", "fast"]}}, 65 | "description": "Blink speed.", 66 | }, 67 | "invalid-field": {"required": True, "selector": {"boolean": None}}, 68 | }, 69 | "response": {"optional": True}, 70 | } 71 | } 72 | } 73 | 74 | monkeypatch.setattr( 75 | "custom_components.pyscript.stubs.generator.async_get_all_descriptions", fake_service_descriptions 76 | ) 77 | 78 | stubs_dir = Path(hass.config.path(FOLDER)) / "modules" / "stubs" 79 | builtins_target = stubs_dir / "pyscript_builtins.py" 80 | generated_target = stubs_dir / "pyscript_generated.py" 81 | 82 | if stubs_dir.exists(): 83 | # Clean up artifacts from previous runs to avoid false positives. 84 | for child in stubs_dir.iterdir(): 85 | child.unlink() 86 | else: 87 | stubs_dir.mkdir(parents=True, exist_ok=True) 88 | 89 | response: dict[str, Any] = await hass.services.async_call( 90 | DOMAIN, 91 | SERVICE_GENERATE_STUBS, 92 | {}, 93 | blocking=True, 94 | return_response=True, 95 | ) 96 | 97 | expected_ignored: list[str] = [ 98 | "blink(invalid-field)", 99 | "light.lamp.invalid attr", 100 | ] 101 | assert response["ignored_identifiers"] == sorted(expected_ignored) 102 | assert response["status"] == "OK" 103 | assert builtins_target.exists() 104 | assert generated_target.exists() 105 | 106 | generated_content = generated_target.read_text(encoding="utf-8") 107 | assert "class light" in generated_content 108 | assert "class _light_state(StateVal)" in generated_content 109 | assert "lamp: _light_state" in generated_content 110 | assert "def blink(self, *, brightness: int, speed" in generated_content 111 | assert "def blink(*, entity_id: str, brightness: int, speed:" in generated_content 112 | assert "Blink the light once." in generated_content 113 | assert "Literal" in generated_content 114 | assert "'slow'" in generated_content 115 | assert "'fast'" in generated_content 116 | assert "-> dict[str, Any]" in generated_content 117 | 118 | original_builtins = ( 119 | Path(__file__).resolve().parent.parent 120 | / "custom_components" 121 | / "pyscript" 122 | / "stubs" 123 | / "pyscript_builtins.py" 124 | ) 125 | assert builtins_target.read_text(encoding="utf-8") == original_builtins.read_text(encoding="utf-8") 126 | 127 | # Clean up generated files so other tests start with a blank slate. 128 | generated_target.unlink() 129 | builtins_target.unlink() 130 | try: 131 | stubs_dir.rmdir() 132 | except OSError: 133 | # Directory contains other content; leave it in place. 134 | pass 135 | 136 | 137 | @pytest.mark.asyncio 138 | async def test_stub_imports_are_ignored(hass, caplog): 139 | """Verify importing from stubs.* does not raise even when the module is missing.""" 140 | 141 | await setup_script( 142 | hass, 143 | notify_q=None, 144 | now=dt(2024, 2, 2, 0, 0, 0), 145 | source=""" 146 | from stubs import helper1 147 | from stubs.fake_module import helper2 148 | from stubs.fake_module.deep import helper3 149 | 150 | @service 151 | def stub_import_ready(): 152 | log.info("stub import ready") 153 | """, 154 | script_name="/stub_import.py", 155 | ) 156 | 157 | assert hass.services.has_service(DOMAIN, "stub_import_ready") 158 | assert "ModuleNotFoundError" not in caplog.text 159 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pyscript: Python Scripting for Home Assistant 2 | 3 | [![GitHub Release](https://img.shields.io/github/release/custom-components/pyscript.svg?style=for-the-badge)](https://github.com/custom-components/pyscript/releases) 4 | [![License](https://img.shields.io/github/license/custom-components/pyscript.svg?style=for-the-badge)](LICENSE) 5 | [![hacs](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/hacs/integration) 6 | [![Project Maintenance](https://img.shields.io/badge/maintainer-%40craigbarratt-blue.svg?style=for-the-badge)](https://github.com/craigbarratt) 7 | 8 | This HACS custom integration allows you to write Python functions and scripts that can implement a 9 | wide range of automation, logic and triggers. State variables are bound to Python variables and 10 | services are callable as Python functions, so it's easy and concise to implement logic. 11 | 12 | Functions you write can be configured to be called as a service or run upon time, state-change or 13 | event triggers. Functions can also call any service, fire events and set state variables. Functions 14 | can sleep or wait for additional changes in state variables or events, without slowing or affecting 15 | other operations. You can think of these functions as small programs that run in parallel, 16 | independently of each other, and they could be active for extended periods of time. 17 | 18 | Pyscript also provides a kernel that interfaces with the Jupyter front-ends (eg, notebook, console, 19 | lab and VSCode). That allows you to develop and test pyscript code interactively. Plus you can interact 20 | with much of HASS by looking at state variables, calling services etc. 21 | 22 | Pyscript can also generate IDE stub modules by calling the `pyscript.generate_stubs` service. 23 | See the “IDE Helpers” section of the docs for setup details. 24 | 25 | ## Documentation 26 | 27 | Here is the [pyscript documentation](https://hacs-pyscript.readthedocs.io/en/stable). 28 | 29 | For more information about the Jupyter kernel, see the [README](https://github.com/craigbarratt/hass-pyscript-jupyter/blob/master/README.md). 30 | There is also a [Jupyter notebook tutorial](https://nbviewer.jupyter.org/github/craigbarratt/hass-pyscript-jupyter/blob/master/pyscript_tutorial.ipynb), 31 | which can be downloaded and run interactively in Jupyter notebook connected to your live HASS with pyscript. 32 | 33 | ## Installation 34 | 35 | ### Option 1: HACS 36 | 37 | Under HACS -> Integrations, select "+", search for `pyscript` and install it. 38 | 39 | ### Option 2: Manual 40 | 41 | From the [latest release](https://github.com/custom-components/pyscript/releases) download the zip file `hass-custom-pyscript.zip` 42 | ```bash 43 | cd YOUR_HASS_CONFIG_DIRECTORY # same place as configuration.yaml 44 | mkdir -p custom_components/pyscript 45 | cd custom_components/pyscript 46 | unzip hass-custom-pyscript.zip 47 | ``` 48 | 49 | Alternatively, you can install the current GitHub master version by cloning and copying: 50 | ```bash 51 | mkdir SOME_LOCAL_WORKSPACE 52 | cd SOME_LOCAL_WORKSPACE 53 | git clone https://github.com/custom-components/pyscript.git 54 | mkdir -p YOUR_HASS_CONFIG_DIRECTORY/custom_components 55 | cp -pr pyscript/custom_components/pyscript YOUR_HASS_CONFIG_DIRECTORY/custom_components 56 | ``` 57 | 58 | ### Install Jupyter Kernel 59 | 60 | Installing the Pyscript Jupyter kernel is optional. The steps to install and use it are in 61 | this [README](https://github.com/craigbarratt/hass-pyscript-jupyter/blob/master/README.md). 62 | 63 | ## Configuration 64 | 65 | * Go to the Integrations menu in the Home Assistant Configuration UI and add `Pyscript Python scripting` from there. Alternatively, add `pyscript:` to `/configuration.yaml`; pyscript has two optional configuration parameters that allow any python package to be imported if set and to expose `hass` as a variable; both default to `false`: 66 | ```yaml 67 | pyscript: 68 | allow_all_imports: true 69 | hass_is_global: true 70 | ``` 71 | * Add files with a suffix of `.py` in the folder `/pyscript`. 72 | * Restart HASS. 73 | * Whenever you change a script file, make a `reload` service call to `pyscript`. 74 | * Watch the HASS log for `pyscript` errors and logger output from your scripts. 75 | 76 | ## Contributing 77 | 78 | Contributions are welcome! You are encouraged to submit PRs, bug reports, feature requests or add to 79 | the Wiki with examples and tutorials. It would be fun to hear about unique and clever applications 80 | you develop. Please see this [README](https://github.com/custom-components/pyscript/tree/master/tests) 81 | for setting up a development environment and running tests. 82 | 83 | Even if you aren't a developer, please participate in our 84 | [discussions community](https://github.com/custom-components/pyscript/discussions). 85 | Helping other users is another great way to contribute to pyscript! 86 | 87 | ## Useful Links 88 | 89 | * [Documentation stable](https://hacs-pyscript.readthedocs.io/en/stable): latest release 90 | * [Documentation latest](https://hacs-pyscript.readthedocs.io/en/latest): current master in Github 91 | * [Discussion and help](https://github.com/custom-components/pyscript/discussions) 92 | * [Issues](https://github.com/custom-components/pyscript/issues) 93 | * [Wiki](https://github.com/custom-components/pyscript/wiki) 94 | * [GitHub repository](https://github.com/custom-components/pyscript) (please add a star if you like pyscript!) 95 | * [Release notes](https://github.com/custom-components/pyscript/releases) 96 | * [Jupyter notebook tutorial](https://nbviewer.jupyter.org/github/craigbarratt/hass-pyscript-jupyter/blob/master/pyscript_tutorial.ipynb) 97 | 98 | ## Copyright 99 | 100 | Copyright (c) 2020-2025 Craig Barratt. May be freely used and copied according to the terms of the 101 | [Apache 2.0 License](LICENSE). 102 | -------------------------------------------------------------------------------- /custom_components/pyscript/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for pyscript.""" 2 | 3 | import json 4 | from typing import Any, Dict 5 | 6 | import voluptuous as vol 7 | 8 | from homeassistant import config_entries 9 | from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry 10 | from homeassistant.core import callback 11 | 12 | from .const import CONF_ALLOW_ALL_IMPORTS, CONF_HASS_IS_GLOBAL, CONF_INSTALLED_PACKAGES, DOMAIN 13 | 14 | CONF_BOOL_ALL = {CONF_ALLOW_ALL_IMPORTS, CONF_HASS_IS_GLOBAL} 15 | 16 | PYSCRIPT_SCHEMA = vol.Schema( 17 | { 18 | vol.Optional(CONF_ALLOW_ALL_IMPORTS, default=False): bool, 19 | vol.Optional(CONF_HASS_IS_GLOBAL, default=False): bool, 20 | }, 21 | extra=vol.ALLOW_EXTRA, 22 | ) 23 | 24 | 25 | class PyscriptOptionsConfigFlow(config_entries.OptionsFlow): 26 | """Handle a pyscript options flow.""" 27 | 28 | def __init__(self) -> None: 29 | """Initialize pyscript options flow.""" 30 | self._conf_app_id: str | None = None 31 | self._show_form = False 32 | 33 | async def async_step_init(self, user_input: Dict[str, Any] = None) -> Dict[str, Any]: 34 | """Manage the pyscript options.""" 35 | if self.config_entry.source == SOURCE_IMPORT: 36 | self._show_form = True 37 | return await self.async_step_no_ui_configuration_allowed() 38 | 39 | if user_input is None: 40 | return self.async_show_form( 41 | step_id="init", 42 | data_schema=vol.Schema( 43 | { 44 | vol.Optional(name, default=self.config_entry.data.get(name, False)): bool 45 | for name in CONF_BOOL_ALL 46 | }, 47 | extra=vol.ALLOW_EXTRA, 48 | ), 49 | ) 50 | 51 | if any( 52 | name not in self.config_entry.data or user_input[name] != self.config_entry.data[name] 53 | for name in CONF_BOOL_ALL 54 | ): 55 | updated_data = self.config_entry.data.copy() 56 | updated_data.update(user_input) 57 | self.hass.config_entries.async_update_entry(entry=self.config_entry, data=updated_data) 58 | return self.async_create_entry(title="", data={}) 59 | 60 | self._show_form = True 61 | return await self.async_step_no_update() 62 | 63 | async def async_step_no_ui_configuration_allowed( 64 | self, user_input: Dict[str, Any] = None 65 | ) -> Dict[str, Any]: 66 | """Tell user no UI configuration is allowed.""" 67 | if self._show_form: 68 | self._show_form = False 69 | return self.async_show_form(step_id="no_ui_configuration_allowed", data_schema=vol.Schema({})) 70 | 71 | return self.async_create_entry(title="", data={}) 72 | 73 | async def async_step_no_update(self, user_input: Dict[str, Any] = None) -> Dict[str, Any]: 74 | """Tell user no update to process.""" 75 | if self._show_form: 76 | self._show_form = False 77 | return self.async_show_form(step_id="no_update", data_schema=vol.Schema({})) 78 | 79 | return self.async_create_entry(title="", data={}) 80 | 81 | 82 | class PyscriptConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 83 | """Handle a pyscript config flow.""" 84 | 85 | VERSION = 1 86 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH 87 | 88 | @staticmethod 89 | @callback 90 | def async_get_options_flow(config_entry: ConfigEntry) -> PyscriptOptionsConfigFlow: 91 | """Get the options flow for this handler.""" 92 | return PyscriptOptionsConfigFlow() 93 | 94 | async def async_step_user(self, user_input: Dict[str, Any] = None) -> Dict[str, Any]: 95 | """Handle a flow initialized by the user.""" 96 | if user_input is not None: 97 | if len(self.hass.config_entries.async_entries(DOMAIN)) > 0: 98 | return self.async_abort(reason="single_instance_allowed") 99 | 100 | await self.async_set_unique_id(DOMAIN) 101 | return self.async_create_entry(title=DOMAIN, data=user_input) 102 | 103 | return self.async_show_form(step_id="user", data_schema=PYSCRIPT_SCHEMA) 104 | 105 | async def async_step_import(self, import_config: Dict[str, Any] = None) -> Dict[str, Any]: 106 | """Import a config entry from configuration.yaml.""" 107 | # Convert OrderedDict to dict 108 | import_config = json.loads(json.dumps(import_config)) 109 | 110 | # Check if import config entry matches any existing config entries 111 | # so we can update it if necessary 112 | entries = self.hass.config_entries.async_entries(DOMAIN) 113 | if entries: 114 | entry = entries[0] 115 | updated_data = entry.data.copy() 116 | 117 | # Update values for all keys, excluding `allow_all_imports` for entries 118 | # set up through the UI. 119 | for key, val in import_config.items(): 120 | if entry.source == SOURCE_IMPORT or key not in CONF_BOOL_ALL: 121 | updated_data[key] = val 122 | 123 | # Remove values for all keys in entry.data that are not in the imported config, 124 | # excluding `allow_all_imports` for entries set up through the UI. 125 | for key in entry.data: 126 | if ( 127 | (entry.source == SOURCE_IMPORT or key not in CONF_BOOL_ALL) 128 | and key != CONF_INSTALLED_PACKAGES 129 | and key not in import_config 130 | ): 131 | updated_data.pop(key) 132 | 133 | # Update and reload entry if data needs to be updated 134 | if updated_data != entry.data: 135 | self.hass.config_entries.async_update_entry(entry=entry, data=updated_data) 136 | return self.async_abort(reason="updated_entry") 137 | 138 | return self.async_abort(reason="already_configured") 139 | 140 | return await self.async_step_user(user_input=import_config) 141 | -------------------------------------------------------------------------------- /tests/test_tasks.py: -------------------------------------------------------------------------------- 1 | """Test the pyscript apps, modules and import features.""" 2 | 3 | import asyncio 4 | import re 5 | from unittest.mock import patch 6 | 7 | from mock_open import MockOpen 8 | import pytest 9 | 10 | from custom_components.pyscript.const import DOMAIN, FOLDER 11 | from homeassistant.const import EVENT_STATE_CHANGED 12 | from homeassistant.setup import async_setup_component 13 | 14 | 15 | async def wait_until_done(notify_q): 16 | """Wait for the done handshake.""" 17 | return await asyncio.wait_for(notify_q.get(), timeout=4) 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_tasks(hass, caplog): 22 | """Test starting tasks.""" 23 | 24 | conf_dir = hass.config.path(FOLDER) 25 | 26 | file_contents = { 27 | f"{conf_dir}/hello.py": """ 28 | 29 | # 30 | # check starting multiple tasks, each stopping the prior one 31 | # 32 | def task1(cnt, last): 33 | task.unique('task1') 34 | if not last: 35 | task.sleep(10) 36 | log.info(f"finished task1, cnt={cnt}") 37 | 38 | for cnt in range(10): 39 | task.create(task1, cnt, cnt == 9) 40 | 41 | # 42 | # check the return value after wait 43 | # 44 | def task2(arg): 45 | return 2 * arg 46 | 47 | t2a = task.create(task2, 21) 48 | t2b = task.create(task2, 51) 49 | done, pending = task.wait({t2a, t2b}) 50 | log.info(f"task2() results = {[t2a.result(), t2b.result()]}, len(done) = {len(done)};") 51 | 52 | # 53 | # check the return value with a regular function 54 | # 55 | @pyscript_compile 56 | def task3(arg): 57 | return 2 * arg 58 | 59 | t3a = task.create(task3, 22) 60 | t3b = task.create(task3, 52) 61 | done, pending = task.wait({t3a, t3b}) 62 | log.info(f"task3() results = {[t3a.result(), t3b.result()]}, len(done) = {len(done)};") 63 | 64 | 65 | # 66 | # check that we can do a done callback 67 | # 68 | def task4(arg): 69 | task.wait_until(state_trigger="pyscript.var4 == '1'") 70 | return 2 * arg 71 | 72 | def callback4a(arg): 73 | log.info(f"callback4a arg = {arg}") 74 | 75 | def callback4b(arg): 76 | log.info(f"callback4b arg = {arg}") 77 | 78 | def callback4c(arg): 79 | log.info(f"callback4c arg = {arg}") 80 | 81 | def callback4d(arg): 82 | x = 0 83 | log.info(f"callback4d arg = {arg}") 84 | return 1 / x 85 | 86 | t4 = task.create(task4, 23) 87 | task.add_done_callback(t4, callback4a, 26) 88 | task.add_done_callback(t4, callback4b, 101) 89 | task.add_done_callback(t4, callback4c, 200) 90 | task.add_done_callback(t4, callback4a, 25) 91 | task.add_done_callback(t4, callback4c, 201) 92 | task.add_done_callback(t4, callback4b, 100) 93 | task.add_done_callback(t4, callback4a, 24) 94 | task.add_done_callback(t4, callback4d, 24) 95 | task.remove_done_callback(t4, callback4c) 96 | task.remove_done_callback(t4, task4) 97 | pyscript.var4 = 1 98 | done, pending = task.wait({t4}) 99 | log.info(f"task4() result = {t4.result()}, len(done) = {len(done)};") 100 | 101 | # 102 | # make sure we can't cancel a non-user task 103 | # 104 | try: 105 | task.cancel() 106 | except TypeError as exc: 107 | log.info(f"task.cancel: {exc}") 108 | 109 | # 110 | # check that we can cancel ourselves 111 | # 112 | def task5(arg=None): 113 | log.info(f"task5 arg = {arg}") 114 | task.cancel() 115 | log.info(f"task5 BOTCH") 116 | 117 | t5 = task.create(task5, 83) 118 | done, pending = task.wait({t5}) 119 | 120 | """, 121 | } 122 | 123 | mock_open = MockOpen() 124 | for key, value in file_contents.items(): 125 | mock_open[key].read_data = value 126 | 127 | def isfile_side_effect(arg): 128 | return arg in file_contents 129 | 130 | def glob_side_effect(path, recursive=None, root_dir=None, dir_fd=None, include_hidden=False): 131 | result = [] 132 | path_re = path.replace("*", "[^/]*").replace(".", "\\.") 133 | path_re = path_re.replace("[^/]*[^/]*/", ".*") 134 | for this_path in file_contents: 135 | if re.match(path_re, this_path): 136 | result.append(this_path) 137 | return result 138 | 139 | conf = {"apps": {"world": {}}} 140 | with patch("custom_components.pyscript.os.path.isdir", return_value=True), patch( 141 | "custom_components.pyscript.glob.iglob" 142 | ) as mock_glob, patch("custom_components.pyscript.global_ctx.open", mock_open), patch( 143 | "custom_components.pyscript.open", mock_open 144 | ), patch( 145 | "homeassistant.config.load_yaml_config_file", return_value={"pyscript": conf} 146 | ), patch( 147 | "custom_components.pyscript.os.path.getmtime", return_value=1000 148 | ), patch( 149 | "custom_components.pyscript.watchdog_start", return_value=None 150 | ), patch( 151 | "custom_components.pyscript.global_ctx.os.path.getmtime", return_value=1000 152 | ), patch( 153 | "custom_components.pyscript.os.path.isfile" 154 | ) as mock_isfile: 155 | mock_isfile.side_effect = isfile_side_effect 156 | mock_glob.side_effect = glob_side_effect 157 | assert await async_setup_component(hass, "pyscript", {DOMAIN: conf}) 158 | 159 | notify_q = asyncio.Queue(0) 160 | 161 | async def state_changed(event): 162 | var_name = event.data["entity_id"] 163 | if var_name != "pyscript.done": 164 | return 165 | value = event.data["new_state"].state 166 | await notify_q.put(value) 167 | 168 | hass.bus.async_listen(EVENT_STATE_CHANGED, state_changed) 169 | 170 | assert caplog.text.count("finished task1, cnt=9") == 1 171 | assert "task2() results = [42, 102], len(done) = 2;" in caplog.text 172 | assert "task3() results = [44, 104], len(done) = 2;" in caplog.text 173 | assert "task4() result = 46, len(done) = 1;" in caplog.text 174 | assert caplog.text.count("callback4a arg =") == 1 175 | assert "callback4a arg = 24" in caplog.text 176 | assert caplog.text.count("callback4b arg =") == 1 177 | assert "callback4b arg = 100" in caplog.text 178 | assert "callback4c arg =" not in caplog.text 179 | assert caplog.text.count("is not a user-started task") == 1 180 | assert caplog.text.count("ZeroDivisionError: division by zero") == 1 181 | assert "task5 arg = 83" in caplog.text 182 | assert "task5 BOTCH" not in caplog.text 183 | -------------------------------------------------------------------------------- /tests/test_apps_modules.py: -------------------------------------------------------------------------------- 1 | """Test the pyscript apps, modules and import features.""" 2 | 3 | from ast import literal_eval 4 | import asyncio 5 | import re 6 | from unittest.mock import patch 7 | 8 | from mock_open import MockOpen 9 | import pytest 10 | 11 | from custom_components.pyscript.const import DOMAIN, FOLDER 12 | from homeassistant.const import EVENT_STATE_CHANGED 13 | from homeassistant.setup import async_setup_component 14 | 15 | 16 | async def wait_until_done(notify_q): 17 | """Wait for the done handshake.""" 18 | return await asyncio.wait_for(notify_q.get(), timeout=4) 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_service_exists(hass, caplog): 23 | """Test importing a pyscript module.""" 24 | 25 | conf_dir = hass.config.path(FOLDER) 26 | 27 | file_contents = { 28 | f"{conf_dir}/hello.py": """ 29 | import xyz2 30 | from xyz2 import f_minus 31 | 32 | @service(supports_response = "optional") 33 | def func1(): 34 | pyscript.done = [xyz2.f_add(1, 2), xyz2.f_mult(3, 4), xyz2.f_add(10, 20), f_minus(50, 20)] 35 | return {"a": 1} 36 | """, 37 | # 38 | # this will fail to load since import doesn't exist 39 | # 40 | f"{conf_dir}/bad_import.py": """ 41 | import no_such_package 42 | 43 | @service 44 | def func10(): 45 | pass 46 | """, 47 | # 48 | # this will fail to load since import has a syntax error 49 | # 50 | f"{conf_dir}/bad_import2.py": """ 51 | import bad_module 52 | 53 | @service 54 | def func11(): 55 | pass 56 | """, 57 | # 58 | # This will load, since there is an apps/world config entry 59 | # 60 | f"{conf_dir}/apps/world.py": """ 61 | from xyz2 import * 62 | 63 | @service 64 | def func20(): 65 | pyscript.done = [get_x(), get_name(), other_name(), f_add(1, 5), f_mult(3, 6), f_add(10, 30), f_minus(50, 30)] 66 | """, 67 | # 68 | # This will not load, since there is no apps/world2 config entry 69 | # 70 | f"{conf_dir}/apps/world2.py": """ 71 | from xyz2 import * 72 | 73 | @service 74 | def func10(): 75 | pass 76 | """, 77 | f"{conf_dir}/modules/xyz2/__init__.py": """ 78 | from .other import f_minus, other_name 79 | 80 | log.info(f"modules/xyz2 global_ctx={pyscript.get_global_ctx()};") 81 | 82 | x = 99 83 | 84 | def f_add(a, b): 85 | return a + b 86 | 87 | def f_mult(a, b): 88 | return a * b 89 | 90 | def get_x(): 91 | return x 92 | 93 | def get_name(): 94 | return __name__ 95 | """, 96 | f"{conf_dir}/modules/xyz2/other.py": """ 97 | def f_minus(a, b): 98 | return a - b 99 | 100 | def other_name(): 101 | return __name__ 102 | """, 103 | # 104 | # this module has a syntax error (missing :) 105 | # 106 | f"{conf_dir}/modules/bad_module.py": """ 107 | def func12() 108 | pass 109 | """, 110 | # 111 | # this script file should auto-load 112 | # 113 | f"{conf_dir}/scripts/func13.py": """ 114 | @service 115 | def func13(): 116 | pass 117 | """, 118 | # 119 | # this script file should auto-load 120 | # 121 | f"{conf_dir}/scripts/a/b/c/d/func14.py": """ 122 | @service 123 | def func14(): 124 | pass 125 | 126 | log.info(f"func14 global_ctx={pyscript.get_global_ctx()};") 127 | 128 | """, 129 | # 130 | # this script file should not auto-load 131 | # 132 | f"{conf_dir}/scripts/a/b/c/d/#func15.py": """ 133 | @service 134 | def func15(): 135 | pass 136 | """, 137 | # 138 | # this script file should not auto-load 139 | # 140 | f"{conf_dir}/scripts/#a/b/c/d/func15.py": """ 141 | @service 142 | def func15(): 143 | pass 144 | """, 145 | } 146 | 147 | mock_open = MockOpen() 148 | for key, value in file_contents.items(): 149 | mock_open[key].read_data = value 150 | 151 | def isfile_side_effect(arg): 152 | return arg in file_contents 153 | 154 | def glob_side_effect(path, recursive=None, root_dir=None, dir_fd=None, include_hidden=False): 155 | result = [] 156 | path_re = path.replace("*", "[^/]*").replace(".", "\\.") 157 | path_re = path_re.replace("[^/]*[^/]*/", ".*") 158 | for this_path in file_contents: 159 | if re.match(path_re, this_path): 160 | result.append(this_path) 161 | return result 162 | 163 | conf = {"apps": {"world": {}}} 164 | with patch("custom_components.pyscript.os.path.isdir", return_value=True), patch( 165 | "custom_components.pyscript.glob.iglob" 166 | ) as mock_glob, patch("custom_components.pyscript.global_ctx.open", mock_open), patch( 167 | "custom_components.pyscript.open", mock_open 168 | ), patch( 169 | "homeassistant.config.load_yaml_config_file", return_value={"pyscript": conf} 170 | ), patch( 171 | "custom_components.pyscript.watchdog_start", return_value=None 172 | ), patch( 173 | "custom_components.pyscript.os.path.getmtime", return_value=1000 174 | ), patch( 175 | "custom_components.pyscript.global_ctx.os.path.getmtime", return_value=1000 176 | ), patch( 177 | "custom_components.pyscript.os.path.isfile" 178 | ) as mock_isfile: 179 | mock_isfile.side_effect = isfile_side_effect 180 | mock_glob.side_effect = glob_side_effect 181 | assert await async_setup_component(hass, "pyscript", {DOMAIN: conf}) 182 | 183 | notify_q = asyncio.Queue(0) 184 | 185 | async def state_changed(event): 186 | var_name = event.data["entity_id"] 187 | if var_name != "pyscript.done": 188 | return 189 | value = event.data["new_state"].state 190 | await notify_q.put(value) 191 | 192 | hass.bus.async_listen(EVENT_STATE_CHANGED, state_changed) 193 | 194 | assert not hass.services.has_service("pyscript", "func10") 195 | assert not hass.services.has_service("pyscript", "func11") 196 | assert hass.services.has_service("pyscript", "func1") 197 | assert hass.services.has_service("pyscript", "func13") 198 | assert hass.services.has_service("pyscript", "func14") 199 | assert not hass.services.has_service("pyscript", "func15") 200 | 201 | service_ret = await hass.services.async_call( 202 | "pyscript", "func1", {}, return_response=True, blocking=True 203 | ) 204 | ret = await wait_until_done(notify_q) 205 | assert literal_eval(ret) == [1 + 2, 3 * 4, 10 + 20, 50 - 20] 206 | assert service_ret == {"a": 1} 207 | 208 | await hass.services.async_call("pyscript", "func20", {}) 209 | ret = await wait_until_done(notify_q) 210 | assert literal_eval(ret) == [99, "xyz2", "xyz2.other", 1 + 5, 3 * 6, 10 + 30, 50 - 30] 211 | 212 | assert "modules/xyz2 global_ctx=modules.xyz2;" in caplog.text 213 | assert "func14 global_ctx=scripts.a.b.c.d.func14;" in caplog.text 214 | assert "ModuleNotFoundError: import of no_such_package not allowed" in caplog.text 215 | # assert "SyntaxError: invalid syntax (bad_module.py, line 2)" in caplog.text # <= 3.9 216 | assert "SyntaxError: expected ':' (bad_module.py, line 2)" in caplog.text 217 | -------------------------------------------------------------------------------- /tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | """Test pyscript user-defined decorators.""" 2 | 3 | from ast import literal_eval 4 | import asyncio 5 | from datetime import datetime as dt 6 | from unittest.mock import mock_open, patch 7 | 8 | import pytest 9 | 10 | from custom_components.pyscript import trigger 11 | from custom_components.pyscript.const import DOMAIN 12 | from custom_components.pyscript.function import Function 13 | from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED 14 | from homeassistant.setup import async_setup_component 15 | 16 | 17 | async def setup_script(hass, notify_q, now, source): 18 | """Initialize and load the given pyscript.""" 19 | scripts = [ 20 | "/hello.py", 21 | ] 22 | 23 | Function.hass = None 24 | 25 | with patch("custom_components.pyscript.os.path.isdir", return_value=True), patch( 26 | "custom_components.pyscript.glob.iglob", return_value=scripts 27 | ), patch("custom_components.pyscript.global_ctx.open", mock_open(read_data=source)), patch( 28 | "custom_components.pyscript.trigger.dt_now", return_value=now 29 | ), patch( 30 | "homeassistant.config.load_yaml_config_file", return_value={} 31 | ), patch( 32 | "custom_components.pyscript.open", mock_open(read_data=source) 33 | ), patch( 34 | "custom_components.pyscript.watchdog_start", return_value=None 35 | ), patch( 36 | "custom_components.pyscript.os.path.getmtime", return_value=1000 37 | ), patch( 38 | "custom_components.pyscript.global_ctx.os.path.getmtime", return_value=1000 39 | ), patch( 40 | "custom_components.pyscript.install_requirements", 41 | return_value=None, 42 | ): 43 | assert await async_setup_component(hass, "pyscript", {DOMAIN: {}}) 44 | 45 | # 46 | # I'm not sure how to run the mock all the time, so just force the dt_now() 47 | # trigger function to return the given list of times in now. 48 | # 49 | def return_next_time(): 50 | if isinstance(now, list): 51 | if len(now) > 1: 52 | return now.pop(0) 53 | return now[0] 54 | return now 55 | 56 | trigger.__dict__["dt_now"] = return_next_time 57 | 58 | if notify_q: 59 | 60 | async def state_changed(event): 61 | var_name = event.data["entity_id"] 62 | if var_name != "pyscript.done": 63 | return 64 | value = event.data["new_state"].state 65 | await notify_q.put(value) 66 | 67 | hass.bus.async_listen(EVENT_STATE_CHANGED, state_changed) 68 | 69 | 70 | async def wait_until_done(notify_q): 71 | """Wait for the done handshake.""" 72 | return await asyncio.wait_for(notify_q.get(), timeout=4) 73 | 74 | 75 | @pytest.mark.asyncio 76 | async def test_decorator_errors(hass, caplog): 77 | """Test decorator syntax and run-time errors.""" 78 | notify_q = asyncio.Queue(0) 79 | await setup_script( 80 | hass, 81 | notify_q, 82 | [dt(2020, 7, 1, 11, 59, 59, 999999)], 83 | """ 84 | seq_num = 0 85 | 86 | def add_startup_trig(func): 87 | @time_trigger("startup") 88 | def dec_add_startup_wrapper(*args, **kwargs): 89 | return func(*args, **kwargs) 90 | return dec_add_startup_wrapper 91 | 92 | def once(func): 93 | def once_func(*args, **kwargs): 94 | return func(*args, **kwargs) 95 | return once_func 96 | 97 | def twice(func): 98 | def twice_func(*args, **kwargs): 99 | func(*args, **kwargs) 100 | return func(*args, **kwargs) 101 | return twice_func 102 | 103 | @twice 104 | @add_startup_trig 105 | @twice 106 | def func_startup_sync(trigger_type=None, trigger_time=None): 107 | global seq_num 108 | 109 | seq_num += 1 110 | log.info(f"func_startup_sync setting pyscript.done = {seq_num}, trigger_type = {trigger_type}, trigger_time = {trigger_time}") 111 | pyscript.done = seq_num 112 | 113 | trig_list = ["pyscript.var1 == '100'", "pyscript.var1 == '1'"] 114 | @state_trigger(*trig_list) 115 | @once 116 | def func1(): 117 | global seq_num 118 | 119 | seq_num += 1 120 | pyscript.done = seq_num 121 | 122 | @state_trigger("pyscript.var1 == '2'") 123 | @twice 124 | def func2(): 125 | global seq_num 126 | 127 | seq_num += 1 128 | pyscript.done = seq_num 129 | 130 | @state_trigger("pyscript.var1 == '3'") 131 | @twice 132 | @twice 133 | @once 134 | @once 135 | @once 136 | def func3(): 137 | global seq_num 138 | 139 | seq_num += 1 140 | pyscript.done = seq_num 141 | 142 | def repeat(num_times): 143 | num_times += 0 144 | def decorator_repeat(func): 145 | @state_trigger("pyscript.var1 == '4'") 146 | def wrapper_repeat(*args, **kwargs): 147 | for _ in range(num_times): 148 | value = func(*args, **kwargs) 149 | return value 150 | return wrapper_repeat 151 | return decorator_repeat 152 | 153 | @repeat(3) 154 | def func4(): 155 | global seq_num 156 | 157 | seq_num += 1 158 | pyscript.done = seq_num 159 | 160 | @state_trigger("pyscript.var1 == '5'") 161 | def func5(value=None): 162 | global seq_num, startup_test 163 | 164 | seq_num += 1 165 | pyscript.done = [seq_num, int(value)] 166 | 167 | @add_startup_trig 168 | def startup_test(): 169 | global seq_num 170 | 171 | seq_num += 1 172 | pyscript.done = [seq_num, int(value)] 173 | 174 | def add_state_trig(value): 175 | def dec_add_state_trig(func): 176 | @state_trigger(f"pyscript.var1 == '{value}'") 177 | def dec_add_state_wrapper(*args, **kwargs): 178 | return func(*args, **kwargs) 179 | return dec_add_state_wrapper 180 | return dec_add_state_trig 181 | 182 | @add_state_trig(6) # same as @state_trigger("pyscript.var1 == '6'") 183 | @add_state_trig(8) # same as @state_trigger("pyscript.var1 == '8'") 184 | @state_trigger("pyscript.var1 == '10'") 185 | def func6(value): 186 | global seq_num 187 | 188 | seq_num += 1 189 | pyscript.done = [seq_num, int(value)] 190 | 191 | """, 192 | ) 193 | seq_num = 0 194 | 195 | # fire event to start triggers, and handshake when they are running 196 | hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) 197 | for _ in range(4): 198 | seq_num += 1 199 | assert literal_eval(await wait_until_done(notify_q)) == seq_num 200 | 201 | hass.states.async_set("pyscript.var1", 0) 202 | hass.states.async_set("pyscript.var1", 1) 203 | seq_num += 1 204 | assert literal_eval(await wait_until_done(notify_q)) == seq_num 205 | 206 | hass.states.async_set("pyscript.var1", 2) 207 | for _ in range(2): 208 | seq_num += 1 209 | assert literal_eval(await wait_until_done(notify_q)) == seq_num 210 | 211 | hass.states.async_set("pyscript.var1", 3) 212 | for _ in range(4): 213 | seq_num += 1 214 | assert literal_eval(await wait_until_done(notify_q)) == seq_num 215 | 216 | hass.states.async_set("pyscript.var1", 4) 217 | for _ in range(3): 218 | seq_num += 1 219 | assert literal_eval(await wait_until_done(notify_q)) == seq_num 220 | 221 | hass.states.async_set("pyscript.var1", 5) 222 | for _ in range(2): 223 | seq_num += 1 224 | assert literal_eval(await wait_until_done(notify_q)) == [seq_num, 5] 225 | 226 | for i in range(3): 227 | hass.states.async_set("pyscript.var1", 6 + 2 * i) 228 | seq_num += 1 229 | assert literal_eval(await wait_until_done(notify_q)) == [seq_num, 6 + 2 * i] 230 | -------------------------------------------------------------------------------- /docs/tutorial.rst: -------------------------------------------------------------------------------- 1 | Tutorial 2 | ======== 3 | 4 | Jupyter Tutorial 5 | ---------------- 6 | 7 | The best way to learn about pyscript is to interactively step through the 8 | `Jupyter tutorial `__. 9 | After you have installed the pyscript Jupyter kernel, the tutorial can be downloaded 10 | with: 11 | 12 | .. code:: bash 13 | 14 | wget https://github.com/craigbarratt/hass-pyscript-jupyter/raw/master/pyscript_tutorial.ipynb 15 | 16 | and opened with: 17 | 18 | .. code:: bash 19 | 20 | jupyter notebook pyscript_tutorial.ipynb 21 | 22 | You can step through each command by hitting Enter. There are various ways to navigate and 23 | run cells in Jupyter that you can read in the Jupyter documentation. 24 | 25 | Writing your first script 26 | ------------------------- 27 | 28 | Create a file ``example.py`` in the ``/pyscript`` folder (you 29 | can use any filename, so long as it ends in ``.py``) that contains: 30 | 31 | .. code:: python 32 | 33 | @service 34 | def hello_world(action=None, id=None): 35 | """hello_world example using pyscript.""" 36 | log.info(f"hello world: got action {action} id {id}") 37 | if action == "turn_on" and id is not None: 38 | light.turn_on(entity_id=id, brightness=255) 39 | elif action == "fire" and id is not None: 40 | event.fire(id, param1=12, param2=80) 41 | 42 | After starting Home Assistant, use the Actions tab in the Developer 43 | Tools page to call the service ``pyscript.hello_world`` with parameters 44 | 45 | .. code:: yaml 46 | 47 | action: pyscript.hello_world 48 | data: 49 | action: hello 50 | id: world 51 | 52 | 53 | The function decorator ``@service`` means ``pyscript.hello_world`` is 54 | registered as a service. The expected service parameters are keyword 55 | arguments to the function. This function prints a log message showing 56 | the ``action`` and ``id`` that the service was called with. Then, if the 57 | action is ``"turn_on"`` and the ``id`` is specified, the 58 | ``light.turn_on`` service is called. Otherwise, if the action is 59 | ``"fire"`` then an event type with that ``id`` is fired with the given 60 | parameters. You can experiment by calling the service with different 61 | parameters. (Of course, it doesn't make much sense to have a function 62 | that either does nothing, calls another service, or fires an event, but, 63 | hey, this is just an example.) 64 | 65 | .. note:: 66 | 67 | You'll need to look at the log messages to see the output (unless you are using Jupyter, in which 68 | case all log messages will be displayed, independent of the log setting). The log message won't 69 | be visible unless the ``Logger`` is enabled at least for level ``info``, for example: 70 | 71 | .. code:: yaml 72 | 73 | logger: 74 | default: info 75 | logs: 76 | custom_components.pyscript: info 77 | 78 | An example using triggers 79 | ------------------------- 80 | 81 | Here's another example: 82 | 83 | .. code:: python 84 | 85 | @state_trigger("security.rear_motion == '1' or security.side_motion == '1'") 86 | @time_active("range(sunset - 20min, sunrise + 15min)") 87 | def motion_light_rear(): 88 | """Turn on rear light for 5 minutes when there is motion and it's dark""" 89 | log.info(f"triggered; turning on the light") 90 | light.turn_on(entity_id="light.outside_rear", brightness=255) 91 | task.sleep(300) 92 | light.turn_off(entity_id="light.outside_rear") 93 | 94 | This introduces two new function decorators 95 | 96 | - ``@state_trigger`` describes the condition(s) that trigger the 97 | function (the other two trigger types are ``@time_trigger`` and 98 | ``@event_trigger``, which we'll describe below). This condition is 99 | evaluated each time the variables it refers to change, and if it 100 | evaluates to ``True`` or non-zero then the trigger occurs. 101 | 102 | - ``@time_active`` describes a time range that is checked whenever a 103 | potential trigger occurs. The Python function is only executed if the 104 | ``@time_active`` criteria is met. In this example the time range is 105 | from 20 minutes before sunset to 15 minutes after sunrise (i.e., from 106 | dusk to dawn). Whenever the trigger is ``True`` and the active 107 | conditions are met, the function is executed as a new task. The 108 | trigger logic doesn't wait for the function to finish; it goes right 109 | back to checking for the next condition. The function turns on the 110 | rear outside light, waits for 5 minutes, and then turns it off. 111 | 112 | However, this example has a problem. During those 5 minutes, any 113 | additional motion event will cause another instance of the function to 114 | be executed. You might have dozens of them running, which is perfectly 115 | ok for ``pyscript``, but probably not the behavior you want, since as 116 | each earlier one finishes the light will be turned off, which could be 117 | much less than 5 minutes after the most recent motion event. 118 | 119 | There is a special function provided to ensure just one function 120 | uniquely handles a task, if that's the behavior you prefer. Here's the 121 | improved example: 122 | 123 | .. code:: python 124 | 125 | @state_trigger("security.rear_motion == '1' or security.side_motion == '1'") 126 | @time_active("range(sunset - 20min, sunrise + 20min)") 127 | def motion_light_rear(): 128 | """Turn on rear light for 5 minutes when there is motion and it's dark""" 129 | task.unique("motion_light_rear") 130 | log.info(f"triggered; turning on the light") 131 | light.turn_on(entity_id="light.outside_rear", brightness=255) 132 | task.sleep(300) 133 | light.turn_off(entity_id="light.outside_rear") 134 | 135 | The ``task.unique`` function will terminate any task that previously 136 | called ``task.unique("motion_light_rear")``, and our instance will 137 | survive. (The function takes a second argument that causes the opposite 138 | to happen: the older task survives and we are terminated - so long!) 139 | 140 | As before, this example will turn on the light for 5 minutes, but when 141 | there is a new motion event, the old function (which is part way through 142 | waiting for 5 minutes) is terminated, and we start another 5 minute 143 | timer. The effect is the light will stay on for 5 minutes after the last 144 | motion event, and stays on until there are no motion events for at least 145 | 5 minutes. If instead the second argument to ``task.unique`` is set, 146 | that means the new task is terminated instead. The result is that the 147 | light will go on for 5 minutes following a motion event, and any new 148 | motion events during that time will be ignored since each new triggered 149 | function will be terminated. Depending on your application, either 150 | behavior might be preferred. 151 | 152 | There are some other improvements we could make. We could check if the 153 | light is already on so we don't have to turn it on again by checking 154 | the relevant state variable: 155 | 156 | .. code:: python 157 | 158 | @state_trigger("security.rear_motion == '1' or security.side_motion == '1'") 159 | @time_active("range(sunset - 20min, sunrise + 20min)") 160 | def motion_light_rear(): 161 | """Turn on rear light for 5 minutes when there is motion and it's dark""" 162 | task.unique("motion_light_rear") 163 | log.info(f"triggered; turning on the light") 164 | if light.outside_rear != "on": 165 | light.turn_on(entity_id="light.outside_rear", brightness=255) 166 | task.sleep(300) 167 | light.turn_off(entity_id="light.outside_rear") 168 | 169 | You could also create another function that calls 170 | ``task.unique("motion_light_rear")`` if the light is manually turned on 171 | (by doing a ``@state_trigger`` on the relevant state variable), so that 172 | the motion logic is stopped when there is a manual event that you want 173 | to override the motion logic. 174 | -------------------------------------------------------------------------------- /tests/test_unique.py: -------------------------------------------------------------------------------- 1 | """Test the pyscript component.""" 2 | 3 | from ast import literal_eval 4 | import asyncio 5 | from datetime import datetime as dt 6 | from unittest.mock import mock_open, patch 7 | 8 | import pytest 9 | 10 | from custom_components.pyscript import trigger 11 | from custom_components.pyscript.const import DOMAIN 12 | from custom_components.pyscript.function import Function 13 | from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED 14 | from homeassistant.setup import async_setup_component 15 | 16 | 17 | async def setup_script(hass, notify_q, now, source): 18 | """Initialize and load the given pyscript.""" 19 | scripts = [ 20 | "/some/config/dir/pyscripts/hello.py", 21 | ] 22 | 23 | Function.hass = None 24 | 25 | with patch("custom_components.pyscript.os.path.isdir", return_value=True), patch( 26 | "custom_components.pyscript.glob.iglob", return_value=scripts 27 | ), patch("custom_components.pyscript.global_ctx.open", mock_open(read_data=source)), patch( 28 | "custom_components.pyscript.open", mock_open(read_data=source) 29 | ), patch( 30 | "custom_components.pyscript.trigger.dt_now", return_value=now 31 | ), patch( 32 | "homeassistant.config.load_yaml_config_file", return_value={} 33 | ), patch( 34 | "custom_components.pyscript.watchdog_start", return_value=None 35 | ), patch( 36 | "custom_components.pyscript.os.path.getmtime", return_value=1000 37 | ), patch( 38 | "custom_components.pyscript.global_ctx.os.path.getmtime", return_value=1000 39 | ), patch( 40 | "custom_components.pyscript.install_requirements", 41 | return_value=None, 42 | ): 43 | assert await async_setup_component(hass, "pyscript", {DOMAIN: {}}) 44 | 45 | # 46 | # I'm not sure how to run the mock all the time, so just force the dt_now() 47 | # trigger function to return the fixed time, now. 48 | # 49 | trigger.__dict__["dt_now"] = lambda: now 50 | 51 | if notify_q: 52 | 53 | async def state_changed(event): 54 | var_name = event.data["entity_id"] 55 | if var_name != "pyscript.done": 56 | return 57 | value = event.data["new_state"].state 58 | await notify_q.put(value) 59 | 60 | hass.bus.async_listen(EVENT_STATE_CHANGED, state_changed) 61 | 62 | 63 | async def wait_until_done(notify_q): 64 | """Wait for the done handshake.""" 65 | return await asyncio.wait_for(notify_q.get(), timeout=4) 66 | 67 | 68 | @pytest.mark.asyncio 69 | async def test_task_unique(hass, caplog): 70 | """Test task.unique .""" 71 | notify_q = asyncio.Queue(0) 72 | await setup_script( 73 | hass, 74 | notify_q, 75 | dt(2020, 7, 1, 11, 59, 59, 999999), 76 | """ 77 | 78 | seq_num = 0 79 | 80 | @time_trigger("startup") 81 | @task_unique("func6") 82 | def funcStartupSync(): 83 | global seq_num, funcStartupSync_id 84 | 85 | seq_num += 1 86 | log.info(f"funcStartupSync setting pyscript.done = {seq_num}") 87 | pyscript.done = seq_num 88 | funcStartupSync_id = task.current_task() 89 | # 90 | # stick around so the task.unique() still applies 91 | # 92 | assert task.current_task() == task.name2id("func6") 93 | assert task.current_task() == task.name2id()["func6"] 94 | task.unique("func7") 95 | assert task.current_task() == task.name2id("func7") 96 | assert task.current_task() == task.name2id()["func7"] 97 | task.sleep(10000) 98 | 99 | @service 100 | def service_cleanup(): 101 | global seq_num 102 | 103 | seq_num += 1 104 | task.cancel(funcStartupSync_id) 105 | log.info(f"service_cleanup seq_num = {seq_num}") 106 | pyscript.done = [seq_num] 107 | 108 | @state_trigger("pyscript.f0var1 == '1'") 109 | def func0(var_name=None, value=None): 110 | global seq_num 111 | 112 | seq_num += 1 113 | pyscript.done = [seq_num, var_name] 114 | result = task.wait_until(state_trigger=["pyscript.f0var2"]) 115 | seq_num += 1 116 | result["context"] = {"user_id": result["context"].user_id, "parent_id": result["context"].parent_id, "id": "1234"} 117 | pyscript.done = [seq_num, var_name, result] 118 | 119 | @state_trigger("pyscript.f1var1 == '1'") 120 | def func1(var_name=None, value=None): 121 | global seq_num 122 | 123 | seq_num += 1 124 | log.info(f"func1 var = {var_name}, value = {value}") 125 | task.unique("func1") 126 | pyscript.done = [seq_num, var_name] 127 | # this should terminate our task, so the 2nd done won't happen 128 | # if it did, we would get out of sequence in the assert 129 | task.unique("func1") 130 | pyscript.done = [seq_num, var_name] 131 | 132 | @state_trigger("pyscript.f2var1 == '1'") 133 | def func2(var_name=None, value=None): 134 | global seq_num 135 | 136 | seq_num += 1 137 | mySeqNum = seq_num 138 | log.info(f"func2 var = {var_name}, value = {value}") 139 | task.unique("func2") 140 | while 1: 141 | task.wait_until(state_trigger=["pyscript.f2var1 == '2'", "pyscript.no_such_var == '5'", "pyscript.no_such_var2"]) 142 | pyscript.f2var1 = 0 143 | pyscript.done = [mySeqNum, var_name] 144 | 145 | @state_trigger("pyscript.f3var1 == '1'") 146 | def func3(var_name=None, value=None): 147 | global seq_num 148 | 149 | seq_num += 1 150 | log.info(f"func3 var = {var_name}, value = {value}") 151 | task.unique("func2") 152 | pyscript.done = [seq_num, var_name] 153 | 154 | @state_trigger("pyscript.f4var1 == '1'") 155 | @task_unique("func4") 156 | def func4(): 157 | global seq_num 158 | 159 | seq_num += 1 160 | pyscript.done = [seq_num, "pyscript.f4var1"] 161 | res = task.wait_until(state_trigger=["False", "False", "pyscript.f4var2 == '1'"]) 162 | pyscript.done = [seq_num, "pyscript.f4var2"] 163 | 164 | @state_trigger("pyscript.f5var1 == '1'") 165 | @task_unique("func5", kill_me=True) 166 | def func5(): 167 | global seq_num 168 | 169 | seq_num += 1 170 | pyscript.done = [seq_num, "pyscript.f5var1"] 171 | res = task.wait_until(state_trigger="pyscript.f5var2 == '1'") 172 | pyscript.done = [seq_num, "pyscript.f5var2"] 173 | 174 | 175 | @state_trigger("pyscript.f4var1 == '1'") 176 | def func6(): 177 | task.unique("func6", kill_me=True) 178 | # mess up the sequence numbers if task.unique fails to kill us 179 | pyscript.done = [999] 180 | 181 | """, 182 | ) 183 | 184 | seq_num = 0 185 | 186 | hass.states.async_set("pyscript.f1var1", 0) 187 | hass.states.async_set("pyscript.f2var1", 0) 188 | hass.states.async_set("pyscript.f3var1", 0) 189 | 190 | seq_num += 1 191 | # fire event to startup triggers, and handshake when they are running 192 | hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) 193 | assert literal_eval(await wait_until_done(notify_q)) == seq_num 194 | 195 | seq_num += 1 196 | hass.states.async_set("pyscript.f0var1", 1) 197 | assert literal_eval(await wait_until_done(notify_q)) == [seq_num, "pyscript.f0var1"] 198 | 199 | seq_num += 1 200 | hass.states.async_set("pyscript.f1var1", 1) 201 | assert literal_eval(await wait_until_done(notify_q)) == [seq_num, "pyscript.f1var1"] 202 | 203 | for _ in range(5): 204 | # 205 | # repeat this test 5 times 206 | # 207 | seq_num += 1 208 | hass.states.async_set("pyscript.f1var1", 0) 209 | hass.states.async_set("pyscript.f1var1", 1) 210 | assert literal_eval(await wait_until_done(notify_q)) == [ 211 | seq_num, 212 | "pyscript.f1var1", 213 | ] 214 | 215 | # get func2() through wait_notify and get reply; should be in wait_notify() 216 | seq_num += 1 217 | hass.states.async_set("pyscript.f2var1", 1) 218 | hass.states.async_set("pyscript.f2var1", 2) 219 | assert literal_eval(await wait_until_done(notify_q)) == [seq_num, "pyscript.f2var1"] 220 | 221 | # now run func3() which will kill func2() 222 | seq_num += 1 223 | hass.states.async_set("pyscript.f3var1", 1) 224 | assert literal_eval(await wait_until_done(notify_q)) == [seq_num, "pyscript.f3var1"] 225 | 226 | # now run func3() a few more times, and also try to re-trigger func2() 227 | # should be no more acks from func2() 228 | for _ in range(10): 229 | # 230 | # repeat this test 10 times 231 | # 232 | seq_num += 1 233 | hass.states.async_set("pyscript.f2var1", 2) 234 | hass.states.async_set("pyscript.f2var1", 0) 235 | hass.states.async_set("pyscript.f3var1", 0) 236 | hass.states.async_set("pyscript.f3var1", 1) 237 | assert literal_eval(await wait_until_done(notify_q)) == [ 238 | seq_num, 239 | "pyscript.f3var1", 240 | ] 241 | 242 | # 243 | # now run func4() a few times; each one should stop the last one 244 | # 245 | hass.states.async_set("pyscript.f4var2", 0) 246 | for _ in range(10): 247 | seq_num += 1 248 | hass.states.async_set("pyscript.f4var1", 0) 249 | hass.states.async_set("pyscript.f4var1", 1) 250 | assert literal_eval(await wait_until_done(notify_q)) == [ 251 | seq_num, 252 | "pyscript.f4var1", 253 | ] 254 | # 255 | # now let the last one complete, and check the seq number 256 | # 257 | hass.states.async_set("pyscript.f4var2", 0) 258 | hass.states.async_set("pyscript.f4var2", 1) 259 | assert literal_eval(await wait_until_done(notify_q)) == [ 260 | seq_num, 261 | "pyscript.f4var2", 262 | ] 263 | 264 | # 265 | # now run func5() a few times; only the first one should 266 | # start and the rest will not 267 | # 268 | seq_num += 1 269 | hass.states.async_set("pyscript.f5var2", 0) 270 | hass.states.async_set("pyscript.f5var1", 0) 271 | hass.states.async_set("pyscript.f5var1", 1) 272 | assert literal_eval(await wait_until_done(notify_q)) == [ 273 | seq_num, 274 | "pyscript.f5var1", 275 | ] 276 | for _ in range(10): 277 | hass.states.async_set("pyscript.f5var1", 0) 278 | hass.states.async_set("pyscript.f5var1", 1) 279 | # 280 | # now let the first one complete, and check the seq number 281 | # 282 | hass.states.async_set("pyscript.f5var2", 0) 283 | hass.states.async_set("pyscript.f5var2", 1) 284 | assert literal_eval(await wait_until_done(notify_q)) == [ 285 | seq_num, 286 | "pyscript.f5var2", 287 | ] 288 | 289 | # 290 | # now go back to func0, which is waiting on any change to pyscript.f0var2 291 | # 292 | seq_num += 1 293 | hass.states.async_set("pyscript.f0var2", 1) 294 | context = {"user_id": None, "parent_id": None, "id": "1234"} 295 | assert literal_eval(await wait_until_done(notify_q)) == [ 296 | seq_num, 297 | "pyscript.f0var1", 298 | { 299 | "old_value": None, 300 | "trigger_type": "state", 301 | "value": "1", 302 | "var_name": "pyscript.f0var2", 303 | "context": context, 304 | }, 305 | ] 306 | 307 | seq_num += 1 308 | await hass.services.async_call("pyscript", "service_cleanup", {}) 309 | assert literal_eval(await wait_until_done(notify_q)) == [seq_num] 310 | -------------------------------------------------------------------------------- /tests/test_reload.py: -------------------------------------------------------------------------------- 1 | """Test the pyscript apps, modules and import features.""" 2 | 3 | import asyncio 4 | import re 5 | from unittest.mock import patch 6 | 7 | from mock_open import MockOpen 8 | import pytest 9 | 10 | from custom_components.pyscript.const import DOMAIN, FOLDER 11 | from homeassistant.const import EVENT_STATE_CHANGED 12 | from homeassistant.setup import async_setup_component 13 | 14 | 15 | @pytest.mark.asyncio 16 | async def test_reload(hass, caplog): 17 | """Test reload a pyscript module.""" 18 | 19 | conf_dir = hass.config.path(FOLDER) 20 | 21 | file_contents = { 22 | f"{conf_dir}/hello.py": """ 23 | import xyz2 24 | from xyz2 import xyz 25 | 26 | # 27 | # ensure a regular script doesn't have pyscript.app_config set 28 | # 29 | try: 30 | x = pyscript.app_config 31 | assert False 32 | except NameError: 33 | pass 34 | 35 | log.info(f"{__name__} global_ctx={pyscript.get_global_ctx()} xyz={xyz} xyz2.xyz={xyz2.xyz}") 36 | 37 | @service 38 | def func1(): 39 | pass 40 | """, 41 | # 42 | # This will load, since there is an apps/world config entry 43 | # 44 | f"{conf_dir}/apps/world.py": """ 45 | from xyz2 import * 46 | 47 | log.info(f"{__name__} global_ctx={pyscript.get_global_ctx()} xyz={xyz}") 48 | 49 | @service 50 | def func2(): 51 | pass 52 | 53 | @time_trigger 54 | def startup(): 55 | log.info(f"{__name__} is starting up") 56 | """, 57 | # 58 | # This will load, since there is an apps/world2 config entry 59 | # 60 | f"{conf_dir}/apps/world2/__init__.py": """ 61 | from .other import * 62 | 63 | assert pyscript.config['apps']['world2'] == pyscript.app_config 64 | 65 | log.info(f"{__name__} global_ctx={pyscript.get_global_ctx()} var1={pyscript.config['apps']['world2']['var1']}, other_abc={other_abc}") 66 | 67 | @service 68 | def func3(): 69 | pass 70 | """, 71 | f"{conf_dir}/apps/world2/other.py": """ 72 | other_abc = 987 73 | 74 | log.info(f"{__name__} global_ctx={pyscript.get_global_ctx()}") 75 | 76 | # 77 | # ensure a sub file in the app doesn't have pyscript.app_config set 78 | # 79 | try: 80 | x = pyscript.app_config 81 | assert False 82 | except NameError: 83 | pass 84 | 85 | @time_trigger("shutdown") 86 | def shutdown(trigger_time=None): 87 | log.info(f"{__name__} global_ctx={pyscript.get_global_ctx()} shutdown trigger_time={trigger_time}") 88 | """, 89 | f"{conf_dir}/modules/xyz2/__init__.py": """ 90 | from .other import xyz 91 | 92 | # 93 | # ensure a module doesn't have pyscript.app_config set 94 | # 95 | try: 96 | x = pyscript.app_config 97 | assert False 98 | except NameError: 99 | pass 100 | 101 | log.info(f"modules/xyz2 global_ctx={pyscript.get_global_ctx()};") 102 | """, 103 | f"{conf_dir}/modules/xyz2/other.py": """ 104 | log.info(f"modules/xyz2/other global_ctx={pyscript.get_global_ctx()};") 105 | 106 | xyz = 123 107 | """, 108 | # 109 | # these shouldn't load since the package takes precedence 110 | # 111 | f"{conf_dir}/modules/xyz2.py": """ 112 | log.info(f"BOTCH shouldn't load {__name__}") 113 | """, 114 | f"{conf_dir}/apps/world2.py": """ 115 | log.info(f"BOTCH shouldn't load {__name__}") 116 | """, 117 | } 118 | 119 | mock_open = MockOpen() 120 | for key, value in file_contents.items(): 121 | mock_open[key].read_data = value 122 | 123 | def isfile_side_effect(arg): 124 | return arg in file_contents 125 | 126 | def glob_side_effect(path, recursive=None, root_dir=None, dir_fd=None, include_hidden=False): 127 | result = [] 128 | path_re = path.replace("*", "[^/]*").replace(".", "\\.") 129 | path_re = path_re.replace("[^/]*[^/]*/", ".*") 130 | for this_path in file_contents: 131 | if re.match(path_re, this_path): 132 | result.append(this_path) 133 | return result 134 | 135 | conf = {"apps": {"world": {}, "world2": {"var1": 100}}} 136 | with patch("custom_components.pyscript.os.path.isdir", return_value=True), patch( 137 | "custom_components.pyscript.glob.iglob" 138 | ) as mock_glob, patch("custom_components.pyscript.global_ctx.open", mock_open), patch( 139 | "custom_components.pyscript.open", mock_open 140 | ), patch( 141 | "homeassistant.util.yaml.loader.open", mock_open 142 | ), patch( 143 | "homeassistant.config.load_yaml_config_file", return_value={"pyscript": conf} 144 | ), patch( 145 | "custom_components.pyscript.watchdog_start", return_value=None 146 | ), patch( 147 | "custom_components.pyscript.os.path.getmtime", return_value=1000 148 | ), patch( 149 | "custom_components.pyscript.global_ctx.os.path.getmtime", return_value=1000 150 | ), patch( 151 | "custom_components.pyscript.os.path.isfile" 152 | ) as mock_isfile: 153 | mock_isfile.side_effect = isfile_side_effect 154 | mock_glob.side_effect = glob_side_effect 155 | assert await async_setup_component(hass, "pyscript", {DOMAIN: conf}) 156 | 157 | notify_q = asyncio.Queue(0) 158 | 159 | async def state_changed(event): 160 | var_name = event.data["entity_id"] 161 | if var_name != "pyscript.done": 162 | return 163 | value = event.data["new_state"].state 164 | await notify_q.put(value) 165 | 166 | hass.bus.async_listen(EVENT_STATE_CHANGED, state_changed) 167 | 168 | assert hass.services.has_service("pyscript", "func1") 169 | assert hass.services.has_service("pyscript", "func2") 170 | assert hass.services.has_service("pyscript", "func3") 171 | 172 | assert "modules/xyz2 global_ctx=modules.xyz2;" in caplog.text 173 | assert "modules/xyz2/other global_ctx=modules.xyz2.other;" in caplog.text 174 | assert "hello global_ctx=file.hello xyz=123 xyz2.xyz=123" in caplog.text 175 | assert "world2.other global_ctx=apps.world2.other" in caplog.text 176 | assert "world2 global_ctx=apps.world2 var1=100, other_abc=987" in caplog.text 177 | 178 | # 179 | # add a new script file 180 | # 181 | file_contents[ 182 | f"{conf_dir}/hello2.py" 183 | ] = """ 184 | log.info(f"{__name__} global_ctx={pyscript.get_global_ctx()};") 185 | 186 | @service 187 | def func20(): 188 | pass 189 | """ 190 | mock_open[f"{conf_dir}/hello2.py"].read_data = file_contents[f"{conf_dir}/hello2.py"] 191 | 192 | # 193 | # should not load the new script if we reload something else 194 | # 195 | await hass.services.async_call("pyscript", "reload", {"global_ctx": "file.hello"}, blocking=True) 196 | assert not hass.services.has_service("pyscript", "func20") 197 | assert "hello2 global_ctx=file.hello2;" not in caplog.text 198 | 199 | # 200 | # should load new file 201 | # 202 | await hass.services.async_call("pyscript", "reload", {}, blocking=True) 203 | assert hass.services.has_service("pyscript", "func20") 204 | assert "hello2 global_ctx=file.hello2;" in caplog.text 205 | 206 | # 207 | # delete the script file 208 | # 209 | del file_contents[f"{conf_dir}/hello2.py"] 210 | 211 | # 212 | # should not delete the script file if we reload something else 213 | # 214 | await hass.services.async_call("pyscript", "reload", {"global_ctx": "file.hello"}, blocking=True) 215 | assert hass.services.has_service("pyscript", "func20") 216 | 217 | # 218 | # should delete the script file 219 | # 220 | await hass.services.async_call("pyscript", "reload", {}, blocking=True) 221 | assert not hass.services.has_service("pyscript", "func20") 222 | 223 | # 224 | # change a module file and confirm the parent script is reloaded too 225 | # 226 | file_contents[ 227 | f"{conf_dir}/modules/xyz2/other.py" 228 | ] = """ 229 | log.info(f"modules/xyz2/other global_ctx={pyscript.get_global_ctx()};") 230 | 231 | xyz = 456 232 | """ 233 | mock_open[f"{conf_dir}/modules/xyz2/other.py"].read_data = file_contents[ 234 | f"{conf_dir}/modules/xyz2/other.py" 235 | ] 236 | 237 | await hass.services.async_call("pyscript", "reload", {}, blocking=True) 238 | assert "hello global_ctx=file.hello xyz=456 xyz2.xyz=456" in caplog.text 239 | 240 | # 241 | # change the app config 242 | # 243 | conf["apps"]["world2"]["var1"] = 200 244 | await hass.services.async_call("pyscript", "reload", {}, blocking=True) 245 | assert "world2 global_ctx=apps.world2 var1=200, other_abc=987" in caplog.text 246 | assert "world2.other global_ctx=apps.world2.other shutdown trigger_time=shutdown" in caplog.text 247 | 248 | # 249 | # change a module inside an app 250 | # 251 | file_contents[ 252 | f"{conf_dir}/apps/world2/other.py" 253 | ] = """ 254 | other_abc = 654 255 | 256 | log.info(f"{__name__} global_ctx={pyscript.get_global_ctx()}") 257 | 258 | @time_trigger("shutdown") 259 | def shutdown(trigger_time=None): 260 | log.info(f"{__name__} global_ctx={pyscript.get_global_ctx()} shutdown_new trigger_time={trigger_time}") 261 | """ 262 | mock_open[f"{conf_dir}/apps/world2/other.py"].read_data = file_contents[ 263 | f"{conf_dir}/apps/world2/other.py" 264 | ] 265 | await hass.services.async_call("pyscript", "reload", {}, blocking=True) 266 | assert "world2 global_ctx=apps.world2 var1=200, other_abc=654" in caplog.text 267 | assert "world2.other global_ctx=apps.world2.other shutdown trigger_time=shutdown" in caplog.text 268 | 269 | # 270 | # now confirm certain files reloaded the correct number of times, 271 | # and reload everything a few times 272 | # 273 | for i in range(3): 274 | assert caplog.text.count("world global_ctx=apps.world xyz=") == 2 + i 275 | assert caplog.text.count("world is starting up") == 2 + i 276 | assert caplog.text.count("world2 global_ctx=apps.world2 var1=") == 3 + i 277 | assert caplog.text.count("hello global_ctx=file.hello xyz=") == 4 + i 278 | assert caplog.text.count("modules/xyz2/other global_ctx=modules.xyz2.other") == 2 + i 279 | assert caplog.text.count("modules/xyz2 global_ctx=modules.xyz2") == 2 + i 280 | assert ( 281 | caplog.text.count("world2.other global_ctx=apps.world2.other shutdown trigger_time=shutdown") 282 | == 2 283 | ) 284 | assert ( 285 | caplog.text.count( 286 | "world2.other global_ctx=apps.world2.other shutdown_new trigger_time=shutdown" 287 | ) 288 | == i 289 | ) 290 | if i < 2: 291 | await hass.services.async_call("pyscript", "reload", {"global_ctx": "*"}, blocking=True) 292 | while caplog.text.count("world is starting up") < 3 + i: 293 | await asyncio.sleep(0.001) 294 | 295 | # 296 | # make sure files that shouldn't load were not loaded 297 | # 298 | assert "BOTCH shouldn't load" not in caplog.text 299 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2020, Craig Barratt 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Tests for pyscript config flow.""" 2 | 3 | import logging 4 | from unittest.mock import patch 5 | 6 | import pytest 7 | 8 | from custom_components.pyscript import PYSCRIPT_SCHEMA 9 | from custom_components.pyscript.const import CONF_ALLOW_ALL_IMPORTS, CONF_HASS_IS_GLOBAL, DOMAIN 10 | from homeassistant import data_entry_flow 11 | from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | @pytest.fixture(name="pyscript_bypass_setup") 17 | def pyscript_bypass_setup_fixture(): 18 | """Mock component setup.""" 19 | logging.getLogger("pytest_homeassistant_custom_component.common").setLevel(logging.WARNING) 20 | with patch("custom_components.pyscript.async_setup_entry", return_value=True): 21 | yield 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_user_flow_minimum_fields(hass, pyscript_bypass_setup): 26 | """Test user config flow with minimum fields.""" 27 | # test form shows 28 | result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER}) 29 | assert result["type"] == data_entry_flow.FlowResultType.FORM 30 | assert result["step_id"] == "user" 31 | 32 | result = await hass.config_entries.flow.async_configure(result["flow_id"], user_input={}) 33 | 34 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 35 | assert CONF_ALLOW_ALL_IMPORTS in result["data"] 36 | assert CONF_HASS_IS_GLOBAL in result["data"] 37 | assert not result["data"][CONF_ALLOW_ALL_IMPORTS] 38 | assert not result["data"][CONF_HASS_IS_GLOBAL] 39 | 40 | 41 | @pytest.mark.asyncio 42 | async def test_user_flow_all_fields(hass, pyscript_bypass_setup): 43 | """Test user config flow with all fields.""" 44 | # test form shows 45 | result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER}) 46 | 47 | assert result["type"] == data_entry_flow.FlowResultType.FORM 48 | assert result["step_id"] == "user" 49 | 50 | result = await hass.config_entries.flow.async_configure( 51 | result["flow_id"], user_input={CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True} 52 | ) 53 | 54 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 55 | assert CONF_ALLOW_ALL_IMPORTS in result["data"] 56 | assert result["data"][CONF_ALLOW_ALL_IMPORTS] 57 | assert result["data"][CONF_HASS_IS_GLOBAL] 58 | 59 | 60 | @pytest.mark.asyncio 61 | async def test_user_already_configured(hass, pyscript_bypass_setup): 62 | """Test service is already configured during user setup.""" 63 | result = await hass.config_entries.flow.async_init( 64 | DOMAIN, 65 | context={"source": SOURCE_USER}, 66 | data={CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True}, 67 | ) 68 | 69 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 70 | 71 | result = await hass.config_entries.flow.async_init( 72 | DOMAIN, 73 | context={"source": SOURCE_USER}, 74 | data={CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True}, 75 | ) 76 | 77 | assert result["type"] == data_entry_flow.FlowResultType.ABORT 78 | assert result["reason"] == "single_instance_allowed" 79 | 80 | 81 | @pytest.mark.asyncio 82 | async def test_import_flow(hass, pyscript_bypass_setup): 83 | """Test import config flow works.""" 84 | result = await hass.config_entries.flow.async_init( 85 | DOMAIN, context={"source": SOURCE_IMPORT}, data=PYSCRIPT_SCHEMA({}) 86 | ) 87 | 88 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 89 | 90 | 91 | @pytest.mark.asyncio 92 | async def test_import_flow_update_allow_all_imports(hass, pyscript_bypass_setup): 93 | """Test import config flow updates existing entry when `allow_all_imports` has changed.""" 94 | result = await hass.config_entries.flow.async_init( 95 | DOMAIN, context={"source": SOURCE_IMPORT}, data=PYSCRIPT_SCHEMA({}) 96 | ) 97 | 98 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 99 | 100 | result = await hass.config_entries.flow.async_init( 101 | DOMAIN, 102 | context={"source": SOURCE_IMPORT}, 103 | data={CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True}, 104 | ) 105 | 106 | assert result["type"] == data_entry_flow.FlowResultType.ABORT 107 | assert result["reason"] == "updated_entry" 108 | 109 | 110 | @pytest.mark.asyncio 111 | async def test_import_flow_update_apps_from_none(hass, pyscript_bypass_setup): 112 | """Test import config flow updates existing entry when `apps` has changed from None to something.""" 113 | result = await hass.config_entries.flow.async_init( 114 | DOMAIN, context={"source": SOURCE_IMPORT}, data=PYSCRIPT_SCHEMA({}) 115 | ) 116 | 117 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 118 | 119 | result = await hass.config_entries.flow.async_init( 120 | DOMAIN, context={"source": SOURCE_IMPORT}, data={"apps": {"test_app": {"param": 1}}} 121 | ) 122 | 123 | assert result["type"] == data_entry_flow.FlowResultType.ABORT 124 | assert result["reason"] == "updated_entry" 125 | 126 | 127 | @pytest.mark.asyncio 128 | async def test_import_flow_update_apps_to_none(hass, pyscript_bypass_setup): 129 | """Test import config flow updates existing entry when `apps` has changed from something to None.""" 130 | result = await hass.config_entries.flow.async_init( 131 | DOMAIN, context={"source": SOURCE_IMPORT}, data=PYSCRIPT_SCHEMA({"apps": {"test_app": {"param": 1}}}) 132 | ) 133 | 134 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 135 | 136 | result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_IMPORT}, data={}) 137 | 138 | assert result["type"] == data_entry_flow.FlowResultType.ABORT 139 | assert result["reason"] == "updated_entry" 140 | 141 | 142 | @pytest.mark.asyncio 143 | async def test_import_flow_no_update(hass, pyscript_bypass_setup): 144 | """Test import config flow doesn't update existing entry when data is same.""" 145 | result = await hass.config_entries.flow.async_init( 146 | DOMAIN, context={"source": SOURCE_IMPORT}, data=PYSCRIPT_SCHEMA({}) 147 | ) 148 | 149 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 150 | 151 | result = await hass.config_entries.flow.async_init( 152 | DOMAIN, context={"source": SOURCE_IMPORT}, data=PYSCRIPT_SCHEMA({}) 153 | ) 154 | 155 | assert result["type"] == data_entry_flow.FlowResultType.ABORT 156 | assert result["reason"] == "already_configured" 157 | 158 | 159 | @pytest.mark.asyncio 160 | async def test_import_flow_update_user(hass, pyscript_bypass_setup): 161 | """Test import config flow update excludes `allow_all_imports` from being updated when updated entry was a user entry.""" 162 | result = await hass.config_entries.flow.async_init( 163 | DOMAIN, 164 | context={"source": SOURCE_USER}, 165 | data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True}), 166 | ) 167 | 168 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 169 | 170 | result = await hass.config_entries.flow.async_init( 171 | DOMAIN, context={"source": SOURCE_IMPORT}, data={"apps": {"test_app": {"param": 1}}} 172 | ) 173 | 174 | assert result["type"] == data_entry_flow.FlowResultType.ABORT 175 | assert result["reason"] == "updated_entry" 176 | 177 | assert hass.config_entries.async_entries(DOMAIN)[0].data == { 178 | CONF_ALLOW_ALL_IMPORTS: True, 179 | CONF_HASS_IS_GLOBAL: True, 180 | "apps": {"test_app": {"param": 1}}, 181 | } 182 | 183 | 184 | @pytest.mark.asyncio 185 | async def test_import_flow_update_import(hass, pyscript_bypass_setup): 186 | """Test import config flow update includes `allow_all_imports` in update when updated entry was imported entry.""" 187 | result = await hass.config_entries.flow.async_init( 188 | DOMAIN, 189 | context={"source": SOURCE_IMPORT}, 190 | data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True}), 191 | ) 192 | 193 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 194 | 195 | result = await hass.config_entries.flow.async_init( 196 | DOMAIN, context={"source": SOURCE_IMPORT}, data={"apps": {"test_app": {"param": 1}}} 197 | ) 198 | 199 | assert result["type"] == data_entry_flow.FlowResultType.ABORT 200 | assert result["reason"] == "updated_entry" 201 | 202 | assert hass.config_entries.async_entries(DOMAIN)[0].data == {"apps": {"test_app": {"param": 1}}} 203 | 204 | 205 | @pytest.mark.asyncio 206 | async def test_options_flow_import(hass, pyscript_bypass_setup): 207 | """Test options flow aborts because configuration needs to be managed via configuration.yaml.""" 208 | result = await hass.config_entries.flow.async_init( 209 | DOMAIN, 210 | context={"source": SOURCE_IMPORT}, 211 | data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True}), 212 | ) 213 | await hass.async_block_till_done() 214 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 215 | entry = result["result"] 216 | 217 | result = await hass.config_entries.options.async_init(entry.entry_id, data=None) 218 | 219 | assert result["type"] == data_entry_flow.FlowResultType.FORM 220 | assert result["step_id"] == "no_ui_configuration_allowed" 221 | 222 | result = await hass.config_entries.options.async_configure(result["flow_id"], user_input=None) 223 | 224 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 225 | assert result["title"] == "" 226 | 227 | 228 | @pytest.mark.asyncio 229 | async def test_options_flow_user_change(hass, pyscript_bypass_setup): 230 | """Test options flow updates config entry when options change.""" 231 | result = await hass.config_entries.flow.async_init( 232 | DOMAIN, 233 | context={"source": SOURCE_USER}, 234 | data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True}), 235 | ) 236 | await hass.async_block_till_done() 237 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 238 | entry = result["result"] 239 | 240 | result = await hass.config_entries.options.async_init(entry.entry_id) 241 | 242 | assert result["type"] == data_entry_flow.FlowResultType.FORM 243 | assert result["step_id"] == "init" 244 | 245 | result = await hass.config_entries.options.async_configure( 246 | result["flow_id"], user_input={CONF_ALLOW_ALL_IMPORTS: False, CONF_HASS_IS_GLOBAL: False} 247 | ) 248 | await hass.async_block_till_done() 249 | 250 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 251 | assert result["title"] == "" 252 | 253 | assert entry.data[CONF_ALLOW_ALL_IMPORTS] is False 254 | assert entry.data[CONF_HASS_IS_GLOBAL] is False 255 | 256 | 257 | @pytest.mark.asyncio 258 | async def test_options_flow_user_no_change(hass, pyscript_bypass_setup): 259 | """Test options flow aborts when options don't change.""" 260 | result = await hass.config_entries.flow.async_init( 261 | DOMAIN, 262 | context={"source": SOURCE_USER}, 263 | data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True}), 264 | ) 265 | await hass.async_block_till_done() 266 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 267 | entry = result["result"] 268 | 269 | result = await hass.config_entries.options.async_init(entry.entry_id) 270 | 271 | assert result["type"] == data_entry_flow.FlowResultType.FORM 272 | assert result["step_id"] == "init" 273 | 274 | result = await hass.config_entries.options.async_configure( 275 | result["flow_id"], user_input={CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True} 276 | ) 277 | 278 | assert result["type"] == data_entry_flow.FlowResultType.FORM 279 | assert result["step_id"] == "no_update" 280 | 281 | result = await hass.config_entries.options.async_configure(result["flow_id"], user_input=None) 282 | 283 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 284 | assert result["title"] == "" 285 | 286 | 287 | @pytest.mark.asyncio 288 | async def test_config_entry_reload(hass): 289 | """Test that config entry reload does not duplicate listeners.""" 290 | with patch("homeassistant.config.load_yaml_config_file", return_value={}), patch( 291 | "custom_components.pyscript.watchdog_start", return_value=None 292 | ): 293 | result = await hass.config_entries.flow.async_init( 294 | DOMAIN, 295 | context={"source": SOURCE_USER}, 296 | data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True}), 297 | ) 298 | await hass.async_block_till_done() 299 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 300 | entry = result["result"] 301 | listeners = hass.bus.async_listeners() 302 | await hass.config_entries.async_reload(entry.entry_id) 303 | await hass.async_block_till_done() 304 | assert listeners == hass.bus.async_listeners() 305 | -------------------------------------------------------------------------------- /custom_components/pyscript/global_ctx.py: -------------------------------------------------------------------------------- 1 | """Global context handling.""" 2 | 3 | import logging 4 | import os 5 | from types import ModuleType 6 | from typing import Any, Callable, Dict, List, Optional, Set, Union 7 | 8 | from homeassistant.config_entries import ConfigEntry 9 | 10 | from .const import CONF_HASS_IS_GLOBAL, CONFIG_ENTRY, DOMAIN, FOLDER, LOGGER_PATH 11 | from .eval import AstEval, EvalFunc 12 | from .function import Function 13 | from .trigger import TrigInfo 14 | 15 | _LOGGER = logging.getLogger(LOGGER_PATH + ".global_ctx") 16 | 17 | 18 | class GlobalContext: 19 | """Define class for global variables and trigger context.""" 20 | 21 | def __init__( 22 | self, 23 | name, 24 | global_sym_table: Dict[str, Any] = None, 25 | manager=None, 26 | rel_import_path: str = None, 27 | app_config: Dict[str, Any] = None, 28 | source: str = None, 29 | mtime: float = None, 30 | ) -> None: 31 | """Initialize GlobalContext.""" 32 | self.name: str = name 33 | self.global_sym_table: Dict[str, Any] = global_sym_table if global_sym_table else {} 34 | self.triggers: Set[EvalFunc] = set() 35 | self.triggers_delay_start: Set[EvalFunc] = set() 36 | self.logger: logging.Logger = logging.getLogger(LOGGER_PATH + "." + name) 37 | self.manager: GlobalContextMgr = manager 38 | self.auto_start: bool = False 39 | self.module: ModuleType = None 40 | self.rel_import_path: str = rel_import_path 41 | self.source: str = source 42 | self.file_path: str = None 43 | self.mtime: float = mtime 44 | self.app_config: Dict[str, Any] = app_config 45 | self.imports: Set[str] = set() 46 | config_entry: ConfigEntry = Function.hass.data.get(DOMAIN, {}).get(CONFIG_ENTRY, {}) 47 | if config_entry.data.get(CONF_HASS_IS_GLOBAL, False): 48 | # 49 | # expose hass as a global variable if configured 50 | # 51 | self.global_sym_table["hass"] = Function.hass 52 | if app_config: 53 | self.global_sym_table["pyscript.app_config"] = app_config.copy() 54 | 55 | def trigger_register(self, func: EvalFunc) -> bool: 56 | """Register a trigger function; return True if start now.""" 57 | self.triggers.add(func) 58 | if self.auto_start: 59 | return True 60 | self.triggers_delay_start.add(func) 61 | return False 62 | 63 | def trigger_unregister(self, func: EvalFunc) -> None: 64 | """Unregister a trigger function.""" 65 | self.triggers.discard(func) 66 | self.triggers_delay_start.discard(func) 67 | 68 | def set_auto_start(self, auto_start: bool) -> None: 69 | """Set the auto-start flag.""" 70 | self.auto_start = auto_start 71 | 72 | def start(self) -> None: 73 | """Start any unstarted triggers.""" 74 | for func in self.triggers_delay_start: 75 | func.trigger_start() 76 | self.triggers_delay_start = set() 77 | 78 | def stop(self) -> None: 79 | """Stop all triggers and auto_start.""" 80 | for func in self.triggers: 81 | func.trigger_stop() 82 | self.triggers = set() 83 | self.triggers_delay_start = set() 84 | self.set_auto_start(False) 85 | 86 | def get_name(self) -> str: 87 | """Return the global context name.""" 88 | return self.name 89 | 90 | def set_logger_name(self, name) -> None: 91 | """Set the global context logging name.""" 92 | self.logger = logging.getLogger(LOGGER_PATH + "." + name) 93 | 94 | def get_global_sym_table(self) -> Dict[str, Any]: 95 | """Return the global symbol table.""" 96 | return self.global_sym_table 97 | 98 | def get_source(self) -> str: 99 | """Return the source code.""" 100 | return self.source 101 | 102 | def get_app_config(self) -> Dict[str, Any]: 103 | """Return the app config.""" 104 | return self.app_config 105 | 106 | def get_mtime(self) -> float: 107 | """Return the mtime.""" 108 | return self.mtime 109 | 110 | def get_file_path(self) -> str: 111 | """Return the file path.""" 112 | return self.file_path 113 | 114 | def get_imports(self) -> Set[str]: 115 | """Return the imports.""" 116 | return self.imports 117 | 118 | def get_trig_info(self, name: str, trig_args: Dict[str, Any]) -> TrigInfo: 119 | """Return a new trigger info instance with the given args.""" 120 | return TrigInfo(name, trig_args, self) 121 | 122 | async def module_import(self, module_name: str, import_level: int) -> List[Optional[str]]: 123 | """Import a pyscript module from the pyscript/modules or apps folder.""" 124 | 125 | pyscript_dir = Function.hass.config.path(FOLDER) 126 | module_path = module_name.replace(".", "/") 127 | file_paths = [] 128 | 129 | def find_first_file(file_paths: List[Set[str]]) -> List[Optional[Union[str, ModuleType]]]: 130 | for ctx_name, path, rel_path in file_paths: 131 | abs_path = os.path.join(pyscript_dir, path) 132 | if os.path.isfile(abs_path): 133 | return [ctx_name, abs_path, rel_path] 134 | return None 135 | 136 | # 137 | # first build a list of potential import files 138 | # 139 | if import_level > 0: 140 | if self.rel_import_path is None: 141 | raise ImportError("attempted relative import with no known parent package") 142 | path = self.rel_import_path 143 | if path.endswith("/__init__"): 144 | path = os.path.dirname(path) 145 | ctx_name = self.name 146 | for _ in range(import_level - 1): 147 | path = os.path.dirname(path) 148 | idx = ctx_name.rfind(".") 149 | if path.find("/") < 0 or idx < 0: 150 | raise ImportError("attempted relative import above parent package") 151 | ctx_name = ctx_name[0:idx] 152 | ctx_name += f".{module_name}" 153 | module_info = [ctx_name, f"{path}/{module_path}.py", path] 154 | path += f"/{module_path}" 155 | file_paths.append([ctx_name, f"{path}/__init__.py", path]) 156 | file_paths.append(module_info) 157 | module_name = ctx_name[ctx_name.find(".") + 1 :] 158 | 159 | else: 160 | if self.rel_import_path is not None and self.rel_import_path.startswith("apps/"): 161 | ctx_name = f"apps.{module_name}" 162 | file_paths.append([ctx_name, f"apps/{module_path}/__init__.py", f"apps/{module_path}"]) 163 | file_paths.append([ctx_name, f"apps/{module_path}.py", f"apps/{module_path}"]) 164 | 165 | ctx_name = f"modules.{module_name}" 166 | file_paths.append([ctx_name, f"modules/{module_path}/__init__.py", f"modules/{module_path}"]) 167 | file_paths.append([ctx_name, f"modules/{module_path}.py", None]) 168 | 169 | # 170 | # now see if we have loaded it already 171 | # 172 | for ctx_name, _, _ in file_paths: 173 | mod_ctx = self.manager.get(ctx_name) 174 | if mod_ctx and mod_ctx.module: 175 | self.imports.add(mod_ctx.get_name()) 176 | return [mod_ctx.module, None] 177 | 178 | # 179 | # not loaded already, so try to find and import it 180 | # 181 | file_info = await Function.hass.async_add_executor_job(find_first_file, file_paths) 182 | if not file_info: 183 | return [None, None] 184 | 185 | [ctx_name, file_path, rel_import_path] = file_info 186 | 187 | mod = ModuleType(module_name) 188 | global_ctx = GlobalContext( 189 | ctx_name, global_sym_table=mod.__dict__, manager=self.manager, rel_import_path=rel_import_path 190 | ) 191 | global_ctx.set_auto_start(self.auto_start) 192 | _, error_ctx = await self.manager.load_file(global_ctx, file_path) 193 | if error_ctx: 194 | _LOGGER.error( 195 | "module_import: failed to load module %s, ctx = %s, path = %s", 196 | module_name, 197 | ctx_name, 198 | file_path, 199 | ) 200 | return [None, error_ctx] 201 | global_ctx.module = mod 202 | self.imports.add(ctx_name) 203 | return [mod, None] 204 | 205 | 206 | class GlobalContextMgr: 207 | """Define class for all global contexts.""" 208 | 209 | # 210 | # map of context names to contexts 211 | # 212 | contexts = {} 213 | 214 | # 215 | # sequence number for sessions 216 | # 217 | name_seq = 0 218 | 219 | def __init__(self) -> None: 220 | """Report an error if GlobalContextMgr in instantiated.""" 221 | _LOGGER.error("GlobalContextMgr class is not meant to be instantiated") 222 | 223 | @classmethod 224 | def init(cls) -> None: 225 | """Initialize GlobalContextMgr.""" 226 | 227 | def get_global_ctx_factory(ast_ctx: AstEval) -> Callable[[], str]: 228 | """Generate a pyscript.get_global_ctx() function with given ast_ctx.""" 229 | 230 | async def get_global_ctx(): 231 | return ast_ctx.get_global_ctx_name() 232 | 233 | return get_global_ctx 234 | 235 | def list_global_ctx_factory(ast_ctx: AstEval) -> Callable[[], List[str]]: 236 | """Generate a pyscript.list_global_ctx() function with given ast_ctx.""" 237 | 238 | async def list_global_ctx(): 239 | ctx_names = set(cls.contexts.keys()) 240 | curr_ctx_name = ast_ctx.get_global_ctx_name() 241 | ctx_names.discard(curr_ctx_name) 242 | return [curr_ctx_name] + sorted(sorted(ctx_names)) 243 | 244 | return list_global_ctx 245 | 246 | def set_global_ctx_factory(ast_ctx: AstEval) -> Callable[[str], None]: 247 | """Generate a pyscript.set_global_ctx() function with given ast_ctx.""" 248 | 249 | async def set_global_ctx(name): 250 | global_ctx = cls.get(name) 251 | if global_ctx is None: 252 | raise NameError(f"global context '{name}' does not exist") 253 | ast_ctx.set_global_ctx(global_ctx) 254 | ast_ctx.set_logger_name(global_ctx.name) 255 | 256 | return set_global_ctx 257 | 258 | ast_funcs = { 259 | "pyscript.get_global_ctx": get_global_ctx_factory, 260 | "pyscript.list_global_ctx": list_global_ctx_factory, 261 | "pyscript.set_global_ctx": set_global_ctx_factory, 262 | } 263 | 264 | Function.register_ast(ast_funcs) 265 | 266 | @classmethod 267 | def get(cls, name: str) -> Optional[str]: 268 | """Return the GlobalContext given a name.""" 269 | return cls.contexts.get(name, None) 270 | 271 | @classmethod 272 | def set(cls, name: str, global_ctx: GlobalContext) -> None: 273 | """Save the GlobalContext by name.""" 274 | cls.contexts[name] = global_ctx 275 | 276 | @classmethod 277 | def items(cls) -> List[Set[Union[str, GlobalContext]]]: 278 | """Return all the global context items.""" 279 | return sorted(cls.contexts.items()) 280 | 281 | @classmethod 282 | def delete(cls, name: str) -> None: 283 | """Delete the given GlobalContext.""" 284 | if name in cls.contexts: 285 | global_ctx = cls.contexts[name] 286 | global_ctx.stop() 287 | del cls.contexts[name] 288 | 289 | @classmethod 290 | def new_name(cls, root: str) -> str: 291 | """Find a unique new name by appending a sequence number to root.""" 292 | while True: 293 | name = f"{root}{cls.name_seq}" 294 | cls.name_seq += 1 295 | if name not in cls.contexts: 296 | return name 297 | 298 | @classmethod 299 | async def load_file( 300 | cls, global_ctx: GlobalContext, file_path: str, source: str = None, reload: bool = False 301 | ) -> Set[Union[bool, AstEval]]: 302 | """Load, parse and run the given script file; returns error ast_ctx on error, or None if ok.""" 303 | 304 | mtime = None 305 | if source is None: 306 | 307 | def read_file(path: str) -> Set[Union[str, float]]: 308 | try: 309 | with open(path, encoding="utf-8") as file_desc: 310 | source = file_desc.read() 311 | return source, os.path.getmtime(path) 312 | except Exception as exc: 313 | _LOGGER.error("%s", exc) 314 | return None, 0 315 | 316 | source, mtime = await Function.hass.async_add_executor_job(read_file, file_path) 317 | 318 | if source is None: 319 | return False, None 320 | 321 | ctx_curr = cls.get(global_ctx.get_name()) 322 | if ctx_curr: 323 | # stop triggers and destroy old global context 324 | ctx_curr.stop() 325 | cls.delete(global_ctx.get_name()) 326 | 327 | # 328 | # create new ast eval context and parse source file 329 | # 330 | ast_ctx = AstEval(global_ctx.get_name(), global_ctx) 331 | Function.install_ast_funcs(ast_ctx) 332 | 333 | if not ast_ctx.parse(source, filename=file_path): 334 | exc = ast_ctx.get_exception_long() 335 | ast_ctx.get_logger().error(exc) 336 | global_ctx.stop() 337 | return False, ast_ctx 338 | await ast_ctx.eval() 339 | exc = ast_ctx.get_exception_long() 340 | if exc is not None: 341 | ast_ctx.get_logger().error(exc) 342 | global_ctx.stop() 343 | return False, ast_ctx 344 | global_ctx.source = source 345 | global_ctx.file_path = file_path 346 | if mtime is not None: 347 | global_ctx.mtime = mtime 348 | cls.set(global_ctx.get_name(), global_ctx) 349 | 350 | _LOGGER.info("%s %s", "Reloaded" if reload else "Loaded", file_path) 351 | 352 | return True, None 353 | -------------------------------------------------------------------------------- /tests/test_decorator_errors.py: -------------------------------------------------------------------------------- 1 | """Test pyscript decorator syntax error and eval-time exception reporting.""" 2 | 3 | from ast import literal_eval 4 | import asyncio 5 | from datetime import datetime as dt 6 | from unittest.mock import mock_open, patch 7 | 8 | import pytest 9 | 10 | from custom_components.pyscript import trigger 11 | from custom_components.pyscript.const import DOMAIN 12 | from custom_components.pyscript.function import Function 13 | from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED 14 | from homeassistant.setup import async_setup_component 15 | 16 | 17 | async def setup_script(hass, notify_q, now, source): 18 | """Initialize and load the given pyscript.""" 19 | scripts = [ 20 | "/hello.py", 21 | ] 22 | 23 | Function.hass = None 24 | 25 | with patch("custom_components.pyscript.os.path.isdir", return_value=True), patch( 26 | "custom_components.pyscript.glob.iglob", return_value=scripts 27 | ), patch("custom_components.pyscript.global_ctx.open", mock_open(read_data=source)), patch( 28 | "custom_components.pyscript.trigger.dt_now", return_value=now 29 | ), patch( 30 | "homeassistant.config.load_yaml_config_file", return_value={} 31 | ), patch( 32 | "custom_components.pyscript.open", mock_open(read_data=source) 33 | ), patch( 34 | "custom_components.pyscript.watchdog_start", return_value=None 35 | ), patch( 36 | "custom_components.pyscript.os.path.getmtime", return_value=1000 37 | ), patch( 38 | "custom_components.pyscript.global_ctx.os.path.getmtime", return_value=1000 39 | ), patch( 40 | "custom_components.pyscript.install_requirements", 41 | return_value=None, 42 | ): 43 | assert await async_setup_component(hass, "pyscript", {DOMAIN: {}}) 44 | 45 | # 46 | # I'm not sure how to run the mock all the time, so just force the dt_now() 47 | # trigger function to return the given list of times in now. 48 | # 49 | def return_next_time(): 50 | if isinstance(now, list): 51 | if len(now) > 1: 52 | return now.pop(0) 53 | return now[0] 54 | return now 55 | 56 | trigger.__dict__["dt_now"] = return_next_time 57 | 58 | if notify_q: 59 | 60 | async def state_changed(event): 61 | var_name = event.data["entity_id"] 62 | if var_name != "pyscript.done": 63 | return 64 | value = event.data["new_state"].state 65 | await notify_q.put(value) 66 | 67 | hass.bus.async_listen(EVENT_STATE_CHANGED, state_changed) 68 | 69 | 70 | async def wait_until_done(notify_q): 71 | """Wait for the done handshake.""" 72 | return await asyncio.wait_for(notify_q.get(), timeout=4) 73 | 74 | 75 | @pytest.mark.asyncio 76 | async def test_decorator_errors(hass, caplog): 77 | """Test decorator syntax and run-time errors.""" 78 | notify_q = asyncio.Queue(0) 79 | await setup_script( 80 | hass, 81 | notify_q, 82 | [dt(2020, 7, 1, 10, 59, 59, 999999), dt(2020, 7, 1, 11, 59, 59, 999999)], 83 | """ 84 | seq_num = 0 85 | 86 | @time_trigger("startup") 87 | def func_startup_sync(trigger_type=None, trigger_time=None): 88 | global seq_num 89 | 90 | seq_num += 1 91 | log.info(f"func_startup_sync setting pyscript.done = {seq_num}, trigger_type = {trigger_type}, trigger_time = {trigger_time}") 92 | pyscript.done = seq_num 93 | 94 | @state_trigger("z + ") 95 | def func1(): 96 | pass 97 | 98 | @event_trigger("some_event", "func(") 99 | def func2(): 100 | pass 101 | 102 | @state_trigger("True") 103 | @state_active("z = 1") 104 | def func3(): 105 | pass 106 | 107 | @state_trigger("1 / int(pyscript.var1)") 108 | def func5(): 109 | pass 110 | 111 | @state_trigger("False", ["False", "False", "pyscript.var1"]) 112 | @state_active("1 / pyscript.var1") 113 | def func6(): 114 | pass 115 | 116 | @state_trigger("False", "False", ["pyscript.var7"]) 117 | def func7(): 118 | global seq_num 119 | 120 | try: 121 | task.wait_until(state_trigger="z +") 122 | except SyntaxError as exc: 123 | log.error(exc) 124 | 125 | try: 126 | task.wait_until(event_trigger=["event", "z+"]) 127 | except SyntaxError as exc: 128 | log.error(exc) 129 | 130 | try: 131 | task.wait_until(state_trigger="pyscript.var1 + 1") 132 | except TypeError as exc: 133 | log.error(exc) 134 | 135 | seq_num += 1 136 | pyscript.done = seq_num 137 | 138 | @state_trigger("pyscript.var_done") 139 | def func_wrapup(): 140 | global seq_num 141 | 142 | seq_num += 1 143 | pyscript.done = seq_num 144 | 145 | @state_trigger("z") 146 | def func8(): 147 | pass 148 | 149 | """, 150 | ) 151 | seq_num = 0 152 | 153 | seq_num += 1 154 | # fire event to start triggers, and handshake when they are running 155 | hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) 156 | assert literal_eval(await wait_until_done(notify_q)) == seq_num 157 | 158 | hass.states.async_set("pyscript.var1", 1) 159 | hass.states.async_set("pyscript.var1", 0) 160 | 161 | seq_num += 1 162 | hass.states.async_set("pyscript.var7", 1) 163 | assert literal_eval(await wait_until_done(notify_q)) == seq_num 164 | 165 | seq_num += 1 166 | hass.states.async_set("pyscript.var_done", 1) 167 | assert literal_eval(await wait_until_done(notify_q)) == seq_num 168 | 169 | assert ( 170 | # "SyntaxError: unexpected EOF while parsing (file.hello.func1 @state_trigger(), line 1)" # <= 3.9 171 | "SyntaxError: invalid syntax (file.hello.func1 @state_trigger(), line 1)" # >= 3.10 172 | in caplog.text 173 | ) 174 | assert ( 175 | # "SyntaxError: unexpected EOF while parsing (file.hello.func2 @event_trigger(), line 1)" # <= 3.9 176 | "SyntaxError: '(' was never closed (file.hello.func2 @event_trigger(), line 1)" # >= 3.10 177 | in caplog.text 178 | ) 179 | assert "SyntaxError: invalid syntax (file.hello.func3 @state_active(), line 1)" in caplog.text 180 | assert ( 181 | "trigger file.hello.func8: @state_trigger is not watching any variables; will never trigger" 182 | in caplog.text 183 | ) 184 | assert ( 185 | """Exception in line 1: 186 | 1 / int(pyscript.var1) 187 | ^ 188 | ZeroDivisionError: division by zero""" 189 | in caplog.text 190 | ) 191 | 192 | assert ( 193 | """Exception in line 1: 194 | 1 / pyscript.var1 195 | ^ 196 | TypeError: unsupported operand type(s) for /: 'int' and 'StateVal'""" 197 | in caplog.text 198 | ) 199 | 200 | # assert "unexpected EOF while parsing (file.hello.func7 state_trigger, line 1)" in caplog.text # <= 3.9 201 | assert "invalid syntax (file.hello.func7 state_trigger, line 1)" in caplog.text 202 | assert 'can only concatenate str (not "int") to str' in caplog.text 203 | 204 | 205 | @pytest.mark.asyncio 206 | async def test_decorator_errors_missing_trigger(hass, caplog): 207 | """Test decorator syntax and run-time errors.""" 208 | notify_q = asyncio.Queue(0) 209 | await setup_script( 210 | hass, 211 | notify_q, 212 | [dt(2020, 7, 1, 10, 59, 59, 999999)], 213 | """ 214 | @state_active("z + ") 215 | def func4(): 216 | pass 217 | """, 218 | ) 219 | assert ( 220 | "func4 defined in file.hello: needs at least one trigger decorator (ie: event_trigger, mqtt_trigger, state_trigger, time_trigger, webhook_trigger)" 221 | in caplog.text 222 | ) 223 | 224 | 225 | @pytest.mark.asyncio 226 | async def test_decorator_errors_missing_arg(hass, caplog): 227 | """Test decorator syntax and run-time errors.""" 228 | notify_q = asyncio.Queue(0) 229 | await setup_script( 230 | hass, 231 | notify_q, 232 | [dt(2020, 7, 1, 10, 59, 59, 999999)], 233 | """ 234 | @state_trigger 235 | def func8(): 236 | pass 237 | """, 238 | ) 239 | assert ( 240 | "TypeError: function 'func8' defined in file.hello: decorator @state_trigger needs at least one argument" 241 | in caplog.text 242 | ) 243 | 244 | 245 | @pytest.mark.asyncio 246 | async def test_decorator_errors_missing_arg2(hass, caplog): 247 | """Test decorator syntax and run-time errors.""" 248 | notify_q = asyncio.Queue(0) 249 | await setup_script( 250 | hass, 251 | notify_q, 252 | [dt(2020, 7, 1, 10, 59, 59, 999999)], 253 | """ 254 | @event_trigger 255 | def func9(): 256 | pass 257 | """, 258 | ) 259 | assert ( 260 | "TypeError: function 'func9' defined in file.hello: decorator @event_trigger needs at least one argument" 261 | in caplog.text 262 | ) 263 | 264 | 265 | @pytest.mark.asyncio 266 | async def test_decorator_errors_bad_arg_type(hass, caplog): 267 | """Test decorator syntax and run-time errors.""" 268 | notify_q = asyncio.Queue(0) 269 | await setup_script( 270 | hass, 271 | notify_q, 272 | [dt(2020, 7, 1, 10, 59, 59, 999999)], 273 | """ 274 | @state_trigger([None]) 275 | def func10(): 276 | pass 277 | """, 278 | ) 279 | assert ( 280 | "TypeError: function 'func10' defined in file.hello: decorator @state_trigger argument 1 should be a string, or list, or set" 281 | in caplog.text 282 | ) 283 | 284 | 285 | @pytest.mark.asyncio 286 | async def test_decorator_errors_bad_arg_type2(hass, caplog): 287 | """Test decorator syntax and run-time errors.""" 288 | notify_q = asyncio.Queue(0) 289 | await setup_script( 290 | hass, 291 | notify_q, 292 | [dt(2020, 7, 1, 10, 59, 59, 999999)], 293 | """ 294 | @state_trigger(False) 295 | def func11(): 296 | pass 297 | """, 298 | ) 299 | assert ( 300 | "TypeError: function 'func11' defined in file.hello: decorator @state_trigger argument 1 should be a string" 301 | in caplog.text 302 | ) 303 | 304 | 305 | @pytest.mark.asyncio 306 | async def test_service_reload_error(hass, caplog): 307 | """Test using a reserved name generates an error.""" 308 | 309 | await setup_script( 310 | hass, 311 | None, 312 | dt(2020, 7, 1, 11, 59, 59, 999999), 313 | """ 314 | @service 315 | def reload(): 316 | pass 317 | """, 318 | ) 319 | assert ( 320 | "SyntaxError: function 'reload' defined in file.hello: @service conflicts with builtin service" 321 | in caplog.text 322 | ) 323 | 324 | 325 | @pytest.mark.asyncio 326 | async def test_service_state_active_extra_args(hass, caplog): 327 | """Test using extra args to state_active generates an error.""" 328 | 329 | await setup_script( 330 | hass, 331 | None, 332 | dt(2020, 7, 1, 11, 59, 59, 999999), 333 | """ 334 | @state_active("arg1", "too many args") 335 | def func4(): 336 | pass 337 | """, 338 | ) 339 | assert ( 340 | "TypeError: function 'func4' defined in file.hello: decorator @state_active got 2 arguments, expected 1" 341 | in caplog.text 342 | ) 343 | 344 | 345 | @pytest.mark.asyncio 346 | async def test_service_wrong_arg_type(hass, caplog): 347 | """Test using too many args with service an error.""" 348 | 349 | await setup_script( 350 | hass, 351 | None, 352 | dt(2020, 7, 1, 11, 59, 59, 999999), 353 | """ 354 | @service(1) 355 | def func5(): 356 | pass 357 | """, 358 | ) 359 | assert ( 360 | "TypeError: function 'func5' defined in file.hello: decorator @service argument 1 should be a string" 361 | in caplog.text 362 | ) 363 | 364 | 365 | @pytest.mark.asyncio 366 | async def test_time_trigger_wrong_arg_type(hass, caplog): 367 | """Test using wrong argument type generates an error.""" 368 | 369 | await setup_script( 370 | hass, 371 | None, 372 | dt(2020, 7, 1, 11, 59, 59, 999999), 373 | """ 374 | @time_trigger("wrong arg type", 50) 375 | def func6(): 376 | pass 377 | """, 378 | ) 379 | assert ( 380 | "TypeError: function 'func6' defined in file.hello: decorator @time_trigger argument 2 should be a string" 381 | in caplog.text 382 | ) 383 | 384 | 385 | @pytest.mark.asyncio 386 | async def test_decorator_kwargs(hass, caplog): 387 | """Test invalid keyword arguments generates an error.""" 388 | 389 | await setup_script( 390 | hass, 391 | None, 392 | dt(2020, 7, 1, 11, 59, 59, 999999), 393 | """ 394 | @time_trigger("invalid kwargs", arg=10) 395 | def func7(): 396 | pass 397 | """, 398 | ) 399 | assert ( 400 | "TypeError: function 'func7' defined in file.hello: decorator @time_trigger invalid keyword argument 'arg'" 401 | in caplog.text 402 | ) 403 | 404 | 405 | @pytest.mark.asyncio 406 | async def test_decorator_kwargs2(hass, caplog): 407 | """Test invalid keyword arguments generates an error.""" 408 | 409 | await setup_script( 410 | hass, 411 | None, 412 | dt(2020, 7, 1, 11, 59, 59, 999999), 413 | """ 414 | @task_unique("invalid kwargs", arg=10) 415 | def func7(): 416 | pass 417 | """, 418 | ) 419 | assert ( 420 | "TypeError: function 'func7' defined in file.hello: decorator @task_unique invalid keyword argument 'arg'" 421 | in caplog.text 422 | ) 423 | 424 | 425 | @pytest.mark.asyncio 426 | async def test_decorator_kwargs3(hass, caplog): 427 | """Test invalid keyword arguments type generates an error.""" 428 | 429 | await setup_script( 430 | hass, 431 | None, 432 | dt(2020, 7, 1, 11, 59, 59, 999999), 433 | """ 434 | @state_trigger("abc.xyz", kwargs=10) 435 | def func7(): 436 | pass 437 | """, 438 | ) 439 | assert ( 440 | "TypeError: function 'func7' defined in file.hello: decorator @state_trigger keyword 'kwargs' should be type dict" 441 | in caplog.text 442 | ) 443 | 444 | 445 | @pytest.mark.asyncio 446 | async def test_decorator_kwargs4(hass, caplog): 447 | """Test invalid keyword arguments type generates an error.""" 448 | 449 | await setup_script( 450 | hass, 451 | None, 452 | dt(2020, 7, 1, 11, 59, 59, 999999), 453 | """ 454 | @state_trigger("abc.xyz", watch=10) 455 | def func7(): 456 | pass 457 | """, 458 | ) 459 | assert ( 460 | "TypeError: function 'func7' defined in file.hello: decorator @state_trigger keyword 'watch' should be type list or set" 461 | in caplog.text 462 | ) 463 | 464 | 465 | @pytest.mark.asyncio 466 | async def test_webhooks_method(hass, caplog): 467 | """Test invalid keyword arguments type generates an error.""" 468 | 469 | await setup_script( 470 | hass, 471 | None, 472 | dt(2020, 7, 1, 11, 59, 59, 999999), 473 | """ 474 | @webhook_trigger("hook", methods=["bad"]) 475 | def func8(): 476 | pass 477 | """, 478 | ) 479 | assert ( 480 | "TypeError: function 'func8' defined in file.hello: {'bad'} aren't valid webhook_trigger methods" 481 | in caplog.text 482 | ) 483 | -------------------------------------------------------------------------------- /custom_components/pyscript/requirements.py: -------------------------------------------------------------------------------- 1 | """Requirements helpers for pyscript.""" 2 | 3 | import glob 4 | import logging 5 | import os 6 | import sys 7 | 8 | from homeassistant.loader import bind_hass 9 | from homeassistant.requirements import async_process_requirements 10 | 11 | from .const import ( 12 | ATTR_INSTALLED_VERSION, 13 | ATTR_SOURCES, 14 | ATTR_VERSION, 15 | CONF_ALLOW_ALL_IMPORTS, 16 | CONF_INSTALLED_PACKAGES, 17 | DOMAIN, 18 | LOGGER_PATH, 19 | REQUIREMENTS_FILE, 20 | REQUIREMENTS_PATHS, 21 | UNPINNED_VERSION, 22 | ) 23 | 24 | if sys.version_info[:2] >= (3, 8): 25 | from importlib.metadata import ( # pylint: disable=no-name-in-module,import-error 26 | PackageNotFoundError, 27 | version as installed_version, 28 | ) 29 | else: 30 | from importlib_metadata import ( # pylint: disable=import-error 31 | PackageNotFoundError, 32 | version as installed_version, 33 | ) 34 | 35 | _LOGGER = logging.getLogger(LOGGER_PATH) 36 | 37 | 38 | def get_installed_version(pkg_name): 39 | """Get installed version of package. Returns None if not found.""" 40 | try: 41 | return installed_version(pkg_name) 42 | except PackageNotFoundError: 43 | return None 44 | 45 | 46 | def update_unpinned_versions(package_dict): 47 | """Check for current installed version of each unpinned package.""" 48 | requirements_to_pop = [] 49 | for package in package_dict: 50 | if package_dict[package] != UNPINNED_VERSION: 51 | continue 52 | 53 | package_dict[package] = get_installed_version(package) 54 | if not package_dict[package]: 55 | _LOGGER.error("%s wasn't able to be installed", package) 56 | requirements_to_pop.append(package) 57 | 58 | for package in requirements_to_pop: 59 | package_dict.pop(package) 60 | 61 | return package_dict 62 | 63 | 64 | @bind_hass 65 | def process_all_requirements(pyscript_folder, requirements_paths, requirements_file): 66 | """ 67 | Load all lines from requirements_file located in requirements_paths. 68 | 69 | Returns files and a list of packages, if any, that need to be installed. 70 | """ 71 | 72 | # Re-import Version to avoid dealing with multiple flake and pylint errors 73 | from packaging.version import Version # pylint: disable=import-outside-toplevel 74 | 75 | all_requirements_to_process = {} 76 | for root in requirements_paths: 77 | for requirements_path in glob.glob(os.path.join(pyscript_folder, root, requirements_file)): 78 | with open(requirements_path, "r", encoding="utf-8") as requirements_fp: 79 | all_requirements_to_process[requirements_path] = requirements_fp.readlines() 80 | 81 | all_requirements_to_install = {} 82 | for requirements_path, pkg_lines in all_requirements_to_process.items(): 83 | for pkg in pkg_lines: 84 | # Remove inline comments which are accepted by pip but not by Home 85 | # Assistant's installation method. 86 | # https://rosettacode.org/wiki/Strip_comments_from_a_string#Python 87 | i = pkg.find("#") 88 | if i >= 0: 89 | pkg = pkg[:i] 90 | pkg = pkg.strip() 91 | 92 | if not pkg or len(pkg) == 0: 93 | continue 94 | 95 | try: 96 | # Attempt to get version of package. Do nothing if it's found since 97 | # we want to use the version that's already installed to be safe 98 | parts = pkg.split("==") 99 | if len(parts) > 2 or "," in pkg or ">" in pkg or "<" in pkg: 100 | _LOGGER.error( 101 | ( 102 | "Ignoring invalid requirement '%s' specified in '%s'; if a specific version" 103 | "is required, the requirement must use the format 'pkg==version'" 104 | ), 105 | requirements_path, 106 | pkg, 107 | ) 108 | continue 109 | if len(parts) == 1: 110 | new_version = UNPINNED_VERSION 111 | else: 112 | new_version = parts[1] 113 | pkg_name = parts[0] 114 | 115 | current_pinned_version = all_requirements_to_install.get(pkg_name, {}).get(ATTR_VERSION) 116 | current_sources = all_requirements_to_install.get(pkg_name, {}).get(ATTR_SOURCES, []) 117 | # If a version hasn't already been recorded, record this one 118 | if not current_pinned_version: 119 | all_requirements_to_install[pkg_name] = { 120 | ATTR_VERSION: new_version, 121 | ATTR_SOURCES: [requirements_path], 122 | ATTR_INSTALLED_VERSION: get_installed_version(pkg_name), 123 | } 124 | 125 | # If the new version is unpinned and there is an existing pinned version, use existing 126 | # pinned version 127 | elif new_version == UNPINNED_VERSION and current_pinned_version != UNPINNED_VERSION: 128 | _LOGGER.warning( 129 | ( 130 | "Unpinned requirement for package '%s' detected in '%s' will be ignored in " 131 | "favor of the pinned version '%s' detected in '%s'" 132 | ), 133 | pkg_name, 134 | requirements_path, 135 | current_pinned_version, 136 | str(current_sources), 137 | ) 138 | # If the new version is pinned and the existing version is unpinned, use the new pinned 139 | # version 140 | elif new_version != UNPINNED_VERSION and current_pinned_version == UNPINNED_VERSION: 141 | _LOGGER.warning( 142 | ( 143 | "Unpinned requirement for package '%s' detected in '%s will be ignored in " 144 | "favor of the pinned version '%s' detected in '%s'" 145 | ), 146 | pkg_name, 147 | str(current_sources), 148 | new_version, 149 | requirements_path, 150 | ) 151 | all_requirements_to_install[pkg_name] = { 152 | ATTR_VERSION: new_version, 153 | ATTR_SOURCES: [requirements_path], 154 | ATTR_INSTALLED_VERSION: get_installed_version(pkg_name), 155 | } 156 | # If the already recorded version is the same as the new version, append the current 157 | # path so we can show sources 158 | elif ( 159 | new_version == UNPINNED_VERSION and current_pinned_version == UNPINNED_VERSION 160 | ) or Version(current_pinned_version) == Version(new_version): 161 | all_requirements_to_install[pkg_name][ATTR_SOURCES].append(requirements_path) 162 | # If the already recorded version is lower than the new version, use the new one 163 | elif Version(current_pinned_version) < Version(new_version): 164 | _LOGGER.warning( 165 | ( 166 | "Version '%s' for package '%s' detected in '%s' will be ignored in " 167 | "favor of the higher version '%s' detected in '%s'" 168 | ), 169 | current_pinned_version, 170 | pkg_name, 171 | str(current_sources), 172 | new_version, 173 | requirements_path, 174 | ) 175 | all_requirements_to_install[pkg_name].update( 176 | {ATTR_VERSION: new_version, ATTR_SOURCES: [requirements_path]} 177 | ) 178 | # If the already recorded version is higher than the new version, ignore the new one 179 | elif Version(current_pinned_version) > Version(new_version): 180 | _LOGGER.warning( 181 | ( 182 | "Version '%s' for package '%s' detected in '%s' will be ignored in " 183 | "favor of the higher version '%s' detected in '%s'" 184 | ), 185 | new_version, 186 | pkg_name, 187 | requirements_path, 188 | current_pinned_version, 189 | str(current_sources), 190 | ) 191 | except ValueError: 192 | # Not valid requirements line so it can be skipped 193 | _LOGGER.debug("Ignoring '%s' because it is not a valid package", pkg) 194 | 195 | return all_requirements_to_install 196 | 197 | 198 | @bind_hass 199 | async def install_requirements(hass, config_entry, pyscript_folder): 200 | """Install missing requirements from requirements.txt.""" 201 | 202 | pyscript_installed_packages = config_entry.data.get(CONF_INSTALLED_PACKAGES, {}).copy() 203 | 204 | # Import packaging inside install_requirements so that we can use Home Assistant to install it 205 | # if it can't been found 206 | try: 207 | from packaging.version import Version # pylint: disable=import-outside-toplevel 208 | except ModuleNotFoundError: 209 | await async_process_requirements(hass, DOMAIN, ["packaging"]) 210 | from packaging.version import Version # pylint: disable=import-outside-toplevel 211 | 212 | all_requirements = await hass.async_add_executor_job( 213 | process_all_requirements, pyscript_folder, REQUIREMENTS_PATHS, REQUIREMENTS_FILE 214 | ) 215 | 216 | requirements_to_install = {} 217 | 218 | if all_requirements and not config_entry.data.get(CONF_ALLOW_ALL_IMPORTS, False): 219 | _LOGGER.error( 220 | ( 221 | "Requirements detected but 'allow_all_imports' is set to False, set " 222 | "'allow_all_imports' to True if you want packages to be installed" 223 | ) 224 | ) 225 | return 226 | 227 | for package in all_requirements: 228 | pkg_installed_version = all_requirements[package].get(ATTR_INSTALLED_VERSION) 229 | version_to_install = all_requirements[package][ATTR_VERSION] 230 | sources = all_requirements[package][ATTR_SOURCES] 231 | # If package is already installed, we need to run some checks 232 | if pkg_installed_version: 233 | # If the version to install is unpinned and there is already something installed, 234 | # defer to what is installed 235 | if version_to_install == UNPINNED_VERSION: 236 | _LOGGER.debug( 237 | ( 238 | "Skipping unpinned version of package '%s' because version '%s' is " 239 | "already installed" 240 | ), 241 | package, 242 | pkg_installed_version, 243 | ) 244 | # If installed package is not the same version as the one we last installed, 245 | # that means that the package is externally managed now so we shouldn't touch it 246 | # and should remove it from our internal tracker 247 | if ( 248 | package in pyscript_installed_packages 249 | and pyscript_installed_packages[package] != pkg_installed_version 250 | ): 251 | pyscript_installed_packages.pop(package) 252 | continue 253 | 254 | # If installed package is not the same version as the one we last installed, 255 | # that means that the package is externally managed now so we shouldn't touch it 256 | # and should remove it from our internal tracker 257 | if package in pyscript_installed_packages and Version( 258 | pyscript_installed_packages[package] 259 | ) != Version(pkg_installed_version): 260 | _LOGGER.warning( 261 | ( 262 | "Version '%s' for package '%s' detected in '%s' will be ignored in favor of" 263 | " the version '%s' which was installed outside of pyscript" 264 | ), 265 | version_to_install, 266 | package, 267 | str(sources), 268 | pkg_installed_version, 269 | ) 270 | pyscript_installed_packages.pop(package) 271 | # If there is a version mismatch between what we want and what is installed, we 272 | # can overwrite it since we know it was last installed by us 273 | elif package in pyscript_installed_packages and Version(version_to_install) != Version( 274 | pkg_installed_version 275 | ): 276 | requirements_to_install[package] = all_requirements[package] 277 | # If there is an installed version that we have not previously installed, we 278 | # should not install it 279 | else: 280 | _LOGGER.debug( 281 | ( 282 | "Version '%s' for package '%s' detected in '%s' will be ignored because it" 283 | " is already installed" 284 | ), 285 | version_to_install, 286 | package, 287 | str(sources), 288 | ) 289 | # Anything not already installed in the environment can be installed 290 | else: 291 | requirements_to_install[package] = all_requirements[package] 292 | 293 | if requirements_to_install: 294 | _LOGGER.info( 295 | "Installing the following packages: %s", 296 | str(requirements_to_install), 297 | ) 298 | await async_process_requirements( 299 | hass, 300 | DOMAIN, 301 | [ 302 | ( 303 | f"{package}=={pkg_info[ATTR_VERSION]}" 304 | if pkg_info[ATTR_VERSION] != UNPINNED_VERSION 305 | else package 306 | ) 307 | for package, pkg_info in requirements_to_install.items() 308 | ], 309 | ) 310 | else: 311 | _LOGGER.debug("No new packages to install") 312 | 313 | # Update package tracker in config entry for next time 314 | pyscript_installed_packages.update( 315 | {package: pkg_info[ATTR_VERSION] for package, pkg_info in requirements_to_install.items()} 316 | ) 317 | 318 | # If any requirements were unpinned, get their version now so they can be pinned later 319 | if any(version == UNPINNED_VERSION for version in pyscript_installed_packages.values()): 320 | pyscript_installed_packages = await hass.async_add_executor_job( 321 | update_unpinned_versions, pyscript_installed_packages 322 | ) 323 | if pyscript_installed_packages != config_entry.data.get(CONF_INSTALLED_PACKAGES, {}): 324 | new_data = config_entry.data.copy() 325 | new_data[CONF_INSTALLED_PACKAGES] = pyscript_installed_packages 326 | hass.config_entries.async_update_entry(entry=config_entry, data=new_data) 327 | -------------------------------------------------------------------------------- /tests/test_requirements.py: -------------------------------------------------------------------------------- 1 | """Test requirements helpers.""" 2 | 3 | import logging 4 | from unittest.mock import patch 5 | 6 | import pytest 7 | from pytest import fixture 8 | from pytest_homeassistant_custom_component.common import MockConfigEntry 9 | 10 | from custom_components.pyscript.const import ( 11 | ATTR_INSTALLED_VERSION, 12 | ATTR_SOURCES, 13 | ATTR_VERSION, 14 | CONF_ALLOW_ALL_IMPORTS, 15 | CONF_INSTALLED_PACKAGES, 16 | DOMAIN, 17 | REQUIREMENTS_FILE, 18 | REQUIREMENTS_PATHS, 19 | UNPINNED_VERSION, 20 | ) 21 | from custom_components.pyscript.requirements import install_requirements, process_all_requirements 22 | 23 | PYSCRIPT_FOLDER = "tests/test_data/test_requirements" 24 | 25 | 26 | @fixture(autouse=True) 27 | def bypass_package_install_fixture(): 28 | """Bypass package installation.""" 29 | with patch("custom_components.pyscript.requirements.async_process_requirements"): 30 | yield 31 | 32 | 33 | @pytest.mark.asyncio 34 | async def test_install_requirements(hass, caplog): 35 | """Test install_requirements function.""" 36 | with patch( 37 | "custom_components.pyscript.requirements.process_all_requirements" 38 | ) as process_requirements, patch( 39 | "custom_components.pyscript.requirements.async_process_requirements" 40 | ) as ha_install_requirements: 41 | entry = MockConfigEntry(domain=DOMAIN, data={CONF_ALLOW_ALL_IMPORTS: True}) 42 | entry.add_to_hass(hass) 43 | 44 | # Check that packages get installed correctly 45 | process_requirements.return_value = { 46 | "my-package-name": { 47 | ATTR_SOURCES: [ 48 | f"{PYSCRIPT_FOLDER}/requirements.txt", 49 | f"{PYSCRIPT_FOLDER}/apps/app1/requirements.txt", 50 | ], 51 | ATTR_VERSION: "2.0.1", 52 | ATTR_INSTALLED_VERSION: None, 53 | }, 54 | "my-package-name-alternate": { 55 | ATTR_SOURCES: [f"{PYSCRIPT_FOLDER}/requirements.txt"], 56 | ATTR_VERSION: "2.0.1", 57 | ATTR_INSTALLED_VERSION: None, 58 | }, 59 | } 60 | await install_requirements(hass, entry, PYSCRIPT_FOLDER) 61 | await hass.async_block_till_done() 62 | assert ha_install_requirements.called 63 | assert ha_install_requirements.call_args[0][2] == [ 64 | "my-package-name==2.0.1", 65 | "my-package-name-alternate==2.0.1", 66 | ] 67 | assert CONF_INSTALLED_PACKAGES in entry.data 68 | assert entry.data[CONF_INSTALLED_PACKAGES] == { 69 | "my-package-name": "2.0.1", 70 | "my-package-name-alternate": "2.0.1", 71 | } 72 | 73 | # Check that we stop tracking packages whose version no longer matches what 74 | # we have stored (see previous line for what we currently have stored) 75 | ha_install_requirements.reset_mock() 76 | caplog.clear() 77 | process_requirements.return_value = { 78 | "my-package-name": { 79 | ATTR_SOURCES: [ 80 | f"{PYSCRIPT_FOLDER}/requirements.txt", 81 | f"{PYSCRIPT_FOLDER}/apps/app1/requirements.txt", 82 | ], 83 | ATTR_VERSION: "2.0.1", 84 | ATTR_INSTALLED_VERSION: "2.0.1", 85 | }, 86 | "my-package-name-alternate": { 87 | ATTR_SOURCES: [f"{PYSCRIPT_FOLDER}/requirements.txt"], 88 | ATTR_VERSION: "2.0.1", 89 | ATTR_INSTALLED_VERSION: "1.2.1", 90 | }, 91 | } 92 | await install_requirements(hass, entry, PYSCRIPT_FOLDER) 93 | await hass.async_block_till_done() 94 | assert not ha_install_requirements.called 95 | assert caplog.record_tuples == [ 96 | ( 97 | "custom_components.pyscript", 98 | logging.WARNING, 99 | ( 100 | "Version '2.0.1' for package 'my-package-name-alternate' detected " 101 | "in '['tests/test_data/test_requirements/requirements.txt']' will " 102 | "be ignored in favor of the version '1.2.1' which was installed " 103 | "outside of pyscript" 104 | ), 105 | ) 106 | ] 107 | assert entry.data[CONF_INSTALLED_PACKAGES] == {"my-package-name": "2.0.1"} 108 | 109 | # Check that version upgrades are handled if the version was installed 110 | # by us before 111 | ha_install_requirements.reset_mock() 112 | caplog.clear() 113 | process_requirements.return_value = { 114 | "my-package-name": { 115 | ATTR_SOURCES: [ 116 | f"{PYSCRIPT_FOLDER}/requirements.txt", 117 | f"{PYSCRIPT_FOLDER}/apps/app1/requirements.txt", 118 | ], 119 | ATTR_VERSION: "2.2.0", 120 | ATTR_INSTALLED_VERSION: "2.0.1", 121 | }, 122 | } 123 | await install_requirements(hass, entry, PYSCRIPT_FOLDER) 124 | await hass.async_block_till_done() 125 | assert ha_install_requirements.called 126 | assert ha_install_requirements.call_args[0][2] == ["my-package-name==2.2.0"] 127 | assert entry.data[CONF_INSTALLED_PACKAGES] == {"my-package-name": "2.2.0"} 128 | 129 | # Check that we don't install untracked but existing packages 130 | ha_install_requirements.reset_mock() 131 | caplog.clear() 132 | process_requirements.return_value = { 133 | "my-package-name-alternate": { 134 | ATTR_SOURCES: [f"{PYSCRIPT_FOLDER}/requirements.txt"], 135 | ATTR_VERSION: "2.0.1", 136 | ATTR_INSTALLED_VERSION: "1.2.1", 137 | }, 138 | } 139 | await install_requirements(hass, entry, PYSCRIPT_FOLDER) 140 | await hass.async_block_till_done() 141 | assert not ha_install_requirements.called 142 | assert entry.data[CONF_INSTALLED_PACKAGES] == {"my-package-name": "2.2.0"} 143 | 144 | # Check that we can downgrade as long as we installed the package 145 | ha_install_requirements.reset_mock() 146 | caplog.clear() 147 | process_requirements.return_value = { 148 | "my-package-name": { 149 | ATTR_SOURCES: [f"{PYSCRIPT_FOLDER}/requirements.txt"], 150 | ATTR_VERSION: "2.0.1", 151 | ATTR_INSTALLED_VERSION: "2.2.0", 152 | }, 153 | } 154 | await install_requirements(hass, entry, PYSCRIPT_FOLDER) 155 | await hass.async_block_till_done() 156 | assert ha_install_requirements.called 157 | assert ha_install_requirements.call_args[0][2] == ["my-package-name==2.0.1"] 158 | assert entry.data[CONF_INSTALLED_PACKAGES] == {"my-package-name": "2.0.1"} 159 | 160 | 161 | @pytest.mark.asyncio 162 | async def test_install_unpinned_requirements(hass, caplog): 163 | """Test install_requirements function with unpinned versions.""" 164 | with patch( 165 | "custom_components.pyscript.requirements.process_all_requirements" 166 | ) as process_requirements, patch( 167 | "custom_components.pyscript.requirements.async_process_requirements" 168 | ) as ha_install_requirements: 169 | entry = MockConfigEntry(domain=DOMAIN, data={CONF_ALLOW_ALL_IMPORTS: True}) 170 | entry.add_to_hass(hass) 171 | 172 | # Check that unpinned version gets skipped because a version is already 173 | # installed 174 | process_requirements.return_value = { 175 | "my-package-name": { 176 | ATTR_SOURCES: [ 177 | f"{PYSCRIPT_FOLDER}/requirements.txt", 178 | f"{PYSCRIPT_FOLDER}/apps/app1/requirements.txt", 179 | ], 180 | ATTR_VERSION: UNPINNED_VERSION, 181 | ATTR_INSTALLED_VERSION: "2.0.1", 182 | }, 183 | } 184 | await install_requirements(hass, entry, PYSCRIPT_FOLDER) 185 | await hass.async_block_till_done() 186 | assert not ha_install_requirements.called 187 | 188 | # Check that unpinned version gets installed because it isn't already 189 | # installed 190 | process_requirements.return_value = { 191 | "my-package-name": { 192 | ATTR_SOURCES: [ 193 | f"{PYSCRIPT_FOLDER}/requirements.txt", 194 | f"{PYSCRIPT_FOLDER}/apps/app1/requirements.txt", 195 | ], 196 | ATTR_VERSION: UNPINNED_VERSION, 197 | ATTR_INSTALLED_VERSION: None, 198 | }, 199 | "my-package-name-1": { 200 | ATTR_SOURCES: [ 201 | f"{PYSCRIPT_FOLDER}/requirements.txt", 202 | f"{PYSCRIPT_FOLDER}/apps/app1/requirements.txt", 203 | ], 204 | ATTR_VERSION: "2.0.1", 205 | ATTR_INSTALLED_VERSION: None, 206 | }, 207 | } 208 | await install_requirements(hass, entry, PYSCRIPT_FOLDER) 209 | await hass.async_block_till_done() 210 | assert ha_install_requirements.called 211 | assert ha_install_requirements.call_args[0][2] == ["my-package-name", "my-package-name-1==2.0.1"] 212 | # my-package-name will show as not installed and therefore won't be included 213 | assert entry.data[CONF_INSTALLED_PACKAGES] == {"my-package-name-1": "2.0.1"} 214 | 215 | # Check that entry.data[CONF_INSTALLED_PACKAGES] gets updated with a version number 216 | # when unpinned version was requested 217 | with patch("custom_components.pyscript.requirements.installed_version", return_value="1.1.1"): 218 | process_requirements.return_value = { 219 | "my-package-name": { 220 | ATTR_SOURCES: [ 221 | f"{PYSCRIPT_FOLDER}/requirements.txt", 222 | f"{PYSCRIPT_FOLDER}/apps/app1/requirements.txt", 223 | ], 224 | ATTR_VERSION: UNPINNED_VERSION, 225 | ATTR_INSTALLED_VERSION: None, 226 | }, 227 | "my-package-name-1": { 228 | ATTR_SOURCES: [ 229 | f"{PYSCRIPT_FOLDER}/requirements.txt", 230 | f"{PYSCRIPT_FOLDER}/apps/app1/requirements.txt", 231 | ], 232 | ATTR_VERSION: "2.0.1", 233 | ATTR_INSTALLED_VERSION: None, 234 | }, 235 | } 236 | await install_requirements(hass, entry, PYSCRIPT_FOLDER) 237 | await hass.async_block_till_done() 238 | assert ha_install_requirements.called 239 | assert ha_install_requirements.call_args[0][2] == ["my-package-name", "my-package-name-1==2.0.1"] 240 | assert entry.data[CONF_INSTALLED_PACKAGES] == { 241 | "my-package-name": "1.1.1", 242 | "my-package-name-1": "2.0.1", 243 | } 244 | 245 | # Check that package gets removed from entry.data[CONF_INSTALLED_PACKAGES] when it was 246 | # previously installed by pyscript but version was changed presumably by another system 247 | process_requirements.return_value = { 248 | "my-package-name": { 249 | ATTR_SOURCES: [ 250 | f"{PYSCRIPT_FOLDER}/requirements.txt", 251 | f"{PYSCRIPT_FOLDER}/apps/app1/requirements.txt", 252 | ], 253 | ATTR_VERSION: UNPINNED_VERSION, 254 | ATTR_INSTALLED_VERSION: "2.0.0", 255 | }, 256 | "my-package-name-1": { 257 | ATTR_SOURCES: [ 258 | f"{PYSCRIPT_FOLDER}/requirements.txt", 259 | f"{PYSCRIPT_FOLDER}/apps/app1/requirements.txt", 260 | ], 261 | ATTR_VERSION: "2.0.1", 262 | ATTR_INSTALLED_VERSION: None, 263 | }, 264 | } 265 | await install_requirements(hass, entry, PYSCRIPT_FOLDER) 266 | await hass.async_block_till_done() 267 | assert ha_install_requirements.called 268 | assert ha_install_requirements.call_args[0][2] == ["my-package-name-1==2.0.1"] 269 | assert entry.data[CONF_INSTALLED_PACKAGES] == {"my-package-name-1": "2.0.1"} 270 | 271 | 272 | @pytest.mark.asyncio 273 | async def test_install_requirements_not_allowed(hass): 274 | """Test that install requirements will not work because 'allow_all_imports' is False.""" 275 | with patch( 276 | "custom_components.pyscript.requirements.process_all_requirements" 277 | ) as process_requirements, patch( 278 | "custom_components.pyscript.requirements.async_process_requirements" 279 | ) as ha_install_requirements: 280 | entry = MockConfigEntry(domain=DOMAIN, data={CONF_ALLOW_ALL_IMPORTS: False}) 281 | entry.add_to_hass(hass) 282 | 283 | # Check that packages get installed correctly 284 | process_requirements.return_value = { 285 | "my-package-name": { 286 | ATTR_SOURCES: [ 287 | f"{PYSCRIPT_FOLDER}/requirements.txt", 288 | f"{PYSCRIPT_FOLDER}/apps/app1/requirements.txt", 289 | ], 290 | ATTR_VERSION: "2.0.1", 291 | ATTR_INSTALLED_VERSION: None, 292 | }, 293 | "my-package-name-alternate": { 294 | ATTR_SOURCES: [f"{PYSCRIPT_FOLDER}/requirements.txt"], 295 | ATTR_VERSION: "2.0.1", 296 | ATTR_INSTALLED_VERSION: None, 297 | }, 298 | } 299 | assert await install_requirements(hass, entry, PYSCRIPT_FOLDER) is None 300 | await hass.async_block_till_done() 301 | 302 | assert not ha_install_requirements.called 303 | 304 | 305 | def test_process_requirements(): 306 | """Test process requirements function.""" 307 | with patch("custom_components.pyscript.requirements.installed_version", return_value=None): 308 | all_requirements = process_all_requirements(PYSCRIPT_FOLDER, REQUIREMENTS_PATHS, REQUIREMENTS_FILE) 309 | assert all_requirements == { 310 | "my-package-name": { 311 | ATTR_SOURCES: [ 312 | f"{PYSCRIPT_FOLDER}/requirements.txt", 313 | f"{PYSCRIPT_FOLDER}/apps/app1/requirements.txt", 314 | ], 315 | ATTR_VERSION: "2.0.1", 316 | ATTR_INSTALLED_VERSION: None, 317 | }, 318 | "my-package-name-alternate": { 319 | ATTR_SOURCES: [f"{PYSCRIPT_FOLDER}/requirements.txt"], 320 | ATTR_VERSION: "2.0.1", 321 | ATTR_INSTALLED_VERSION: None, 322 | }, 323 | "my-package-name-alternate-1": { 324 | ATTR_SOURCES: [f"{PYSCRIPT_FOLDER}/requirements.txt"], 325 | ATTR_VERSION: "0.0.1", 326 | ATTR_INSTALLED_VERSION: None, 327 | }, 328 | } 329 | 330 | with patch("custom_components.pyscript.requirements.installed_version") as installed_version: 331 | installed_version.side_effect = ["2.0.1", "1.0.0", None, None] 332 | all_requirements = process_all_requirements(PYSCRIPT_FOLDER, REQUIREMENTS_PATHS, REQUIREMENTS_FILE) 333 | assert all_requirements == { 334 | "my-package-name": { 335 | ATTR_SOURCES: [ 336 | f"{PYSCRIPT_FOLDER}/requirements.txt", 337 | f"{PYSCRIPT_FOLDER}/apps/app1/requirements.txt", 338 | ], 339 | ATTR_VERSION: "2.0.1", 340 | ATTR_INSTALLED_VERSION: "2.0.1", 341 | }, 342 | "my-package-name-alternate": { 343 | ATTR_SOURCES: [f"{PYSCRIPT_FOLDER}/requirements.txt"], 344 | ATTR_VERSION: "2.0.1", 345 | ATTR_INSTALLED_VERSION: "1.0.0", 346 | }, 347 | "my-package-name-alternate-1": { 348 | ATTR_SOURCES: [f"{PYSCRIPT_FOLDER}/requirements.txt"], 349 | ATTR_VERSION: "0.0.1", 350 | ATTR_INSTALLED_VERSION: None, 351 | }, 352 | } 353 | --------------------------------------------------------------------------------