├── .editorconfig ├── .github └── workflows │ ├── pr-build.yml │ └── release.yml ├── .gitignore ├── LICENSE.txt ├── README.md ├── build.gradle.kts ├── client ├── build.gradle.kts └── src │ └── main │ ├── kotlin │ └── org │ │ └── imdc │ │ └── extensions │ │ └── client │ │ ├── ClientHook.kt │ │ └── ClientProjectExtensions.kt │ └── resources │ └── org │ └── imdc │ └── extensions │ └── client │ └── ClientProjectExtensions.properties ├── common ├── build.gradle.kts └── src │ ├── main │ ├── java │ │ └── org │ │ │ └── imdc │ │ │ └── extensions │ │ │ └── common │ │ │ └── UtilitiesExtensions.java │ ├── kotlin │ │ └── org │ │ │ └── imdc │ │ │ └── extensions │ │ │ └── common │ │ │ ├── Constants.kt │ │ │ ├── DatasetExtensions.kt │ │ │ ├── ExtensionDocProvider.kt │ │ │ ├── ProjectExtensions.kt │ │ │ ├── PyDatasetBuilder.kt │ │ │ ├── TagExtensions.kt │ │ │ ├── Utilities.kt │ │ │ └── expressions │ │ │ ├── IsAvailableFunction.kt │ │ │ ├── LogicalPredicate.kt │ │ │ └── UUID4Function.kt │ └── resources │ │ └── org │ │ └── imdc │ │ └── extensions │ │ └── common │ │ ├── DatasetExtensions.properties │ │ ├── TagExtensions.properties │ │ └── UtilitiesExtensions.properties │ └── test │ ├── kotlin │ └── org │ │ └── imdc │ │ └── extensions │ │ └── common │ │ ├── DSBuilder.kt │ │ ├── DatasetEqualityTests.kt │ │ ├── DatasetExtensionsTests.kt │ │ ├── ExpressionUtils.kt │ │ ├── JythonTest.kt │ │ ├── LogicalFunctionsTests.kt │ │ ├── UUID4Tests.kt │ │ └── UtilitiesExtensionsTests.kt │ └── resources │ └── org │ └── imdc │ └── extensions │ └── common │ ├── sample.xls │ ├── sample.xlsx │ └── sample2.xlsx ├── designer ├── build.gradle.kts └── src │ └── main │ ├── kotlin │ └── org │ │ └── imdc │ │ └── extensions │ │ └── designer │ │ ├── DesignerHook.kt │ │ └── DesignerProjectExtensions.kt │ └── resources │ └── org │ └── imdc │ └── extensions │ └── designer │ └── DesignerProjectExtensions.properties ├── docker-compose.yml ├── gateway ├── build.gradle.kts └── src │ └── main │ ├── kotlin │ └── org │ │ └── imdc │ │ └── extensions │ │ └── gateway │ │ ├── GatewayHook.kt │ │ ├── GatewayProjectExtensions.kt │ │ ├── GatewayTagExtensions.kt │ │ └── HistoryServlet.kt │ └── resources │ └── org │ └── imdc │ └── extensions │ └── gateway │ └── GatewayProjectExtensions.properties ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── renovate.json └── settings.gradle.kts /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{kt,kts}] 2 | ij_kotlin_allow_trailing_comma=true 3 | ij_kotlin_allow_trailing_comma_on_call_site=true 4 | -------------------------------------------------------------------------------- /.github/workflows/pr-build.yml: -------------------------------------------------------------------------------- 1 | name: Build PRs 2 | on: pull_request 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: actions/setup-java@v4 9 | with: 10 | distribution: 'zulu' 11 | java-version: 11 12 | cache: 'gradle' 13 | - name: Build 14 | run: ./gradlew build 15 | - name: Upload Unsigned Module 16 | uses: actions/upload-artifact@v3 17 | with: 18 | name: ignition-extensions-unsigned 19 | path: build/Ignition-Extensions.unsigned.modl 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish new version upon tag commit 2 | on: 3 | push: 4 | tags: 5 | - '[0-9].[0-9].[0-9]' 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: actions/setup-java@v4 12 | with: 13 | distribution: 'zulu' 14 | java-version: 11 15 | cache: 'gradle' 16 | - name: Deserialize signing certs 17 | run: | 18 | echo ${{ secrets.CERT_BASE64 }} | base64 --decode > cert.p7b 19 | echo ${{ secrets.KEYSTORE_BASE64 }} | base64 --decode > keystore.pfx 20 | - name: Build & create signed module 21 | run: > 22 | ./gradlew 23 | -Pversion=${{github.ref_name}} 24 | -PsignModule=true 25 | build 26 | signModule 27 | --certFile=cert.p7b 28 | --certPassword="${{ secrets.CERT_PASSWORD }}" 29 | --keystoreFile=keystore.pfx 30 | --keystorePassword="${{ secrets.KEYSTORE_PASSWORD }}" 31 | --certAlias=ignition-extensions 32 | - name: Create release 33 | uses: marvinpinto/action-automatic-releases@latest 34 | with: 35 | repo_token: ${{ secrets.GITHUB_TOKEN }} 36 | prerelease: false 37 | files: build/Ignition-Extensions.modl 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | **/build/ 3 | .gradle/ 4 | 5 | local.properties 6 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | Copyright (c) 2023 Ignition Module Development Community 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 4 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation 5 | the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and 6 | to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of 8 | the Software. 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO 10 | THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 11 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 12 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 13 | DEALINGS IN THE SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ignition Extensions 2 | 3 | A (hopefully) community driven Ignition module project to house utilities that are often useful, but just too niche (or 4 | potentially risky) to go into Ignition itself. 5 | 6 | # Usage 7 | 8 | Simply download the .modl file from 9 | the [latest release](https://github.com/IgnitionModuleDevelopmentCommunity/ignition-extensions/releases) and install it 10 | to your gateway. 11 | 12 | # Contribution 13 | 14 | Contributions are welcome. This project is polyglot and set up for both Kotlin and Java. There are example utilities 15 | written in both Kotlin and Java to extend from. Ideas for new features should start as issues for broader discussion. 16 | 17 | # Building 18 | 19 | This project uses Gradle, and the Gradle Module Plugin. Use `./gradlew build` to assemble artifacts, 20 | and `./gradlew zipModule` to build an unsigned module file for installation into a development gateway. 21 | 22 | # Testing 23 | 24 | The easiest way to test is a local Docker installation. Simple run `docker compose up` in the root of this repository to 25 | stand up a local development gateway. Use `./gradlew deployModl` to install the locally built module on that test 26 | gateway. 27 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlin) 3 | alias(libs.plugins.modl) 4 | } 5 | 6 | allprojects { 7 | repositories { 8 | mavenCentral() 9 | maven("https://nexus.inductiveautomation.com/repository/public") 10 | } 11 | } 12 | 13 | subprojects { 14 | // cascade version, which will be set at command line in CI, down to subprojects 15 | version = rootProject.version 16 | } 17 | 18 | ignitionModule { 19 | name = "Ignition Extensions" 20 | fileName = "Ignition-Extensions.modl" 21 | id = "org.imdc.extensions.IgnitionExtensions" 22 | moduleVersion = "${project.version}" 23 | moduleDescription = "Useful but niche extensions to Ignition for power users" 24 | license = "LICENSE.txt" 25 | requiredIgnitionVersion = libs.versions.ignition 26 | 27 | projectScopes.putAll( 28 | mapOf( 29 | projects.common.dependencyProject.path to "GDC", 30 | projects.gateway.dependencyProject.path to "G", 31 | projects.designer.dependencyProject.path to "D", 32 | projects.client.dependencyProject.path to "C", 33 | ), 34 | ) 35 | 36 | hooks.putAll( 37 | mapOf( 38 | "org.imdc.extensions.gateway.GatewayHook" to "G", 39 | "org.imdc.extensions.designer.DesignerHook" to "D", 40 | "org.imdc.extensions.client.ClientHook" to "C", 41 | ), 42 | ) 43 | 44 | skipModlSigning = !findProperty("signModule").toString().toBoolean() 45 | } 46 | 47 | tasks.deployModl { 48 | hostGateway = "http://localhost:18088" 49 | } 50 | -------------------------------------------------------------------------------- /client/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `java-library` 3 | kotlin("jvm") 4 | } 5 | 6 | kotlin { 7 | jvmToolchain(libs.versions.java.map(String::toInt).get()) 8 | } 9 | 10 | dependencies { 11 | compileOnly(libs.bundles.client) 12 | compileOnly(projects.common) 13 | } 14 | -------------------------------------------------------------------------------- /client/src/main/kotlin/org/imdc/extensions/client/ClientHook.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.client 2 | 3 | import com.inductiveautomation.ignition.client.model.ClientContext 4 | import com.inductiveautomation.ignition.common.BundleUtil 5 | import com.inductiveautomation.ignition.common.expressions.ExpressionFunctionManager 6 | import com.inductiveautomation.ignition.common.licensing.LicenseState 7 | import com.inductiveautomation.ignition.common.script.ScriptManager 8 | import com.inductiveautomation.vision.api.client.AbstractClientModuleHook 9 | import org.imdc.extensions.common.DatasetExtensions 10 | import org.imdc.extensions.common.ExtensionDocProvider 11 | import org.imdc.extensions.common.PyDatasetBuilder 12 | import org.imdc.extensions.common.UtilitiesExtensions 13 | import org.imdc.extensions.common.addPropertyBundle 14 | import org.imdc.extensions.common.expressions.IsAvailableFunction 15 | import org.imdc.extensions.common.expressions.LogicalPredicate.Companion.registerLogicFunctions 16 | import org.imdc.extensions.common.expressions.UUID4Function 17 | 18 | @Suppress("unused") 19 | class ClientHook : AbstractClientModuleHook() { 20 | private lateinit var context: ClientContext 21 | 22 | override fun startup(context: ClientContext, activationState: LicenseState) { 23 | this.context = context 24 | 25 | BundleUtil.get().apply { 26 | addPropertyBundle() 27 | addPropertyBundle() 28 | addPropertyBundle() 29 | } 30 | 31 | PyDatasetBuilder.register() 32 | } 33 | 34 | override fun shutdown() { 35 | PyDatasetBuilder.unregister() 36 | } 37 | 38 | override fun initializeScriptManager(manager: ScriptManager) { 39 | manager.apply { 40 | addScriptModule("system.dataset", DatasetExtensions, ExtensionDocProvider) 41 | addScriptModule("system.util", UtilitiesExtensions(context), ExtensionDocProvider) 42 | addScriptModule("system.project", ClientProjectExtensions(context), ExtensionDocProvider) 43 | } 44 | } 45 | 46 | override fun configureFunctionFactory(factory: ExpressionFunctionManager) { 47 | factory.apply { 48 | addFunction( 49 | IsAvailableFunction.NAME, 50 | IsAvailableFunction.CATEGORY, 51 | IsAvailableFunction(), 52 | ) 53 | registerLogicFunctions() 54 | addFunction( 55 | UUID4Function.NAME, 56 | UUID4Function.CATEGORY, 57 | UUID4Function(), 58 | ) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /client/src/main/kotlin/org/imdc/extensions/client/ClientProjectExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.client 2 | 3 | import com.inductiveautomation.ignition.client.model.ClientContext 4 | import com.inductiveautomation.ignition.common.project.Project 5 | import com.inductiveautomation.ignition.common.script.hints.ScriptFunction 6 | import org.imdc.extensions.common.ProjectExtensions 7 | 8 | class ClientProjectExtensions(private val context: ClientContext) : ProjectExtensions { 9 | @ScriptFunction(docBundlePrefix = "ClientProjectExtensions") 10 | override fun getProject(): Project { 11 | return requireNotNull(context.project) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /client/src/main/resources/org/imdc/extensions/client/ClientProjectExtensions.properties: -------------------------------------------------------------------------------- 1 | getProject.desc=Retrieves the current project. 2 | getProject.returns=The current project as a RuntimeProject instance. 3 | -------------------------------------------------------------------------------- /common/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `java-library` 3 | kotlin("jvm") 4 | } 5 | 6 | kotlin { 7 | jvmToolchain(libs.versions.java.map(String::toInt).get()) 8 | } 9 | 10 | dependencies { 11 | compileOnly(libs.ignition.common) 12 | testImplementation(libs.ignition.common) 13 | testImplementation(libs.bundles.kotest) 14 | testImplementation(libs.mockk) 15 | } 16 | 17 | tasks { 18 | withType { 19 | useJUnitPlatform() 20 | jvmArgs = listOf("--add-opens", "java.base/java.io=ALL-UNNAMED") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /common/src/main/java/org/imdc/extensions/common/UtilitiesExtensions.java: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.common; 2 | 3 | import java.io.IOException; 4 | import java.util.HashMap; 5 | import java.util.List; 6 | import java.util.Map; 7 | import java.util.UUID; 8 | import java.util.concurrent.ExecutionException; 9 | import java.util.concurrent.TimeUnit; 10 | import java.util.concurrent.TimeoutException; 11 | 12 | import com.inductiveautomation.ignition.common.PyUtilities; 13 | import com.inductiveautomation.ignition.common.TypeUtilities; 14 | import com.inductiveautomation.ignition.common.expressions.ConstantExpression; 15 | import com.inductiveautomation.ignition.common.expressions.Expression; 16 | import com.inductiveautomation.ignition.common.expressions.ExpressionParseContext; 17 | import com.inductiveautomation.ignition.common.expressions.FunctionFactory; 18 | import com.inductiveautomation.ignition.common.expressions.parsing.ELParserHarness; 19 | import com.inductiveautomation.ignition.common.model.CommonContext; 20 | import com.inductiveautomation.ignition.common.model.values.QualifiedValue; 21 | import com.inductiveautomation.ignition.common.script.PyArgParser; 22 | import com.inductiveautomation.ignition.common.script.ScriptContext; 23 | import com.inductiveautomation.ignition.common.script.builtin.KeywordArgs; 24 | import com.inductiveautomation.ignition.common.script.hints.ScriptFunction; 25 | import com.inductiveautomation.ignition.common.tags.model.TagPath; 26 | import com.inductiveautomation.ignition.common.tags.paths.parser.TagPathParser; 27 | import org.apache.commons.lang3.tuple.Pair; 28 | import org.jetbrains.annotations.NotNull; 29 | import org.python.core.Py; 30 | import org.python.core.PyBaseString; 31 | import org.python.core.PyException; 32 | import org.python.core.PyList; 33 | import org.python.core.PyObject; 34 | 35 | public class UtilitiesExtensions { 36 | private final CommonContext context; 37 | 38 | private static final ELParserHarness EXPRESSION_PARSER = new ELParserHarness(); 39 | 40 | public UtilitiesExtensions(CommonContext context) { 41 | this.context = context; 42 | } 43 | 44 | @ScriptFunction(docBundlePrefix = "UtilitiesExtensions") 45 | @UnsafeExtension 46 | public CommonContext getContext() { 47 | return context; 48 | } 49 | 50 | @ScriptFunction(docBundlePrefix = "UtilitiesExtensions") 51 | @KeywordArgs(names = {"object"}, types = {PyObject.class}) 52 | public PyObject deepCopy(PyObject[] args, String[] keywords) { 53 | PyArgParser parsedArgs = PyArgParser.parseArgs(args, keywords, this.getClass(), "deepCopy"); 54 | var toConvert = parsedArgs.getPyObject("object") 55 | .orElseThrow(() -> Py.TypeError("deepCopy requires one argument, got none")); 56 | return recursiveConvert(toConvert); 57 | } 58 | 59 | private static PyObject recursiveConvert(@NotNull PyObject object) { 60 | if (object.isMappingType()) { 61 | return PyUtilities.streamEntries(object) 62 | .collect(PyUtilities.toPyDictionary(Pair::getKey, pair -> recursiveConvert(pair.getValue()))); 63 | } else if (PyUtilities.isSequence(object)) { 64 | return PyUtilities.stream(object) 65 | .map(UtilitiesExtensions::recursiveConvert) 66 | .collect(PyUtilities.toPyList()); 67 | } else if (object instanceof PyBaseString) { 68 | return object; 69 | } else { 70 | try { 71 | Iterable iterable = object.asIterable(); 72 | return new PyList(iterable.iterator()); 73 | } catch (PyException pye) { 74 | return object; 75 | } 76 | } 77 | } 78 | 79 | @ScriptFunction(docBundlePrefix = "UtilitiesExtensions") 80 | @KeywordArgs(names = {"expression"}, types = {String.class}) 81 | public QualifiedValue evalExpression(PyObject[] args, String[] keywords) throws Exception { 82 | if (args.length == 0) { 83 | throw Py.ValueError("Must supply at least one argument to evalExpression"); 84 | } 85 | 86 | String expression = TypeUtilities.toString(TypeUtilities.pyToJava(args[0])); 87 | 88 | var keywordMap = new HashMap(); 89 | for (int i = 0; i < keywords.length; i++) { 90 | keywordMap.put(keywords[i], TypeUtilities.pyToJava(args[i + 1])); 91 | } 92 | ExpressionParseContext parseContext = new KeywordParseContext(keywordMap); 93 | 94 | Expression actualExpression = EXPRESSION_PARSER.parse(expression, parseContext); 95 | try { 96 | actualExpression.startup(); 97 | return actualExpression.execute(); 98 | } finally { 99 | actualExpression.shutdown(); 100 | } 101 | } 102 | 103 | private class KeywordParseContext implements ExpressionParseContext { 104 | private final Map keywords; 105 | 106 | private KeywordParseContext(Map keywords) { 107 | this.keywords = keywords; 108 | } 109 | 110 | @Override 111 | public Expression createBoundExpression(String reference) throws RuntimeException { 112 | if (reference == null || reference.isEmpty()) { 113 | throw new IllegalArgumentException("Invalid path " + reference); 114 | } 115 | if (keywords.containsKey(reference)) { 116 | return new ConstantExpression(keywords.get(reference)); 117 | } else { 118 | try { 119 | TagPath path = TagPathParser.parse(ScriptContext.defaultTagProvider(), reference); 120 | var tagValues = context.getTagManager().readAsync(List.of(path)).get(30, TimeUnit.SECONDS); 121 | return new ConstantExpression(tagValues.get(0).getValue()); 122 | } catch (IOException | InterruptedException | ExecutionException | TimeoutException e) { 123 | throw new RuntimeException(e); 124 | } 125 | } 126 | } 127 | 128 | @Override 129 | public FunctionFactory getFunctionFactory() { 130 | return context.getExpressionFunctionFactory(); 131 | } 132 | } 133 | 134 | @ScriptFunction(docBundlePrefix = "UtilitiesExtensions") 135 | public UUID getUUID4() { 136 | return UUID.randomUUID(); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/imdc/extensions/common/Constants.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.common 2 | 3 | const val MODULE_ID = "io.github.paulgriffith.extensions.IgnitionExtensions" 4 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/imdc/extensions/common/DatasetExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.common 2 | 3 | import com.inductiveautomation.ignition.common.Dataset 4 | import com.inductiveautomation.ignition.common.PyUtilities 5 | import com.inductiveautomation.ignition.common.TypeUtilities 6 | import com.inductiveautomation.ignition.common.script.PyArgParser 7 | import com.inductiveautomation.ignition.common.script.builtin.KeywordArgs 8 | import com.inductiveautomation.ignition.common.script.hints.ScriptArg 9 | import com.inductiveautomation.ignition.common.script.hints.ScriptFunction 10 | import com.inductiveautomation.ignition.common.util.DatasetBuilder 11 | import com.inductiveautomation.ignition.common.xmlserialization.ClassNameResolver 12 | import org.apache.poi.ss.usermodel.Cell 13 | import org.apache.poi.ss.usermodel.CellType 14 | import org.apache.poi.ss.usermodel.DateUtil 15 | import org.apache.poi.ss.usermodel.WorkbookFactory 16 | import org.python.core.Py 17 | import org.python.core.PyBaseString 18 | import org.python.core.PyBoolean 19 | import org.python.core.PyFloat 20 | import org.python.core.PyFunction 21 | import org.python.core.PyInteger 22 | import org.python.core.PyList 23 | import org.python.core.PyLong 24 | import org.python.core.PyObject 25 | import org.python.core.PyString 26 | import org.python.core.PyStringMap 27 | import org.python.core.PyType 28 | import org.python.core.PyUnicode 29 | import java.io.File 30 | import java.math.BigDecimal 31 | import java.util.Date 32 | import kotlin.jvm.optionals.getOrElse 33 | import kotlin.math.max 34 | import kotlin.streams.asSequence 35 | 36 | object DatasetExtensions { 37 | @Suppress("unused") 38 | @ScriptFunction(docBundlePrefix = "DatasetExtensions") 39 | @KeywordArgs( 40 | names = ["dataset", "mapper", "preserveColumnTypes"], 41 | types = [Dataset::class, PyFunction::class, Boolean::class], 42 | ) 43 | fun map(args: Array, keywords: Array): Dataset? { 44 | val parsedArgs = PyArgParser.parseArgs( 45 | args, 46 | keywords, 47 | arrayOf("dataset", "mapper", "preserveColumnTypes"), 48 | arrayOf(Dataset::class.java, PyObject::class.java, Boolean::class.javaObjectType), 49 | "map", 50 | ) 51 | val dataset = parsedArgs.requirePyObject("dataset").toJava() 52 | val mapper = parsedArgs.requirePyObject("mapper") 53 | val preserveColumnTypes = parsedArgs.getBoolean("preserveColumnTypes").filter { it }.isPresent 54 | 55 | val columnTypes = if (preserveColumnTypes) { 56 | dataset.columnTypes 57 | } else { 58 | List(dataset.columnCount) { Any::class.java } 59 | } 60 | 61 | val builder = DatasetBuilder.newBuilder().colNames(dataset.columnNames).colTypes(columnTypes) 62 | 63 | for (row in dataset.rowIndices) { 64 | val columnValues = Array(dataset.columnCount) { col -> 65 | Py.java2py(dataset[row, col]) 66 | } 67 | val returnValue = mapper.__call__(columnValues, dataset.columnNames.toTypedArray()) 68 | 69 | val newValues = returnValue.asIterable().map(TypeUtilities::pyToJava).toTypedArray() 70 | builder.addRow(*newValues) 71 | } 72 | 73 | return builder.build() 74 | } 75 | 76 | @Suppress("unused") 77 | @ScriptFunction(docBundlePrefix = "DatasetExtensions") 78 | @KeywordArgs( 79 | names = ["dataset", "filter"], 80 | types = [Dataset::class, PyFunction::class], 81 | ) 82 | fun filter(args: Array, keywords: Array): Dataset? { 83 | val parsedArgs = PyArgParser.parseArgs( 84 | args, 85 | keywords, 86 | arrayOf("dataset", "filter"), 87 | arrayOf(Dataset::class.java, PyObject::class.java), 88 | "filter", 89 | ) 90 | val dataset = parsedArgs.requirePyObject("dataset").toJava() 91 | val filter = parsedArgs.requirePyObject("filter") 92 | 93 | val builder = DatasetBuilder.newBuilder() 94 | .colNames(dataset.columnNames) 95 | .colTypes(dataset.columnTypes) 96 | 97 | for (row in dataset.rowIndices) { 98 | val filterArgs = Array(dataset.columnCount + 1) { col -> 99 | if (col == 0) { 100 | Py.newInteger(row) 101 | } else { 102 | Py.java2py(dataset[row, col - 1]) 103 | } 104 | } 105 | val filterKeywords = Array(dataset.columnCount + 1) { col -> 106 | if (col == 0) { 107 | "row" 108 | } else { 109 | dataset.getColumnName(col - 1) 110 | } 111 | } 112 | val returnValue = filter.__call__(filterArgs, filterKeywords).__nonzero__() 113 | if (returnValue) { 114 | val columnValues = Array(dataset.columnCount) { col -> 115 | dataset[row, col] 116 | } 117 | builder.addRow(*columnValues) 118 | } 119 | } 120 | 121 | return builder.build() 122 | } 123 | 124 | @Suppress("unused") 125 | @ScriptFunction(docBundlePrefix = "DatasetExtensions") 126 | @KeywordArgs( 127 | names = ["dataset", "output", "includeTypes"], 128 | types = [Dataset::class, Appendable::class, Boolean::class], 129 | ) 130 | fun print(args: Array, keywords: Array) { 131 | val parsedArgs = PyArgParser.parseArgs( 132 | args, 133 | keywords, 134 | arrayOf("dataset", "output", "includeTypes"), 135 | Array(3) { PyObject::class.java }, 136 | "print", 137 | ) 138 | val dataset = parsedArgs.requirePyObject("dataset").toJava() 139 | val appendable = parsedArgs.getPyObject("output") 140 | .orElse(Py.getSystemState().stdout) 141 | .let(::PyObjectAppendable) 142 | val includeTypes = parsedArgs.getBoolean("includeTypes").orElse(false) 143 | 144 | return printDataset(appendable, dataset, includeTypes) 145 | } 146 | 147 | internal fun printDataset(appendable: Appendable, dataset: Dataset, includeTypes: Boolean = false) { 148 | val typeNames = List(dataset.columnCount) { column -> 149 | if (includeTypes) { 150 | dataset.getColumnType(column).simpleName 151 | } else { 152 | "" 153 | } 154 | } 155 | 156 | val columnWidths = IntArray(dataset.columnCount) { column -> 157 | maxOf( 158 | // longest value in a row 159 | if (dataset.rowCount > 0) { 160 | dataset.rowIndices.maxOf { row -> dataset[row, column].toString().length } 161 | } else { 162 | 0 163 | }, 164 | // longest value in a header 165 | if (includeTypes) { 166 | dataset.getColumnName(column).length + typeNames[column].length + 3 // 3 = two parens and a space 167 | } else { 168 | dataset.getColumnName(column).length 169 | }, 170 | // absolute minimum width for markdown table (and human eyeballs) 171 | 3, 172 | ) 173 | } 174 | 175 | val separator = "|" 176 | 177 | fun Sequence.joinToBuffer() { 178 | joinTo( 179 | buffer = appendable, 180 | separator = " $separator ", 181 | prefix = "$separator ", 182 | postfix = " $separator\n", 183 | ) 184 | } 185 | 186 | // headers 187 | sequence { 188 | yield("Row") 189 | for (column in dataset.columnIndices) { 190 | val headerValue = buildString { 191 | append(dataset.getColumnName(column)) 192 | if (includeTypes) { 193 | append(" (").append(typeNames[column]).append(")") 194 | } 195 | while (length < columnWidths[column]) { 196 | insert(0, ' ') 197 | } 198 | } 199 | yield(headerValue) 200 | } 201 | }.joinToBuffer() 202 | 203 | // separator 204 | sequence { 205 | yield("---") 206 | for (column in dataset.columnIndices) { 207 | yield("-".repeat(columnWidths[column])) 208 | } 209 | }.joinToBuffer() 210 | 211 | // data 212 | val maxRowLength = dataset.rowCount.toString().length.coerceAtLeast(3) 213 | for (row in dataset.rowIndices) { 214 | sequence { 215 | yield(row.toString().padStart(maxRowLength)) 216 | for (column in dataset.columnIndices) { 217 | yield(dataset[row, column].toString().padStart(columnWidths[column])) 218 | } 219 | }.joinToBuffer() 220 | } 221 | } 222 | 223 | @Suppress("unused") 224 | @ScriptFunction(docBundlePrefix = "DatasetExtensions") 225 | @KeywordArgs( 226 | names = ["dataset", "filterNull"], 227 | types = [Dataset::class, Boolean::class], 228 | ) 229 | fun toDict(args: Array, keywords: Array): PyStringMap { 230 | val parsedArgs = PyArgParser.parseArgs( 231 | args, 232 | keywords, 233 | arrayOf("dataset", "filterNull"), 234 | Array(2) { Any::class.java }, 235 | "toDict", 236 | ) 237 | val dataset = parsedArgs.requirePyObject("dataset").toJava() 238 | val filterNull = parsedArgs.getBoolean("filterNull").orElse(false) 239 | return PyStringMap( 240 | dataset.columnIndices.associate { col -> 241 | dataset.getColumnName(col) to PyList( 242 | buildList { 243 | for (row in dataset.rowIndices) { 244 | val value = dataset[row, col] 245 | if (value != null || !filterNull) { 246 | add(value) 247 | } 248 | } 249 | }, 250 | ) 251 | }, 252 | ) 253 | } 254 | 255 | @Suppress("unused") 256 | @ScriptFunction(docBundlePrefix = "DatasetExtensions") 257 | @KeywordArgs( 258 | names = ["input", "headerRow", "sheetNumber", "firstRow", "lastRow", "firstColumn", "lastColumn", "typeOverrides"], 259 | types = [ByteArray::class, Int::class, Int::class, Int::class, Int::class, Int::class, Int::class, PyStringMap::class], 260 | ) 261 | fun fromExcel(args: Array, keywords: Array): Dataset { 262 | val parsedArgs = PyArgParser.parseArgs( 263 | args, 264 | keywords, 265 | arrayOf( 266 | "input", 267 | "headerRow", 268 | "sheetNumber", 269 | "firstRow", 270 | "lastRow", 271 | "firstColumn", 272 | "lastColumn", 273 | "typeOverrides", 274 | ), 275 | Array(8) { Any::class.java }, 276 | "fromExcel", 277 | ) 278 | 279 | val typeOverrides = parsedArgs.getPyObject("typeOverrides") 280 | .map(PyUtilities::streamEntries) 281 | .map { stream -> 282 | stream.asSequence().associate { (pyKey, value) -> 283 | pyKey.asIndex() to value.asJavaClass() 284 | } 285 | }.getOrElse { emptyMap() } 286 | 287 | when (val input = parsedArgs.requirePyObject("input").toJava()) { 288 | is String -> WorkbookFactory.create(File(input)) 289 | is ByteArray -> WorkbookFactory.create(input.inputStream().buffered()) 290 | else -> throw Py.TypeError("Unable to create Workbook from input; should be string or binary data. Got ${input::class.simpleName} instead.") 291 | }.use { workbook -> 292 | val sheetNumber = parsedArgs.getInteger("sheetNumber").orElse(0) 293 | val sheet = workbook.getSheetAt(sheetNumber) 294 | 295 | val headerRow = parsedArgs.getInteger("headerRow").orElse(-1) 296 | val firstRow = parsedArgs.getInteger("firstRow").orElseGet { max(sheet.firstRowNum, headerRow + 1) } 297 | val lastRow = parsedArgs.getInteger("lastRow").orElseGet { sheet.lastRowNum } 298 | 299 | val dataRange = firstRow..lastRow 300 | 301 | if (firstRow >= lastRow) { 302 | throw Py.ValueError("firstRow ($firstRow) must be less than lastRow ($lastRow)") 303 | } 304 | if (headerRow >= 0 && headerRow in dataRange) { 305 | throw Py.ValueError("headerRow must not be in firstRow..lastRow ($dataRange)") 306 | } 307 | 308 | val columnRow = sheet.getRow(if (headerRow >= 0) headerRow else firstRow) 309 | val firstColumn = 310 | parsedArgs.getInteger("firstColumn").orElseGet { columnRow.firstCellNum.toInt() } 311 | val lastColumn = 312 | parsedArgs.getInteger("lastColumn").map { it + 1 }.orElseGet { columnRow.lastCellNum.toInt() } 313 | if (firstColumn >= lastColumn) { 314 | throw Py.ValueError("firstColumn ($firstColumn) must be less than lastColumn ($lastColumn)") 315 | } 316 | 317 | val columnCount = lastColumn - firstColumn 318 | 319 | val dataset = DatasetBuilder() 320 | dataset.colNames( 321 | List(columnCount) { 322 | if (headerRow >= 0) { 323 | columnRow.getCell(it + firstColumn).toString() 324 | } else { 325 | "Col $it" 326 | } 327 | }, 328 | ) 329 | 330 | var typesSet = false 331 | val columnTypes = mutableListOf>() 332 | 333 | for (i in dataRange) { 334 | if (i == headerRow) { 335 | continue 336 | } 337 | 338 | val row = sheet.getRow(i) 339 | 340 | val rowValues = Array(columnCount) { j -> 341 | val cell: Cell? = row.getCell(j + firstColumn) 342 | 343 | val actualValue: Any? = when (cell?.cellType) { 344 | CellType.NUMERIC -> { 345 | if (DateUtil.isCellDateFormatted(cell)) { 346 | if (!typesSet) { 347 | columnTypes.add(Date::class.java) 348 | } 349 | cell.dateCellValue 350 | } else { 351 | val numericCellValue = cell.numericCellValue 352 | if (BigDecimal(numericCellValue).scale() == 0) { 353 | if (!typesSet) { 354 | columnTypes.add(Int::class.javaObjectType) 355 | } 356 | numericCellValue.toInt() 357 | } else { 358 | if (!typesSet) { 359 | columnTypes.add(Double::class.javaObjectType) 360 | } 361 | numericCellValue 362 | } 363 | } 364 | } 365 | 366 | CellType.STRING -> { 367 | if (!typesSet) { 368 | columnTypes.add(String::class.java) 369 | } 370 | cell.stringCellValue 371 | } 372 | 373 | CellType.BOOLEAN -> { 374 | if (!typesSet) { 375 | columnTypes.add(Boolean::class.javaObjectType) 376 | } 377 | cell.booleanCellValue 378 | } 379 | 380 | else -> { 381 | if (!typesSet) { 382 | columnTypes.add(Any::class.java) 383 | } 384 | null 385 | } 386 | } 387 | val typeOverride = typeOverrides[j] 388 | if (typeOverride != null) { 389 | if (!typesSet) { 390 | columnTypes[j] = typeOverride 391 | } 392 | try { 393 | TypeUtilities.coerceGeneric(actualValue, typeOverride) 394 | } catch (e: ClassCastException) { 395 | throw Py.TypeError(e.message) 396 | } 397 | } else { 398 | actualValue 399 | } 400 | } 401 | 402 | if (!typesSet) { 403 | typesSet = true 404 | dataset.colTypes(columnTypes) 405 | } 406 | 407 | dataset.addRow(*rowValues) 408 | } 409 | 410 | return dataset.build() 411 | } 412 | } 413 | 414 | @Suppress("unused") 415 | @ScriptFunction(docBundlePrefix = "DatasetExtensions") 416 | fun equals( 417 | @ScriptArg("dataset1") ds1: Dataset, 418 | @ScriptArg("dataset2") ds2: Dataset, 419 | ): Boolean { 420 | return ds1 === ds2 || (columnsEqual(ds1, ds2) && valuesEqual(ds1, ds2)) 421 | } 422 | 423 | @Suppress("unused") 424 | @ScriptFunction(docBundlePrefix = "DatasetExtensions") 425 | fun valuesEqual( 426 | @ScriptArg("dataset1") ds1: Dataset, 427 | @ScriptArg("dataset2") ds2: Dataset, 428 | ): Boolean { 429 | if (ds1 === ds2) { 430 | return true 431 | } 432 | if (ds1.rowCount != ds2.rowCount || ds1.columnCount != ds2.columnCount) { 433 | return false 434 | } 435 | return ds1.rowIndices.all { row -> 436 | ds1.columnIndices.all { col -> 437 | ds1[row, col] == ds2[row, col] 438 | } 439 | } 440 | } 441 | 442 | @Suppress("unused") 443 | @ScriptFunction(docBundlePrefix = "DatasetExtensions") 444 | @JvmOverloads 445 | fun columnsEqual( 446 | @ScriptArg("dataset1") ds1: Dataset, 447 | @ScriptArg("dataset2") ds2: Dataset, 448 | @ScriptArg("ignoreCase") ignoreCase: Boolean = false, 449 | @ScriptArg("includeTypes") includeTypes: Boolean = true, 450 | ): Boolean { 451 | if (ds1 === ds2) { 452 | return true 453 | } 454 | if (ds1.columnCount != ds2.columnCount) { 455 | return false 456 | } 457 | 458 | val columnNames = ds1.columnNames zip ds2.columnNames 459 | val columnNamesMatch = columnNames.all { (left, right) -> 460 | left.equals(right, ignoreCase) 461 | } 462 | if (!columnNamesMatch) { 463 | return false 464 | } 465 | 466 | if (!includeTypes) { 467 | return true 468 | } 469 | 470 | val columnTypes = ds1.columnTypes.asSequence() zip ds2.columnTypes.asSequence() 471 | return columnTypes.all { (left, right) -> 472 | left == right 473 | } 474 | } 475 | 476 | @ScriptFunction(docBundlePrefix = "DatasetExtensions") 477 | @KeywordArgs( 478 | names = ["**columns"], 479 | types = [KeywordArgs::class], 480 | ) 481 | fun builder(args: Array, keywords: Array): DatasetBuilder { 482 | if (args.size != keywords.size) throw Py.ValueError("builder must be called with only keyword arguments") 483 | val colNames = keywords.toList() 484 | val colTypes = args.mapIndexed { i, type -> 485 | try { 486 | type.asJavaClass() 487 | } catch (e: ClassCastException) { 488 | throw Py.TypeError("${keywords[i]} was a ${type::class.simpleName}, but should be a type or valid string typecode") 489 | } 490 | } 491 | return DatasetBuilder.newBuilder().colNames(colNames).colTypes(colTypes) 492 | } 493 | 494 | private val classNameResolver = ClassNameResolver.createBasic() 495 | 496 | internal fun PyObject.asJavaClass(): Class<*>? = when (this) { 497 | is PyBaseString -> classNameResolver.classForName(asString()) 498 | !is PyType -> throw ClassCastException() 499 | PyString.TYPE, PyUnicode.TYPE -> String::class.java 500 | PyBoolean.TYPE -> Boolean::class.java 501 | PyInteger.TYPE -> Int::class.java 502 | PyLong.TYPE -> Long::class.java 503 | PyFloat.TYPE -> Double::class.java 504 | else -> toJava>() 505 | } 506 | } 507 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/imdc/extensions/common/ExtensionDocProvider.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.common 2 | 3 | import com.inductiveautomation.ignition.common.script.hints.PropertiesFileDocProvider 4 | import com.inductiveautomation.ignition.common.script.hints.ScriptFunctionDocProvider 5 | import java.lang.reflect.Method 6 | 7 | private val propertiesFileDocProvider = PropertiesFileDocProvider() 8 | 9 | private val WARNING = """ 10 | THIS IS AN UNOFFICIAL IGNITION EXTENSION. 11 | IT MAY RELY ON OR EXPOSE UNDOCUMENTED OR DANGEROUS FUNCTIONALITY. 12 | USE AT YOUR OWN RISK. 13 | """.trimIndent() 14 | 15 | object ExtensionDocProvider : ScriptFunctionDocProvider by propertiesFileDocProvider { 16 | override fun getMethodDescription(path: String, method: Method): String { 17 | val methodDescription: String? = propertiesFileDocProvider.getMethodDescription(path, method) 18 | val unsafeAnnotation = method.getAnnotation() 19 | 20 | return buildString { 21 | if (unsafeAnnotation != null) { 22 | append("") 23 | append(WARNING) 24 | if (unsafeAnnotation.note.isNotEmpty()) { 25 | append("
").append(unsafeAnnotation.note) 26 | } 27 | append("


