diff --git a/CODEOWNERS b/CODEOWNERS index 44604919af1a8..2eebae4bc7bcf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -12,6 +12,7 @@ /bundles/org.openhab.automation.jythonscripting/ @HolgerHees /bundles/org.openhab.automation.pidcontroller/ @fwolter /bundles/org.openhab.automation.pwm/ @fwolter +/bundles/org.openhab.automation.pythonscripting/ @HolgerHees /bundles/org.openhab.binding.adorne/ @theiding /bundles/org.openhab.binding.ahawastecollection/ @soenkekueper /bundles/org.openhab.binding.airgradient/ @austvik diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 9952c007b0d73..923c3d5903f98 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -51,6 +51,11 @@ org.openhab.automation.pwm ${project.version} + + org.openhab.addons.bundles + org.openhab.automation.pythonscripting + ${project.version} + org.openhab.addons.bundles org.openhab.binding.adorne diff --git a/bundles/org.openhab.automation.pythonscripting/NOTICE b/bundles/org.openhab.automation.pythonscripting/NOTICE new file mode 100644 index 0000000000000..38d625e349232 --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.automation.pythonscripting/README.md b/bundles/org.openhab.automation.pythonscripting/README.md new file mode 100644 index 0000000000000..aad2930b3a38a --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/README.md @@ -0,0 +1,495 @@ +# Python 3 Binding + +This add-on provides support for Python 3 that can be used as a scripting language within automation rules. +It is based on [GraalPy](https://www.graalvm.org/python/) from the [GraalVM project](https://www.graalvm.org/). + +Also included is [openhab-python](https://github.com/HolgerHees/openhab-python), a python library to support automation in openHAB. It provides convenient access to common core openHAB functions that make the full range of Java APIs easily accessible and usable + +[[toc]] + +## Creating Python Scripts + +When this add-on is installed, you can select Python3 as a scripting language when creating a script action within the rule editor of the UI. + +Alternatively, you can create scripts in the `automation/python` configuration directory. If you create an empty file called `test.py`, you will see a log line with information similar to: + +```text +... [INFO ] [ort.loader.AbstractScriptFileWatcher]] - (Re-)Loading script '/openhab/conf/automation/python/test.py' +``` + +To enable debug logging, use the [console logging]({{base}}/administration/logging.html) commands to +enable debug logging for the automation functionality: + +```text +log:set DEBUG org.openhab.automation.pythonscripting +``` + +## Scripting Basics + +Lets start with a simple script + +```python +from openhab import rule +from openhab.triggers import GenericCronTrigger + +@rule( triggers = [ GenericCronTrigger("*/5 * * * * ?") ] ) +class Test: + def execute(self, module, input): + self.logger.info("Rule was triggered") +``` + +or another one, using the [scope module](#module-scope) + +```python +from openhab import rule +from openhab.triggers import ItemCommandTrigger + +import scope + +@rule( triggers = [ ItemCommandTrigger("Item1", scope.ON) ] ) +class Test: + def execute(self, module, input): + self.logger.info("Rule was triggered") +``` + +::: tip Note +By default, the scope, Registry and logger is automatically imported for UI based rules +::: + + +## `PY3` Transformation + +openHAB provides several [data transformation services](https://www.openhab.org/addons/#transform) as well as the script transformations, that are available from the framework and need no additional installation. +It allows transforming values using any of the available scripting languages, which means Python Scripting is supported as well. +See the [transformation docs](https://openhab.org/docs/configuration/transformations.html#script-transformation) for more general information on the usage of script transformations. + +Use Python Scripting as script transformation by: + +1. Creating a script in the `$OPENHAB_CONF/transform` folder with the `.py` extension. + The script should take one argument `input` and return a value that supports `toString()` or `null`: + + ```python + def calc(input): + # Do some data transformation here, e.g. + return "String has" + data.length + "characters"; + calc(input) + ``` + +2. Using `PY3(.py):%s` as Item state transformation. +3. Passing parameters is also possible by using a URL like syntax: `PY3(.py?arg=value)`. + Parameters are injected into the script and can be referenced like variables. + +Simple transformations can aso be given as an inline script: `PY3(|...)`, e.g. `PY3(|"String has " + input.length + "characters")`. +It should start with the `|` character, quotes within the script may need to be escaped with a backslash `\` when used with another quoted string as in text configurations. + +::: tip Note +By default, the scope, Registry and logger is automatically imported for `PY3` Transformation scripts +::: + +## Examples + +### Simple rule + +```python +from openhab import rule, Registry +from openhab.triggers import GenericCronTrigger, ItemStateUpdateTrigger, ItemCommandTrigger, EphemerisCondition, when, onlyif + +import scope + +@rule() +@when("Time cron */5 * * * * ?") +def test1(module, input): + test1.logger.info("Rule 1 was triggered") + +@rule() +@when("Item Item1 received command") +@when("Item Item1 received update") +@onlyif("Today is a holiday") +def test2(module, input): + Registry.getItem("Item2").sendCommand(scope.ON) + +@rule( + triggers = [ GenericCronTrigger("*/5 * * * * ?") ] +) +class Test3: + def execute(self, module, input): + self.logger.info("Rule 3 was triggered") + +@rule( + triggers = [ + ItemStateUpdateTrigger("Item1"), + ItemCommandTrigger("Item1", scope.ON) + ], + conditions = [ + EphemerisCondition("notholiday") + ] +) +class Test4: + def execute(self, module, input): + if Registry.getItem("Item2").postUpdateIfDifferent(scope.OFF): + self.logger.info("Item2 was updated") +``` + +### Query thing status info + +```python +from openhab import logger, Registry + +info = Registry.getThing("zwave:serial_zstick:512").getStatusInfo() +logger.info(info.toString()); +``` + +### Query historic item + +```python +from openhab import logger, Registry +from datetime import datetime + +historicItem = Registry.getItem("Item1").getPersistence().persistedState( datetime.now().astimezone() ) +logger.info( historicItem.getState().toString() ); + +historicItem = Registry.getItem("Item2").getPersistence("jdbc").persistedState( datetime.now().astimezone() ) +logger.info( historicItem.getState().toString() ); +``` + +### Using scope + +Simple usage of jsr223 scope objects + +```python +from openhab import Registry + +from scope import ON + +Registry.getItem("Item1").sendCommand(ON) +``` + +### Logging + +There are 3 ways of logging. + +1. using normal print statements. In this case they are redirected to the default openhab logfile and marked with log level INFO or ERROR + +```python +import sys + +print("log message") + +print("error message", file=sys.stderr) + +``` + +2. using the logging module. Here you get a logging object, already initialized with the prefix "org.openhab.core.automation.pythonscripting" + +```python +from openhab import logging + +logging.info("info message") + +logging.error("error message") +``` + +3. using the rule based logging module. Here you get a logging object, already initialized with the prefix "org.openhab.core.automation.pythonscripting." + +```python +from openhab import rule +from openhab.triggers import GenericCronTrigger + +@rule( triggers = [ GenericCronTrigger("*/5 * * * * ?") ] ) +class Test: + def execute(self, module, input): + self.logger.info("Rule was triggered") +``` + +## Decorators + +### decorator @rule + +the decorator will register the decorated class as a rule. It will wrap and extend the class with the following functionalities + +- Register the class or function as a rule +- If name is not provided, a fallback name in the form "{filename}.{function_or_classname}" is created +- Triggers can be added with argument "triggers", with a function called "buildTriggers" (only classes) or with an [@when decorator](#decorator-when) +- Conditions can be added with argument "conditions", with a function called "buildConditions" (only classes) or with an [@onlyif decorator](#decorator-onlyif) +- The execute function is wrapped within a try / except to provide meaningful error logs +- A logger object (self.logger or {functionname}.logger) with the prefix "org.automation.pythonscripting.{filename}.{function_or_classname}" is available +- You can enable a profiler to analyze runtime with argument "profile=1" +- Every run is logging total runtime and trigger reasons + +```python +from openhab import rule +from openhab.triggers import GenericCronTrigger + +@rule( triggers = [ GenericCronTrigger("*/5 * * * * ?") ] ) +class Test: + def execute(self, module, input): + self.logger.info("Rule 3 was triggered") +``` + +``` +2025-01-09 09:35:11.002 [INFO ] [tomation.pythonscripting.demo1.Test2] - Rule executed in 0.1 ms [Item: Item1] +2025-01-09 09:35:15.472 [INFO ] [tomation.pythonscripting.demo1.Test1] - Rule executed in 0.1 ms [Other: TimerEvent] +``` + +**'execute'** callback **'input'** parameter + +Depending on which trigger type is used, corresponding [event objects](https://www.openhab.org/javadoc/latest/org/openhab/core/items/events/itemevent) are passed via the "input" parameter + +The type of the event can also be queried via [AbstractEvent.getTopic](https://www.openhab.org/javadoc/latest/org/openhab/core/events/abstractevent) + +### decorator @when + +```python +@when("Item Test_String_1 changed from 'old test string' to 'new test string'") +@when("Item gTest_Contact_Sensors changed") +@when("Member of gTest_Contact_Sensors changed from ON to OFF") +@when("Descendent of gTest_Contact_Sensors changed from OPEN to CLOSED") + +@when("Item Test_Switch_2 received update ON") +@when("Member of gTest_Switches received update") + +@when("Item Test_Switch_1 received command") +@when("Item Test_Switch_2 received command OFF") + +@when("Thing hue:device:default:lamp1 received update ONLINE") + +@when("Thing hue:device:default:lamp1 changed from ONLINE to OFFLINE") + +@when("Channel hue:device:default:lamp1:color triggered START") + +@when("System started") +@when("System reached start level 50") + +@when("Time cron 55 55 5 * * ?") +@when("Time is midnight") +@when("Time is noon") + +@when("Time is 10:50") + +@when("Datetime is Test_Datetime_1") +@when("Datetime is Test_Datetime_2 time only") + +@when("Item added") +@when("Item removed") +@when("Item updated") + +@when("Thing added") +@when("Thing removed") +@when("Thing updated") +``` + +### decorator @onlyif + +```python +@onlyif("Item Test_Switch_2 equals ON") +@onlyif("Today is a holiday") +@onlyif("It's not a holiday") +@onlyif("Tomorrow is not a holiday") +@onlyif("Today plus 1 is weekend") +@onlyif("Today minus 1 is weekday") +@onlyif("Today plus 3 is a weekend") +@onlyif("Today offset -3 is a weekend") +@onylyf("Today minus 3 is not a holiday") +@onlyif("Yesterday was in dayset") +@onlyif("Time 9:00 to 14:00") +``` + +## Modules + +### module scope + +The scope module encapsulates all [default jsr223 objects/presents](https://www.openhab.org/docs/configuration/jsr223.html#default-preset-importpreset-not-required) into a new object. You can use it like below + +```python +from scope import * # this makes all jsr223 objects available + +print(ON) +``` + +```python +from scope import ON, OFF # this imports specific jsr223 objects + +print(ON) +``` + +```python +import scope # this imports just the module + +print(scope.ON) +``` + +You can also import additional [jsr223 presents](https://www.openhab.org/docs/configuration/jsr223.html#rulesimple-preset) like + +```python +from scope import RuleSimple +from scope import RuleSupport +from scope import RuleFactories +from scope import ScriptAction +from scope import cache +from scope import osgi +``` + +Additionally you can import all java classes from 'org.openhab' package like + + +```python +from org.openhab.core import OpenHAB + +print(str(OpenHAB.getVersion())) +``` + +### module openhab + +| Class | Usage | Description | +| ------------------------ | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| rule | @rule( name=None, description=None, tags=None, triggers=None, conditions=None, profile=None) | [Rule decorator](#decorator-rule) to wrap a custom class into a rule | +| logger | logger.info, logger.warn ... | Logger object with prefix 'org.automation.pythonscripting.{filename}' | +| Registry | see [Registry class](#class-registry) | Static Registry class used to get items, things or channels | +| Timer | see [Timer class](#class-timer) | Static Timer class to create, start and stop timers | +| Set | see [Set class](#class-set) | Set object | + +### module openhab.actions + +| Class | Usage | Description | +| ------------------------ | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Audio | see [openhab Audio api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/audio) | | +| BusEvent | see [openhab BusEvent api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/busevent) | | +| Ephemeris | see [openhab Ephemeris api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/ephemeris) | | +| Exec | see [openhab Exec api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/exec) | e.g. Exec.executeCommandLine(timedelta(seconds=1), "whoami") | +| HTTP | see [openhab HTTP api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/http) | | +| Log | see [openhab Log api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/log) | | +| Ping | see [openhab Ping api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/ping) | | +| ScriptExecution | see [openhab ScriptExecution api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/scriptexecution) | | +| Semantic | see [openhab Semantic api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/semantic) | | +| ThingAction | see [openhab ThingAction api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/things) | | +| Transformation | see [openhab Transformation api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/transformation) | | +| Voice | see [openhab Voice api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/voice) | | +| NotificationAction | | e.g. NotificationAction.sendNotification("test@test.org", "Window is open") | + +### module openhab.triggers + +| Class | Usage | Description | +| ------------------------ | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| when | @when(term_as_string) | [When trigger decorator](#decorator-when) to create a trigger by a term | +| onlyif | @onlyif(term_as_string) | [Onlyif condition decorator](#decorator-onlyif) to create a condition by a term | +| ChannelEventTrigger | ChannelEventTrigger(channel_uid, event=None, trigger_name=None) | | +| ItemStateUpdateTrigger | ItemStateUpdateTrigger(item_name, state=None, trigger_name=None) | | +| ItemStateChangeTrigger | ItemStateChangeTrigger(item_name, state=None, previous_state=None, trigger_name=None) | | +| ItemCommandTrigger | ItemCommandTrigger(item_name, command=None, trigger_name=None) | | +| GroupStateUpdateTrigger | GroupStateUpdateTrigger(group_name, state=None, trigger_name=None) | | +| GroupStateChangeTrigger | GroupStateChangeTrigger(group_name, state=None, previous_state=None, trigger_name=None)| | +| GroupCommandTrigger | GroupCommandTrigger(group_name, command=None, trigger_name=None) | | +| ThingStatusUpdateTrigger | ThingStatusUpdateTrigger(thing_uid, status=None, trigger_name=None) | | +| ThingStatusChangeTrigger | ThingStatusChangeTrigger(thing_uid, status=None, previous_status=None, trigger_name=None)| | +| SystemStartlevelTrigger | SystemStartlevelTrigger(startlevel, trigger_name=None) | for startlevel see [openHAB StartLevelService API](https://www.openhab.org/javadoc/latest/org/openhab/core/service/startlevelservice#) | +| GenericCronTrigger | GenericCronTrigger(cron_expression, trigger_name=None) | | +| TimeOfDayTrigger | TimeOfDayTrigger(time, trigger_name=None) | | +| DateTimeTrigger | DateTimeTrigger(cron_expression, trigger_name=None) | | +| PWMTrigger | PWMTrigger(cron_expression, trigger_name=None) | | +| GenericEventTrigger | GenericEventTrigger(event_source, event_types, event_topic="*/*", trigger_name=None) | | +| ItemEventTrigger | ItemEventTrigger(event_types, item_name=None, trigger_name=None) | | +| ThingEventTrigger | ThingEventTrigger(event_types, thing_uid=None, trigger_name=None) | | +| | | | +| ItemStateCondition | ItemStateCondition(item_name, operator, state, condition_name=None) | | +| EphemerisCondition | EphemerisCondition(dayset, offset=0, condition_name=None) | | +| TimeOfDayCondition | TimeOfDayCondition(start_time, end_time, condition_name=None) | | +| IntervalCondition | IntervalCondition(min_interval, condition_name=None) | | + +## Classes + +### class Registry + +| Function | Usage | Return Value | +| ------------------------ | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| getThing | getThing(uid) | [Thing](#class-thing) | +| getChannel | getChannel(uid) | [Channel](#class-channel) | +| getItem | getItem(item_name) | [Item](#class-item) or [GroupItem](#class-groupitem) | +| resolveItem | resolveItem(item_or_item_name) | [Item](#class-item) or [GroupItem](#class-groupitem) | +| getItemState | getItemState(item_name, default = None) | [openHAB State](https://www.openhab.org/javadoc/latest/org/openhab/core/types/state) | +| getItemMetadata | getItemMetadata(item_or_item_name, namespace) | [openHAB Metadata](https://www.openhab.org/javadoc/latest/org/openhab/core/items/metadata) | +| setItemMetadata | setItemMetadata(item_or_item_name, namespace, value, configuration=None) | [openHAB Metadata](https://www.openhab.org/javadoc/latest/org/openhab/core/items/metadata) | +| removeItemMetadata | removeItemMetadata(item_or_item_name, namespace = None) | [openHAB Metadata](https://www.openhab.org/javadoc/latest/org/openhab/core/items/metadata) | + + +### class Item + +Item is a wrapper around [openHAB Item](https://www.openhab.org/javadoc/latest/org/openhab/core/items/item) with additional functionality. + +| Function | Usage | Return Value | +| ------------------------ | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| postUpdate | postUpdate(state) | | +| postUpdateIfDifferent | postUpdateIfDifferent(state) | | +| sendCommand | sendCommand(command) | | +| sendCommandIfDifferent | sendCommandIfDifferent(command) | | +| getPersistence | getPersistence(service_id = None) | [ItemPersistence](#class-itempersistence) | +| getSemantic | getSemantic() | [ItemSemantic](#class-itemsemantic) | +| <...> | see [openHAB Item api](https://www.openhab.org/javadoc/latest/org/openhab/core/items/item) | | + +### class GroupItem + +GroupItem is an extended [Item](#class-item) which wraps results from getAllMembers & getMembers into [Items](#class-item) + +### class ItemPersistence + +ItemPersistence is a wrapper around [openHAB PersistenceExtensions](https://www.openhab.org/javadoc/latest/org/openhab/core/persistence/extensions/persistenceextensions). The parameters 'item' and 'serviceId', as part of the Wrapped Java API, are not needed, because they are inserted automatically. + +| Function | Usage | Description | +| ------------------------ | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| getStableMinMaxState | getStableMinMaxState(time_slot, end_time = None) | Average calculation which takes into account the values ​​depending on their duration | +| getStableState | getStableState(time_slot, end_time = None) | Average calculation which takes into account the values ​​depending on their duration | +| <...> | see [openHAB PersistenceExtensions api](https://www.openhab.org/javadoc/latest/org/openhab/core/persistence/extensions/persistenceextensions) | | + +### class ItemSemantic + +ItemSemantic is a wrapper around [openHAB Semantics](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/semantics). The parameters 'item', as part of the Wrapped Java API, is not needed because it is inserted automatically. + +| Function | Usage | +| ------------------------ | ------------------------------------------------------------------------------------- | +| <...> | see [openHAB Semantics api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/semantics) | + +### class Thing + +Thing is a wrapper around [openHAB Thing](https://www.openhab.org/javadoc/latest/org/openhab/core/thing/thing). + +| Function | Usage | +| ------------------------ | ------------------------------------------------------------------------------------- | +| <...> | see [openHAB Thing api](https://www.openhab.org/javadoc/latest/org/openhab/core/thing/thing) | + +### class Channel + +Channel is a wrapper around [openHAB Channel](https://www.openhab.org/javadoc/latest/org/openhab/core/thing/type/channelgrouptype). + +| Function | Usage | +| ------------------------ | ------------------------------------------------------------------------------------- | +| <...> | see [openHAB Channel api](https://www.openhab.org/javadoc/latest/org/openhab/core/thing/type/channelgrouptype) | + +### class Timer + +| Function | Usage | Description | +| ------------------------ | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| createTimeout | createTimeout(duration, callback, args=[], kwargs={}, old_timer = None, max_count = 0 ) | Create a timer that will run callback with arguments args and keyword arguments kwargs, after duration seconds have passed. If old_timer from e.g previous call is provided, it will be stopped if not already triggered. If max_count together with old_timer is provided, then 'max_count' times the old timer will be stopped and recreated, before the callback will be triggered immediately | + +### class Set + +This is a helper class which makes it possible to use a python 'set' as an argument for java class method calls + +## Others + +### python <=> java conversion + +Conversion occurs in both directions + +| Python class | Java class | +| ------------------------- | ------------- | +| datetime with timezone | ZonedDateTime | +| datetime without timezone | Instant | +| timedelta | Duration | +| list | Collection | +| Set(set) | Set | +| Item | Item | + +### limitations + +- graalby can't handle arguments in constructors of java objects. Means you can't instantiate a javaobject in python with a parameter. https://github.com/oracle/graalpython/issues/367 +- graalpy does not really support python 'set' types as arguments of function calls to java objects https://github.com/oracle/graalpython/issues/260 + - The reason is that Java is not able to distinguish what is a python list and what is a python set. A workaround is to use the class [Set](#class-set) diff --git a/bundles/org.openhab.automation.pythonscripting/TODO.md b/bundles/org.openhab.automation.pythonscripting/TODO.md new file mode 100644 index 0000000000000..6b37c2882a1ea --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/TODO.md @@ -0,0 +1,30 @@ +TODO +- fix binding reload +- fix eclipse build +- fix dependency watcher for libs as symlinks + - is currently not possible, because core openhab filewatcher is not able to handle it + - also the "multi lib path" implementation from jruby dows not work, because the core watcher allowes only path objects inside the base path "/openhab/conf/" +- extend SimpleRule => instead of wrapping in helper class (needs upcoming graalpy 24.2.0 release => register_interop_type) => expected March 18, 2025 + - not high prio. maybe not needed + +DONE +- @when and @onlyif support +- better error messages if error is not catched by "helper.py => handle_exception". In java stack trace we do not get correct file and line number + - stdout and stderr redirect to Logger => syntax errors + - sys.excepthook in helper.py => handle import errors => "Failed to execute script: ModuleNotFoundError: No module named 'Time'" + - sys.excepthook in helper.py => handle name errors => "Failed to execute script: NameError: name 'txf' is not defined" +- implement metadata access +- implement getStableState (average calculation which takes into account the values ​​depending on their duration) +- check why assigning tag 'Schedule' to rules with GenericCronTrigger is freezing webui schedule page + - reason was the cron expression syntax which was not possible to visualize (unlimited and every 5 minutes) +- implement dependency tracker and reloader +- implement persistence access +- implement semantic access +- implement caching +- find a good concept for handling datetime conversion +- find a way to use SET or Collection as arguments in python for native java objects + - https://github.com/oracle/graalpython/issues/260 + - In python I try to add tags to SimpleRule. The problem is that SimpleRule.setTags expects a SET as a parameter which is currently not supported in graalpy + RESOLUTION: list is mapped to Collection and a set must be wrapped into a 'GraalWrapperSet' python object. This will force java to convert it to a SET + (not perfect but it works) + diff --git a/bundles/org.openhab.automation.pythonscripting/bnd.bnd b/bundles/org.openhab.automation.pythonscripting/bnd.bnd new file mode 100644 index 0000000000000..fa3abf956d0eb --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/bnd.bnd @@ -0,0 +1,21 @@ +Bundle-SymbolicName: ${project.artifactId} +DynamicImport-Package: * +Import-Package: org.openhab.core.automation.module.script,org.openhab.core.items,org.openhab.core.library.types,javax.management,javax.script,javax.xml.datatype,javax.xml.stream;version="[1.0,2)",org.osgi.framework;version="[1.8,2)",org.slf4j;version="[1.7,2)" +Require-Capability: + osgi.extender:= + filter:="(osgi.extender=osgi.serviceloader.processor)", + osgi.serviceloader:= + filter:="(osgi.serviceloader=org.graalvm.polyglot.impl.AbstractPolyglotImpl)"; + cardinality:=multiple +Require-Bundle: org.graalvm.sdk.collections;bundle-version="24.1.2",\ + org.graalvm.sdk.jniutils;bundle-version="24.1.2",\ + org.graalvm.sdk.nativeimage;bundle-version="24.1.2",\ + org.graalvm.sdk.word;bundle-version="24.1.2",\ + org.graalvm.shadowed.icu4j;bundle-version="24.1.2",\ + org.graalvm.truffle.truffle-compiler;bundle-version="24.1.2",\ + org.graalvm.truffle.truffle-runtime;bundle-version="24.1.2" + +SPI-Provider: * +SPI-Consumer: * + +-fixupmessages "Classes found in the wrong directory"; restrict:=error; is:=warning diff --git a/bundles/org.openhab.automation.pythonscripting/pom.xml b/bundles/org.openhab.automation.pythonscripting/pom.xml new file mode 100644 index 0000000000000..da03ad59da245 --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/pom.xml @@ -0,0 +1,262 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 5.0.0-SNAPSHOT + + + org.openhab.automation.pythonscripting + + openHAB Add-ons :: Bundles :: Automation :: Python Scripting + + + !sun.misc.*, + !sun.reflect.*, + !com.sun.management.*, + !jdk.internal.reflect.*, + !jdk.vm.ci.services + + 24.1.2 + + + + + + org.graalvm.polyglot + polyglot + ${graalpy.version} + + + + org.graalvm.regex + regex + ${graalpy.version} + + + + org.graalvm.polyglot + python-community + ${graalpy.version} + pom + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + embed-dependencies + + unpack-dependencies + + + + + + org.apache.maven.plugins + maven-scm-plugin + 2.1.0 + + + jgit + + + + + org.apache.maven.scm + maven-scm-provider-jgit + 2.1.0 + + + + + checkout-openhab-python + + checkout + + generate-resources + + scm:git:https://github.com/HolgerHees/openhab-python + ${project.build.directory}/python + main + branch + + + + + + maven-resources-plugin + 3.3.1 + + + copy-openhab-python + + copy-resources + + process-resources + + ${project.build.directory}/classes/lib/openhab/ + + + ${project.build.directory}/python/src/ + true + + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + + shade + + package + + + + org.lastnpe.eea:eea-all + org.apache.karaf.features:framework + + org.graalvm.sdk:collections + org.graalvm.sdk:jniutils + org.graalvm.sdk:nativeimage + org.graalvm.sdk:word + org.graalvm.shadowed:icu4j + org.graalvm.truffle:truffle-compiler + org.graalvm.truffle:truffle-runtime + + + false + + + + + + + + + + + org.openhab.tools.sat + sat-plugin + + ${project.basedir}/suppressions.properties + + + + + + + diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/feature/feature.xml b/bundles/org.openhab.automation.pythonscripting/src/main/feature/feature.xml new file mode 100644 index 0000000000000..e81fe5a236484 --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/feature/feature.xml @@ -0,0 +1,16 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.osgiify/org.graalvm.sdk.collections/24.1.2 + mvn:org.openhab.osgiify/org.graalvm.sdk.jniutils/24.1.2 + mvn:org.openhab.osgiify/org.graalvm.sdk.nativeimage/24.1.2 + mvn:org.openhab.osgiify/org.graalvm.sdk.word/24.1.2 + mvn:org.openhab.osgiify/org.graalvm.shadowed.icu4j/24.1.2 + mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-compiler/24.1.2 + mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-runtime/24.1.2 + mvn:org.openhab.addons.bundles/org.openhab.automation.pythonscripting/${project.version} + + diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/PythonScriptEngine.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/PythonScriptEngine.java new file mode 100644 index 0000000000000..25eb89654e8de --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/PythonScriptEngine.java @@ -0,0 +1,477 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.automation.pythonscripting.internal; + +import static org.openhab.core.automation.module.script.ScriptEngineFactory.*; +import static org.openhab.core.automation.module.script.ScriptTransformationService.OPENHAB_TRANSFORMATION_SCRIPT; + +import java.io.File; +import java.io.IOException; +import java.nio.file.AccessMode; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import javax.script.ScriptContext; +import javax.script.ScriptException; + +import org.eclipse.jdt.annotation.Nullable; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Engine; +import org.graalvm.polyglot.HostAccess; +import org.graalvm.polyglot.Source; +import org.graalvm.polyglot.Value; +import org.graalvm.polyglot.io.IOAccess; +import org.openhab.automation.pythonscripting.internal.fs.DelegatingFileSystem; +import org.openhab.automation.pythonscripting.internal.fs.watch.PythonDependencyTracker; +import org.openhab.automation.pythonscripting.internal.graal.GraalPythonScriptEngine; +import org.openhab.automation.pythonscripting.internal.scriptengine.InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable; +import org.openhab.automation.pythonscripting.internal.scriptengine.helper.LifecycleTracker; +import org.openhab.automation.pythonscripting.internal.scriptengine.helper.LogOutputStream; +import org.openhab.automation.pythonscripting.internal.wrapper.ScriptExtensionModuleProvider; +import org.openhab.core.OpenHAB; +import org.openhab.core.automation.module.script.ScriptExtensionAccessor; +import org.openhab.core.items.Item; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.event.Level; + +/** + * GraalPython ScriptEngine implementation + * + * @author Holger Hees - Initial contribution + * @author Jeff James - Initial contribution + */ +public class PythonScriptEngine + extends InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable + implements Lock { + private final Logger logger = LoggerFactory.getLogger(PythonScriptEngine.class); + + private static final String PYTHON_OPTION_EXECUTABLE = "python.Executable"; + private static final String PYTHON_OPTION_PYTHONHOME = "python.PythonHome"; + private static final String PYTHON_OPTION_PYTHONPATH = "python.PythonPath"; + private static final String PYTHON_OPTION_INPUTFILEPATH = "python.InputFilePath"; + private static final String PYTHON_OPTION_EMULATEJYTHON = "python.EmulateJython"; + private static final String PYTHON_OPTION_POSIXMODULEBACKEND = "python.PosixModuleBackend"; + private static final String PYTHON_OPTION_DONTWRITEBYTECODEFLAG = "python.DontWriteBytecodeFlag"; + private static final String PYTHON_OPTION_FORCEIMPORTSITE = "python.ForceImportSite"; + private static final String PYTHON_OPTION_CHECKHASHPYCSMODE = "python.CheckHashPycsMode"; + private static final String PYTHON_OPTION_ALWAYSRUNEXCEPTHOOK = "python.AlwaysRunExcepthook"; + + private static final String PYTHON_OPTION_CACHEDIR = "python.PyCachePrefix"; + private static final String PYTHON_CACHEDIR_PATH = Paths + .get(OpenHAB.getUserDataFolder(), "cache", PythonScriptEngine.class.getPackageName(), "cachedir") + .toString(); + + private static final int STACK_TRACE_LENGTH = 5; + + public static final String LOGGER_INIT_NAME = "__logger_init__"; + + /** Shared Polyglot {@link Engine} across all instances of {@link PythonScriptEngine} */ + private static final Engine ENGINE = Engine.newBuilder().allowExperimentalOptions(true) + .option("engine.WarnInterpreterOnly", "false").build(); + + /** Provides unlimited host access as well as custom translations from Python to Java Objects */ + private static final HostAccess HOST_ACCESS = HostAccess.newBuilder(HostAccess.ALL) + // Translate python datetime with timezone to java.time.ZonedDateTime + .targetTypeMapping(Value.class, ZonedDateTime.class, + v -> v.hasMember("ctime") && v.hasMember("isoformat") && v.hasMember("tzinfo") + && !v.getMember("tzinfo").isNull(), + v -> ZonedDateTime.parse(v.invokeMember("isoformat").asString()), + HostAccess.TargetMappingPrecedence.LOW) + + // Translate python datetime without timezone to java.time.Instant + .targetTypeMapping(Value.class, Instant.class, + v -> v.hasMember("ctime") && v.hasMember("isoformat") && v.hasMember("tzinfo") + && v.getMember("tzinfo").isNull(), + v -> Instant.parse(v.invokeMember("isoformat").asString()), HostAccess.TargetMappingPrecedence.LOW) + + // Translate python timedelta to java.time.Duration + .targetTypeMapping(Value.class, Duration.class, + // picking two members to check as Duration has many common function names + v -> v.hasMember("total_seconds") && v.hasMember("total_seconds"), + v -> Duration.ofNanos(v.invokeMember("total_seconds").asLong() * 10000000), + HostAccess.TargetMappingPrecedence.LOW) + + // Translate python item to org.openhab.core.items.Item + .targetTypeMapping(Value.class, Item.class, v -> v.hasMember("raw_item"), + v -> v.getMember("raw_item").as(Item.class), HostAccess.TargetMappingPrecedence.LOW) + + // Translate python GraalWrapperSet to java.util.Set + .targetTypeMapping(Value.class, Set.class, v -> v.hasMember("isSetType"), + PythonScriptEngine::transformGraalWrapperSet, HostAccess.TargetMappingPrecedence.LOW) + + // Translate python list to java.util.Collection + .targetTypeMapping(Value.class, Collection.class, (v) -> v.hasArrayElements(), + (v) -> v.as(Collection.class), HostAccess.TargetMappingPrecedence.LOW) + + .build(); + + /** {@link Lock} synchronization of multi-thread access */ + private final Lock lock = new ReentrantLock(); + + // these fields start as null because they are populated on first use + private @Nullable Consumer scriptDependencyListener; + private final ScriptExtensionModuleProvider scriptExtensionModuleProvider; + private final LifecycleTracker lifecycleTracker; + + private PythonScriptEngineConfiguration pythonScriptEngineConfiguration; + + private boolean initialized = false; + + private final LogOutputStream scriptOutputStream; + private final LogOutputStream scriptErrorStream; + + /** + * Creates an implementation of ScriptEngine {@code (& Invocable)}, wrapping the contained engine, + * that tracks the script lifecycle and provides hooks for scripts to do so too. + * + * @param pythonDependencyTracker + * @param injectionEnabled + * @param scopeEnabled + * @param cachingEnabled + * @param jythonEmulation + */ + public PythonScriptEngine(PythonDependencyTracker pythonDependencyTracker, + PythonScriptEngineConfiguration pythonScriptEngineConfiguration) { + this.pythonScriptEngineConfiguration = pythonScriptEngineConfiguration; + + scriptOutputStream = new LogOutputStream(logger, Level.INFO); + scriptErrorStream = new LogOutputStream(logger, Level.ERROR); + + scriptExtensionModuleProvider = new ScriptExtensionModuleProvider(); + lifecycleTracker = new LifecycleTracker(); + + Context.Builder contextConfig = Context.newBuilder(GraalPythonScriptEngine.LANGUAGE_ID) // + .out(scriptOutputStream) // + .err(scriptErrorStream) // + .allowIO(IOAccess.newBuilder() // + .allowHostSocketAccess(true) // + .fileSystem(new DelegatingFileSystem(FileSystems.getDefault().provider()) { + @Override + public void checkAccess(Path path, Set modes, + LinkOption... linkOptions) throws IOException { + if (pythonScriptEngineConfiguration.isDependencyTrackingEnabled()) { + if (path.startsWith(PythonScriptEngineFactory.PYTHON_LIB_PATH)) { + Consumer localScriptDependencyListener = scriptDependencyListener; + if (localScriptDependencyListener != null) { + localScriptDependencyListener.accept(path.toString()); + } + } + } + super.checkAccess(path, modes, linkOptions); + } + }).build()) // + .allowHostAccess(HOST_ACCESS) // + // usage of .allowAllAccess(true) includes + // - allowCreateThread(true) + // - allowCreateProcess(true) + // - allowHostClassLoading(true) + // - allowHostClassLookup(true) + // - allowPolyglotAccess(PolyglotAccess.ALL) + // - allowIO(true) + // - allowEnvironmentAccess(EnvironmentAccess.INHERIT) + .allowAllAccess(true) // + // allow class lookup like "org.slf4j.LoggerFactory" from inline scripts + .hostClassLoader(getClass().getClassLoader()) // + // allow running Python native extensions + .allowNativeAccess(true) // + // allow experimental options + .allowExperimentalOptions(true) // + // choose the backend for the POSIX module + .option(PYTHON_OPTION_POSIXMODULEBACKEND, "java") // + // Force to automatically import site.py module, to make Python packages available + .option(PYTHON_OPTION_FORCEIMPORTSITE, Boolean.toString(true)) // + // The sys.executable path, a virtual path that is used by the interpreter + // to discover packages + .option(PYTHON_OPTION_EXECUTABLE, + PythonScriptEngineFactory.PYTHON_DEFAULT_PATH.resolve("bin").resolve("python").toString()) + // Set the python home to be read from the embedded resources + .option(PYTHON_OPTION_PYTHONHOME, PythonScriptEngineFactory.PYTHON_DEFAULT_PATH.toString()) // + // Set python path to point to sources stored in + .option(PYTHON_OPTION_PYTHONPATH, + PythonScriptEngineFactory.PYTHON_LIB_PATH.toString() + File.pathSeparator + + PythonScriptEngineFactory.PYTHON_DEFAULT_PATH.toString()) + // pass the path to be executed + .option(PYTHON_OPTION_INPUTFILEPATH, PythonScriptEngineFactory.PYTHON_DEFAULT_PATH.toString()) // + // make sure the TopLevelExceptionHandler calls the excepthook to print Python exceptions + .option(PYTHON_OPTION_ALWAYSRUNEXCEPTHOOK, Boolean.toString(true)) // + // emulate jython behavior (will slowdown the engine) + .option(PYTHON_OPTION_EMULATEJYTHON, + String.valueOf(pythonScriptEngineConfiguration.isJythonEmulation())); + + if (pythonScriptEngineConfiguration.isCachingEnabled()) { + contextConfig.option(PYTHON_OPTION_DONTWRITEBYTECODEFLAG, Boolean.toString(false)) // + .option(PYTHON_OPTION_CACHEDIR, PYTHON_CACHEDIR_PATH); + } else { + contextConfig.option(PYTHON_OPTION_DONTWRITEBYTECODEFLAG, Boolean.toString(true)) // + // causes the interpreter to always assume hash-based pycs are valid + .option(PYTHON_OPTION_CHECKHASHPYCSMODE, "never"); + } + + delegate = GraalPythonScriptEngine.create(ENGINE, contextConfig); + } + + @Override + protected void beforeInvocation() { + lock.lock(); + logger.debug("Lock acquired before invocation."); + + if (initialized) { + return; + } + + logger.debug("Initializing GraalPython script engine..."); + + ScriptContext ctx = getScriptContext(); + + // these are added post-construction, so we need to fetch them late + String engineIdentifier = (String) ctx.getAttribute(CONTEXT_KEY_ENGINE_IDENTIFIER); + if (engineIdentifier == null) { + throw new IllegalStateException("Failed to retrieve engine identifier from engine bindings"); + } + + ScriptExtensionAccessor scriptExtensionAccessor = (ScriptExtensionAccessor) ctx + .getAttribute(CONTEXT_KEY_EXTENSION_ACCESSOR); + if (scriptExtensionAccessor == null) { + throw new IllegalStateException("Failed to retrieve script extension accessor from engine bindings"); + } + + Consumer scriptDependencyListener = (Consumer) ctx + .getAttribute(CONTEXT_KEY_DEPENDENCY_LISTENER); + if (scriptDependencyListener == null) { + logger.warn( + "Failed to retrieve script script dependency listener from engine bindings. Script dependency tracking will be disabled."); + } + this.scriptDependencyListener = scriptDependencyListener; + + if (pythonScriptEngineConfiguration.isScopeEnabled()) { + // Wrap the "import" function to also allow loading modules from the ScriptExtensionModuleProvider + BiFunction, Object> wrapImportFn = (name, fromlist) -> scriptExtensionModuleProvider + .locatorFor(delegate.getPolyglotContext(), engineIdentifier, scriptExtensionAccessor) + .locateModule(name, fromlist); + delegate.getBindings(ScriptContext.ENGINE_SCOPE).put(ScriptExtensionModuleProvider.IMPORT_PROXY_NAME, + wrapImportFn); + try { + String wrapperContent = new String( + Files.readAllBytes(PythonScriptEngineFactory.PYTHON_WRAPPER_FILE_PATH)); + delegate.getPolyglotContext().eval(Source.newBuilder(GraalPythonScriptEngine.LANGUAGE_ID, + wrapperContent, PythonScriptEngineFactory.PYTHON_WRAPPER_FILE_PATH.toString()).build()); + + // inject scope, Registry and logger + if (!pythonScriptEngineConfiguration.isInjection(PythonScriptEngineConfiguration.INJECTION_DISABLED) + && (ctx.getAttribute("javax.script.filename") == null || pythonScriptEngineConfiguration + .isInjection(PythonScriptEngineConfiguration.INJECTION_ENABLED_FOR_ALL_SCRIPTS))) { + String injectionContent = "import scope\nfrom openhab import Registry, logger"; + delegate.getPolyglotContext().eval(Source + .newBuilder(GraalPythonScriptEngine.LANGUAGE_ID, injectionContent, "").build()); + } + } catch (IOException e) { + throw new IllegalArgumentException("Failed to generate import wrapper", e); + } + } + + // logger initialization, for non file based scripts, has to be delayed, because ruleUID is not available yet + if (ctx.getAttribute("javax.script.filename") == null) { + Runnable wrapperLoggerFn = () -> setScriptLogger(); + delegate.getBindings(ScriptContext.ENGINE_SCOPE).put(LOGGER_INIT_NAME, wrapperLoggerFn); + } else { + setScriptLogger(); + } + + initialized = true; + } + + @Override + protected String beforeInvocation(String source) { + source = super.beforeInvocation(source); + + // Happens for Transform and UI based rules (eval and compile) + // and has to be evaluate every time, because of changing and late injected ruleUID + if (delegate.getBindings(ScriptContext.ENGINE_SCOPE).get(LOGGER_INIT_NAME) != null) { + return LOGGER_INIT_NAME + "()\n" + source; + } + + return source; + } + + @Override + protected Object afterInvocation(Object obj) { + lock.unlock(); + logger.debug("Lock released after invocation."); + return super.afterInvocation(obj); + } + + @Override + protected Exception afterThrowsInvocation(Exception e) { + // OPS4J Pax Logging holds a reference to the exception, which causes the OpenhabGraalJSScriptEngine to not be + // removed from heap by garbage collection and causing a memory leak. + // Therefore, don't pass the exceptions itself to the logger, but only their message! + if (e instanceof ScriptException) { + // PolyglotException will always be wrapped into ScriptException and they will be visualized in + // org.openhab.core.automation.module.script.internal.ScriptEngineManagerImpl + if (scriptErrorStream.getLogger().isDebugEnabled()) { + scriptErrorStream.getLogger().debug("Failed to execute script (PolyglotException): {}", + stringifyThrowable(e.getCause())); + } + } else if (e.getCause() instanceof IllegalArgumentException) { + scriptErrorStream.getLogger().error("Failed to execute script (IllegalArgumentException): {}", + stringifyThrowable(e.getCause())); + } + + lock.unlock(); + + return super.afterThrowsInvocation(e); + } + + @Override + // collect JSR223 (scope) variables separately, because they are delivered via 'import scope' + public void put(String key, Object value) { + if ("javax.script.filename".equals(key)) { + // super.put("__file__", value); + super.put(key, value); + } else { + // use a custom lifecycleTracker to handle dispose hook before polyglot context is closed + // original lifecycleTracker is handling it when polyglot context is already closed + if ("lifecycleTracker".equals(key)) { + value = lifecycleTracker; + } + if (pythonScriptEngineConfiguration.isScopeEnabled()) { + scriptExtensionModuleProvider.put(key, value); + } else { + super.put(key, value); + } + } + } + + @Override + public void lock() { + lock.lock(); + logger.debug("Lock acquired."); + } + + @Override + public void lockInterruptibly() throws InterruptedException { + lock.lockInterruptibly(); + } + + @Override + public boolean tryLock() { + boolean acquired = lock.tryLock(); + if (acquired) { + logger.debug("Lock acquired."); + } else { + logger.debug("Lock not acquired."); + } + return acquired; + } + + @Override + public boolean tryLock(long l, TimeUnit timeUnit) throws InterruptedException { + boolean acquired = lock.tryLock(l, timeUnit); + if (acquired) { + logger.debug("Lock acquired."); + } else { + logger.debug("Lock not acquired."); + } + return acquired; + } + + @Override + public void unlock() { + lock.unlock(); + logger.debug("Lock released."); + } + + @Override + public void close() throws Exception { + this.lifecycleTracker.dispose(); + super.close(); + } + + @Override + public Condition newCondition() { + return lock.newCondition(); + } + + /** + * Initializes the logger. + * This cannot be done on script engine creation because the context variables are not yet initialized. + * Therefore, the logger needs to be initialized on the first use after script engine creation. + */ + private void setScriptLogger() { + ScriptContext ctx = getScriptContext(); + Object fileName = ctx.getAttribute("javax.script.filename"); + Object ruleUID = ctx.getAttribute("ruleUID"); + Object ohEngineIdentifier = ctx.getAttribute("oh.engine-identifier"); + + String identifier = "stack"; + if (fileName != null) { + identifier = fileName.toString().replaceAll("^.*[/\\\\]", ""); + } else if (ruleUID != null) { + identifier = ruleUID.toString(); + } else if (ohEngineIdentifier != null) { + if (ohEngineIdentifier.toString().startsWith(OPENHAB_TRANSFORMATION_SCRIPT)) { + identifier = ohEngineIdentifier.toString().replaceAll(OPENHAB_TRANSFORMATION_SCRIPT, "transformation."); + } + } + + Logger scriptLogger = LoggerFactory.getLogger("org.openhab.automation.pythonscripting." + identifier); + + scriptOutputStream.setLogger(scriptLogger); + scriptErrorStream.setLogger(scriptLogger); + } + + private ScriptContext getScriptContext() { + ScriptContext ctx = delegate.getContext(); + if (ctx == null) { + throw new IllegalStateException("Failed to retrieve script context"); + } + return ctx; + } + + private String stringifyThrowable(Throwable throwable) { + String message = throwable.getMessage(); + StackTraceElement[] stackTraceElements = throwable.getStackTrace(); + String stackTrace = Arrays.stream(stackTraceElements).limit(STACK_TRACE_LENGTH) + .map(t -> " at " + t.toString()).collect(Collectors.joining(System.lineSeparator())) + + System.lineSeparator() + " ... " + stackTraceElements.length + " more"; + return (message != null) ? message + System.lineSeparator() + stackTrace : stackTrace; + } + + private static Set transformGraalWrapperSet(Value value) { + Set set = new HashSet(); + for (int i = 0; i < value.getArraySize(); ++i) { + Value element = value.getArrayElement(i); + set.add(element.asString()); + } + return set; + } +} diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/PythonScriptEngineConfiguration.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/PythonScriptEngineConfiguration.java new file mode 100644 index 0000000000000..5fd03957a31dc --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/PythonScriptEngineConfiguration.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.automation.pythonscripting.internal; + +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.config.core.ConfigParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Processes Python Configuration Parameters. + * + * @author Holger Hees - Initial contribution + */ +@NonNullByDefault +public class PythonScriptEngineConfiguration { + + private final Logger logger = LoggerFactory.getLogger(PythonScriptEngineConfiguration.class); + + private static final String CFG_INJECTION_ENABLED = "injectionEnabled"; + private static final String CFG_HELPER_ENABLED = "helperEnabled"; + private static final String CFG_SCOPE_ENABLED = "scopeEnabled"; + private static final String CFG_DEPENDENCY_TRACKING_ENABLED = "dependencyTrackingEnabled"; + private static final String CFG_CACHING_ENABLED = "cachingEnabled"; + private static final String CFG_JYTHON_EMULATION = "jythonEmulation"; + + public static final int INJECTION_DISABLED = 0; + public static final int INJECTION_ENABLED_FOR_ALL_SCRIPTS = 1; + public static final int INJECTION_ENABLED_FOR_NON_FILE_BASED_SCRIPTS = 2; + private int injectionEnabled = 0; + private boolean helperEnabled = false; + private boolean scopeEnabled = false; + private boolean dependencyTrackingEnabled = false; + private boolean cachingEnabled = false; + private boolean jythonEmulation = false; + + /** + * Update configuration + * + * @param config Configuration parameters to apply to ScriptEngine + */ + void update(Map config) { + logger.trace("Python Script Engine Configuration: {}", config); + + this.scopeEnabled = ConfigParser.valueAsOrElse(config.get(CFG_SCOPE_ENABLED), Boolean.class, true); + this.helperEnabled = ConfigParser.valueAsOrElse(config.get(CFG_HELPER_ENABLED), Boolean.class, true); + this.injectionEnabled = ConfigParser.valueAsOrElse(config.get(CFG_INJECTION_ENABLED), Integer.class, + INJECTION_ENABLED_FOR_NON_FILE_BASED_SCRIPTS); + this.dependencyTrackingEnabled = ConfigParser.valueAsOrElse(config.get(CFG_DEPENDENCY_TRACKING_ENABLED), + Boolean.class, true); + this.cachingEnabled = ConfigParser.valueAsOrElse(config.get(CFG_CACHING_ENABLED), Boolean.class, true); + this.jythonEmulation = ConfigParser.valueAsOrElse(config.get(CFG_JYTHON_EMULATION), Boolean.class, false); + } + + public boolean isScopeEnabled() { + return scopeEnabled; + } + + public boolean isHelperEnabled() { + return helperEnabled; + } + + public boolean isInjection(int type) { + return injectionEnabled == type; + } + + public boolean isDependencyTrackingEnabled() { + return dependencyTrackingEnabled; + } + + public boolean isCachingEnabled() { + return cachingEnabled; + } + + public boolean isJythonEmulation() { + return jythonEmulation; + } +} diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/PythonScriptEngineFactory.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/PythonScriptEngineFactory.java new file mode 100644 index 0000000000000..f0fc9fb4e6ba7 --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/PythonScriptEngineFactory.java @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.automation.pythonscripting.internal; + +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.lang.module.ModuleDescriptor.Version; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Enumeration; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.script.ScriptEngine; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.automation.pythonscripting.internal.fs.watch.PythonDependencyTracker; +import org.openhab.core.OpenHAB; +import org.openhab.core.automation.module.script.ScriptDependencyTracker; +import org.openhab.core.automation.module.script.ScriptEngineFactory; +import org.openhab.core.config.core.ConfigurableService; +import org.osgi.framework.Constants; +import org.osgi.framework.FrameworkUtil; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Modified; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This is an implementation of {@link ScriptEngineFactory} for Python. + * + * @author Holger Hees - initial contribution + * @author Jeff James - initial contribution + */ +@Component(service = ScriptEngineFactory.class, configurationPid = "org.openhab.automation.pythonscripting", property = Constants.SERVICE_PID + + "=org.openhab.automation.pythonscripting") +@ConfigurableService(category = "automation", label = "Python Scripting", description_uri = "automation:pythonscripting") +@NonNullByDefault +public class PythonScriptEngineFactory implements ScriptEngineFactory { + private final Logger logger = LoggerFactory.getLogger(PythonScriptEngineFactory.class); + + public static final Path PYTHON_DEFAULT_PATH = Paths.get(OpenHAB.getConfigFolder(), "automation", "python"); + public static final Path PYTHON_LIB_PATH = PYTHON_DEFAULT_PATH.resolve("lib"); + + private static final Path PYTHON_OPENHAB_LIB_PATH = PYTHON_LIB_PATH.resolve("openhab"); + + public static final Path PYTHON_WRAPPER_FILE_PATH = PYTHON_OPENHAB_LIB_PATH.resolve("__wrapper__.py"); + private static final Path PYTHON_INIT_FILE_PATH = PYTHON_OPENHAB_LIB_PATH.resolve("__init__.py"); + + public static final String SCRIPT_TYPE = "application/x-python3"; + private final List scriptTypes = Arrays.asList(PythonScriptEngineFactory.SCRIPT_TYPE, "PY3"); + + private final PythonDependencyTracker pythonDependencyTracker; + private final PythonScriptEngineConfiguration pythonScriptEngineConfiguration; + + @Activate + public PythonScriptEngineFactory(final @Reference PythonDependencyTracker pythonDependencyTracker, + Map config) { + logger.debug("Loading PythonScriptEngineFactory"); + + this.pythonDependencyTracker = pythonDependencyTracker; + this.pythonScriptEngineConfiguration = new PythonScriptEngineConfiguration(); + + modified(config); + + if (this.pythonScriptEngineConfiguration.isHelperEnabled()) { + initHelperLib(); + } + } + + @Deactivate + public void cleanup() { + logger.debug("Unloading PythonScriptEngineFactory"); + } + + @Modified + protected void modified(Map config) { + this.pythonScriptEngineConfiguration.update(config); + } + + @Override + public List getScriptTypes() { + return scriptTypes; + } + + @Override + public void scopeValues(ScriptEngine scriptEngine, Map scopeValues) { + for (Entry entry : scopeValues.entrySet()) { + scriptEngine.put(entry.getKey(), entry.getValue()); + } + } + + @Override + public @Nullable ScriptEngine createScriptEngine(String scriptType) { + if (!scriptTypes.contains(scriptType)) { + return null; + } + return new PythonScriptEngine(pythonDependencyTracker, pythonScriptEngineConfiguration); + } + + @Override + public @Nullable ScriptDependencyTracker getDependencyTracker() { + return pythonDependencyTracker; + } + + private void initHelperLib() { + try { + String resourceLibPath = PYTHON_OPENHAB_LIB_PATH.toString() + .substring(PYTHON_DEFAULT_PATH.toString().length()) + "/"; + + Enumeration resourceFiles = FrameworkUtil.getBundle(PythonScriptEngineFactory.class) + .findEntries(resourceLibPath, "*.py", true); + + if (Files.exists(PythonScriptEngineFactory.PYTHON_OPENHAB_LIB_PATH) + && Files.list(PYTHON_OPENHAB_LIB_PATH).count() > 0) { + Pattern pattern = Pattern.compile("__version__\\s*=\\s*\"([0-9]+\\.[0-9]+\\.[0-9]+)\"", + Pattern.CASE_INSENSITIVE); + + Version includedVersion = null; + try (InputStream is = PythonScriptEngineFactory.class.getClassLoader() + .getResourceAsStream(resourceLibPath + PYTHON_INIT_FILE_PATH.getFileName().toString())) { + try (InputStreamReader isr = new InputStreamReader(is); + BufferedReader reader = new BufferedReader(isr)) { + String fileContent = reader.lines().collect(Collectors.joining(System.lineSeparator())); + Matcher includedMatcher = pattern.matcher(fileContent); + if (includedMatcher.find()) { + includedVersion = Version.parse(includedMatcher.group(1)); + } + } + } + + Version currentVersion = null; + String fileContent = Files.readString(PYTHON_INIT_FILE_PATH, StandardCharsets.UTF_8); + Matcher currentMatcher = pattern.matcher(fileContent); + if (currentMatcher.find()) { + currentVersion = Version.parse(currentMatcher.group(1)); + } + + if (currentVersion == null) { + logger.warn("Unable to detect installed helper lib version. Skip installing helper libs."); + return; + } else if (includedVersion == null) { + logger.error("Unable to detect provided helper lib version. Skip installing helper libs."); + return; + } else if (currentVersion.compareTo(includedVersion) >= 0) { + logger.info("Newest helper lib version is deployed."); + return; + } + } + + logger.info("Deploy helper libs into {}.", PythonScriptEngineFactory.PYTHON_OPENHAB_LIB_PATH.toString()); + + if (Files.exists(PythonScriptEngineFactory.PYTHON_OPENHAB_LIB_PATH)) { + try (Stream paths = Files.walk(PythonScriptEngineFactory.PYTHON_OPENHAB_LIB_PATH)) { + paths.sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete); + } + } + + Files.createDirectories(PythonScriptEngineFactory.PYTHON_OPENHAB_LIB_PATH, + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxr-xr-x"))); + + while (resourceFiles.hasMoreElements()) { + URL resourceFile = resourceFiles.nextElement(); + String resourcePath = resourceFile.getPath(); + + try (InputStream is = PythonScriptEngineFactory.class.getClassLoader() + .getResourceAsStream(resourcePath)) { + Path target = PythonScriptEngineFactory.PYTHON_OPENHAB_LIB_PATH + .resolve(resourcePath.substring(resourcePath.lastIndexOf('/') + 1)); + + Files.copy(is, target); + Files.setPosixFilePermissions(target, PosixFilePermissions.fromString("rw-r--r--")); + } + } + } catch (Exception e) { + logger.error("Exception during helper lib initialisation", e); + } + } +} diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/fs/DelegatingFileSystem.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/fs/DelegatingFileSystem.java new file mode 100644 index 0000000000000..104a02bc40c1d --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/fs/DelegatingFileSystem.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.automation.pythonscripting.internal.fs; + +import java.io.IOException; +import java.net.URI; +import java.nio.channels.SeekableByteChannel; +import java.nio.file.AccessMode; +import java.nio.file.DirectoryStream; +import java.nio.file.LinkOption; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.spi.FileSystemProvider; +import java.util.Map; +import java.util.Set; + +import org.graalvm.polyglot.io.FileSystem; + +/** + * Delegate wrapping a {@link FileSystem} + * + * @author Holger Hees - Initial contribution (Reused from jsscripting) + */ +public class DelegatingFileSystem implements FileSystem { + private FileSystemProvider delegate; + + public DelegatingFileSystem(FileSystemProvider delegate) { + this.delegate = delegate; + } + + @Override + public Path parsePath(URI uri) { + return Paths.get(uri); + } + + @Override + public Path parsePath(String path) { + return Paths.get(path); + } + + @Override + public void checkAccess(Path path, Set modes, LinkOption... linkOptions) throws IOException { + delegate.checkAccess(path, modes.toArray(new AccessMode[0])); + } + + @Override + public void createDirectory(Path dir, FileAttribute... attrs) throws IOException { + delegate.createDirectory(dir, attrs); + } + + @Override + public void delete(Path path) throws IOException { + delegate.delete(path); + } + + @Override + public SeekableByteChannel newByteChannel(Path path, Set options, FileAttribute... attrs) + throws IOException { + return delegate.newByteChannel(path, options, attrs); + } + + @Override + public DirectoryStream newDirectoryStream(Path dir, DirectoryStream.Filter filter) + throws IOException { + return delegate.newDirectoryStream(dir, filter); + } + + @Override + public Path toAbsolutePath(Path path) { + return path.toAbsolutePath(); + } + + @Override + public Path toRealPath(Path path, LinkOption... linkOptions) throws IOException { + return path.toRealPath(linkOptions); + } + + @Override + public Map readAttributes(Path path, String attributes, LinkOption... options) throws IOException { + return delegate.readAttributes(path, attributes, options); + } +} diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/fs/watch/PythonDependencyTracker.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/fs/watch/PythonDependencyTracker.java new file mode 100644 index 0000000000000..0be4ff9c4e2dc --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/fs/watch/PythonDependencyTracker.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.automation.pythonscripting.internal.fs.watch; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.automation.pythonscripting.internal.PythonScriptEngineFactory; +import org.openhab.core.automation.module.script.ScriptDependencyTracker; +import org.openhab.core.automation.module.script.rulesupport.loader.AbstractScriptDependencyTracker; +import org.openhab.core.service.WatchService; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.osgi.service.component.annotations.ReferencePolicy; + +/** + * Tracks Python module dependencies + * + * @author Holger Hees - Initial contribution (Reused from jsscripting) + */ +@Component(service = PythonDependencyTracker.class) +@NonNullByDefault +public class PythonDependencyTracker extends AbstractScriptDependencyTracker { + + @Activate + public PythonDependencyTracker(@Reference(target = WatchService.CONFIG_WATCHER_FILTER) WatchService watchService) { + super(watchService, PythonScriptEngineFactory.PYTHON_LIB_PATH.toString()); + } + + @Deactivate + @Override + public void deactivate() { + super.deactivate(); + } + + @Override + @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC, unbind = "removeChangeTracker") + public void addChangeTracker(ScriptDependencyTracker.Listener listener) { + super.addChangeTracker(listener); + } + + @Override + public void removeChangeTracker(ScriptDependencyTracker.Listener listener) { + super.removeChangeTracker(listener); + } +} diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/fs/watch/PythonScriptFileWatcher.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/fs/watch/PythonScriptFileWatcher.java new file mode 100644 index 0000000000000..754ae7d9d59a9 --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/fs/watch/PythonScriptFileWatcher.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.automation.pythonscripting.internal.fs.watch; + +import java.nio.file.Path; +import java.util.Optional; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.automation.pythonscripting.internal.PythonScriptEngineFactory; +import org.openhab.core.automation.module.script.ScriptDependencyTracker; +import org.openhab.core.automation.module.script.ScriptEngineManager; +import org.openhab.core.automation.module.script.rulesupport.loader.AbstractScriptFileWatcher; +import org.openhab.core.automation.module.script.rulesupport.loader.ScriptFileWatcher; +import org.openhab.core.service.ReadyService; +import org.openhab.core.service.StartLevelService; +import org.openhab.core.service.WatchService; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * Monitors {@code /automation/python} for Python files, but not libraries + * + * @author Holger Hees - Initial contribution (Reused from jsscripting) + */ +@Component(immediate = true, service = { ScriptFileWatcher.class, ScriptDependencyTracker.Listener.class }) +@NonNullByDefault +public class PythonScriptFileWatcher extends AbstractScriptFileWatcher { + @Activate + public PythonScriptFileWatcher( + final @Reference(target = WatchService.CONFIG_WATCHER_FILTER) WatchService watchService, + final @Reference ScriptEngineManager manager, final @Reference ReadyService readyService, + final @Reference StartLevelService startLevelService) { + super(watchService, manager, readyService, startLevelService, + PythonScriptEngineFactory.PYTHON_DEFAULT_PATH.toString(), true); + } + + @Override + protected Optional getScriptType(Path scriptFilePath) { + if (!scriptFilePath.startsWith(PythonScriptEngineFactory.PYTHON_LIB_PATH)) { + Optional scriptFileSuffix = super.getScriptType(scriptFilePath); + if (scriptFileSuffix.isPresent() && "py".equals(scriptFileSuffix.get())) { + return Optional.of(PythonScriptEngineFactory.SCRIPT_TYPE); + } + } + return Optional.empty(); + } +} diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/graal/GraalPythonBindings.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/graal/GraalPythonBindings.java new file mode 100644 index 0000000000000..a03c7c7a21f49 --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/graal/GraalPythonBindings.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.automation.pythonscripting.internal.graal; + +import java.util.AbstractMap; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import javax.script.ScriptContext; +import javax.script.ScriptEngine; + +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Value; + +/*** + * @author Holger Hees - Initial contribution + * @author Jeff James - Initial contribution + */ +final class GraalPythonBindings extends AbstractMap implements javax.script.Bindings, AutoCloseable { + private Context context; + private Map global; + + private Context.Builder contextBuilder; + // ScriptContext of the ScriptEngine where these bindings form ENGINE_SCOPE bindings + private ScriptContext scriptContext; + private ScriptEngine scriptEngine; + + GraalPythonBindings(Context.Builder contextBuilder, ScriptContext scriptContext, ScriptEngine scriptEngine) { + this.contextBuilder = contextBuilder; + this.scriptContext = scriptContext; + this.scriptEngine = scriptEngine; + } + + GraalPythonBindings(Context context, ScriptContext scriptContext, ScriptEngine scriptEngine) { + this.context = context; + this.scriptContext = scriptContext; + this.scriptEngine = scriptEngine; + + initGlobal(); + } + + private void requireContext() { + if (context == null) { + context = GraalPythonScriptEngine.createDefaultContext(contextBuilder, scriptContext); + + initGlobal(); + } + } + + private void initGlobal() { + this.global = new HashMap(); + + requireContext(); + + context.getBindings(GraalPythonScriptEngine.LANGUAGE_ID).putMember("__engine__", scriptEngine); + if (scriptContext != null) { + context.getBindings(GraalPythonScriptEngine.LANGUAGE_ID).putMember("__context__", scriptContext); + } + } + + @Override + public Object put(String key, Object v) { + requireContext(); + + context.getBindings(GraalPythonScriptEngine.LANGUAGE_ID).putMember(key, v); + return global.put(key, v); + } + + @Override + public void clear() { + if (context != null) { + Value binding = context.getBindings(GraalPythonScriptEngine.LANGUAGE_ID); + for (var entry : global.entrySet()) { + binding.removeMember(entry.getKey()); + } + } + } + + @Override + public Object get(Object key) { + requireContext(); + return global.get(key); + } + + @Override + public Object remove(Object key) { + requireContext(); + Object prev = get(key); + context.getBindings(GraalPythonScriptEngine.LANGUAGE_ID).removeMember((String) key); + global.remove(key); + return prev; + } + + public Context getContext() { + requireContext(); + return context; + } + + @Override + public Set> entrySet() { + requireContext(); + return global.entrySet(); + } + + @Override + public void close() { + if (context != null) { + context.close(); + } + } + + void updateEngineScriptContext(ScriptContext scriptContext) { + this.scriptContext = scriptContext; + } +} diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/graal/GraalPythonScriptEngine.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/graal/GraalPythonScriptEngine.java new file mode 100644 index 0000000000000..fc6a394b9446a --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/graal/GraalPythonScriptEngine.java @@ -0,0 +1,434 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.automation.pythonscripting.internal.graal; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Reader; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; + +import javax.script.AbstractScriptEngine; +import javax.script.Bindings; +import javax.script.Compilable; +import javax.script.CompiledScript; +import javax.script.Invocable; +import javax.script.ScriptContext; +import javax.script.ScriptEngine; +import javax.script.ScriptException; + +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Context.Builder; +import org.graalvm.polyglot.Engine; +import org.graalvm.polyglot.HostAccess; +import org.graalvm.polyglot.PolyglotException; +import org.graalvm.polyglot.Source; +import org.graalvm.polyglot.SourceSection; +import org.graalvm.polyglot.Value; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A Graal.Python implementation of the script engine. It provides access to the polyglot context using + * {@link #getPolyglotContext()}. + * + * @author Holger Hees - Initial contribution + * @author Jeff James - Initial contribution + */ +public final class GraalPythonScriptEngine extends AbstractScriptEngine + implements Compilable, Invocable, AutoCloseable { + + public static final String LANGUAGE_ID = "python"; + private static final String POLYGLOT_CONTEXT = "polyglot.context"; + + private static final String PYTHON_OPTION_POSIXMODULEBACKEND = "python.PosixModuleBackend"; + private static final String PYTHON_OPTION_DONTWRITEBYTECODEFLAG = "python.DontWriteBytecodeFlag"; + private static final String PYTHON_OPTION_FORCEIMPORTSITE = "python.ForceImportSite"; + private static final String PYTHON_OPTION_CHECKHASHPYCSMODE = "python.CheckHashPycsMode"; + + static final String MAGIC_OPTION_PREFIX = "polyglot.py."; + + private final Logger logger = LoggerFactory.getLogger(GraalPythonScriptEngine.class); + + interface MagicBindingsOptionSetter { + + String getOptionKey(); + + Context.Builder setOption(Builder builder, Object value); + } + + private final GraalPythonScriptEngineFactory factory; + private final Context.Builder contextConfig; + + GraalPythonScriptEngine(GraalPythonScriptEngineFactory factory) { + this(factory, factory.getPolyglotEngine(), null); + } + + GraalPythonScriptEngine(GraalPythonScriptEngineFactory factory, Engine engine, Context.Builder contextConfig) { + Engine engineToUse = (engine != null) ? engineToUse = engine : factory.getPolyglotEngine(); + // this.factory = (factory == null) ? new GraalPythonScriptEngineFactory(engineToUse) : factory; + + Context.Builder contextConfigToUse = contextConfig; + if (contextConfigToUse == null) { + contextConfigToUse = Context.newBuilder(LANGUAGE_ID) // + .allowExperimentalOptions(true) // + .allowAllAccess(true) // + .allowHostAccess(HostAccess.ALL) // + // allow creating python threads + .allowCreateThread(true) // + // allow running Python native extensions + .allowNativeAccess(true) // + // allow exporting Python values to polyglot bindings and accessing Java + // choose the backend for the POSIX module + .option(PYTHON_OPTION_POSIXMODULEBACKEND, "java") // + // equivalent to the Python -B flag + .option(PYTHON_OPTION_DONTWRITEBYTECODEFLAG, "true") // + // Force to automatically import site.py module, to make Python packages available + .option(PYTHON_OPTION_FORCEIMPORTSITE, "true") // + // causes the interpreter to always assume hash-based pycs are valid + .option(PYTHON_OPTION_CHECKHASHPYCSMODE, "never"); + } + this.factory = (factory == null) ? new GraalPythonScriptEngineFactory(engineToUse) : factory; + this.contextConfig = contextConfigToUse.engine(engineToUse); + this.context.setBindings(new GraalPythonBindings(this.contextConfig, this.context, this), + ScriptContext.ENGINE_SCOPE); + } + + static Context createDefaultContext(Context.Builder builder, ScriptContext ctxt) { + return builder.build(); + } + + /** + * Closes the current context and makes it unusable. Operations performed after closing will + * throw an {@link IllegalStateException}. + */ + @Override + public void close() { + logger.debug("GraalPythonScriptEngine closed"); + + Bindings bindings = this.getBindings(ScriptContext.ENGINE_SCOPE); + if (bindings instanceof GraalPythonBindings pythonBingings) { + // Break circular reference. Not sure if this is really needed. + pythonBingings.updateEngineScriptContext(null); + pythonBingings.close(); + } + + getPolyglotContext().close(); + } + + /** + * Returns the polyglot engine associated with this script engine. + */ + public Engine getPolyglotEngine() { + return factory.getPolyglotEngine(); + } + + public Context getPolyglotContext() { + return getOrCreateGraalPythonBindings(context).getContext(); + } + + static Value evalInternal(Context context, String script) { + return context.eval(Source.newBuilder(LANGUAGE_ID, script, "internal-script").internal(true).buildLiteral()); + } + + @Override + public Bindings createBindings() { + return new GraalPythonBindings(contextConfig, null, this); + } + + @Override + public void setBindings(Bindings bindings, int scope) { + if (scope == ScriptContext.ENGINE_SCOPE) { + Bindings oldBindings = getBindings(scope); + if (oldBindings instanceof GraalPythonBindings) { + ((GraalPythonBindings) oldBindings).updateEngineScriptContext(null); + } + } + super.setBindings(bindings, scope); + if (scope == ScriptContext.ENGINE_SCOPE && (bindings instanceof GraalPythonBindings)) { + ((GraalPythonBindings) bindings).updateEngineScriptContext(getContext()); + } + } + + @Override + public Object eval(Reader reader, ScriptContext ctxt) throws ScriptException { + return eval(createSource(read(reader), ctxt), ctxt); + } + + static String read(Reader reader) throws ScriptException { + final StringBuilder builder = new StringBuilder(); + final char[] buffer = new char[1024]; + try { + try { + while (true) { + final int count = reader.read(buffer); + if (count == -1) { + break; + } + builder.append(buffer, 0, count); + } + } finally { + reader.close(); + } + return builder.toString(); + } catch (IOException ioex) { + throw new ScriptException(ioex); + } + } + + @Override + public Object eval(String script, ScriptContext ctxt) throws ScriptException { + return eval(createSource(script, ctxt), ctxt); + } + + private static Source createSource(String script, ScriptContext ctxt) throws ScriptException { + final Object val = ctxt.getAttribute(ScriptEngine.FILENAME); + if (val == null) { + return Source.newBuilder(LANGUAGE_ID, script, "").buildLiteral(); + } else { + try { + return Source.newBuilder(LANGUAGE_ID, new File(val.toString())).content(script).build(); + } catch (IOException ioex) { + throw new ScriptException(ioex); + } + } + } + + private Object eval(Source source, ScriptContext scriptContext) throws ScriptException { + GraalPythonBindings engineBindings = getOrCreateGraalPythonBindings(scriptContext); + Context polyglotContext = engineBindings.getContext(); + try { + return polyglotContext.eval(source).as(Object.class); + } catch (PolyglotException e) { + throw toScriptException(e); + } + } + + private static ScriptException toScriptException(PolyglotException ex) { + ScriptException sex; + if (ex.isHostException()) { + Throwable hostException = ex.asHostException(); + // ScriptException (unlike almost any other exception) does not + // accept Throwable cause (requires the cause to be Exception) + Exception cause; + if (hostException instanceof Exception) { + cause = (Exception) hostException; + } else { + cause = new Exception(hostException); + } + // Make the host exception accessible through the cause chain + sex = new ScriptException(cause); + // Re-use the stack-trace of PolyglotException (with guest-language stack-frames) + sex.setStackTrace(ex.getStackTrace()); + } else { + SourceSection sourceSection = ex.getSourceLocation(); + if (sourceSection != null && sourceSection.isAvailable()) { + Source source = sourceSection.getSource(); + String fileName = source.getPath(); + if (fileName == null) { + fileName = source.getName(); + } + int lineNo = sourceSection.getStartLine(); + int columnNo = sourceSection.getStartColumn(); + sex = new ScriptException(ex.getMessage(), fileName, lineNo, columnNo); + sex.initCause(ex); + } else { + sex = new ScriptException(ex); + } + } + return sex; + } + + private GraalPythonBindings getOrCreateGraalPythonBindings(ScriptContext scriptContext) { + Bindings engineB = scriptContext.getBindings(ScriptContext.ENGINE_SCOPE); + if (engineB instanceof GraalPythonBindings) { + return ((GraalPythonBindings) engineB); + } else { + GraalPythonBindings bindings = new GraalPythonBindings(createContext(engineB), scriptContext, this); + bindings.putAll(engineB); + return bindings; + } + } + + private Context createContext(Bindings engineB) { + Object ctx = engineB.get(POLYGLOT_CONTEXT); + if (!(ctx instanceof Context)) { + Context.Builder builder = contextConfig; + ctx = createDefaultContext(builder, context); + engineB.put(POLYGLOT_CONTEXT, ctx); + } + return (Context) ctx; + } + + @Override + public GraalPythonScriptEngineFactory getFactory() { + return factory; + } + + @Override + public Object invokeMethod(Object thiz, String name, Object... args) throws ScriptException, NoSuchMethodException { + if (thiz == null) { + throw new IllegalArgumentException("thiz is not a valid object."); + } + GraalPythonBindings engineBindings = getOrCreateGraalPythonBindings(context); + Value thisValue = engineBindings.getContext().asValue(thiz); + + if (!thisValue.canInvokeMember(name)) { + if (!thisValue.hasMember(name)) { + throw noSuchMethod(name); + } else { + throw notCallable(name); + } + } + try { + return thisValue.invokeMember(name, args).as(Object.class); + } catch (PolyglotException e) { + throw toScriptException(e); + } + } + + @Override + public Object invokeFunction(String name, Object... args) throws ScriptException, NoSuchMethodException { + GraalPythonBindings engineBindings = getOrCreateGraalPythonBindings(context); + Value function = engineBindings.getContext().getBindings(LANGUAGE_ID).getMember(name); + + if (function == null) { + throw noSuchMethod(name); + } else if (!function.canExecute()) { + throw notCallable(name); + } + try { + return function.execute(args).as(Object.class); + } catch (PolyglotException e) { + throw toScriptException(e); + } + } + + private static NoSuchMethodException noSuchMethod(String name) throws NoSuchMethodException { + throw new NoSuchMethodException(name); + } + + private static NoSuchMethodException notCallable(String name) throws NoSuchMethodException { + throw new NoSuchMethodException(name + " is not a function"); + } + + @Override + public T getInterface(Class clasz) { + checkInterface(clasz); + return getInterfaceInner(evalInternal(getPolyglotContext(), "this"), clasz); + } + + @Override + public T getInterface(Object thiz, Class clasz) { + if (thiz == null) { + throw new IllegalArgumentException("this cannot be null"); + } + checkInterface(clasz); + Value thisValue = getPolyglotContext().asValue(thiz); + checkThis(thisValue); + return getInterfaceInner(thisValue, clasz); + } + + private static void checkInterface(Class clasz) { + if (clasz == null || !clasz.isInterface()) { + throw new IllegalArgumentException("interface Class expected in getInterface"); + } + } + + private static void checkThis(Value thiz) { + if (thiz.isHostObject() || !thiz.hasMembers()) { + throw new IllegalArgumentException("getInterface cannot be called on non-script object"); + } + } + + private static T getInterfaceInner(Value thiz, Class iface) { + if (!isInterfaceImplemented(iface, thiz)) { + return null; + } + return thiz.as(iface); + } + + @Override + public CompiledScript compile(String script) throws ScriptException { + Source source = createSource(script, getContext()); + return compile(source); + } + + @Override + public CompiledScript compile(Reader reader) throws ScriptException { + Source source = createSource(read(reader), getContext()); + return compile(source); + } + + private CompiledScript compile(Source source) throws ScriptException { + checkSyntax(source); + return new CompiledScript() { + @Override + public ScriptEngine getEngine() { + return GraalPythonScriptEngine.this; + } + + @Override + public Object eval(ScriptContext ctx) throws ScriptException { + return GraalPythonScriptEngine.this.eval(source, ctx); + } + }; + } + + private void checkSyntax(Source source) throws ScriptException { + try { + getPolyglotContext().parse(source); + } catch (PolyglotException pex) { + throw toScriptException(pex); + } + } + + /** + * Creates a new GraalPython script engine from a polyglot Engine instance with a base configuration + * for new polyglot {@link Context} instances. Polyglot context instances can be accessed from + * {@link ScriptContext} instances using {@link #getPolyglotContext()}. The + * {@link Builder#out(OutputStream) out},{@link Builder#err(OutputStream) err} and + * {@link Builder#in(InputStream) in} stream configuration are not inherited from the provided + * polyglot context config. Instead {@link ScriptContext} output and input streams are used. + * + * @param engine the engine to be used for context configurations or null if a + * default engine should be used. + * @param newContextConfig a base configuration to create new context instances or + * null if the default configuration should be used to construct new + * context instances. + */ + public static GraalPythonScriptEngine create(Engine engine, Context.Builder newContextConfig) { + return new GraalPythonScriptEngine(null, engine, newContextConfig); + } + + private static boolean isInterfaceImplemented(final Class iface, final Value obj) { + for (final Method method : iface.getMethods()) { + // ignore methods of java.lang.Object class + if (method.getDeclaringClass() == Object.class) { + continue; + } + + // skip check for default methods - non-abstract, interface methods + if (!Modifier.isAbstract(method.getModifiers())) { + continue; + } + + if (!obj.canInvokeMember(method.getName())) { + return false; + } + } + return true; + } +} diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/graal/GraalPythonScriptEngineFactory.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/graal/GraalPythonScriptEngineFactory.java new file mode 100644 index 0000000000000..d9e2148dc5750 --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/graal/GraalPythonScriptEngineFactory.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.automation.pythonscripting.internal.graal; + +import java.lang.ref.WeakReference; +import java.util.List; +import java.util.Objects; + +import javax.script.ScriptEngine; +import javax.script.ScriptEngineFactory; + +import org.graalvm.polyglot.Engine; +import org.graalvm.polyglot.Language; + +/** + * @author Holger Hees - Initial contribution + * @author Jeff James - Initial contribution + */ +public final class GraalPythonScriptEngineFactory implements ScriptEngineFactory { + private WeakReference defaultEngine; + private final Engine userDefinedEngine; + + private static final String ENGINE_NAME = "Graal.py"; + private static final String NAME = "python3"; + + private static final String[] EXTENSIONS = { "py" }; + + public GraalPythonScriptEngineFactory() { + super(); + this.userDefinedEngine = null; + + defaultEngine = new WeakReference(createDefaultEngine()); + } + + public GraalPythonScriptEngineFactory(Engine engine) { + this.defaultEngine = null; + this.userDefinedEngine = engine; + } + + private static Engine createDefaultEngine() { + return Engine.newBuilder() // + .allowExperimentalOptions(true) // + .option("engine.WarnInterpreterOnly", "false") // + .build(); + } + + /** + * Returns the underlying polyglot engine. + */ + public Engine getPolyglotEngine() { + if (userDefinedEngine != null) { + return userDefinedEngine; + } else { + Engine engine = defaultEngine == null ? null : defaultEngine.get(); + if (engine == null) { + engine = createDefaultEngine(); + defaultEngine = new WeakReference<>(engine); + } + return engine; + } + } + + @Override + public String getEngineName() { + return ENGINE_NAME; + } + + @Override + public String getEngineVersion() { + return getPolyglotEngine().getVersion(); + } + + @Override + public List getExtensions() { + return List.of(EXTENSIONS); + } + + @Override + public List getMimeTypes() { + Language language = getPolyglotEngine().getLanguages().get(GraalPythonScriptEngine.LANGUAGE_ID); + return List.copyOf(language.getMimeTypes()); + } + + @Override + public List getNames() { + Language language = getPolyglotEngine().getLanguages().get(GraalPythonScriptEngine.LANGUAGE_ID); + return List.of(language.getName(), GraalPythonScriptEngine.LANGUAGE_ID, language.getImplementationName()); + } + + @Override + public String getLanguageName() { + Language language = getPolyglotEngine().getLanguages().get(GraalPythonScriptEngine.LANGUAGE_ID); + return language.getName(); + } + + @Override + public String getLanguageVersion() { + Language language = getPolyglotEngine().getLanguages().get(GraalPythonScriptEngine.LANGUAGE_ID); + return language.getVersion(); + } + + @Override + public Object getParameter(String key) { + switch (key) { + case ScriptEngine.NAME: + return NAME; + case ScriptEngine.ENGINE: + return getEngineName(); + case ScriptEngine.ENGINE_VERSION: + return getEngineVersion(); + case ScriptEngine.LANGUAGE: + return getLanguageName(); + case ScriptEngine.LANGUAGE_VERSION: + return getLanguageVersion(); + default: + return null; + } + } + + @Override + public GraalPythonScriptEngine getScriptEngine() { + return new GraalPythonScriptEngine(this); + } + + @Override + public String getMethodCallSyntax(final String obj, final String method, final String... args) { + Objects.requireNonNull(obj); + Objects.requireNonNull(method); + final StringBuilder sb = new StringBuilder().append(obj).append('.').append(method).append('('); + final int len = args.length; + + if (len > 0) { + Objects.requireNonNull(args[0]); + sb.append(args[0]); + } + for (int i = 1; i < len; i++) { + Objects.requireNonNull(args[i]); + sb.append(',').append(args[i]); + } + sb.append(')'); + + return sb.toString(); + } + + @Override + public String getOutputStatement(final String toDisplay) { + return "print(\"" + toDisplay + "\")"; + } + + @Override + public String getProgram(final String... statements) { + final StringBuilder sb = new StringBuilder(); + + for (final String statement : statements) { + Objects.requireNonNull(statement); + sb.append(statement).append(';'); + } + + return sb.toString(); + } +} diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/package-info.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/package-info.java new file mode 100644 index 0000000000000..ab104eb1d43f0 --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +@org.osgi.annotation.bundle.Header(name = org.osgi.framework.Constants.DYNAMICIMPORT_PACKAGE, value = "*") +package org.openhab.automation.pythonscripting.internal; + +/** + * Additional information for the Python Scripting package + * + * @author Holger Hees - Initial contribution + */ diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scope/AbstractScriptExtensionProvider.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scope/AbstractScriptExtensionProvider.java new file mode 100644 index 0000000000000..bd1dbf5f04c24 --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scope/AbstractScriptExtensionProvider.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.automation.pythonscripting.internal.scope; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.automation.module.script.ScriptExtensionProvider; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.annotations.Activate; + +/** + * Base class to offer support for script extension providers + * + * @author Holger Hees - Initial contribution (Reused from jsscripting) + */ +@NonNullByDefault +public abstract class AbstractScriptExtensionProvider implements ScriptExtensionProvider { + private Map> types = new HashMap<>(); + private Map> idToTypes = new ConcurrentHashMap<>(); + + protected abstract String getPresetName(); + + protected abstract void initializeTypes(final BundleContext context); + + protected void addType(String name, Function value) { + types.put(name, value); + } + + @Activate + public void activate(final BundleContext context) { + types.clear(); + initializeTypes(context); + } + + @Override + public Collection getDefaultPresets() { + return Collections.emptyList(); + } + + @Override + public Collection getPresets() { + return Set.of(getPresetName()); + } + + @Override + public Collection getTypes() { + return types.keySet(); + } + + @Override + public @Nullable Object get(String scriptIdentifier, String type) throws IllegalArgumentException { + Map forScript = idToTypes.computeIfAbsent(scriptIdentifier, k -> new HashMap<>()); + return forScript.computeIfAbsent(type, + k -> Objects.nonNull(types.get(k)) ? types.get(k).apply(scriptIdentifier) : null); + } + + @Override + public Map importPreset(String scriptIdentifier, String preset) { + if (getPresetName().equals(preset)) { + Map results = new HashMap<>(types.size()); + for (String type : types.keySet()) { + Object value = get(scriptIdentifier, type); + if (value != null) { + results.put(type, value); + } + } + return results; + } + + return Collections.emptyMap(); + } + + @Override + public void unload(String scriptIdentifier) { + // ignore by default + } +} diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scope/OSGiScriptExtensionProvider.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scope/OSGiScriptExtensionProvider.java new file mode 100644 index 0000000000000..847da61e15549 --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scope/OSGiScriptExtensionProvider.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.automation.pythonscripting.internal.scope; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.automation.module.script.ScriptExtensionProvider; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.annotations.Component; + +/** + * ScriptExtensionProvider which provides various functions to help scripts to work with OSGi + * + * @author Holger Hees - Initial contribution (Reused from jsscripting) + */ +@NonNullByDefault +@Component(immediate = true, service = ScriptExtensionProvider.class) +public class OSGiScriptExtensionProvider extends AbstractScriptExtensionProvider { + + @Override + protected String getPresetName() { + return "osgi"; + } + + @Override + protected void initializeTypes(final BundleContext context) { + addType("bundleContext", k -> context); + } +} diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/DelegatingScriptEngineWithInvocableAndCompilableAndAutocloseable.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/DelegatingScriptEngineWithInvocableAndCompilableAndAutocloseable.java new file mode 100644 index 0000000000000..ebc8daea361f1 --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/DelegatingScriptEngineWithInvocableAndCompilableAndAutocloseable.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.automation.pythonscripting.internal.scriptengine; + +import java.io.Reader; + +import javax.script.Bindings; +import javax.script.Compilable; +import javax.script.CompiledScript; +import javax.script.Invocable; +import javax.script.ScriptContext; +import javax.script.ScriptEngine; +import javax.script.ScriptEngineFactory; +import javax.script.ScriptException; + +/** + * {@link ScriptEngine} implementation that delegates to a supplied ScriptEngine instance. Allows overriding specific + * methods. + * + * @author Holger Hees - Initial contribution (Reused from jsscripting) + */ +public abstract class DelegatingScriptEngineWithInvocableAndCompilableAndAutocloseable + implements ScriptEngine, Invocable, Compilable, AutoCloseable { + protected T delegate; + + @Override + public Object eval(String s, ScriptContext scriptContext) throws ScriptException { + return delegate.eval(s, scriptContext); + } + + @Override + public Object eval(Reader reader, ScriptContext scriptContext) throws ScriptException { + return delegate.eval(reader, scriptContext); + } + + @Override + public Object eval(String s) throws ScriptException { + return delegate.eval(s); + } + + @Override + public Object eval(Reader reader) throws ScriptException { + return delegate.eval(reader); + } + + @Override + public Object eval(String s, Bindings bindings) throws ScriptException { + return delegate.eval(s, bindings); + } + + @Override + public Object eval(Reader reader, Bindings bindings) throws ScriptException { + return delegate.eval(reader, bindings); + } + + @Override + public void put(String s, Object o) { + delegate.put(s, o); + } + + @Override + public Object get(String s) { + return delegate.get(s); + } + + @Override + public Bindings getBindings(int i) { + return delegate.getBindings(i); + } + + @Override + public void setBindings(Bindings bindings, int i) { + delegate.setBindings(bindings, i); + } + + @Override + public Bindings createBindings() { + return delegate.createBindings(); + } + + @Override + public ScriptContext getContext() { + return delegate.getContext(); + } + + @Override + public void setContext(ScriptContext scriptContext) { + delegate.setContext(scriptContext); + } + + @Override + public ScriptEngineFactory getFactory() { + return delegate.getFactory(); + } + + @Override + public Object invokeMethod(Object o, String s, Object... objects) + throws ScriptException, NoSuchMethodException, IllegalArgumentException { + return delegate.invokeMethod(o, s, objects); + } + + @Override + public Object invokeFunction(String s, Object... objects) throws ScriptException, NoSuchMethodException { + return delegate.invokeFunction(s, objects); + } + + @Override + public T getInterface(Class aClass) { + return delegate.getInterface(aClass); + } + + @Override + public T getInterface(Object o, Class aClass) { + return delegate.getInterface(o, aClass); + } + + @Override + public CompiledScript compile(String s) throws ScriptException { + return delegate.compile(s); + } + + @Override + public CompiledScript compile(Reader reader) throws ScriptException { + return delegate.compile(reader); + } + + @Override + public void close() throws Exception { + delegate.close(); + } +} diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable.java new file mode 100644 index 0000000000000..a10640e19e978 --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable.java @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.automation.pythonscripting.internal.scriptengine; + +import java.io.Reader; +import java.lang.reflect.UndeclaredThrowableException; + +import javax.script.Bindings; +import javax.script.Compilable; +import javax.script.CompiledScript; +import javax.script.Invocable; +import javax.script.ScriptContext; +import javax.script.ScriptEngine; +import javax.script.ScriptException; + +/** + * Delegate allowing AOP-style interception of calls, either before Invocation, or upon a {@link ScriptException} being + * thrown. + * + * @param The delegate class + * @author Holger Hees - Initial contribution (Reused from jsscripting) + */ +public abstract class InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable + extends DelegatingScriptEngineWithInvocableAndCompilableAndAutocloseable { + + protected String beforeInvocation(String source) { + beforeInvocation(); + return source; + } + + protected Reader beforeInvocation(Reader reader) { + beforeInvocation(); + return reader; + } + + protected void beforeInvocation() { + } + + protected Object afterInvocation(Object obj) { + return obj; + } + + protected Exception afterThrowsInvocation(Exception e) { + return e; + } + + @Override + public Object eval(String s, ScriptContext scriptContext) throws ScriptException { + try { + return afterInvocation(super.eval(beforeInvocation(s), scriptContext)); + } catch (ScriptException se) { + throw (ScriptException) afterThrowsInvocation(se); + } catch (Exception e) { + throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions + } + } + + @Override + public Object eval(Reader reader, ScriptContext scriptContext) throws ScriptException { + try { + return afterInvocation(super.eval(beforeInvocation(reader), scriptContext)); + } catch (ScriptException se) { + throw (ScriptException) afterThrowsInvocation(se); + } catch (Exception e) { + throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions + } + } + + @Override + public Object eval(String s) throws ScriptException { + try { + return afterInvocation(super.eval(beforeInvocation(s))); + } catch (ScriptException se) { + throw (ScriptException) afterThrowsInvocation(se); + } catch (Exception e) { + throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions + } + } + + @Override + public Object eval(Reader reader) throws ScriptException { + try { + return afterInvocation(super.eval(beforeInvocation(reader))); + } catch (ScriptException se) { + throw (ScriptException) afterThrowsInvocation(se); + } catch (Exception e) { + throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions + } + } + + @Override + public Object eval(String s, Bindings bindings) throws ScriptException { + try { + return afterInvocation(super.eval(beforeInvocation(s), bindings)); + } catch (ScriptException se) { + throw (ScriptException) afterThrowsInvocation(se); + } catch (Exception e) { + throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions + } + } + + @Override + public Object eval(Reader reader, Bindings bindings) throws ScriptException { + try { + return afterInvocation(super.eval(beforeInvocation(reader), bindings)); + } catch (ScriptException se) { + throw (ScriptException) afterThrowsInvocation(se); + } catch (Exception e) { + throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions + } + } + + @Override + public Object invokeMethod(Object o, String s, Object... objects) + throws ScriptException, NoSuchMethodException, NullPointerException, IllegalArgumentException { + try { + beforeInvocation(); + return afterInvocation(super.invokeMethod(o, s, objects)); + } catch (ScriptException se) { + throw (ScriptException) afterThrowsInvocation(se); + } catch (NoSuchMethodException e) { // Make sure to unlock on exceptions from Invocable.invokeMethod to avoid + // deadlocks + throw (NoSuchMethodException) afterThrowsInvocation(e); + } catch (NullPointerException e) { + throw (NullPointerException) afterThrowsInvocation(e); + } catch (IllegalArgumentException e) { + throw (IllegalArgumentException) afterThrowsInvocation(e); + } catch (Exception e) { + throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions + } + } + + @Override + public Object invokeFunction(String s, Object... objects) + throws ScriptException, NoSuchMethodException, NullPointerException { + try { + beforeInvocation(); + return afterInvocation(super.invokeFunction(s, objects)); + } catch (ScriptException se) { + throw (ScriptException) afterThrowsInvocation(se); + } catch (NoSuchMethodException e) { // Make sure to unlock on exceptions from Invocable.invokeFunction to avoid + // deadlocks + throw (NoSuchMethodException) afterThrowsInvocation(e); + } catch (NullPointerException e) { + throw (NullPointerException) afterThrowsInvocation(e); + } catch (Exception e) { + throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions + } + } + + @Override + public CompiledScript compile(String s) throws ScriptException { + try { + return wrapCompiledScript((CompiledScript) afterInvocation(super.compile(beforeInvocation(s)))); + } catch (ScriptException se) { + throw (ScriptException) afterThrowsInvocation(se); + } catch (Exception e) { + throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions + } + } + + @Override + public CompiledScript compile(Reader reader) throws ScriptException { + try { + return wrapCompiledScript((CompiledScript) afterInvocation(super.compile(beforeInvocation(reader)))); + } catch (ScriptException se) { + throw (ScriptException) afterThrowsInvocation(se); + } catch (Exception e) { + throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions + } + } + + private CompiledScript wrapCompiledScript(CompiledScript script) throws ScriptException { + return new CompiledScript() { + @Override + public ScriptEngine getEngine() { + return InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable.this; + } + + @Override + public Object eval(ScriptContext context) throws ScriptException { + return script.eval(context); + } + }; + } +} diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/helper/LifecycleTracker.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/helper/LifecycleTracker.java new file mode 100644 index 0000000000000..75fc6701d9ad1 --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/helper/LifecycleTracker.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.automation.pythonscripting.internal.scriptengine.helper; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * LifecycleTracker implementation + * + * @author Holger Hees - Initial contribution + */ +@NonNullByDefault +public class LifecycleTracker { + List disposables = new ArrayList<>(); + + public void addDisposeHook(Runnable disposable) { + disposables.add(disposable); + } + + public void dispose() { + for (Runnable disposable : disposables) { + disposable.run(); + } + } +} diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/helper/LogOutputStream.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/helper/LogOutputStream.java new file mode 100644 index 0000000000000..bff0ab70864a0 --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/helper/LogOutputStream.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.automation.pythonscripting.internal.scriptengine.helper; + +import java.io.OutputStream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.slf4j.Logger; +import org.slf4j.event.Level; + +/** + * LogOutputStream implementation + * + * @author Holger Hees - Initial contribution + */ +@NonNullByDefault +public class LogOutputStream extends OutputStream { + private static final int DEFAULT_BUFFER_LENGTH = 2048; + private static final String LINE_SEPARATOR = System.lineSeparator(); + private static final int LINE_SEPARATOR_SIZE = LINE_SEPARATOR.length(); + + private Logger logger; + private Level level; + + private int bufLength; + private byte[] buf; + private int count; + + public LogOutputStream(Logger logger, Level level) { + this.logger = logger; + this.level = level; + + bufLength = DEFAULT_BUFFER_LENGTH; + buf = new byte[DEFAULT_BUFFER_LENGTH]; + count = 0; + } + + public void setLogger(Logger logger) { + this.logger = logger; + } + + public Logger getLogger() { + return logger; + } + + @Override + public void write(int b) { + // don't log nulls + if (b == 0) { + return; + } + + if (count == bufLength) { + growBuffer(); + } + + buf[count] = (byte) b; + count++; + } + + @Override + public void flush() { + if (count == 0) { + return; + } + + // don't print out blank lines; + if (count == LINE_SEPARATOR_SIZE) { + if (((char) buf[0]) == LINE_SEPARATOR.charAt(0) + && ((count == 1) || ((count == 2) && ((char) buf[1]) == LINE_SEPARATOR.charAt(1)))) { + reset(); + return; + } + } else if (count > LINE_SEPARATOR_SIZE) { + // remove linebreaks at the end + if (((char) buf[count - 1]) == LINE_SEPARATOR.charAt(LINE_SEPARATOR_SIZE - 1) + && ((LINE_SEPARATOR_SIZE == 1) || ((LINE_SEPARATOR_SIZE == 2) + && ((char) buf[count - 1]) == LINE_SEPARATOR.charAt(LINE_SEPARATOR_SIZE - 2)))) { + count -= LINE_SEPARATOR_SIZE; + } + } + + final byte[] line = new byte[count]; + System.arraycopy(buf, 0, line, 0, count); + logger.atLevel(level).log(new String(line)); + reset(); + } + + private void growBuffer() { + final int newBufLength = bufLength + DEFAULT_BUFFER_LENGTH; + final byte[] newBuf = new byte[newBufLength]; + System.arraycopy(buf, 0, newBuf, 0, bufLength); + buf = newBuf; + bufLength = newBufLength; + } + + private void reset() { + // don't shrink buffer. assuming that if it grew that it will likely grow similarly again + count = 0; + } +} diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/wrapper/ModuleLocator.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/wrapper/ModuleLocator.java new file mode 100644 index 0000000000000..ccf80a73fd95e --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/wrapper/ModuleLocator.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.automation.pythonscripting.internal.wrapper; + +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Locates modules from a module name + * + * @author Holger Hees - Initial contribution + */ +@NonNullByDefault +public interface ModuleLocator { + Map locateModule(String name, List fromlist); +} diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/wrapper/ScriptExtensionModuleProvider.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/wrapper/ScriptExtensionModuleProvider.java new file mode 100644 index 0000000000000..277a78d0c450d --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/wrapper/ScriptExtensionModuleProvider.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.automation.pythonscripting.internal.wrapper; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.locks.Lock; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.graalvm.polyglot.Context; +import org.openhab.core.automation.module.script.ScriptExtensionAccessor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Class providing script extensions via CommonJS modules (with module name `@runtime`). + * + * @author Jonathan Gilbert - Initial contribution + * @author Florian Hotze - Pass in lock object for multi-thread synchronization; Switch to {@link Lock} for multi-thread + * synchronization + */ + +@NonNullByDefault +public class ScriptExtensionModuleProvider { + + public static final String IMPORT_PROXY_NAME = "__import_proxy__"; + + public static final String OENHAB_MODULE_PREFIX = "org.openhab"; + public static final String SCOPE_MODULE_PREFIX = "scope"; + + private final Logger logger = LoggerFactory.getLogger(ScriptExtensionModuleProvider.class); + + private Map globals = new HashMap(); + + public ScriptExtensionModuleProvider() { + } + + public ModuleLocator locatorFor(Context ctx, String engineIdentifier, + ScriptExtensionAccessor scriptExtensionAccessor) { + return (name, fromlist) -> { + Map symbols = new HashMap(); + if (name.startsWith(OENHAB_MODULE_PREFIX)) { + List classList = new ArrayList(); + if (!fromlist.isEmpty() && fromlist.contains("*")) { + logger.error("Wildcard support of java packages not supported"); + } else { + if (fromlist.isEmpty()) { + classList.add(name); + } else { + for (String from : fromlist) { + classList.add(name + "." + from); + } + } + } + symbols.put("class_list", classList); + } else if (name.startsWith(SCOPE_MODULE_PREFIX)) { + String[] segments = name.split("\\."); + if (name.equals(SCOPE_MODULE_PREFIX)) { + Map possibleSymbols = new HashMap(this.globals); + + if (fromlist.isEmpty() || fromlist.contains("*")) { + symbols = possibleSymbols; + } else { + symbols = possibleSymbols.entrySet().stream().filter(x -> fromlist.contains(x.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + List filteredFromlist = fromlist.stream().filter(x -> !possibleSymbols.containsKey(x)) + .toList(); + + if (!filteredFromlist.isEmpty()) { + for (String from : filteredFromlist) { + Map fromSymbols = scriptExtensionAccessor.findPreset(from, + engineIdentifier); + if (!fromSymbols.isEmpty()) { + symbols.put(from, fromSymbols); + } + } + } + } + } else { + return scriptExtensionAccessor.findPreset(segments[1], engineIdentifier); + } + } + return symbols; + }; + } + + public void put(String key, Object value) { + this.globals.put(key, value); + } +} diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.automation.pythonscripting/src/main/resources/OH-INF/addon/addon.xml new file mode 100644 index 0000000000000..dbd18acd28cdb --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,13 @@ + + + + automation + Python Scripting + This adds a Python script engine. + none + + org.openhab.automation.pythonscripting + + diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.automation.pythonscripting/src/main/resources/OH-INF/config/config.xml new file mode 100644 index 0000000000000..fc785750b0d0a --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/src/main/resources/OH-INF/config/config.xml @@ -0,0 +1,76 @@ + + + + + + This group defines Python's environment. + true + + + + + This group defines Python's system behavior. + true + + + + + + An scope module is an encapsulated module containing all openhab jsr223 objects and can be imported with 'import scope'
+ Additionally you can run an import like 'from org.openhab.core import OpenHAB' + ]]>
+ true + true +
+ + + + If disabled, the openHAB Python helper module can be installed manually by copying it to /conf/automation/python/lib/openhab" + ]]> + true + + + + + + + + + + 2 + + + + Dependency tracking allows your scripts to automatically reload when one of its dependencies is updated. + You may want to disable dependency tracking if you plan on editing or updating a shared library, but don't want all + your scripts to reload until you can test it. + true + true + + + + + Disable this option will result in a slower startup performance, because scripts have to be recompiled every. + ]]> + true + true + + + + Jython Migration Guide. + ]]> + false + true + +
+
diff --git a/bundles/org.openhab.automation.pythonscripting/suppressions.properties b/bundles/org.openhab.automation.pythonscripting/suppressions.properties new file mode 100644 index 0000000000000..c423ca5862672 --- /dev/null +++ b/bundles/org.openhab.automation.pythonscripting/suppressions.properties @@ -0,0 +1,3 @@ +# Please check here how to add suppressions https://maven.apache.org/plugins/maven-pmd-plugin/examples/violation-exclusions.html +org.openhab.automation.pythonscripting.internal.OpenhabGraalPythonScriptEngine=UnusedPrivateField +org.openhab.automation.pythonscripting.internal.scriptengine.InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable=AvoidThrowingNullPointerException,AvoidCatchingNPE diff --git a/bundles/pom.xml b/bundles/pom.xml index 97e43d8a0ec55..49e6b08bdc35b 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -25,6 +25,7 @@ org.openhab.automation.jythonscripting org.openhab.automation.pidcontroller org.openhab.automation.pwm + org.openhab.automation.pythonscripting org.openhab.io.homekit org.openhab.io.hueemulation