") 28 | } 29 | append(methodDescription.orEmpty()) 30 | } 31 | } 32 | } 33 | 34 | annotation class UnsafeExtension(val note: String = "") 35 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/imdc/extensions/common/ProjectExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.common 2 | 3 | import com.inductiveautomation.ignition.common.project.Project 4 | 5 | interface ProjectExtensions { 6 | fun getProject(): Project 7 | } 8 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/imdc/extensions/common/PyDatasetBuilder.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.common 2 | 3 | import com.inductiveautomation.ignition.common.Dataset 4 | import com.inductiveautomation.ignition.common.script.DisposablePyObjectAdapter 5 | import com.inductiveautomation.ignition.common.util.DatasetBuilder 6 | import com.inductiveautomation.ignition.common.xmlserialization.ClassNameResolver 7 | import org.imdc.extensions.common.DatasetExtensions.asJavaClass 8 | import org.python.core.Py 9 | import org.python.core.PyObject 10 | import org.python.core.adapter.PyObjectAdapter 11 | 12 | @Suppress("unused") 13 | class PyDatasetBuilder(private val builder: DatasetBuilder) : PyObject() { 14 | private val resolver = ClassNameResolver.createBasic() 15 | 16 | fun colNames(vararg names: String) = apply { 17 | builder.colNames(names.toList()) 18 | } 19 | 20 | fun colNames(names: List) = apply { 21 | builder.colNames(names) 22 | } 23 | 24 | fun colTypes(vararg types: PyObject) = apply { 25 | if (types.singleOrNull()?.isSequenceType == true) { 26 | builder.colTypes(types.first().asIterable().map { it.asJavaClass() }) 27 | } else { 28 | builder.colTypes(types.map { it.asJavaClass() }) 29 | } 30 | } 31 | 32 | fun colTypes(types: List>) = apply { 33 | builder.colTypes(types) 34 | } 35 | 36 | fun addRow(vararg values: Any?) = apply { 37 | builder.addRow(*values) 38 | } 39 | 40 | fun build(): Dataset = builder.build() 41 | 42 | companion object { 43 | private val adapter = DisposablePyObjectAdapter(DatasetBuilderAdapter()) 44 | 45 | fun register() { 46 | Py.getAdapter().addPostClass(adapter) 47 | } 48 | 49 | fun unregister() { 50 | adapter.dispose() 51 | } 52 | } 53 | } 54 | 55 | class DatasetBuilderAdapter : PyObjectAdapter { 56 | override fun adapt(o: Any?): PyObject = PyDatasetBuilder(o as DatasetBuilder) 57 | override fun canAdapt(o: Any?): Boolean = o is DatasetBuilder 58 | } 59 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/imdc/extensions/common/TagExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.common 2 | 3 | import com.inductiveautomation.ignition.common.config.PyTagDictionary 4 | import com.inductiveautomation.ignition.common.config.PyTagList 5 | import com.inductiveautomation.ignition.common.script.PyArgParser 6 | import com.inductiveautomation.ignition.common.script.ScriptContext 7 | import com.inductiveautomation.ignition.common.script.builtin.KeywordArgs 8 | import com.inductiveautomation.ignition.common.script.hints.ScriptFunction 9 | import com.inductiveautomation.ignition.common.tags.config.TagConfigurationModel 10 | import com.inductiveautomation.ignition.common.tags.model.TagPath 11 | import com.inductiveautomation.ignition.common.tags.paths.parser.TagPathParser 12 | import org.python.core.PyDictionary 13 | import org.python.core.PyObject 14 | 15 | abstract class TagExtensions { 16 | @UnsafeExtension 17 | @ScriptFunction(docBundlePrefix = "TagExtensions") 18 | @KeywordArgs( 19 | names = ["basePath", "recursive"], 20 | types = [String::class, Boolean::class], 21 | ) 22 | fun getLocalConfiguration(args: Array, keywords: Array): PyTagList { 23 | val parsedArgs = PyArgParser.parseArgs( 24 | args, 25 | keywords, 26 | arrayOf("basePath", "recursive"), 27 | arrayOf(Any::class.java, Any::class.java), 28 | "getLocalConfiguration", 29 | ) 30 | val configurationModels = getConfigurationImpl( 31 | parseTagPath(parsedArgs.requireString("basePath")), 32 | parsedArgs.getBoolean("recursive").orElse(false), 33 | ) 34 | 35 | return configurationModels.toPyTagList() 36 | } 37 | 38 | protected open fun parseTagPath(path: String): TagPath { 39 | val parsed = TagPathParser.parse(ScriptContext.defaultTagProvider(), path) 40 | if (TagPathParser.isRelativePath(parsed) && ScriptContext.relativeTagPathRoot() != null) { 41 | return TagPathParser.derelativize(parsed, ScriptContext.relativeTagPathRoot()) 42 | } 43 | return parsed 44 | } 45 | 46 | private fun TagConfigurationModel.toPyDictionary(): PyDictionary { 47 | return PyTagDictionary.Builder() 48 | .setTagPath(path) 49 | .setTagType(type) 50 | .build(this).apply { 51 | if (children.isNotEmpty()) { 52 | put("tags", children.toPyTagList()) 53 | } 54 | } 55 | } 56 | 57 | private fun List.toPyTagList() = fold(PyTagList()) { acc, childModel -> 58 | acc.add(childModel.toPyDictionary()) 59 | acc 60 | } 61 | 62 | protected abstract fun getConfigurationImpl(basePath: TagPath, recursive: Boolean): List 63 | } 64 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/imdc/extensions/common/Utilities.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.common 2 | 3 | import com.inductiveautomation.ignition.common.BundleUtil 4 | import com.inductiveautomation.ignition.common.Dataset 5 | import org.python.core.Py 6 | import org.python.core.PyObject 7 | import java.lang.reflect.Method 8 | 9 | class PyObjectAppendable(target: PyObject) : Appendable { 10 | private val writeMethod = target.__getattr__("write") 11 | 12 | override fun append(csq: CharSequence?): Appendable = apply { 13 | writeMethod.__call__(Py.newStringOrUnicode(csq.toString())) 14 | } 15 | 16 | override fun append(csq: CharSequence?, start: Int, end: Int): Appendable = apply { 17 | append(csq.toString().subSequence(start, end)) 18 | } 19 | 20 | override fun append(c: Char): Appendable = apply { 21 | append(c.toString()) 22 | } 23 | } 24 | 25 | val Dataset.rowIndices: IntRange 26 | get() = (0 until rowCount) 27 | 28 | val Dataset.columnIndices: IntRange 29 | get() = (0 until columnCount) 30 | 31 | operator fun Dataset.get(row: Int, col: Int): Any? { 32 | return getValueAt(row, col) 33 | } 34 | 35 | inline fun PyObject.toJava(): T { 36 | try { 37 | val cast = 38 | this.__tojava__(T::class.java) ?: throw Py.TypeError("Expected ${T::class.java.simpleName}, got None") 39 | return cast as T 40 | } catch (e: ClassCastException) { 41 | throw Py.TypeError("Expected ${T::class.java.simpleName}") 42 | } 43 | } 44 | 45 | inline fun BundleUtil.addPropertyBundle() { 46 | addBundle( 47 | T::class.java.simpleName, 48 | T::class.java.classLoader, 49 | T::class.java.name.replace('.', '/'), 50 | ) 51 | } 52 | 53 | inline fun Method.getAnnotation(): T? { 54 | return getAnnotation(T::class.java) 55 | } 56 | 57 | inline fun Class<*>.isAssignableFrom(): Boolean = this.isAssignableFrom(T::class.java) 58 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/imdc/extensions/common/expressions/IsAvailableFunction.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.common.expressions 2 | 3 | import com.inductiveautomation.ignition.common.expressions.Expression 4 | import com.inductiveautomation.ignition.common.expressions.functions.AbstractFunction 5 | import com.inductiveautomation.ignition.common.model.values.BasicQualifiedValue 6 | import com.inductiveautomation.ignition.common.model.values.QualifiedValue 7 | import com.inductiveautomation.ignition.common.model.values.QualityCode 8 | 9 | class IsAvailableFunction : AbstractFunction() { 10 | override fun validateNumArgs(num: Int): Boolean = num == 1 11 | override fun execute(expressions: Array): QualifiedValue { 12 | val qualifiedValue = expressions[0].execute() 13 | val value = qualifiedValue.quality.isNot(QualityCode.Bad_NotFound) && 14 | qualifiedValue.quality.isNot(QualityCode.Bad_Disabled) 15 | return BasicQualifiedValue(value) 16 | } 17 | 18 | override fun getArgDocString(): String = "value" 19 | override fun getFunctionDisplayName(): String = NAME 20 | override fun getType(): Class<*> = Boolean::class.java 21 | 22 | companion object { 23 | const val NAME = "isAvailable" 24 | const val CATEGORY = "Logic" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/imdc/extensions/common/expressions/LogicalPredicate.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.common.expressions 2 | 3 | import com.inductiveautomation.ignition.common.TypeUtilities 4 | import com.inductiveautomation.ignition.common.expressions.Expression 5 | import com.inductiveautomation.ignition.common.expressions.ExpressionFunctionManager 6 | import com.inductiveautomation.ignition.common.expressions.functions.AbstractFunction 7 | import com.inductiveautomation.ignition.common.model.values.BasicQualifiedValue 8 | import com.inductiveautomation.ignition.common.model.values.QualifiedValue 9 | 10 | sealed class LogicalPredicate(val name: String) : AbstractFunction() { 11 | override fun execute(expressions: Array): QualifiedValue { 12 | return BasicQualifiedValue(expressions.apply()) 13 | } 14 | 15 | abstract fun Array.apply(): Boolean 16 | 17 | override fun getArgDocString(): String = "values..." 18 | override fun getFunctionDisplayName(): String = name 19 | override fun getType(): Class<*> = Boolean::class.java 20 | 21 | object AllOfFunction : LogicalPredicate("allOf") { 22 | override fun Array.apply() = all { it.toBoolean() } 23 | } 24 | 25 | object AnyOfFunction : LogicalPredicate("anyOf") { 26 | override fun Array.apply() = any { it.toBoolean() } 27 | } 28 | 29 | object NoneOfFunction : LogicalPredicate("noneOf") { 30 | override fun Array.apply() = none { it.toBoolean() } 31 | } 32 | 33 | companion object { 34 | private const val CATEGORY = "Logic" 35 | 36 | private fun Expression.toBoolean(): Boolean = TypeUtilities.toBool(execute().value) 37 | 38 | fun ExpressionFunctionManager.registerLogicFunctions() { 39 | addFunction(AllOfFunction.name, CATEGORY, AllOfFunction) 40 | addFunction(AnyOfFunction.name, CATEGORY, AnyOfFunction) 41 | addFunction(NoneOfFunction.name, CATEGORY, NoneOfFunction) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/imdc/extensions/common/expressions/UUID4Function.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.common.expressions 2 | 3 | import com.inductiveautomation.ignition.common.expressions.Expression 4 | import com.inductiveautomation.ignition.common.expressions.functions.AbstractFunction 5 | import com.inductiveautomation.ignition.common.model.values.BasicQualifiedValue 6 | import com.inductiveautomation.ignition.common.model.values.QualifiedValue 7 | import java.util.UUID 8 | 9 | class UUID4Function : AbstractFunction() { 10 | override fun validateNumArgs(num: Int): Boolean = num == 0 11 | override fun execute(expressions: Array): QualifiedValue { 12 | return BasicQualifiedValue(UUID.randomUUID()) 13 | } 14 | 15 | override fun getArgDocString(): String = "" 16 | override fun getFunctionDisplayName(): String = NAME 17 | override fun getType(): Class<*> = UUID::class.java 18 | 19 | companion object { 20 | const val NAME = "uuid4" 21 | const val CATEGORY = "Advanced" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /common/src/main/resources/org/imdc/extensions/common/DatasetExtensions.properties: -------------------------------------------------------------------------------- 1 | map.desc=Applies a function to each row in a dataset, allowing it to be transformed in place efficiently. 2 | map.param.dataset=The dataset to transform. Must not be null. 3 | map.param.mapper=A callable reference to invoke for each row. Will receive each column's value as a named argument. 4 | map.param.preserveColumnTypes=True if the types of the output dataset should match the input. Otherwise, the output dataset will lose type information. 5 | map.returns=A modified dataset. 6 | 7 | filter.desc=Runs a filtering function on each row in a dataset, returning a truncated dataset. 8 | filter.param.dataset=The dataset to filter. Must not be null. 9 | filter.param.filter=A function to run on each row. Will be called with keyword arguments matching column names. The first argument will be named 'row' and is the row index. Return True to keep the row in the output dataset. 10 | filter.returns=A modified dataset. 11 | 12 | print.desc=Prints a dataset to standard output, or the provided buffer. 13 | print.param.dataset=The dataset to print. Must not be null. 14 | print.param.output=The output destination. Defaults to sys.stdout. 15 | print.param.includeTypes=If True, includes the type of the column in the first row. Defaults to False. 16 | print.returns=None. 17 | 18 | toDict.desc=Converts a dataset to dict 19 | toDict.param.dataset=The dataset to convert. Must not be null 20 | toDict.param.filterNull=Gives the option to filter Null/None 21 | toDict.returns=dict of dataset 22 | 23 | fromExcel.desc=Creates a dataset by reading select cells from an Excel spreadsheet. 24 | fromExcel.param.input=The Excel document to read - either the path to a file on disk, or a byte array with the contents of a file. 25 | fromExcel.param.headerRow=The row number to use for the column names in the output dataset. 26 | fromExcel.param.sheetNumber=The sheet number (zero-indexed) in the Excel document to extract data from. 27 | fromExcel.param.firstRow=The first row (zero-indexed) in the Excel document to retrieve data from. If not supplied, the first non-empty row will be used. 28 | fromExcel.param.lastRow=The last row (zero-indexed) in the Excel document to retrieve data from. If not supplied, the last non-empty row will be used. 29 | fromExcel.param.firstColumn=The first column (zero-indexed) in the Excel document to retrieve data from. If not supplied, the first non-empty column will be used. 30 | fromExcel.param.lastColumn=The last column (zero-indexed) in the Excel document to retrieve data from. If not supplied, the last non-empty column will be used. 31 | fromExcel.param.typeOverrides=A dictionary of column index: column types, using the same semantics as system.dataset.builder for types. 32 | fromExcel.returns=A Dataset created from the Excel document. Types are assumed based on the first row of input data. 33 | 34 | equals.desc=Compares two datasets for structural equality. 35 | equals.param.dataset1=The first dataset. Must not be null. 36 | equals.param.dataset2=The second dataset. Must not be null. 37 | equals.returns=True if the two datasets have the same number of columns, with the same types, in the same order, with the same data in each row. 38 | 39 | valuesEqual.desc=Compares two datasets' content. 40 | valuesEqual.param.dataset1=The first dataset. Must not be null. 41 | valuesEqual.param.dataset2=The second dataset. Must not be null. 42 | valuesEqual.returns=True if the two datasets have the same values. 43 | 44 | columnsEqual.desc=Compares two datasets' column definitions. 45 | columnsEqual.param.dataset1=The first dataset. Must not be null. 46 | columnsEqual.param.dataset2=The second dataset. Must not be null. 47 | columnsEqual.param.ignoreCase=Pass True if the column names should be compared case-insensitive. Defaults to False. 48 | columnsEqual.param.includeTypes=Pass True if the column types must match as well. Defaults to True. 49 | columnsEqual.returns=True if the two datasets have the same columns, per additional parameters. 50 | 51 | builder.desc=Creates a new dataset using supplied column names and types. 52 | builder.param.**columns=Optional. Keyword arguments can be supplied to predefine column names and types. The value of the argument should be string "typecode" (see system.dataset.fromCSV) or a Java or Python class instance. 53 | builder.returns=A DatasetBuilder object. Use addRow(value, ...) to add new values, and build() to construct the final dataset. \ 54 | If keyword arguments were not supplied, column names and types can be manually declared using colNames() and colTypes(). -------------------------------------------------------------------------------- /common/src/main/resources/org/imdc/extensions/common/TagExtensions.properties: -------------------------------------------------------------------------------- 1 | getLocalConfiguration.desc=WIP 2 | getLocalConfiguration.param.basePath=WIP 3 | getLocalConfiguration.param.recursive=WIP 4 | getLocalConfiguration.returns=WIP 5 | -------------------------------------------------------------------------------- /common/src/main/resources/org/imdc/extensions/common/UtilitiesExtensions.properties: -------------------------------------------------------------------------------- 1 | getContext.desc=Returns the current scope's context object directly. 2 | getContext.returns=The current scope's context. 3 | 4 | deepCopy.desc=Deep copies the inner object structure into plain Python lists, dictionaries, and primitives. 5 | deepCopy.param.object=The object to convert. 6 | deepCopy.returns=A plain Python primitive object. 7 | 8 | evalExpression.desc=Evaluates the supplied expression. Provide keyword arguments to populate values to curly braces. 9 | evalExpression.param.expression=The expression to evaluate. 10 | evalExpression.returns=A QualifiedValue with the result of the provided expression. 11 | 12 | getUUID4.desc=Returns type 4 pseudo randomly generated UUID. 13 | getUUID4.returns=A type 4 pseudo randomly generated UUID. 14 | 15 | -------------------------------------------------------------------------------- /common/src/test/kotlin/org/imdc/extensions/common/DSBuilder.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.common 2 | 3 | import com.inductiveautomation.ignition.common.BasicDataset 4 | import com.inductiveautomation.ignition.common.Dataset 5 | 6 | class DSBuilder { 7 | data class Column(val name: String, val rows: List<*>, val type: Class<*>) 8 | 9 | val columns = mutableListOf() 10 | 11 | inline fun column(name: String, data: List) { 12 | columns.add(Column(name, data, T::class.java)) 13 | } 14 | 15 | inline fun column(name: String, builder: MutableList.() -> Unit) { 16 | column(name, buildList(builder)) 17 | } 18 | 19 | fun build(): Dataset { 20 | val colCount = columns.size 21 | val rowCount = columns.maxOf { it.rows.size } 22 | val data = Array(colCount) { arrayOfNulls(rowCount) } 23 | 24 | for (c in 0 until colCount) { 25 | for (r in 0 until rowCount) { 26 | data[c][r] = columns.getOrNull(c)?.rows?.getOrNull(r) 27 | } 28 | } 29 | 30 | return BasicDataset(columns.map { it.name }, columns.map { it.type }, data) 31 | } 32 | 33 | companion object { 34 | fun dataset(block: DSBuilder.() -> Unit): Dataset { 35 | return DSBuilder().apply(block).build() 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /common/src/test/kotlin/org/imdc/extensions/common/DatasetEqualityTests.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.common 2 | 3 | import com.inductiveautomation.ignition.common.BasicDataset 4 | import io.kotest.core.spec.style.FunSpec 5 | import io.kotest.matchers.shouldBe 6 | import org.imdc.extensions.common.DSBuilder.Companion.dataset 7 | import java.util.Date 8 | 9 | class DatasetEqualityTests : FunSpec( 10 | { 11 | val dataset1 = dataset { 12 | column("a", listOf(1, 2, 3)) 13 | column("b", listOf(3.14, 2.18, 4.96)) 14 | column("c", listOf("string", "strung", "strang")) 15 | } 16 | val copyOfDs1 = BasicDataset(dataset1) 17 | val ds1WithCaps = BasicDataset(listOf("A", "B", "C"), dataset1.columnTypes, dataset1) 18 | val ds1WithOtherNames = BasicDataset(listOf("ant", "bat", "cat"), dataset1.columnTypes, dataset1) 19 | val dataset2 = dataset { 20 | column("j", listOf(3, 2, 1)) 21 | column("k", listOf(1.0, 2.0, 3.0)) 22 | column("l", listOf("chess", "chass", "chuss")) 23 | } 24 | val copyOfDs2 = BasicDataset(dataset2) 25 | val ds2WithCaps = BasicDataset(listOf("J", "K", "L"), dataset2.columnTypes, dataset2) 26 | val ds2WithOtherNames = BasicDataset(listOf("joe", "kim", "lee"), dataset2.columnTypes, dataset2) 27 | 28 | val dataset3 = dataset { 29 | column("t_stamp", listOf(1667605391000, 1667605392000, 1667605393000, 1667605394000).map(::Date)) 30 | column("tag_1", listOf(1.0, 2.0, 3.0, 4.0)) 31 | column("tag_2", listOf(true, false, true, false)) 32 | } 33 | val copyOfDs3 = BasicDataset(dataset3) 34 | val ds3WithAliases = BasicDataset(listOf("timestamp", "doubleTag", "boolTag"), dataset3.columnTypes, dataset3) 35 | 36 | context("General equality") { 37 | test("Same dataset") { 38 | DatasetExtensions.equals(dataset1, dataset1) shouldBe true 39 | DatasetExtensions.equals(dataset2, dataset2) shouldBe true 40 | DatasetExtensions.equals(dataset3, dataset3) shouldBe true 41 | } 42 | 43 | test("Copied dataset") { 44 | DatasetExtensions.equals(dataset1, copyOfDs1) shouldBe true 45 | DatasetExtensions.equals(dataset2, copyOfDs2) shouldBe true 46 | DatasetExtensions.equals(dataset3, copyOfDs3) shouldBe true 47 | } 48 | 49 | test("Different datasets") { 50 | DatasetExtensions.equals(dataset1, dataset2) shouldBe false 51 | DatasetExtensions.equals(dataset1, dataset3) shouldBe false 52 | DatasetExtensions.equals(dataset2, dataset3) shouldBe false 53 | } 54 | 55 | test("Datasets with same structure, but different columns") { 56 | DatasetExtensions.equals(dataset1, ds1WithCaps) shouldBe false 57 | DatasetExtensions.equals(dataset1, ds1WithOtherNames) shouldBe false 58 | DatasetExtensions.equals(dataset2, ds2WithCaps) shouldBe false 59 | DatasetExtensions.equals(dataset2, ds2WithOtherNames) shouldBe false 60 | DatasetExtensions.equals(dataset3, ds3WithAliases) shouldBe false 61 | } 62 | } 63 | 64 | context("Value equality") { 65 | test("Same dataset") { 66 | DatasetExtensions.valuesEqual(dataset1, dataset1) shouldBe true 67 | DatasetExtensions.valuesEqual(dataset2, dataset2) shouldBe true 68 | DatasetExtensions.valuesEqual(dataset3, dataset3) shouldBe true 69 | } 70 | 71 | test("Copied dataset") { 72 | DatasetExtensions.valuesEqual(dataset1, copyOfDs1) shouldBe true 73 | DatasetExtensions.valuesEqual(dataset2, copyOfDs2) shouldBe true 74 | DatasetExtensions.valuesEqual(dataset3, copyOfDs3) shouldBe true 75 | } 76 | 77 | test("Different datasets") { 78 | DatasetExtensions.valuesEqual(dataset1, dataset2) shouldBe false 79 | DatasetExtensions.valuesEqual(dataset1, dataset3) shouldBe false 80 | DatasetExtensions.valuesEqual(dataset2, dataset3) shouldBe false 81 | } 82 | 83 | test("Datasets with same structure, but different columns") { 84 | DatasetExtensions.valuesEqual(dataset1, ds1WithCaps) shouldBe true 85 | DatasetExtensions.valuesEqual(dataset1, ds1WithOtherNames) shouldBe true 86 | DatasetExtensions.valuesEqual(dataset2, ds2WithCaps) shouldBe true 87 | DatasetExtensions.valuesEqual(dataset2, ds2WithOtherNames) shouldBe true 88 | DatasetExtensions.valuesEqual(dataset3, ds3WithAliases) shouldBe true 89 | } 90 | } 91 | 92 | context("Column equality") { 93 | test("Same dataset") { 94 | DatasetExtensions.columnsEqual(dataset1, dataset1) shouldBe true 95 | DatasetExtensions.columnsEqual(dataset2, dataset2) shouldBe true 96 | DatasetExtensions.columnsEqual(dataset3, dataset3) shouldBe true 97 | } 98 | 99 | test("Copied dataset") { 100 | DatasetExtensions.columnsEqual(dataset1, copyOfDs1) shouldBe true 101 | DatasetExtensions.columnsEqual(dataset2, copyOfDs2) shouldBe true 102 | DatasetExtensions.columnsEqual(dataset3, copyOfDs3) shouldBe true 103 | } 104 | 105 | test("Different datasets") { 106 | DatasetExtensions.columnsEqual(dataset1, dataset2) shouldBe false 107 | DatasetExtensions.columnsEqual(dataset1, dataset3) shouldBe false 108 | DatasetExtensions.columnsEqual(dataset2, dataset3) shouldBe false 109 | } 110 | 111 | test("Datasets with same structure, but different columns") { 112 | DatasetExtensions.columnsEqual(dataset1, ds1WithCaps) shouldBe false 113 | DatasetExtensions.columnsEqual(dataset1, ds1WithCaps, ignoreCase = true) shouldBe true 114 | DatasetExtensions.columnsEqual(dataset1, ds1WithOtherNames) shouldBe false 115 | DatasetExtensions.columnsEqual(dataset2, ds2WithCaps) shouldBe false 116 | DatasetExtensions.columnsEqual(dataset2, ds2WithCaps, ignoreCase = true) shouldBe true 117 | DatasetExtensions.columnsEqual(dataset2, ds2WithOtherNames) shouldBe false 118 | DatasetExtensions.columnsEqual(dataset3, ds3WithAliases) shouldBe false 119 | 120 | val ds1WithDifferentTypes = BasicDataset( 121 | dataset1.columnNames, 122 | listOf(String::class.java, Int::class.java, Boolean::class.java), 123 | dataset1, 124 | ) 125 | DatasetExtensions.columnsEqual(dataset1, ds1WithDifferentTypes) shouldBe false 126 | DatasetExtensions.columnsEqual(dataset1, ds1WithDifferentTypes, ignoreCase = true) shouldBe false 127 | DatasetExtensions.columnsEqual(dataset1, ds1WithDifferentTypes, includeTypes = false) shouldBe true 128 | DatasetExtensions.columnsEqual( 129 | dataset1, 130 | ds1WithDifferentTypes, 131 | includeTypes = false, 132 | ignoreCase = true, 133 | ) shouldBe true 134 | 135 | val ds1WithDifferentTypesAndCase = BasicDataset( 136 | listOf("A", "B", "C"), 137 | dataset1.columnTypes, 138 | dataset1, 139 | ) 140 | DatasetExtensions.columnsEqual(dataset1, ds1WithDifferentTypesAndCase) shouldBe false 141 | DatasetExtensions.columnsEqual(dataset1, ds1WithDifferentTypesAndCase, ignoreCase = true) shouldBe true 142 | DatasetExtensions.columnsEqual( 143 | dataset1, 144 | ds1WithDifferentTypesAndCase, 145 | includeTypes = false, 146 | ) shouldBe false 147 | DatasetExtensions.columnsEqual( 148 | dataset1, 149 | ds1WithDifferentTypesAndCase, 150 | ignoreCase = true, 151 | includeTypes = false, 152 | ) shouldBe true 153 | } 154 | } 155 | }, 156 | ) 157 | -------------------------------------------------------------------------------- /common/src/test/kotlin/org/imdc/extensions/common/DatasetExtensionsTests.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.common 2 | 3 | import com.inductiveautomation.ignition.common.BasicDataset 4 | import com.inductiveautomation.ignition.common.Dataset 5 | import com.inductiveautomation.ignition.common.util.DatasetBuilder 6 | import io.kotest.assertions.withClue 7 | import io.kotest.engine.spec.tempfile 8 | import io.kotest.matchers.shouldBe 9 | import org.imdc.extensions.common.DatasetExtensions.printDataset 10 | import org.python.core.Py 11 | import java.awt.Color 12 | import java.util.Date 13 | 14 | @Suppress("PyUnresolvedReferences", "PyInterpreter") 15 | class DatasetExtensionsTests : JythonTest( 16 | { globals -> 17 | globals["utils"] = DatasetExtensions 18 | globals["builder"] = DatasetBuilder.newBuilder() 19 | globals["dataset"] = DatasetBuilder.newBuilder() 20 | .colNames("a", "b", "c") 21 | .colTypes(Int::class.javaObjectType, Double::class.javaObjectType, String::class.java) 22 | .addRow(1, 3.14, "pi") 23 | .addRow(2, 6.28, "tau") 24 | .build() 25 | 26 | val excelSample = 27 | DatasetExtensionsTests::class.java.getResourceAsStream("sample.xlsx")!!.readAllBytes() 28 | val tempXlsx = tempfile(suffix = "xlsx").also { 29 | it.writeBytes(excelSample) 30 | } 31 | globals["xlsxBytes"] = excelSample 32 | globals["xlsxFile"] = tempXlsx.toString() 33 | 34 | val excelSample2 = 35 | DatasetExtensionsTests::class.java.getResourceAsStream("sample2.xlsx")!!.readAllBytes() 36 | val tempXlsx2 = tempfile(suffix = "xlsx").also { 37 | it.writeBytes(excelSample2) 38 | } 39 | globals["xlsxFile2"] = tempXlsx2.toString() 40 | globals["xlsxBytes2"] = excelSample2 41 | 42 | val xlsSample = 43 | DatasetExtensionsTests::class.java.getResourceAsStream("sample.xls")!!.readAllBytes() 44 | val tempXls = tempfile(suffix = "xls").also { 45 | it.writeBytes(xlsSample) 46 | } 47 | globals["xlsBytes"] = xlsSample 48 | globals["xlsFile"] = tempXls.toString() 49 | 50 | globals["date"] = Date::class.java 51 | globals["color"] = Color::class.java 52 | globals["javaInt"] = Int::class.java 53 | globals["javaString"] = String::class.java 54 | globals["javaBool"] = Boolean::class.java 55 | }, 56 | ) { 57 | private fun Dataset.asClue(assertions: (Dataset) -> Unit) { 58 | withClue( 59 | { 60 | buildString { 61 | printDataset(this, this@asClue, true) 62 | } 63 | }, 64 | ) { 65 | assertions(this) 66 | } 67 | } 68 | 69 | init { 70 | PyDatasetBuilder.register() 71 | 72 | context("Map tests") { 73 | test("Null dataset") { 74 | shouldThrowPyException(Py.TypeError) { 75 | eval("utils.map(None, None)") 76 | } 77 | } 78 | 79 | test("Simple mapper, preserving types") { 80 | eval("utils.map(dataset, lambda a, b, c: (a * 2, b * 2, c * 2), True)").asClue { 81 | it.columnNames shouldBe listOf("a", "b", "c") 82 | it.columnTypes shouldBe listOf( 83 | Int::class.javaObjectType, 84 | Double::class.javaObjectType, 85 | String::class.java, 86 | ) 87 | it.rowCount shouldBe 2 88 | it.getColumnAsList(0) shouldBe listOf(2, 4) 89 | it.getColumnAsList(1) shouldBe listOf(6.28, 12.56) 90 | it.getColumnAsList(2) shouldBe listOf("pipi", "tautau") 91 | } 92 | } 93 | 94 | test("Simple mapper, discarding types") { 95 | eval("utils.map(dataset, lambda a, b, c: (a * 2, b * 2, c * 2), False)").asClue { 96 | it.columnNames shouldBe listOf("a", "b", "c") 97 | it.columnTypes shouldBe listOf(Any::class.java, Any::class.java, Any::class.java) 98 | it.rowCount shouldBe 2 99 | it.getColumnAsList(0) shouldBe listOf(2, 4) 100 | it.getColumnAsList(1) shouldBe listOf(6.28, 12.56) 101 | it.getColumnAsList(2) shouldBe listOf("pipi", "tautau") 102 | } 103 | } 104 | } 105 | 106 | context("Filter tests") { 107 | test("Constant filter") { 108 | eval("utils.filter(dataset, lambda **kwargs: False)").asClue { 109 | it.columnNames shouldBe listOf("a", "b", "c") 110 | it.columnTypes shouldBe listOf( 111 | Int::class.javaObjectType, 112 | Double::class.javaObjectType, 113 | String::class.java, 114 | ) 115 | it.rowCount shouldBe 0 116 | } 117 | } 118 | 119 | test("Conditional filter all kwargs") { 120 | eval("utils.filter(dataset, lambda **kwargs: kwargs['row'] >= 1)").asClue { 121 | it.columnNames shouldBe listOf("a", "b", "c") 122 | it.columnTypes shouldBe listOf( 123 | Int::class.javaObjectType, 124 | Double::class.javaObjectType, 125 | String::class.java, 126 | ) 127 | it.rowCount shouldBe 1 128 | } 129 | } 130 | 131 | test("Conditional filter unpacking row") { 132 | eval("utils.filter(dataset, lambda row, **kwargs: row >= 1)").asClue { 133 | it.columnNames shouldBe listOf("a", "b", "c") 134 | it.columnTypes shouldBe listOf( 135 | Int::class.javaObjectType, 136 | Double::class.javaObjectType, 137 | String::class.java, 138 | ) 139 | it.rowCount shouldBe 1 140 | } 141 | } 142 | } 143 | 144 | context("Print tests") { 145 | context("Basic dataset") { 146 | test("Without types") { 147 | buildString { 148 | printDataset(this, globals["dataset"]) 149 | } shouldBe """ 150 | | Row | a | b | c | 151 | | --- | --- | ---- | --- | 152 | | 0 | 1 | 3.14 | pi | 153 | | 1 | 2 | 6.28 | tau | 154 | 155 | """.trimIndent() 156 | } 157 | 158 | test("With types") { 159 | buildString { 160 | printDataset(this, globals["dataset"], includeTypes = true) 161 | } shouldBe """ 162 | | Row | a (Integer) | b (Double) | c (String) | 163 | | --- | ----------- | ---------- | ---------- | 164 | | 0 | 1 | 3.14 | pi | 165 | | 1 | 2 | 6.28 | tau | 166 | 167 | """.trimIndent() 168 | } 169 | } 170 | 171 | val emptyDataset = BasicDataset( 172 | listOf("a", "b", "c"), 173 | listOf(String::class.java, Int::class.java, Boolean::class.java), 174 | ) 175 | context("Empty dataset") { 176 | test("Without types") { 177 | buildString { 178 | printDataset(this, emptyDataset) 179 | } shouldBe """ 180 | | Row | a | b | c | 181 | | --- | --- | --- | --- | 182 | 183 | """.trimIndent() 184 | } 185 | 186 | test("With types") { 187 | buildString { 188 | printDataset(this, emptyDataset, includeTypes = true) 189 | } shouldBe """ 190 | | Row | a (String) | b (int) | c (boolean) | 191 | | --- | ---------- | ------- | ----------- | 192 | 193 | """.trimIndent() 194 | } 195 | } 196 | } 197 | 198 | context("fromExcel") { 199 | test("XLSX file") { 200 | eval("utils.fromExcel(xlsxFile)").asClue { 201 | it.rowCount shouldBe 100 202 | it.columnCount shouldBe 16 203 | } 204 | } 205 | test("XLS file") { 206 | eval("utils.fromExcel(xlsFile)").asClue { 207 | it.rowCount shouldBe 100 208 | it.columnCount shouldBe 16 209 | } 210 | } 211 | test("XLSX bytes") { 212 | eval("utils.fromExcel(xlsxBytes)").asClue { 213 | it.rowCount shouldBe 100 214 | it.columnCount shouldBe 16 215 | } 216 | } 217 | test("XLS bytes") { 218 | eval("utils.fromExcel(xlsBytes)").asClue { 219 | it.rowCount shouldBe 100 220 | it.columnCount shouldBe 16 221 | } 222 | } 223 | test("With headers") { 224 | eval("utils.fromExcel(xlsxBytes, headerRow=0)").asClue { 225 | it.rowCount shouldBe 99 226 | it.columnCount shouldBe 16 227 | it.columnNames shouldBe listOf( 228 | "Segment", 229 | "Country", 230 | "Product", 231 | "Discount Band", 232 | "Units Sold", 233 | "Manufacturing Price", 234 | "Sale Price", 235 | "Gross Sales", 236 | "Discounts", 237 | "Sales", 238 | "COGS", 239 | "Profit", 240 | "Date", 241 | "Month Number", 242 | "Month Name", 243 | "Year", 244 | ) 245 | } 246 | } 247 | test("With String Override") { 248 | eval("utils.fromExcel(xlsxBytes2, headerRow=0, typeOverrides={2: 'str', 4: 'str'})").asClue { 249 | it.rowCount shouldBe 99 250 | it.columnCount shouldBe 16 251 | it.columnNames shouldBe listOf( 252 | "Segment", 253 | "Country", 254 | "Product", 255 | "Discount Band", 256 | "Units Sold", 257 | "Manufacturing Price", 258 | "Sale Price", 259 | "Gross Sales", 260 | "Discounts", 261 | "Sales", 262 | "COGS", 263 | "Profit", 264 | "Date", 265 | "Month Number", 266 | "Month Name", 267 | "Year", 268 | ) 269 | it.columnTypes shouldBe listOf( 270 | String::class.java, 271 | Any::class.java, 272 | String::class.java, 273 | String::class.java, 274 | String::class.java, 275 | Int::class.javaObjectType, 276 | Int::class.javaObjectType, 277 | Int::class.javaObjectType, 278 | Int::class.javaObjectType, 279 | Int::class.javaObjectType, 280 | Int::class.javaObjectType, 281 | Int::class.javaObjectType, 282 | Date::class.java, 283 | Int::class.javaObjectType, 284 | String::class.java, 285 | String::class.java, 286 | ) 287 | } 288 | } 289 | test("First row") { 290 | eval("utils.fromExcel(xlsxBytes, headerRow=0, firstRow=50)").asClue { 291 | it.rowCount shouldBe 50 292 | it.columnCount shouldBe 16 293 | } 294 | } 295 | test("Last row") { 296 | eval("utils.fromExcel(xlsxBytes, headerRow=0, lastRow=50)").asClue { 297 | it.rowCount shouldBe 50 298 | it.columnCount shouldBe 16 299 | } 300 | } 301 | test("First & last row") { 302 | eval("utils.fromExcel(xlsxBytes, headerRow=0, firstRow=5, lastRow=10)").asClue { 303 | it.rowCount shouldBe 6 304 | it.columnCount shouldBe 16 305 | } 306 | } 307 | test("First column") { 308 | eval("utils.fromExcel(xlsxBytes, headerRow=0, firstColumn=10)").asClue { 309 | it.rowCount shouldBe 99 310 | it.columnCount shouldBe 6 311 | it.columnNames shouldBe listOf( 312 | "COGS", 313 | "Profit", 314 | "Date", 315 | "Month Number", 316 | "Month Name", 317 | "Year", 318 | ) 319 | } 320 | } 321 | test("Last column") { 322 | eval("utils.fromExcel(xlsxBytes, headerRow=0, lastColumn=5)").asClue { 323 | it.rowCount shouldBe 99 324 | it.columnCount shouldBe 6 325 | it.columnNames shouldBe listOf( 326 | "Segment", 327 | "Country", 328 | "Product", 329 | "Discount Band", 330 | "Units Sold", 331 | "Manufacturing Price", 332 | ) 333 | } 334 | } 335 | test("First & last column") { 336 | eval("utils.fromExcel(xlsxBytes, headerRow=0, firstColumn=5, lastColumn=10)").asClue { 337 | it.rowCount shouldBe 99 338 | it.columnCount shouldBe 6 339 | it.columnNames shouldBe listOf( 340 | "Manufacturing Price", 341 | "Sale Price", 342 | "Gross Sales", 343 | "Discounts", 344 | "Sales", 345 | "COGS", 346 | ) 347 | } 348 | } 349 | } 350 | 351 | context("Builder") { 352 | test("Basic usage") { 353 | eval("utils.builder(a=int, b=str, c=bool).addRow(1, '2', False).build()").asClue { 354 | it.rowCount shouldBe 1 355 | it.columnCount shouldBe 3 356 | it.columnNames shouldBe listOf( 357 | "a", 358 | "b", 359 | "c", 360 | ) 361 | it.columnTypes shouldBe listOf( 362 | Int::class.java, 363 | String::class.java, 364 | Boolean::class.java, 365 | ) 366 | } 367 | } 368 | 369 | test("String type codes in builder call") { 370 | eval("utils.builder(a='i', b='str', c='b').addRow(1, '2', False).build()").asClue { 371 | it.rowCount shouldBe 1 372 | it.columnCount shouldBe 3 373 | it.columnNames shouldBe listOf( 374 | "a", 375 | "b", 376 | "c", 377 | ) 378 | it.columnTypes shouldBe listOf( 379 | Int::class.java, 380 | String::class.java, 381 | Boolean::class.java, 382 | ) 383 | } 384 | } 385 | 386 | test("Separate colTypes as java types") { 387 | eval( 388 | """ 389 | utils.builder() \ 390 | .colNames('a', 'b', 'c') \ 391 | .colTypes(javaInt, javaString, javaBool) \ 392 | .addRow(1, '2', False) \ 393 | .build() 394 | """.trimIndent(), 395 | ).asClue { 396 | it.rowCount shouldBe 1 397 | it.columnCount shouldBe 3 398 | it.columnNames shouldBe listOf( 399 | "a", 400 | "b", 401 | "c", 402 | ) 403 | it.columnTypes shouldBe listOf( 404 | Int::class.java, 405 | String::class.java, 406 | Boolean::class.java, 407 | ) 408 | } 409 | } 410 | 411 | test("Separate colTypes as Python types") { 412 | eval( 413 | """ 414 | utils.builder() \ 415 | .colNames('a', 'b', 'c') \ 416 | .colTypes(int, str, bool) \ 417 | .addRow(1, '2', False) \ 418 | .build() 419 | """.trimIndent(), 420 | ).asClue { 421 | it.rowCount shouldBe 1 422 | it.columnCount shouldBe 3 423 | it.columnNames shouldBe listOf( 424 | "a", 425 | "b", 426 | "c", 427 | ) 428 | it.columnTypes shouldBe listOf( 429 | Int::class.java, 430 | String::class.java, 431 | Boolean::class.java, 432 | ) 433 | } 434 | } 435 | 436 | test("Separate colTypes as string shortcodes") { 437 | eval( 438 | """ 439 | utils.builder() \ 440 | .colNames('a', 'b', 'c') \ 441 | .colTypes('i', 'str', 'b') \ 442 | .addRow(1, '2', False) \ 443 | .build() 444 | """.trimIndent(), 445 | ).asClue { 446 | it.rowCount shouldBe 1 447 | it.columnCount shouldBe 3 448 | it.columnNames shouldBe listOf( 449 | "a", 450 | "b", 451 | "c", 452 | ) 453 | it.columnTypes shouldBe listOf( 454 | Int::class.java, 455 | String::class.java, 456 | Boolean::class.java, 457 | ) 458 | } 459 | } 460 | 461 | test("Complex types") { 462 | eval( 463 | """ 464 | utils.builder(date=date, color=color, unicode=unicode) \ 465 | .addRow(date(), color.RED, u'test') \ 466 | .build() 467 | """.trimIndent(), 468 | ).asClue { 469 | it.rowCount shouldBe 1 470 | it.columnCount shouldBe 3 471 | it.columnNames shouldBe listOf( 472 | "date", 473 | "color", 474 | "unicode", 475 | ) 476 | it.columnTypes shouldBe listOf( 477 | Date::class.java, 478 | Color::class.java, 479 | String::class.java, 480 | ) 481 | } 482 | } 483 | 484 | test("Nulls in rows") { 485 | eval( 486 | """ 487 | utils.builder(a=int, b=str, c=bool) \ 488 | .addRow(1, '2', False) \ 489 | .addRow(None, None, None) \ 490 | .build() 491 | """.trimIndent(), 492 | ).asClue { 493 | it.rowCount shouldBe 2 494 | it.columnCount shouldBe 3 495 | it.columnNames shouldBe listOf( 496 | "a", 497 | "b", 498 | "c", 499 | ) 500 | it.columnTypes shouldBe listOf( 501 | Int::class.java, 502 | String::class.java, 503 | Boolean::class.java, 504 | ) 505 | } 506 | } 507 | 508 | test("Empty dataset") { 509 | eval("utils.builder().build()").asClue { 510 | it.rowCount shouldBe 0 511 | it.columnCount shouldBe 0 512 | } 513 | } 514 | 515 | test("Add a row as a list") { 516 | eval( 517 | """ 518 | utils.builder(a=int, b=str, c=bool) \ 519 | .addRow([1, '2', False]) \ 520 | .build() 521 | """.trimIndent(), 522 | ).asClue { 523 | it.rowCount shouldBe 1 524 | it.columnCount shouldBe 3 525 | } 526 | } 527 | 528 | test("Add a row as a tuple") { 529 | eval( 530 | """ 531 | utils.builder(a=int, b=str, c=bool) \ 532 | .addRow((1, '2', False)) \ 533 | .build() 534 | """.trimIndent(), 535 | ).asClue { 536 | it.rowCount shouldBe 1 537 | it.columnCount shouldBe 3 538 | } 539 | } 540 | 541 | test("Columns with complex names") { 542 | eval( 543 | """ 544 | utils.builder(**{'a space': int, u'😍': bool, r',./;\'[]-=<>?:"{}|_+!@#%^&*()`~': str}) \ 545 | .addRow((1, '2', False)) \ 546 | .build() 547 | """.trimIndent(), 548 | ).asClue { 549 | it.rowCount shouldBe 1 550 | it.columnCount shouldBe 3 551 | it.columnNames shouldBe listOf( 552 | "a space", 553 | "\uD83D\uDE0D", 554 | """,./;\'[]-=<>?:"{}|_+!@#%^&*()`~""", 555 | ) 556 | } 557 | } 558 | 559 | test("Invalid types") { 560 | shouldThrowPyException(Py.TypeError) { 561 | eval("utils.builder(a=1).build()") 562 | } 563 | shouldThrowPyException(Py.TypeError) { 564 | eval("utils.builder(a=None).build()") 565 | } 566 | } 567 | 568 | test("Row without enough vararg values") { 569 | shouldThrowPyException(Py.TypeError) { 570 | eval( 571 | """ 572 | utils.builder(a=int, b=str, c=bool) \ 573 | .addRow(1, '2') \ 574 | .build() 575 | """.trimIndent(), 576 | ) 577 | } 578 | } 579 | 580 | test("Row without enough list values") { 581 | shouldThrowPyException(Py.TypeError) { 582 | eval( 583 | """ 584 | utils.builder(a=int, b=str, c=bool) \ 585 | .addRow([1, '2']) \ 586 | .build() 587 | """.trimIndent(), 588 | ) 589 | } 590 | } 591 | 592 | test("Column types regression (issue #24)") { 593 | eval( 594 | """ 595 | builder.colNames(dataset.columnNames) \ 596 | .colTypes(dataset.columnTypes) \ 597 | .build() 598 | """.trimIndent(), 599 | ) 600 | } 601 | } 602 | } 603 | } 604 | -------------------------------------------------------------------------------- /common/src/test/kotlin/org/imdc/extensions/common/ExpressionUtils.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.common 2 | 3 | import com.inductiveautomation.ignition.common.expressions.AbstractFunctionFactory 4 | import com.inductiveautomation.ignition.common.expressions.ConstantExpression 5 | import com.inductiveautomation.ignition.common.expressions.Expression 6 | import com.inductiveautomation.ignition.common.expressions.ExpressionParseContext 7 | import com.inductiveautomation.ignition.common.expressions.FunctionFactory 8 | import com.inductiveautomation.ignition.common.expressions.functions.Function 9 | import com.inductiveautomation.ignition.common.expressions.parsing.ELParserHarness 10 | 11 | class ExpressionTestHarness : AbstractFunctionFactory(null) { 12 | private val parser = ELParserHarness() 13 | 14 | fun evaluate(expression: String, constants: Map = emptyMap()): Any? { 15 | val parseContext = StaticParseContext(constants, this) 16 | return parser.parse(expression, parseContext).execute().value 17 | } 18 | 19 | companion object { 20 | suspend fun withFunction(name: String, function: Function, test: suspend ExpressionTestHarness.() -> Unit) { 21 | ExpressionTestHarness().apply { 22 | addFunction(name, "", function) 23 | test() 24 | } 25 | } 26 | } 27 | } 28 | 29 | class StaticParseContext( 30 | private val keywords: Map, 31 | private val functionFactory: FunctionFactory, 32 | ) : ExpressionParseContext { 33 | override fun createBoundExpression(reference: String): Expression { 34 | return ConstantExpression(keywords.getValue(reference)) 35 | } 36 | 37 | override fun getFunctionFactory(): FunctionFactory { 38 | return functionFactory 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /common/src/test/kotlin/org/imdc/extensions/common/JythonTest.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.common 2 | 3 | import com.inductiveautomation.ignition.common.TypeUtilities 4 | import io.kotest.assertions.fail 5 | import io.kotest.core.listeners.BeforeEachListener 6 | import io.kotest.core.spec.style.FunSpec 7 | import io.kotest.core.test.TestCase 8 | import io.kotest.matchers.Matcher 9 | import io.kotest.matchers.MatcherResult 10 | import org.intellij.lang.annotations.Language 11 | import org.python.core.CompileMode 12 | import org.python.core.CompilerFlags 13 | import org.python.core.Py 14 | import org.python.core.PyBaseException 15 | import org.python.core.PyCode 16 | import org.python.core.PyException 17 | import org.python.core.PyObject 18 | import org.python.core.PyStringMap 19 | import org.python.core.PyType 20 | 21 | @Suppress("PyInterpreter") 22 | abstract class JythonTest(init: FunSpec.(globals: PyStringMap) -> Unit) : FunSpec() { 23 | protected var globals: PyStringMap = PyStringMap() 24 | 25 | init { 26 | extension( 27 | object : BeforeEachListener { 28 | override suspend fun beforeEach(testCase: TestCase) { 29 | globals.clear() 30 | init(this@JythonTest, globals) 31 | } 32 | }, 33 | ) 34 | } 35 | 36 | protected inline fun eval(@Language("python") script: String): T { 37 | exec("$RESULT = $script") 38 | return globals[RESULT] 39 | } 40 | 41 | protected fun exec(@Language("python") script: String?) { 42 | val compiledCall = compile(script) 43 | Py.runCode(compiledCall, null, globals) 44 | } 45 | 46 | private fun compile(@Language("python") script: String?): PyCode { 47 | return Py.compile_flags( 48 | script, 49 | "", 50 | CompileMode.exec, 51 | CompilerFlags(CompilerFlags.PyCF_SOURCE_IS_UTF8), 52 | ) 53 | } 54 | 55 | fun shouldThrowPyException(type: PyObject, case: () -> Unit) { 56 | require(type is PyType) 57 | require(type.isSubType(PyBaseException.TYPE)) 58 | try { 59 | case() 60 | } catch (exception: PyException) { 61 | PyExceptionTypeMatcher(exception).test(type) 62 | return 63 | } 64 | fail("Expected a $type to be thrown, but nothing was thrown") 65 | } 66 | 67 | companion object { 68 | const val RESULT = "__RESULT" 69 | 70 | init { 71 | Py.setSystemState(Py.defaultSystemState) 72 | } 73 | 74 | inline operator fun PyStringMap.get(variable: String): T { 75 | val value = this[Py.newStringOrUnicode(variable)] 76 | return TypeUtilities.pyToJava(value) as T 77 | } 78 | 79 | operator fun PyStringMap.set(key: String, value: Any?) { 80 | return __setitem__(key, Py.java2py(value)) 81 | } 82 | 83 | class PyExceptionTypeMatcher(val expected: PyException) : Matcher { 84 | override fun test(value: PyObject): MatcherResult { 85 | return MatcherResult( 86 | expected.match(value), 87 | failureMessageFn = { "Expected a $expected, but was $value" }, 88 | negatedFailureMessageFn = { "Result should not be a $expected, but was $value" }, 89 | ) 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /common/src/test/kotlin/org/imdc/extensions/common/LogicalFunctionsTests.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.common 2 | 3 | import io.kotest.core.spec.style.FunSpec 4 | import io.kotest.matchers.shouldBe 5 | import org.imdc.extensions.common.ExpressionTestHarness.Companion.withFunction 6 | import org.imdc.extensions.common.expressions.LogicalPredicate.AllOfFunction 7 | import org.imdc.extensions.common.expressions.LogicalPredicate.AnyOfFunction 8 | import org.imdc.extensions.common.expressions.LogicalPredicate.NoneOfFunction 9 | 10 | class LogicalFunctionsTests : FunSpec() { 11 | init { 12 | context("AllOf") { 13 | withFunction("allOf", AllOfFunction) { 14 | test("No arguments") { 15 | evaluate("allOf()") shouldBe true 16 | } 17 | test("Simple booleans") { 18 | evaluate("allOf(true, true)") shouldBe true 19 | evaluate("allOf(true, false)") shouldBe false 20 | evaluate("allOf(false, false)") shouldBe false 21 | } 22 | } 23 | } 24 | context("AnyOf") { 25 | withFunction("anyOf", AnyOfFunction) { 26 | test("No arguments") { 27 | evaluate("anyOf()") shouldBe false 28 | } 29 | test("Simple booleans") { 30 | evaluate("anyOf(true, true)") shouldBe true 31 | evaluate("anyOf(true, false)") shouldBe true 32 | evaluate("anyOf(false, false)") shouldBe false 33 | } 34 | } 35 | } 36 | context("NoneOf") { 37 | withFunction("noneOf", NoneOfFunction) { 38 | test("No arguments") { 39 | evaluate("noneOf()") shouldBe true 40 | } 41 | test("Simple booleans") { 42 | evaluate("noneOf(true, true)") shouldBe false 43 | evaluate("noneOf(true, false)") shouldBe false 44 | evaluate("noneOf(false, false)") shouldBe true 45 | } 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /common/src/test/kotlin/org/imdc/extensions/common/UUID4Tests.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.common 2 | 3 | import io.kotest.core.spec.style.FunSpec 4 | import io.kotest.matchers.should 5 | import io.kotest.matchers.shouldBe 6 | import io.kotest.matchers.types.beInstanceOf 7 | import org.imdc.extensions.common.ExpressionTestHarness.Companion.withFunction 8 | import org.imdc.extensions.common.expressions.UUID4Function 9 | import java.util.* 10 | 11 | class UUID4Tests : FunSpec() { 12 | init { 13 | context("RandomUUID") { 14 | withFunction("uuid4", UUID4Function()) { 15 | test("Instance of UUID") { 16 | evaluate("uuid4()") should beInstanceOf() 17 | } 18 | test("Unique results") { 19 | evaluate("uuid4() = uuid4()") shouldBe false 20 | } 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /common/src/test/kotlin/org/imdc/extensions/common/UtilitiesExtensionsTests.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.common 2 | 3 | import io.kotest.matchers.nulls.shouldBeNull 4 | import io.kotest.matchers.should 5 | import io.kotest.matchers.shouldBe 6 | import io.kotest.matchers.types.beInstanceOf 7 | import io.mockk.mockk 8 | import org.python.core.Py 9 | import org.python.core.PyDictionary 10 | import org.python.core.PyList 11 | import java.util.UUID 12 | 13 | @Suppress("PyUnresolvedReferences", "PyInterpreter") 14 | class UtilitiesExtensionsTests : JythonTest( 15 | { globals -> 16 | globals["utils"] = UtilitiesExtensions(mockk()) 17 | }, 18 | ) { 19 | init { 20 | context("Deep copy tests") { 21 | test("No input") { 22 | shouldThrowPyException(Py.TypeError) { 23 | eval("utils.deepCopy()") 24 | } 25 | } 26 | test("Null input") { 27 | eval("utils.deepCopy(None)").shouldBeNull() 28 | } 29 | test("String input") { 30 | eval("utils.deepCopy('abc')") shouldBe "abc" 31 | eval("utils.deepCopy(u'abc')") shouldBe "abc" 32 | } 33 | context("Simple conversions") { 34 | test("List") { 35 | eval("utils.deepCopy([1, 2, 3])") shouldBe listOf(1, 2, 3) 36 | } 37 | test("Tuple") { 38 | eval("utils.deepCopy((1, 2, 3))") shouldBe listOf(1, 2, 3) 39 | } 40 | test("Set") { 41 | eval("utils.deepCopy({1, 2, 3})") shouldBe listOf(1, 2, 3) 42 | } 43 | test("Dict") { 44 | eval("utils.deepCopy({'a': 1, 'b': 2, 'c': 3})\n") shouldBe mapOf( 45 | "a" to 1, 46 | "b" to 2, 47 | "c" to 3, 48 | ) 49 | } 50 | } 51 | } 52 | context("UUID4 tests") { 53 | test("Instance of UUID") { 54 | eval("utils.getUUID4()") should beInstanceOf() 55 | } 56 | test("Unique results") { 57 | eval("utils.getUUID4() == utils.getUUID4()") shouldBe false 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /common/src/test/resources/org/imdc/extensions/common/sample.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IgnitionModuleDevelopmentCommunity/ignition-extensions/5aba12e3f5b891a4feca3fb311aa5840f40d76b9/common/src/test/resources/org/imdc/extensions/common/sample.xls -------------------------------------------------------------------------------- /common/src/test/resources/org/imdc/extensions/common/sample.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IgnitionModuleDevelopmentCommunity/ignition-extensions/5aba12e3f5b891a4feca3fb311aa5840f40d76b9/common/src/test/resources/org/imdc/extensions/common/sample.xlsx -------------------------------------------------------------------------------- /common/src/test/resources/org/imdc/extensions/common/sample2.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IgnitionModuleDevelopmentCommunity/ignition-extensions/5aba12e3f5b891a4feca3fb311aa5840f40d76b9/common/src/test/resources/org/imdc/extensions/common/sample2.xlsx -------------------------------------------------------------------------------- /designer/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `java-library` 3 | kotlin("jvm") 4 | } 5 | 6 | kotlin { 7 | jvmToolchain(libs.versions.java.map(String::toInt).get()) 8 | } 9 | 10 | dependencies { 11 | compileOnly(libs.bundles.designer) 12 | compileOnly(projects.common) 13 | } 14 | -------------------------------------------------------------------------------- /designer/src/main/kotlin/org/imdc/extensions/designer/DesignerHook.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.designer 2 | 3 | import com.inductiveautomation.ignition.common.BundleUtil 4 | import com.inductiveautomation.ignition.common.expressions.ExpressionFunctionManager 5 | import com.inductiveautomation.ignition.common.licensing.LicenseState 6 | import com.inductiveautomation.ignition.common.script.ScriptManager 7 | import com.inductiveautomation.ignition.designer.model.AbstractDesignerModuleHook 8 | import com.inductiveautomation.ignition.designer.model.DesignerContext 9 | import org.imdc.extensions.common.DatasetExtensions 10 | import org.imdc.extensions.common.ExtensionDocProvider 11 | import org.imdc.extensions.common.PyDatasetBuilder 12 | import org.imdc.extensions.common.UtilitiesExtensions 13 | import org.imdc.extensions.common.addPropertyBundle 14 | import org.imdc.extensions.common.expressions.IsAvailableFunction 15 | import org.imdc.extensions.common.expressions.LogicalPredicate.Companion.registerLogicFunctions 16 | import org.imdc.extensions.common.expressions.UUID4Function 17 | 18 | @Suppress("unused") 19 | class DesignerHook : AbstractDesignerModuleHook() { 20 | private lateinit var context: DesignerContext 21 | 22 | override fun startup(context: DesignerContext, activationState: LicenseState) { 23 | this.context = context 24 | 25 | BundleUtil.get().apply { 26 | addPropertyBundle() 27 | addPropertyBundle() 28 | addPropertyBundle() 29 | } 30 | 31 | PyDatasetBuilder.register() 32 | } 33 | 34 | override fun shutdown() { 35 | PyDatasetBuilder.unregister() 36 | } 37 | 38 | override fun initializeScriptManager(manager: ScriptManager) { 39 | manager.apply { 40 | addScriptModule("system.dataset", DatasetExtensions, ExtensionDocProvider) 41 | addScriptModule("system.util", UtilitiesExtensions(context), ExtensionDocProvider) 42 | addScriptModule("system.project", DesignerProjectExtensions(context), ExtensionDocProvider) 43 | } 44 | } 45 | 46 | override fun configureFunctionFactory(factory: ExpressionFunctionManager) { 47 | factory.apply { 48 | addFunction( 49 | IsAvailableFunction.NAME, 50 | IsAvailableFunction.CATEGORY, 51 | IsAvailableFunction(), 52 | ) 53 | registerLogicFunctions() 54 | addFunction( 55 | UUID4Function.NAME, 56 | UUID4Function.CATEGORY, 57 | UUID4Function(), 58 | ) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /designer/src/main/kotlin/org/imdc/extensions/designer/DesignerProjectExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.designer 2 | 3 | import com.inductiveautomation.ignition.common.script.hints.ScriptFunction 4 | import com.inductiveautomation.ignition.designer.IgnitionDesigner 5 | import com.inductiveautomation.ignition.designer.model.DesignerContext 6 | import com.inductiveautomation.ignition.designer.project.DesignableProject 7 | import org.apache.commons.lang3.reflect.MethodUtils 8 | import org.imdc.extensions.common.ProjectExtensions 9 | import org.imdc.extensions.common.UnsafeExtension 10 | 11 | class DesignerProjectExtensions(private val context: DesignerContext) : ProjectExtensions { 12 | @ScriptFunction(docBundlePrefix = "DesignerProjectExtensions") 13 | @UnsafeExtension 14 | override fun getProject(): DesignableProject { 15 | return requireNotNull(context.project) 16 | } 17 | 18 | @ScriptFunction(docBundlePrefix = "DesignerProjectExtensions") 19 | @UnsafeExtension 20 | fun save() { 21 | MethodUtils.invokeMethod( 22 | context.frame, 23 | true, // forceAccess 24 | "handleSave", 25 | false, // saveAs 26 | null, // newName 27 | false, // commitOnly 28 | false, // skipReopen 29 | false, // showDialog 30 | ) 31 | } 32 | 33 | @ScriptFunction(docBundlePrefix = "DesignerProjectExtensions") 34 | @UnsafeExtension 35 | fun update() { 36 | (context.frame as IgnitionDesigner).updateProject() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /designer/src/main/resources/org/imdc/extensions/designer/DesignerProjectExtensions.properties: -------------------------------------------------------------------------------- 1 | getProject.desc=Retrieves the current project. 2 | getProject.returns=The current project as a DesignableProject instance. 3 | update.desc=Pulls in external changes made to this project from the gateway. 4 | update.returns=None 5 | save.desc=Programmatically invokes the save action. 6 | save.returns=None 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | gateway: 3 | image: inductiveautomation/ignition:8.1.37 4 | ports: 5 | - 18088:8088 6 | - 18000:8000 7 | environment: 8 | GATEWAY_ADMIN_PASSWORD: password 9 | IGNITION_EDITION: standard 10 | ACCEPT_IGNITION_EULA: "Y" 11 | volumes: 12 | - gateway-data:/usr/local/bin/ignition/data 13 | command: > 14 | -n Ignition-module-dev 15 | -d 16 | -- 17 | -Dignition.allowunsignedmodules=true 18 | -Dia.developer.moduleupload=true 19 | 20 | volumes: 21 | gateway-data: 22 | -------------------------------------------------------------------------------- /gateway/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `java-library` 3 | kotlin("jvm") 4 | } 5 | 6 | kotlin { 7 | jvmToolchain(libs.versions.java.map(String::toInt).get()) 8 | } 9 | 10 | dependencies { 11 | compileOnly(libs.bundles.gateway) 12 | compileOnly(projects.common) 13 | } 14 | -------------------------------------------------------------------------------- /gateway/src/main/kotlin/org/imdc/extensions/gateway/GatewayHook.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.gateway 2 | 3 | import com.inductiveautomation.ignition.common.BundleUtil 4 | import com.inductiveautomation.ignition.common.expressions.ExpressionFunctionManager 5 | import com.inductiveautomation.ignition.common.licensing.LicenseState 6 | import com.inductiveautomation.ignition.common.script.ScriptManager 7 | import com.inductiveautomation.ignition.gateway.model.AbstractGatewayModuleHook 8 | import com.inductiveautomation.ignition.gateway.model.GatewayContext 9 | import org.imdc.extensions.common.DatasetExtensions 10 | import org.imdc.extensions.common.ExtensionDocProvider 11 | import org.imdc.extensions.common.PyDatasetBuilder 12 | import org.imdc.extensions.common.UtilitiesExtensions 13 | import org.imdc.extensions.common.addPropertyBundle 14 | import org.imdc.extensions.common.expressions.IsAvailableFunction 15 | import org.imdc.extensions.common.expressions.LogicalPredicate.Companion.registerLogicFunctions 16 | import org.imdc.extensions.common.expressions.UUID4Function 17 | 18 | @Suppress("unused") 19 | class GatewayHook : AbstractGatewayModuleHook() { 20 | private lateinit var context: GatewayContext 21 | 22 | override fun setup(context: GatewayContext) { 23 | this.context = context 24 | 25 | BundleUtil.get().apply { 26 | addPropertyBundle() 27 | addPropertyBundle() 28 | addPropertyBundle() 29 | addPropertyBundle() 30 | } 31 | 32 | PyDatasetBuilder.register() 33 | 34 | context.webResourceManager.addServlet(HistoryServlet.PATH, HistoryServlet::class.java) 35 | } 36 | 37 | override fun startup(activationState: LicenseState) = Unit 38 | 39 | override fun shutdown() { 40 | PyDatasetBuilder.unregister() 41 | context.webResourceManager.removeServlet(HistoryServlet.PATH) 42 | } 43 | 44 | override fun initializeScriptManager(manager: ScriptManager) { 45 | manager.apply { 46 | addScriptModule("system.dataset", DatasetExtensions, ExtensionDocProvider) 47 | addScriptModule("system.util", UtilitiesExtensions(context), ExtensionDocProvider) 48 | addScriptModule("system.project", GatewayProjectExtensions(context), ExtensionDocProvider) 49 | addScriptModule("system.tag", GatewayTagExtensions(context), ExtensionDocProvider) 50 | } 51 | } 52 | 53 | override fun configureFunctionFactory(factory: ExpressionFunctionManager) { 54 | factory.apply { 55 | addFunction( 56 | IsAvailableFunction.NAME, 57 | IsAvailableFunction.CATEGORY, 58 | IsAvailableFunction(), 59 | ) 60 | registerLogicFunctions() 61 | addFunction( 62 | UUID4Function.NAME, 63 | UUID4Function.CATEGORY, 64 | UUID4Function(), 65 | ) 66 | } 67 | } 68 | 69 | override fun isFreeModule() = true 70 | override fun isMakerEditionCompatible() = true 71 | } 72 | -------------------------------------------------------------------------------- /gateway/src/main/kotlin/org/imdc/extensions/gateway/GatewayProjectExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.gateway 2 | 3 | import com.inductiveautomation.ignition.common.project.RuntimeProject 4 | import com.inductiveautomation.ignition.common.script.ScriptContext 5 | import com.inductiveautomation.ignition.common.script.hints.ScriptArg 6 | import com.inductiveautomation.ignition.common.script.hints.ScriptFunction 7 | import com.inductiveautomation.ignition.gateway.model.GatewayContext 8 | import org.imdc.extensions.common.ProjectExtensions 9 | import org.python.core.Py 10 | 11 | class GatewayProjectExtensions(private val context: GatewayContext) : ProjectExtensions { 12 | @ScriptFunction(docBundlePrefix = "GatewayProjectExtensions") 13 | override fun getProject(): RuntimeProject { 14 | val defaultProject = ScriptContext.defaultProject() ?: throw Py.EnvironmentError("No context project populated") 15 | return requireNotNull(getProject(defaultProject)) { "No such project $defaultProject" } 16 | } 17 | 18 | @ScriptFunction(docBundlePrefix = "GatewayProjectExtensions") 19 | fun getProject( 20 | @ScriptArg("project", optional = true) project: String, 21 | ): RuntimeProject? { 22 | return context.projectManager.getProject(project).orElse(null) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /gateway/src/main/kotlin/org/imdc/extensions/gateway/GatewayTagExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.gateway 2 | 3 | import com.inductiveautomation.ignition.common.tags.config.TagConfigurationModel 4 | import com.inductiveautomation.ignition.common.tags.model.TagPath 5 | import com.inductiveautomation.ignition.gateway.model.GatewayContext 6 | import org.imdc.extensions.common.TagExtensions 7 | import org.python.core.Py 8 | 9 | class GatewayTagExtensions(private val context: GatewayContext) : TagExtensions() { 10 | override fun getConfigurationImpl(basePath: TagPath, recursive: Boolean): List { 11 | val provider = ( 12 | context.tagManager.getTagProvider(basePath.source) 13 | ?: throw Py.ValueError("No such tag provider ${basePath.source}") 14 | ) 15 | 16 | return provider.getTagConfigsAsync(listOf(basePath), recursive, true).get() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /gateway/src/main/kotlin/org/imdc/extensions/gateway/HistoryServlet.kt: -------------------------------------------------------------------------------- 1 | package org.imdc.extensions.gateway 2 | 3 | import com.inductiveautomation.ignition.common.QualifiedPathUtils 4 | import com.inductiveautomation.ignition.common.StreamingDatasetWriter 5 | import com.inductiveautomation.ignition.common.gson.stream.JsonWriter 6 | import com.inductiveautomation.ignition.common.model.values.QualityCode 7 | import com.inductiveautomation.ignition.common.sqltags.history.AggregationMode 8 | import com.inductiveautomation.ignition.common.sqltags.history.BasicTagHistoryQueryParams 9 | import com.inductiveautomation.ignition.common.sqltags.history.ReturnFormat 10 | import com.inductiveautomation.ignition.common.util.LoggerEx 11 | import com.inductiveautomation.ignition.gateway.model.GatewayContext 12 | import org.apache.http.entity.ContentType 13 | import java.io.PrintWriter 14 | import java.text.SimpleDateFormat 15 | import java.time.Instant 16 | import java.time.format.DateTimeFormatter 17 | import java.time.temporal.ChronoUnit 18 | import java.util.Date 19 | import javax.servlet.http.HttpServlet 20 | import javax.servlet.http.HttpServletRequest 21 | import javax.servlet.http.HttpServletResponse 22 | 23 | class HistoryServlet : HttpServlet() { 24 | private lateinit var context: GatewayContext 25 | 26 | override fun init() { 27 | context = servletContext.getAttribute(GatewayContext.SERVLET_CONTEXT_KEY) as GatewayContext 28 | } 29 | 30 | override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { 31 | resp.contentType = ContentType.APPLICATION_JSON.toString() 32 | resp.writer.use { writer -> 33 | val historyQuery: BasicTagHistoryQueryParams = 34 | try { 35 | val paths = 36 | req.getParameterValues("path") 37 | ?: throw IllegalArgumentException("Must specify at least one path") 38 | val startDate = 39 | req.getParameter("startDate")?.toDate() ?: Date.from(Instant.now().minus(8, ChronoUnit.HOURS)) 40 | val endDate = req.getParameter("endDate")?.toDate() ?: Date() 41 | val returnSize = req.getParameter("returnSize")?.toInt() ?: -1 42 | val aggregationMode = 43 | req.getParameter("aggregationMode")?.let(AggregationMode::valueOf) ?: AggregationMode.Average 44 | val aliases = req.getParameter("aliases")?.split(',') 45 | 46 | BasicTagHistoryQueryParams( 47 | paths.map(QualifiedPathUtils::toPathFromHistoricalString), 48 | startDate, 49 | endDate, 50 | returnSize, 51 | aggregationMode, 52 | ReturnFormat.Wide, 53 | aliases, 54 | emptyList(), 55 | ) 56 | } catch (e: Exception) { 57 | resp.status = HttpServletResponse.SC_BAD_REQUEST 58 | e.printStackTrace(PrintWriter(writer)) 59 | return 60 | } 61 | 62 | try { 63 | context.tagHistoryManager.queryHistory( 64 | historyQuery, 65 | StreamingJsonWriter( 66 | JsonWriter(writer), 67 | ), 68 | ) 69 | } catch (e: TrialExpiredException) { 70 | resp.status = HttpServletResponse.SC_PAYMENT_REQUIRED 71 | logger.error("Tag historian module reported trial expired", e) 72 | } catch (e: Exception) { 73 | resp.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR 74 | logger.error("Unexpected exception writing JSON content to servlet", e) 75 | } 76 | } 77 | } 78 | 79 | class TrialExpiredException : Exception() 80 | 81 | class StreamingJsonWriter(private val jsonWriter: JsonWriter) : StreamingDatasetWriter { 82 | private lateinit var names: Array 83 | private lateinit var types: Array> 84 | 85 | private val test = DateTimeFormatter.ISO_INSTANT 86 | 87 | override fun initialize( 88 | columnNames: Array, 89 | columnTypes: Array>, 90 | hasQuality: Boolean, 91 | expectedRows: Int, 92 | ) { 93 | this.names = columnNames 94 | this.types = columnTypes 95 | 96 | jsonWriter.beginArray() 97 | } 98 | 99 | override fun write(data: Array, quality: Array): Unit = jsonWriter.run { 100 | if (quality.any { it.`is`(QualityCode.Bad_TrialExpired) }) { 101 | throw TrialExpiredException() 102 | } 103 | 104 | writeObject { 105 | for (index in data.indices) { 106 | name(names[index]) 107 | when (val value = data[index]) { 108 | is Number -> { 109 | when (types[index]) { 110 | Float::class.java, Double::class.java -> value(value.toDouble()) 111 | else -> value(value.toLong()) 112 | } 113 | } 114 | 115 | is Date -> { 116 | value(test.format(value.toInstant())) 117 | } 118 | 119 | is String -> value(value) 120 | is Boolean -> value(value) 121 | 122 | null -> nullValue() 123 | } 124 | } 125 | } 126 | } 127 | 128 | override fun finish() { 129 | jsonWriter.endArray() 130 | } 131 | 132 | override fun finishWithError(exception: java.lang.Exception) = throw exception 133 | 134 | private inline fun JsonWriter.writeObject(block: JsonWriter.() -> Unit) { 135 | beginObject() 136 | block() 137 | endObject() 138 | } 139 | } 140 | 141 | companion object { 142 | const val PATH = "history-extension" 143 | 144 | private val logger = LoggerEx.newBuilder().build(HistoryServlet::class.java) 145 | 146 | private val parsingStrategies = listOf<(String) -> Date>( 147 | SimpleDateFormat.getDateTimeInstance()::parse, 148 | SimpleDateFormat.getInstance()::parse, 149 | { Date(it.toLong()) }, 150 | ) 151 | 152 | private fun String.toDate(): Date? { 153 | return parsingStrategies.firstNotNullOfOrNull { strategy -> 154 | try { 155 | strategy.invoke(this) 156 | } catch (e: Exception) { 157 | null 158 | } 159 | } 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /gateway/src/main/resources/org/imdc/extensions/gateway/GatewayProjectExtensions.properties: -------------------------------------------------------------------------------- 1 | getProject.desc=Retrieves a project by name, or None. 2 | getProject.param.project=The name of the project to retrieve. Defaults to the current project, if known. 3 | getProject.returns=The specified project as a RuntimeProject instance. 4 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | version=0.0.1-SNAPSHOT 2 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | java = "11" 3 | kotlin = "1.9.22" 4 | kotest = "5.8.0" 5 | ignition = "8.1.0" 6 | 7 | [plugins] 8 | kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 9 | modl = { id = "io.ia.sdk.modl", version = "0.1.1" } 10 | ktlint = { id = "org.jlleitschuh.gradle.ktlint", version = "12.1.0" } 11 | 12 | [libraries] 13 | ignition-common = { group = "com.inductiveautomation.ignitionsdk", name = "ignition-common", version.ref = "ignition" } 14 | ignition-gateway-api = { group = "com.inductiveautomation.ignitionsdk", name = "gateway-api", version.ref = "ignition" } 15 | ignition-designer-api = { group = "com.inductiveautomation.ignitionsdk", name = "designer-api", version.ref = "ignition" } 16 | ignition-client-api = { group = "com.inductiveautomation.ignitionsdk", name = "client-api", version.ref = "ignition" } 17 | ignition-vision-client-api = { group = "com.inductiveautomation.ignitionsdk", name = "vision-client-api", version.ref = "ignition" } 18 | 19 | # test framework 20 | kotest-junit = { group = "io.kotest", name = "kotest-runner-junit5", version.ref = "kotest" } 21 | kotest-assertions-core = { group = "io.kotest", name = "kotest-assertions-core", version.ref = "kotest" } 22 | kotest-property = { group = "io.kotest", name = "kotest-property", version.ref = "kotest" } 23 | kotest-data = { group = "io.kotest", name = "kotest-framework-datatest", version.ref = "kotest" } 24 | mockk = { group = "io.mockk", name = "mockk", version = "1.13.9" } 25 | 26 | [bundles] 27 | gateway = [ 28 | "ignition-common", 29 | "ignition-gateway-api", 30 | ] 31 | designer = [ 32 | "ignition-common", 33 | "ignition-designer-api", 34 | ] 35 | client = [ 36 | "ignition-common", 37 | "ignition-client-api", 38 | "ignition-vision-client-api", 39 | ] 40 | kotest = [ 41 | "kotest-assertions-core", 42 | "kotest-data", 43 | "kotest-junit", 44 | "kotest-property", 45 | ] 46 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IgnitionModuleDevelopmentCommunity/ignition-extensions/5aba12e3f5b891a4feca3fb311aa5840f40d76b9/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 87 | 88 | # Use the maximum available, or set MAX_FD != -1 to use that value. 89 | MAX_FD=maximum 90 | 91 | warn () { 92 | echo "$*" 93 | } >&2 94 | 95 | die () { 96 | echo 97 | echo "$*" 98 | echo 99 | exit 1 100 | } >&2 101 | 102 | # OS specific support (must be 'true' or 'false'). 103 | cygwin=false 104 | msys=false 105 | darwin=false 106 | nonstop=false 107 | case "$( uname )" in #( 108 | CYGWIN* ) cygwin=true ;; #( 109 | Darwin* ) darwin=true ;; #( 110 | MSYS* | MINGW* ) msys=true ;; #( 111 | NONSTOP* ) nonstop=true ;; 112 | esac 113 | 114 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 115 | 116 | 117 | # Determine the Java command to use to start the JVM. 118 | if [ -n "$JAVA_HOME" ] ; then 119 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 120 | # IBM's JDK on AIX uses strange locations for the executables 121 | JAVACMD=$JAVA_HOME/jre/sh/java 122 | else 123 | JAVACMD=$JAVA_HOME/bin/java 124 | fi 125 | if [ ! -x "$JAVACMD" ] ; then 126 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 127 | 128 | Please set the JAVA_HOME variable in your environment to match the 129 | location of your Java installation." 130 | fi 131 | else 132 | JAVACMD=java 133 | if ! command -v java >/dev/null 2>&1 134 | then 135 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 136 | 137 | Please set the JAVA_HOME variable in your environment to match the 138 | location of your Java installation." 139 | fi 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 147 | # shellcheck disable=SC3045 148 | MAX_FD=$( ulimit -H -n ) || 149 | warn "Could not query maximum file descriptor limit" 150 | esac 151 | case $MAX_FD in #( 152 | '' | soft) :;; #( 153 | *) 154 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 155 | # shellcheck disable=SC3045 156 | ulimit -n "$MAX_FD" || 157 | warn "Could not set maximum file descriptor limit to $MAX_FD" 158 | esac 159 | fi 160 | 161 | # Collect all arguments for the java command, stacking in reverse order: 162 | # * args from the command line 163 | # * the main class name 164 | # * -classpath 165 | # * -D...appname settings 166 | # * --module-path (only if needed) 167 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 168 | 169 | # For Cygwin or MSYS, switch paths to Windows format before running java 170 | if "$cygwin" || "$msys" ; then 171 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 172 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 173 | 174 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 175 | 176 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 177 | for arg do 178 | if 179 | case $arg in #( 180 | -*) false ;; # don't mess with options #( 181 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 182 | [ -e "$t" ] ;; #( 183 | *) false ;; 184 | esac 185 | then 186 | arg=$( cygpath --path --ignore --mixed "$arg" ) 187 | fi 188 | # Roll the args list around exactly as many times as the number of 189 | # args, so each arg winds up back in the position where it started, but 190 | # possibly modified. 191 | # 192 | # NB: a `for` loop captures its iteration list before it begins, so 193 | # changing the positional parameters here affects neither the number of 194 | # iterations, nor the values presented in `arg`. 195 | shift # remove old arg 196 | set -- "$@" "$arg" # push replacement arg 197 | done 198 | fi 199 | 200 | 201 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 202 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 203 | 204 | # Collect all arguments for the java command; 205 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 206 | # shell script including quotes and variable substitutions, so put them in 207 | # double quotes to make sure that they get re-expanded; and 208 | # * put everything else in single quotes, so that it's not re-expanded. 209 | 210 | set -- \ 211 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 212 | -classpath "$CLASSPATH" \ 213 | org.gradle.wrapper.GradleWrapperMain \ 214 | "$@" 215 | 216 | # Stop when "xargs" is not available. 217 | if ! command -v xargs >/dev/null 2>&1 218 | then 219 | die "xargs is not available" 220 | fi 221 | 222 | # Use "xargs" to parse quoted args. 223 | # 224 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 225 | # 226 | # In Bash we could simply go: 227 | # 228 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 229 | # set -- "${ARGS[@]}" "$@" 230 | # 231 | # but POSIX shell has neither arrays nor command substitution, so instead we 232 | # post-process each arg (as a line of input to sed) to backslash-escape any 233 | # character that might be a shell metacharacter, then use eval to reverse 234 | # that process (while maintaining the separation between arguments), and wrap 235 | # the whole thing up as a single "set" statement. 236 | # 237 | # This will of course break if any of these variables contains a newline or 238 | # an unmatched quote. 239 | # 240 | 241 | eval "set -- $( 242 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 243 | xargs -n1 | 244 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 245 | tr '\n' ' ' 246 | )" '"$@"' 247 | 248 | exec "$JAVACMD" "$@" 249 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ], 6 | "ignoreDeps": [ 7 | "com.inductiveautomation.ignitionsdk:vision-client-api", 8 | "com.inductiveautomation.ignitionsdk:client-api", 9 | "com.inductiveautomation.ignitionsdk:designer-api", 10 | "com.inductiveautomation.ignitionsdk:gateway-api", 11 | "com.inductiveautomation.ignitionsdk:ignition-common" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 2 | 3 | pluginManagement { 4 | repositories { 5 | gradlePluginPortal() 6 | maven("https://nexus.inductiveautomation.com/repository/public") 7 | } 8 | } 9 | 10 | rootProject.name = "ignition-extensions" 11 | 12 | include( 13 | "common", 14 | "gateway", 15 | "designer", 16 | "client", 17 | ) 18 | --------------------------------------------------------------------------------