From 60ce9453872f4daa7a8ead3dce19b75f82c22798 Mon Sep 17 00:00:00 2001 From: AndrewMusser Date: Tue, 21 Nov 2023 11:55:09 -0500 Subject: [PATCH] Prepare for public release --- .github/workflows/release.yml | 59 + .gitignore | 133 ++ .gitmodules | 5 + .vscode/launch.json | 18 + CHANGELOG.md | 43 + LICENSE | 23 + README.md | 16 + docs/POSTINSTALL.txt | 0 docs/PREINSTALL.txt | 0 files/favicon.ico | Bin 0 -> 4286 bytes requirements.txt | 11 + src/ASPython/.gitignore | 129 ++ src/ASPython/.vscode/launch.json | 15 + src/ASPython/.vscode/settings.json | 24 + src/ASPython/ASCncConfig.py | 44 + src/ASPython/ASTools.py | 1552 +++++++++++++++++++ src/ASPython/CHANGELOG.md | 27 + src/ASPython/CmdLineARSim.py | 118 ++ src/ASPython/CmdLineBuild.py | 113 ++ src/ASPython/CmdLineCreateInstaller.py | 125 ++ src/ASPython/CmdLineDeployLibraries.py | 74 + src/ASPython/CmdLineExportLib.py | 121 ++ src/ASPython/CmdLineGetSafetyCrc.py | 61 + src/ASPython/CmdLineGetVersion.py | 63 + src/ASPython/CmdLinePackageHmi.py | 119 ++ src/ASPython/CmdLineRunUnitTests.py | 69 + src/ASPython/ColorCodedLog.py | 189 +++ src/ASPython/ExportLibraries.py | 94 ++ src/ASPython/Files/DisconnectFileDevice.bat | 1 + src/ASPython/Files/InnoInstaller.iss | 118 ++ src/ASPython/Files/favicon.ico | Bin 0 -> 4286 bytes src/ASPython/InstallUpgrades.py | 128 ++ src/ASPython/LICENSE | 21 + src/ASPython/README.md | 26 + src/ASPython/UI.py | 282 ++++ src/ASPython/UnitTestTools.py | 43 + src/ASPython/_version.py | 1 + src/LPM.cmd | 1 + src/LPM.py | 1103 +++++++++++++ utils/BuildInstaller.bat | 1 + utils/Setup.iss | 125 ++ 41 files changed, 5095 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .vscode/launch.json create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/POSTINSTALL.txt create mode 100644 docs/PREINSTALL.txt create mode 100644 files/favicon.ico create mode 100644 requirements.txt create mode 100644 src/ASPython/.gitignore create mode 100644 src/ASPython/.vscode/launch.json create mode 100644 src/ASPython/.vscode/settings.json create mode 100644 src/ASPython/ASCncConfig.py create mode 100644 src/ASPython/ASTools.py create mode 100644 src/ASPython/CHANGELOG.md create mode 100644 src/ASPython/CmdLineARSim.py create mode 100644 src/ASPython/CmdLineBuild.py create mode 100644 src/ASPython/CmdLineCreateInstaller.py create mode 100644 src/ASPython/CmdLineDeployLibraries.py create mode 100644 src/ASPython/CmdLineExportLib.py create mode 100644 src/ASPython/CmdLineGetSafetyCrc.py create mode 100644 src/ASPython/CmdLineGetVersion.py create mode 100644 src/ASPython/CmdLinePackageHmi.py create mode 100644 src/ASPython/CmdLineRunUnitTests.py create mode 100644 src/ASPython/ColorCodedLog.py create mode 100644 src/ASPython/ExportLibraries.py create mode 100644 src/ASPython/Files/DisconnectFileDevice.bat create mode 100644 src/ASPython/Files/InnoInstaller.iss create mode 100644 src/ASPython/Files/favicon.ico create mode 100644 src/ASPython/InstallUpgrades.py create mode 100644 src/ASPython/LICENSE create mode 100644 src/ASPython/README.md create mode 100644 src/ASPython/UI.py create mode 100644 src/ASPython/UnitTestTools.py create mode 100644 src/ASPython/_version.py create mode 100644 src/LPM.cmd create mode 100644 src/LPM.py create mode 100644 utils/BuildInstaller.bat create mode 100644 utils/Setup.iss diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2f40f28 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,59 @@ +name: LPM Installer Publish +run-name: LPM Installer Publish +on: + push: + tags: + - '*' +jobs: + Explore-GitHub-Actions: + runs-on: windows-latest + steps: + - name: Check out repository code + uses: actions/checkout@v4 + with: + submodules: true + token: ${{ secrets.GH_LPM_ASPYTHON_TOKEN }} + - name: Download + Install Inno + uses: pwall2222/inno-setup-download@v0.0.4 + - name: Create the EXE Installer + working-directory: ./utils + run: | + ./BuildInstaller.bat + - name: Code Signing Setup - Setup Certificate + run: | + echo "${{secrets.SM_CLIENT_CERT_FILE_B64 }}" | base64 --decode > /d/Certificate_pkcs12.p12 + cat /d/Certificate_pkcs12.p12 + shell: bash + - name: Code Signing Setup - Set variables + id: variables + run: | + echo "::set-output name=version::${GITHUB_REF#refs/tags/v}" + echo "SM_HOST=${{ secrets.SM_HOST }}" >> "$GITHUB_ENV" + echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> "$GITHUB_ENV" + echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_ENV" + echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> "$GITHUB_ENV" + echo "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" >> $GITHUB_PATH + echo "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools" >> $GITHUB_PATH + echo "C:\Program Files\DigiCert\DigiCert One Signing Manager Tools" >> $GITHUB_PATH + shell: bash + - name: Code Signing Setup - Setup SSM KSP on windows latest + run: | + curl -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/smtools-windows-x64.msi/download -H "x-api-key:%SM_API_KEY%" -o smtools-windows-x64.msi + msiexec /i smtools-windows-x64.msi /quiet /qn + smksp_registrar.exe list + smctl.exe keypair ls + C:\Windows\System32\certutil.exe -csp "DigiCert Signing Manager KSP" -key -user + smksp_cert_sync.exe + shell: cmd + - name: Code Signing Setup - Sign using Signtool + run: | + signtool.exe sign /sha1 ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 "./build/LPM-Setup.exe" + signtool.exe verify /v /pa "./build/LPM-Setup.exe" + - name: Upload Installer to S3 + run: | + aws configure set aws_access_key_id ${{ secrets.LPM_S3_ACCESS_KEY_ID }} + aws configure set aws_secret_access_key ${{ secrets.LPM_S3_SECRET_ACCESS_KEY }} + aws s3 cp ./build/LPM-Setup.exe s3://loupe-lpm-assets/releases/latest/LPM-Setup.exe --region us-west-2 + aws s3 cp ./build/LPM-Setup.exe s3://loupe-lpm-assets/releases/${{ github.ref_name }}/LPM-Setup.exe --region us-west-2 + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36bf581 --- /dev/null +++ b/.gitignore @@ -0,0 +1,133 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +ignore/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Exclude temp directory (for testing) +/temp diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..60d402d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,5 @@ + +[submodule "src/ASPython"] + path = src/ASPython + url = https://github.com/loupeteam/ASPython.git + branch = main diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..fcd814d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Current File", + "type": "python", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "justMyCode": true, + "args": ["install","atn@v0.05.1-rc-test","-src"], + "cwd": "${workspaceFolder}/test" + } + ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4067ac5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,43 @@ +# Change log + +- 1.0.0 - Public release + +- 0.5.3 - Add supporting files in preparation for open-sourcing + - Add automatic code signing of installers to Github workflow + +- 0.5.2 - Deploy LPM as Python source instead of compiled binaries + +- 0.5.1 - Improve the functionality for retrieving a package type + +- 0.5.0 - Simplify login process by only requiring a token to be entered + - Add LPM view and info commands to get individual package info + - Add LPM viewall command to get a list of all available packages + +- 0.4.2 - Fix casing bug for dependency names + +- 0.4.1 - Add support for HMI package types + +- 0.4.0 - Add support for new package types (package vs. library vs. project) + - Add support for project level-configuration settings (i.e. deployment configurations) + - Add convenience command to open an AS project + - Add support for deploying packages in an AS project + +- 0.3.1 - Update copy command to address deprecation warning + +- 0.3.0 - Add support for LPM usage by Jenkins in CI contexts + - Add support for installing source via LPM + - Update ASPython dependency + +- 0.2.0 - Add more capabilities to the publish workflow + +- 0.1.5 - Roll out publish and full authentication functionality + +- 0.1.4 - Rename the starter AS project + +- 0.1.3 - Add support for bootstrapping a starter AS project + +- 0.1.2 - Update ASPython dependency + +- 0.1.1 - Improve anti-virus compatibility by changing packaging tool from PyInstaller to Nuitka + +- 0.1.0 - Initial version \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..043fb63 --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ +MIT License +=========== + +Copyright (c) 2023 Loupe +https://loupe.team/ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..318a855 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Info +This tool is provided by Loupe. +https://loupe.team +info@loupe.team +1-800-240-7042 + +# Description + +LPM is the Loupe Package Manager. This tool is designed to make it easy to interact with Loupe packages within the Automation Studio and webHMI ecosystems. It provides a command line interface for installing packages in a project, and for managing their lifecycle (version update, dependency checks, removal, etc). + +# Documentation + +Documentation for LPM use, including installation instructions and use cases, can be found [here](https://loupeteam.github.io/LoupeDocs/tools/lpm.html). + +# Licensing +This project is licensed under the [MIT License](LICENSE.md). diff --git a/docs/POSTINSTALL.txt b/docs/POSTINSTALL.txt new file mode 100644 index 0000000..e69de29 diff --git a/docs/PREINSTALL.txt b/docs/PREINSTALL.txt new file mode 100644 index 0000000..e69de29 diff --git a/files/favicon.ico b/files/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..418c43705b33f5454a93f006380594437d5544dd GIT binary patch literal 4286 zcmchaJ8KkC7>3VgH5>4V1cqpUu5PraASPyx7u-?>g?2X;<0=~gT$jieja_@rMa398D z3v|E;yo9_wsC^9Hz)!Hh{k;WiAWsj8_B(XEKj-)fn1L$vgOP`aM(t#LH=*|gzQPbB zv78=?B(=|z`%bj2uS;NGJcT}}kLB{PgQU*u`kJcOwiXV6dX3SJhmTxjJ*S29*)??% z)NRyu-L;R>qLqiMTx4$hG%c(%+l)guBz^9E|4W-DwP@yHBWKQnxv$h(HUB!!Ve4xK z?t$ZwMVsceX!J16QTMAau)o%#`PVuQyQezO_4)~pLaRRKbkop-wekVXWlqOnEu@9# zY?J7_ukHhLWeq*+y;0wLOp7C&u#fDk2Vnm{hrN)+gWBeD?94F@uGNn1AxMj){w8Q( z%_Q}g#Y3X)J*aICYk2}3hgS9HIoq6vE~uY>?RaRWoy_CO#oQP8O^ZF8u;zT0I0;!i zI6eoV{=O4!=e&LK3I-r8Iyo^0vyjz8AJ;sCq|Y0*UDI9L?W0z)R1ZTWwZ7b=+^f`2 z?mN+TZ)+!(;-R0!Rfxc~(pt7JvTAclEwXxWUf%)N4xf$vwHEC2cJG^}e??XgKAW8Z zpVjm-3`^DK(pt3Z!SOc=;Jo=?U2`ST%7c5h&xRH7**vdE^WZy(&nyx6ZjxuzdzeGW zcdse1-X4MLY~G=VQE+c`trqKi1RVR72t8QaH^4o^cX8j5GDke$6UWsul%NQOI6fER zUm=`cV6C3-2tCzeC`ZMKQdF#zB0uG7De9RoMFR_e@W03W*rre^RYRpb9|n8OVXioA Oh~o)#DiEm$X#WO3Xhenp literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9a6358f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +certifi==2023.5.7 +charset-normalizer==3.1.0 +idna==3.4 +inquirerpy==0.3.4 +ordered-set==4.1.0 +pfzy==0.3.4 +prompt-toolkit==3.0.38 +requests==2.30.0 +termcolor==2.3.0 +urllib3==2.0.2 +wcwidth==0.2.6 diff --git a/src/ASPython/.gitignore b/src/ASPython/.gitignore new file mode 100644 index 0000000..b6e4761 --- /dev/null +++ b/src/ASPython/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/src/ASPython/.vscode/launch.json b/src/ASPython/.vscode/launch.json new file mode 100644 index 0000000..17e15f2 --- /dev/null +++ b/src/ASPython/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Current File", + "type": "python", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal" + } + ] +} \ No newline at end of file diff --git a/src/ASPython/.vscode/settings.json b/src/ASPython/.vscode/settings.json new file mode 100644 index 0000000..915f355 --- /dev/null +++ b/src/ASPython/.vscode/settings.json @@ -0,0 +1,24 @@ +{ + "cSpell.words": [ + "DAIP", + "ansic", + "ctypes", + "dir", + "etree", + "fnmatch", + "isdir", + "isfile", + "iwusr", + "lxml", + "mkdir", + "nargs", + "path", + "pid", + "popen", + "shutil", + "tcpip", + "windll" + ], + "python.linting.pylintEnabled": true, + "python.linting.enabled": true +} \ No newline at end of file diff --git a/src/ASPython/ASCncConfig.py b/src/ASPython/ASCncConfig.py new file mode 100644 index 0000000..49ad558 --- /dev/null +++ b/src/ASPython/ASCncConfig.py @@ -0,0 +1,44 @@ +''' + * File: ASCncConfig.py + * Copyright (c) 2023 Loupe + * https://loupe.team + * + * This file is part of ASPython, licensed under the MIT License. +''' +''' +AS Tools - Cnc Config + +This package contains functions necessary to perform actions on +AS Cnc Configuration files outside of Automation Studio. + +Requires lxml +''' + +import os.path +# import xml.etree.ElementTree as ET +import lxml.etree as ET + +__version__ = '0.0.0.1' + +def listOfProcs(tree, include_comments=False): + procs = [] + for node in tree.xpath('//BuiltInProcs'): + for child in node: + if child.tag is not ET.Comment: + if include_comments and child.getprevious() is not None and child.getprevious().tag is ET.Comment: + print("") + procs.append(child.getprevious()) + print(child.tag) + procs.append(child) + return procs + +def main(): + tree = ET.parse('test/gmcipubr.cnc') + + # print(ET.tostring(tree, pretty_print=True)) + listOfProcs(tree, include_comments=True) + + +if __name__ == "__main__": + main() + diff --git a/src/ASPython/ASTools.py b/src/ASPython/ASTools.py new file mode 100644 index 0000000..c2f9d47 --- /dev/null +++ b/src/ASPython/ASTools.py @@ -0,0 +1,1552 @@ +''' + * File: ASTools.py + * Copyright (c) 2023 Loupe + * https://loupe.team + * + * This file is part of ASPython, licensed under the MIT License. +''' +''' +AS Tools + +This package contains all functions necessary to perform actions on +AS projects outside of Automation Studio. +''' + +import fnmatch +import os.path +import json +import pathlib +import shutil +import subprocess +from typing import Dict, Tuple, Sequence, Union, List, Optional +import xml.etree.ElementTree as ET +import logging +import sys +import re +import ctypes +import configparser + +# TODO: Support finding as default build paths +# TODO: Build project wrapper +# TODO: Move a lot of functionality into classes +# TODO: Add ability to manage package files +# TODO: Switch to lxml +# TODO: Support SGC +# TODO: Support partial library exports, for example if SG4 is successful but SG4-arm fails +# This will require returning additional information or cleaning up partition exports ourselves +# TODO: Support ARSim + +ASReturnCodes = { + "Errors-Warnings": 3, + "Errors": 2, + "Warnings": 1, + "None": 0 +} + +PVIReturnCodeText = { + 0: 'Application completed successfully', + 28320: 'File not found (.PIL file or "call" command)', + 28321: 'Filename not specified (command line parameter)', + 28322: 'Unable to load BRErrorLB.DLL ("ReadErrorLogBook" command)', + 28323: 'DLL entry point not found ("ReadErrorLogBook" command)', + 28324: 'BR module not found ("Download" command)', + 28325: 'Syntax error in command line', + 28326: 'Unable to start PVI Manager ("StartPVIMan" command)', + 28327: 'Unknown command', + 28328: 'Unable to connect ("Connection" command with "C" parameter)', + 28329: 'Unable to establish connection in bootstrap loader mode', + 28330: 'Error transferring operating system in bootstrap loader mode', + 28331: 'Process aborted', + 28332: 'The specified directory doesn\'t exist', + 28333: 'No directory specified', + 28334: 'The application used to create an AR update file wasn\'t found ("ARUpdateFileGenerate" command)', + 28335: 'The specified AR base file (*.s*) is invalid ("ARUpdateFileGenerate" command)', + 28336: 'Error creating the AR update file ("ARUpdateFileGenerate" command)', + 28337: 'There is no valid connection to the PLC. In order to be able to read the CAN baud rate, the CAN ID or the CAN node number, you need a connection to the PLC', + 28338: 'The specified logger module doesn\'t exist on PLC ("Logger" command)', + 28339: 'The specified .br file is not a valid logger module ("Logger" command)', + 28340: 'The .pil file does not contain any information about the AR version to be installed.', + 28341: 'Transfer to the corresponding target system is not possible since the AR version on the target system does not yet support the transfer mode' +} + +def getASPath(version:str) -> str: + base = "C:\\BrAutomation" + if version.lower() == 'base': + return base + else: + return os.path.join(base, version.upper(), 'Bin-en') + +def getASBuildPath(version:str) -> str: + if version.lower() == 'base': + return getASPath('base') + else: + return os.path.join(getASPath(version), "BR.AS.Build.exe") + +def getPVITransferPath(version:str) -> str: + base = getASPath('base') + return os.path.join(base, 'PVI', version, 'PVI', 'Tools', 'PVITransfer') + +def ASProjetGetConfigs(project: str) -> [str]: + + if(os.path.isfile(project)): + project = os.path.split(project)[0] + + project = os.path.join(project, 'Physical') + + configs = [d for d in os.listdir(project) if os.path.isdir(os.path.join(project, d))] + + return configs + +def batchBuildAsProject(project, ASPath:str, configurations=None, buildMode='Build', tempPath='', logPath='', binaryPath='', simulation=False, additionalArg:Union[str,list,tuple]=None) -> subprocess.CompletedProcess: + if configurations is None: configurations = [] + + for config in configurations: + completedProcess = buildASProject(project, ASPath, configuration=config, buildMode=buildMode, tempPath=tempPath, logPath=logPath, binaryPath=binaryPath, simulation=simulation, additionalArg=additionalArg) + if completedProcess.returncode > ASReturnCodes["Warnings"]: + # Call out the end of a failed build + logging.info(f'Build for configuration {config} has completed with errors, see DEBUG logging for details') + return completedProcess + else: + # Call out the end of a successful build + logging.info(f'Build for configuration {config} has completed without errors, see DEBUG logging for details') + + return completedProcess + +def buildASProject(project, ASPath:str, configuration='', buildMode='Build', tempPath='', binaryPath='', logPath='', simulation=False, additionalArg:Union[str,list,tuple]=None) -> subprocess.CompletedProcess: + + commandLine = [] + commandLine.append(ASPath) + commandLine.append('"' + os.path.abspath(project) + '"') + + if configuration: + commandLine.append('-c') + commandLine.append(configuration) + + # Possible valid values: Build, Rebuild, BuildAndTransfer, BuildAndCreateCompactFlash + if buildMode: + commandLine.append('-buildMode') + commandLine.append(buildMode) # Documentation says this needs " around value but so far testing proves not + if(buildMode.capitalize() == 'Rebuild'): + commandLine.append('-all') + + if tempPath: + commandLine.append('-t') + commandLine.append(tempPath) + + if binaryPath: + commandLine.append('-o') + commandLine.append(binaryPath) + + if simulation: + commandLine.append('-simulation') + + commandLine.append('-buildRUCPackage') + + if additionalArg: + if type(additionalArg) is str: + commandLine.append(additionalArg) + elif type(additionalArg) is list or type(additionalArg) is tuple: + commandLine.extend(additionalArg) + + # Call out the beginning of the build + logging.info(f'Starting build for configuration {configuration}...') + + # Execute the process, and retrieve the process object for further processing. + logging.debug(commandLine) + process = subprocess.Popen(commandLine, stdout=subprocess.PIPE, encoding="utf-8", errors='replace') + + logging.info("Recording build log here: " + os.path.join(logPath, "build.log")) + + with open(os.path.join(logPath, "build.log"), "w", encoding='utf-8') as f: + + # TODO: find out if Jenkins is calling the script, and if not then don't augment the console message + while process.returncode == None: + raw = process.stdout.readline() + data = raw.rstrip() + f.write(raw) + if data != "": + # Search for the "warning" pattern. + warningMatch = re.search('warning [0-9]*:', data) + errorMatch = re.search('error [0-9]*:', data) + if (warningMatch != None): + logging.warning("\033[32m" + data +"\033[0m") + elif (errorMatch != None): + logging.error("\033[31m" + data +"\033[0m") + else: + logging.debug(data) + process.poll() + + return process + +def CreateARSimStructure(RUCPackage:str, destination:str, version:str, startSim:bool=False): + logging.info(f'Creating ARSim structure at {destination}') + RUCPath = os.path.dirname(RUCPackage) + RUCPil = os.path.join(RUCPath, 'CreateARSim.pil') + with open(RUCPil, 'w+') as f: + f.write(f'CreateARsimStructure "{RUCPackage}", "{destination}", "Start={int(startSim)}"\n') + # If ARsim is being started, add a line that waits for a connection to be established. + if startSim: + f.write('Connection "/IF=TCPIP /SA=1", "/DA=2 /DAIP=127.0.0.1 /REPO=11160", "WT=120"') + + arguments = [] + print('PVI version: ' + version) + arguments.append(os.path.join(getPVITransferPath(version), 'PVITransfer.exe')) + # arguments.append('-automatic') # startSim only works with automatic mode + arguments.append('-silent') + # arguments.append('-autoclose') + arguments.append(RUCPil) + logging.debug(arguments) + process = subprocess.run(arguments) + + logging.debug(process) + if(process.returncode == 0): + logging.debug('ARSim created') + + if startSim: + # This because silent and autoclose mode do not support starting arsim + pid = subprocess.Popen(os.path.join(destination, 'ar000loader.exe'), stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE, creationflags=0x00000008) + else: + logging.debug(f'Error in creating ARSimStructure code {process.returncode}: {PVIReturnCodeText[process.returncode]}') + + return process + +class LibExportInfo(object): + def __init__(self, name, path, exception=None, lib=None): + self.name = name + self.path = path + self.lib = lib + self.exception = exception + + super().__init__() + +class ProjectExportInfo(object): + def __init__(self): + self._success = [] + self._failed = [] + super().__init__() + + def addLibInfo(self, libInfo:LibExportInfo): + if libInfo.exception is None: + self._success.append(libInfo) + else: + self._failed.append(libInfo) + + def extend(self, *exportInfo): + for info in exportInfo: + self._success.extend(info._success) + self._failed.extend(info._failed) + + @property + def success(self) -> List[LibExportInfo]: + return self._success + + @property + def failed(self) -> List[LibExportInfo]: + return self._failed + +class Dependency: + def __init__(self, name:str, minVersion='', maxVersion=''): + self.name = name + self.minVersion = minVersion + self.maxVersion = maxVersion + +class BuildConfig: + def __init__(self, name, path='', typ='sg4', hardware=''): + self.name = name + self.type = typ + self.hardware = hardware + self.path = path + +class xmlAsFile: + def __init__(self, path: str, new_data:ET.ElementTree=None): + self.path = path + if (new_data == None): + self.read() + else: + # In this case we create new content based on type. + self._package = new_data + self.package.write(self.path, xml_declaration=True, encoding='utf-8', method='xml') + + def read(self): + '''Reads AS xml file into xml tree''' + if not os.path.exists(self.path): raise FileNotFoundError(self.path) + self._package = ET.parse(self.path) + return self + + def write(self): + '''Writes xml tree to file with AS Namespace''' + # TODO: This loses the . This shouldn't cause any issues though + # This can be solved by extracting xml stuff with file writing (function that returns xml as string) then modify and write that + ns = self._getASNamespace(self.package) + ET.register_namespace('', ns) # TODO: This is a ET global effect + self._indentXml(self.package.getroot()) # When we add items indent gets messed up + self.package.write(self.path, xml_declaration=True, encoding='utf-8', method='xml') + return self + + def find(self, *levels) -> ET.Element: + path = '.' + for level in levels: + path += '/' + self.nameSpaceFormatted + level + + return self.root.find(path) + + def findall(self, *levels) -> List[ET.Element]: + path = '.' + for level in levels: + path += '/' + self.nameSpaceFormatted + level + + return self.root.findall(path) + + @property + def nameSpaceFormatted(self) -> str: + ns = self.nameSpace + if ns != '': + ns = '{' + ns + '}' + return ns + + @property + def nameSpace(self) -> str: + return self._getASNamespace(self.package) + + @property + def root(self) -> ET.Element: + return self.package.getroot() + + @property + def package(self) -> ET.ElementTree: + return self._package + + @property + def dirPath(self) -> str: + return os.path.dirname(self.path) + + @property + def getXmlType(self) -> str: + '''Returns a string representation of xml type + Note: This is for debug and view purposes only at this point, API may change + ''' + # TODO: Populates this list + # Package + # Library + # Program + # Hardware - Not Supported + ns = self._getASNamespace(self.package) + return ns.split('/')[-1] + + @staticmethod + def _indentXml(elem: ET.Element, level=0) -> None: + '''Indent Element and sub elements''' + + i = "\n" + level*" " + if len(elem): + if not elem.text or not elem.text.strip(): + elem.text = i + " " + if not elem.tail or not elem.tail.strip(): + elem.tail = i + for elem in elem: + xmlAsFile._indentXml(elem, level+1) + if not elem.tail or not elem.tail.strip(): + elem.tail = i + else: + if level and (not elem.tail or not elem.tail.strip()): + elem.tail = i + + @staticmethod + def _getASNamespace(package: ET.ElementTree) -> str: + '''Get Automation Studio's namespace for xml files''' + ns = package.getroot().tag.split('}') + if ns[0][0] == '{' : + ns = ns[0][1:] + else: + ns = '' + + return ns + # Examples: 'http://br-automation.co.at/AS/Package', 'http://br-automation.co.at/AS/Physical' + + @staticmethod + def _getASNamespaceFormatted(package: ET.ElementTree) -> str: + '''Get Automation Studio's namespace for xml files formatted for ElementTree''' + ns = xmlAsFile._getASNamespace(package) + if ns != '': + ns = '{' + ns + '}' + return ns + +class Library(xmlAsFile): + ''' + TODO: Lib files appears to support or + AS will change from Files to Objects when a sub folder is added + Using when AS prefers is fine + Using when AS prefers is also fine + If changed to a non preferred method, AS will change back everytime it edits the pkg file + ''' + def __init__(self, path): + if(os.path.isdir(path)): + path = os.path.join(path, getLibraryType(path) + '.lby') + + self.name = os.path.basename(os.path.dirname(path)) # Lib name is same as folder name + self._dependencies = [] + super().__init__(path) + self._xmlTag = self._getXmlTag(self.package) + self._xmlTagChild = self._xmlTag[:-1] # We just want to remove the 's' + + @property + def files(self) -> ET.Element: + return self.find(self._xmlTag) + + @property + def fileList(self): + return self.findall(self._xmlTag, self._xmlTagChild) + + @property + def dependencyList(self): + return self.findall('Dependencies', 'Dependency') + + @property + def dependencies(self) -> List[Dependency]: + self._dependencies.clear() + for element in self.dependencyList: + self._dependencies.append(Dependency(element.get('ObjectName', 'Unknown'), element.get('FromVersion', ''), element.get('ToVersion', ''))) + + return self._dependencies + + @property + def dependencyNames(self) -> List[str]: + names = [] + for dep in self.dependencies: + names.append(dep.name) + + return names + + @property + def version(self) -> str: + return self.root.get("Version", '0') + + @property + def description(self) -> str: + return self.root.get("Description", '') + + @property + def type(self): + return getLibraryType(self.dirPath) + + def addObject(self, *paths): + '''TODO: This should support packages''' + for path in paths: + if not os.path.isfile(path) and not os.path.isdir: raise FileNotFoundError(path) + + name = os.path.split(path)[1] + newPath = os.path.join(self.path, name) + shutil.copyfile(path, newPath) + self._addObjectElement(newPath) + self.write() + + def _addObjectElement(self, path): + element = self._createPkgElement(path, self._xmlTagChild) + self.files.append(element) + if(element.get('Type') == 'Package' and self._xmlTag != 'Objects'): + self._convertXmlTag(self._xmlTag, 'Objects') + + def addDependency(self, *dependency): + for dependent in dependency: + if dependent is not Dependency: raise TypeError('Expected Dependency class got', type(dependent)) + # TODO: Check if dependency exist if so update instead + self.dependencies.append(self._createDependencyElement(dependent)) + + def export(self, dest, buildFolder, buildConfigs, overwrite=False, binary=True, includeVersion=False) -> LibExportInfo: + path = os.path.join(dest, self.name) + if(includeVersion): + path = os.path.join(path, 'V%s' % self.version) + + info = LibExportInfo(self.name, path, None, self) + + try: + if overwrite and os.path.exists(path): + logging.debug('Export already exists, removing %s', path) + shutil.rmtree(path, onerror=self._rmtreeOnError) + + # pathlib.Path(path).mkdir(parents=True, exist_ok=True) # Create directory if it does not exist + + if binary: + self._collectBinaryLibrary(buildFolder, path, buildConfigs) + else: + self._collectSourceLibrary(self.dirPath, path) + + except (FileNotFoundError, FileExistsError) as error: + logging.debug(error) + info.exception = error + + return info + + def synchronize(self): + objects = self.files + + # Read Dir + items = [i for i in os.listdir(self.dirPath)] + usedItems = [] + toRemove = [] + + # Update XML + for obj in objects: + if obj.text not in items: + # print('Removing:', element.text) + # Removing here will cause issues with loop + toRemove.append(obj) + else: + usedItems.append(obj.text) + + for obj in toRemove: + objects.remove(obj) + + for item in items: + if item not in usedItems: + if os.path.splitext(item)[1] != '.lby': + if item not in ('SG4', 'SG3', 'SGC'): # We don't want to add library file to files + self._addObjectElement(os.path.join(self.dirPath, item)) + + # Save + self.write() + + def _convertXmlTag(self, fromTag: str, toTag: str): + childTag = toTag[:-1] + for elem in self.findall(fromTag): + # print(elem) + elem.tag = self.nameSpaceFormatted + toTag + for child in elem: + # print(child) + child.tag = self.nameSpaceFormatted + childTag + if toTag == 'Objects': + # We need to add type and so on + child.set('Type', 'File') + + self._xmlTag = toTag + self._xmlTagChild = childTag + + def _collectBinaryLibrary(self, buildFolder, dest, buildConfigs:List[BuildConfig]) -> None: + '''Copies all files for a binary library into dest''' + + packageFileName = self.type + '.lby' + + # buildPaths["source"] + builds = {} + # builds + for build in buildConfigs: + if builds.get(build.type) is None: + builds[build.type] = build + + # Collect the required source files, while ignoring certain extensions. + self._collectSourceLibrary(self.dirPath, dest, ['.c','.st','.cpp','.git','.vscode','.gitignore','jenkinsfile','CMakeLists.txt'], True) + + if builds.get("sg4") != None: + self._collectConfigBinary(buildFolder, builds["sg4"], self.name, os.path.join(dest, 'SG4')) # Collect SG4 Intel + if builds.get("sg4_arm") != None: + self._collectConfigBinary(buildFolder, builds["sg4_arm"], self.name, os.path.join(dest, 'SG4', 'Arm')) # Collect SG4 ARM + + # TODO: Support SG3 and lower + + os.rename(os.path.join(dest, packageFileName), os.path.join(dest, 'Binary.lby')) + newLib = Library(os.path.join(dest, 'Binary.lby')) + newLib.root.set('SubType','Binary') + newLib.synchronize() + # updateLibraryFile(os.path.join(dest, 'Binary.lby')) + + return + + @staticmethod + def _formatVersionString(version: str) -> str: + new_version_list = [] + for x in version.split(sep='.'): + new_version_list.append(str(int(x))) + return '.'.join(new_version_list) + + @staticmethod + def _createPkgElement(path: str, tag: str) -> ET.Element: + # Create the element from path to be added + attributes = {} + attributes['Type'] = getPkgType(path) + if attributes['Type'] == 'Library': + attributes['Language'] = getLibraryType(path) + if attributes['Type'] == 'Program': + attributes['Language'] = getProgramType(path) + element = ET.Element(tag, attrib=attributes) + element.text = os.path.split(path)[1] + element.tail = "\n" #+2*" " Just stick with newline for now + return element + + @staticmethod + def _createDependencyElement(dependency:Dependency): + # Create the element from path to be added + attributes = {} + attributes['ObjectName'] = dependency.name + if(dependency.minVersion): + attributes['FromVersion'] = dependency.minVersion + if(dependency.maxVersion): + attributes['ToVersion'] = dependency.maxVersion + return ET.Element('Dependency', attributes) + + @staticmethod + def _getXmlTag(package: ET.ElementTree) -> str: + namespace = Library._getASNamespaceFormatted(package) + for child in package.getroot(): + if child.tag.replace(namespace, '') in ('Files', 'Objects'): + return child.tag.replace(namespace, '') + return 'Files' # If none is found. Probably not a .lby file + + @staticmethod + def _rmtreeOnError(func, path, exc_info): + ''' + Error handler for ``shutil.rmtree``. + + If the error is due to an access error (read only file) + it attempts to add write permission and then retries. + + If the error is for another reason it re-raises the error. + + Usage : ``shutil.rmtree(path, onerror=onerror)`` + ''' + import stat + if not os.access(path, os.W_OK): + # Is the error an access error ? + # logging.debug('Access failed on: %s. Allowing access.', path) + os.chmod(path, stat.S_IWUSR) + func(path) + else: + raise Exception(*exc_info) + + @staticmethod + def _collectSourceLibrary(sourceFolder: Union[str], dest: Union[str], excludes=None, ignoreFolders=False) -> None: + '''Copies all files for a source library into dest + + Ignores excludes, a glob style sequence + ''' + if excludes is None: excludes = [] + + def _ignorePatterns(path, names): + ignores = [] + + for name in names: + # First evaluate the filter list. + for item in excludes: + if name.lower().endswith(item.lower()): + ignores.append(name) + # Then add to it all folders if required. + if ignoreFolders and os.path.isdir(os.path.join(path, name)): + ignores.append(name) + return ignores + + # TODO: This errors if directory already exists + # This function just doesn't support that + # dir_util.copy_tree() is an option but it might not support ignore + shutil.copytree(sourceFolder, dest, ignore=_ignorePatterns) + return + + @staticmethod + def _collectConfigBinary(tempPath: str, config: BuildConfig, libraryName: str, dest) -> None: + '''Collects all binary files associated with a HW Config''' + + pathlib.Path(dest).mkdir(parents=True, exist_ok=True) # Create directory if it does not exist + + shutil.copy2(os.path.join(tempPath, 'Objects', config.name, config.hardware, libraryName + '.br'), dest) # Library.br + shutil.copy2(os.path.join(tempPath, 'Includes', libraryName + '.h'), dest) # Library.h + shutil.copy2(os.path.join(tempPath, 'Archives', config.name, config.hardware, 'lib' + libraryName + '.a'), dest) # libLibrary.a + return + + @staticmethod + def _collectLogicalBinary(sourceFolder: Union[str], dest) -> None: + '''Collects all Logical View files required for a binary library''' + + pathlib.Path(dest).mkdir(parents=True, exist_ok=True) # Create directory if it does not exist + + validExtensions = ['fun', 'lby', 'var', 'typ', 'md'] + + for item in os.listdir(sourceFolder): + # Get file extension. + splitItem = item.split('.') + extension = splitItem[-1] + if extension in validExtensions: + shutil.copy(os.path.join(sourceFolder, item), dest) + + return + +class Project(xmlAsFile): + def __init__(self, path: str): + if(os.path.isdir(path)): + # If we are given a dir, find first project file + # If it doesn't exist super will error + projectFile = [f for f in os.listdir(path) if f.endswith('.apj')][0] # Use first .apj found in dir + path = os.path.join(path, projectFile) + + # TODO: Improve error message for file not found + super().__init__(path) + + self.name = os.path.basename(os.path.splitext(path)[0]) # Get project name from .apj path + self.sourcePath = os.path.join(self.dirPath, 'Logical') + self.physicalPath = os.path.join(self.dirPath, 'Physical') + self.tempPath = os.path.join(self.dirPath, 'Temp') + self.binaryPath = os.path.join(self.dirPath, 'Binaries') + self.cacheIgnore = ['_AS', 'Acp10*', 'Arnc0*', 'Mapp*', 'Motion', 'TRF_LIB', 'Mp*', 'As*'] + self.libraries:List[Library] = [] + + self.cacheProject() + + def _checkIgnore(self, iterable, ignores) -> List[str]: + if ignores is not None: + for ignore in ignores: + iterable[:] = [name for name in iterable if not fnmatch.fnmatch(name, ignore)] + return iterable + + def _checkLibIgnore(self, libs:List[Library], ignores) -> List[Library]: + for ignore in ignores: + libs[:] = [lib for lib in libs if not fnmatch.fnmatch(lib.path, ignore)] + return libs + + def _resetCache(self): + self.libraries.clear() + return + + def cacheProject(self): + self._resetCache() + + for root, dirs, files in os.walk(self.sourcePath, topdown=True): + dirs[:] = self._checkIgnore(dirs, self.cacheIgnore) + files[:] = self._checkIgnore(files, self.cacheIgnore) + + for name in files: + if name.endswith('.lby'): # This is a library + try: + lib = Library(os.path.join(root, name)) + self.libraries.append(lib) + except: + # Do nothing if this lib failed to be found. + pass + if name.endswith('.pkg'): # This is a package, and it could contain a link to a referenced library. + package = Package(os.path.join(root, name)) + objects = package.findall('Objects', 'Object') + for item in objects: + # Look for referenced library entries, and add them to the list of libraries. + if (item.get('Type', '').lower() == 'library') & (item.get('Reference', '').lower() == 'true'): + lib = Library(os.path.join(self.sourcePath, '..', item.text)) + self.libraries.append(lib) + return self + + def exportLibraries(self, dest, overwrite=False, buildConfigs:List[BuildConfig]=None, blacklist:list=None, whitelist:list=None, binary=True, includeVersion=False) -> ProjectExportInfo: + if buildConfigs is None: buildConfigs = self.buildConfigs + if whitelist is None: whitelist = [] + if blacklist is None: blacklist = [] + + # Determine which libraries to build. + exportLibs = [] + # If there's a 'whitelist', use this as a permissive filter applied to full library list. + if len(whitelist) > 0: + # Convert the list to lower case. + whitelist = [el.lower() for el in whitelist] + for lib in self.libraries: + if lib.name.lower() in whitelist: + exportLibs.append(lib) + # If there's a 'blacklist', use this as a restrictive filter applied to full library list. + elif len(blacklist) > 0: + # Convert the list to lower case. + blacklist = [el.lower() for el in blacklist] + for lib in self.libraries: + if lib.name.lower() not in blacklist: + exportLibs.append(lib) + else: + exportLibs = self.libraries.copy() + + exportInfo = ProjectExportInfo() + for lib in exportLibs: + print('Exporting ' + lib.name + '...') + result = lib.export(dest, self.tempPath, self.buildConfigs, overwrite=overwrite, binary=binary, includeVersion=includeVersion) + exportInfo.addLibInfo(result) + + return exportInfo + + def exportLibrary(self, library:Library, dest:str, overwrite=False, ignores:Union[tuple,list]=None, binary=True, includeVersion=False, withDependencies=True)-> ProjectExportInfo: + exportInfo = ProjectExportInfo() + + if withDependencies: + # Filter dependencies + depNames = library.dependencyNames + depNames = self._checkIgnore(depNames, ignores) + depNames = self._checkIgnore(depNames, self.cacheIgnore) + dependencies = self.getLibrariesByName(depNames) + + for dep in dependencies: + result = self.exportLibrary(dep, dest, ignores=ignores, overwrite=overwrite, binary=binary, includeVersion=includeVersion) + exportInfo.extend(result) + + result = library.export(dest, self.tempPath, self.buildConfigs, overwrite=overwrite, binary=binary, includeVersion=includeVersion) + exportInfo.addLibInfo(result) + + return exportInfo + + def build(self, *configNames, buildMode='Build', tempPath='', binaryPath='', simulation=False, additionalArgs:Union[str,list,tuple]=None): + for configName in configNames: + simulation_status = self.getHardwareParameter(configName, 'Simulation') + # Set simulation properly in hardware before building + if simulation_status == '': + self.setHardwareParameter(configName, 'Simulation', str(int(simulation))) + elif bool(int(simulation_status)) != simulation: + self.setHardwareParameter(configName, 'Simulation', str(int(simulation))) + + # TODO: Support should be better supported for return status here. Probably a list + return batchBuildAsProject(self.path, getASBuildPath(self.ASVersion), configNames, buildMode, tempPath=tempPath, logPath=self.dirPath, binaryPath=binaryPath, simulation=simulation, additionalArg=additionalArgs) + + def createPIP(self, configName, destination): + logging.info(f'Creating PIP at {destination}') + + # ASVersion is in the format AS45, whereas PVIVersion needs to be in the format V4.5. + pviVersion = self.ASVersion.replace('AS','',1) + pviVersion = 'V' + pviVersion[:1] + '.' + pviVersion[1:] + + # Retrieve the configuration object based on the name. + config = self.getConfigByName(configName) + + # Retrieve RUCPackage location (this automatically gets placed by AS in the Binaries// folder, and has a default name). + RUCPackagePath = os.path.join(self.binaryPath, config.name, config.hardware, 'RUCPackage', 'RUCPackage.zip') + + # Generate a .pil file that will contain a single instruction: CreatePIP. + RUCFolderPath = os.path.dirname(RUCPackagePath) + RUCPilPath = os.path.join(RUCFolderPath, 'CreatePIP.pil') + with open(RUCPilPath, 'w+') as f: + # TODO: may want to get so of the below options from arguments (i.e. initial install, forced reboot, etc). + f.write(f'CreatePIP "{RUCPackagePath}", "InstallMode=ForceReboot InstallRestriction=AllowPartitioning KeepPVValues=0 ExecuteInitExit=1 IgnoreVersion=1", "Default", "SupportLegacyAR=0", "DestinationDirectory={destination}"') + + # Call PVITransfer.exe to run the .pil script that was just created. + arguments = [] + arguments.append(os.path.join(getPVITransferPath(pviVersion), 'PVITransfer.exe')) + arguments.append('-automatic') # bypass GUI prompts + arguments.append('-silent') # don't show GUI at all + arguments.append(RUCPilPath) + arguments.append('-consoleOutput') + logging.debug(arguments) + process = subprocess.run(arguments) + + logging.debug(process) + if(process.returncode == 0): + logging.debug('PIP created') + + else: + logging.debug(f'Error in creating PIP, code {process.returncode}: {PVIReturnCodeText[process.returncode]}') + + return process + + def createArsim(self, *configNames, startSim = False): + '''*Deprecated* - see createSim''' + return self.createSim(configNames, startSim=startSim) + + def createSim(self, *configNames, destination, startSim = False): + pviVersion = self.ASVersion.replace('AS','',1) + pviVersion = 'V' + pviVersion[:1] + '.' + pviVersion[1:] + for configName in configNames: + config = self.getConfigByName(configName) + CreateARSimStructure(os.path.join(self.binaryPath, config.name, config.hardware, 'RUCPackage', 'RUCPackage.zip'), destination, pviVersion, startSim=startSim) + pass + + def startSim(self, configName:str, build=False): + pass + + def getLibraryByName(self, libName:str) -> Library: + for lib in self.libraries: + if lib.name == libName: + return lib + + return None + + def getLibrariesByName(self, libNames:List[str]) -> List[Library]: + libraries = [] + for lib in self.libraries: + if lib.name in libNames: + libraries.append(lib) + + return libraries + + def getConfigByName(self, configName:str) -> BuildConfig: + # TODO: This raises exception if no config is found, StopIteration. Should be more descriptive + return next(i for i in self.buildConfigs if i.name == configName) + + def getConstantValue(self, filePath:str, varName:str): + # Retrieve the value of a constant variable defined in a .VAR file. + fullFilePath = os.path.join(self.dirPath, filePath) + f = open(fullFilePath, "r") + fileContents = f.read() + return re.search(varName + ".*'(.*)'", fileContents).group(1) + + def getIniValue(self, filePath:str, sectionName:str, keyName:str): + # Retrieve the value of a key defined in a .ini file. + fullFilePath = os.path.join(self.dirPath, filePath) + config = configparser.ConfigParser() + config.read(fullFilePath) + return config[sectionName][keyName] + + @property + def buildConfigs(self) -> List[BuildConfig]: + return self._getConfigs(self.physicalPath) + + @property + def buildConfigNames(self) -> List[str]: + names = [] + for config in self.buildConfigs: + names.append(config.name) + return names + + @property + def ASVersion(self) -> str: + with open(self.path, 'r') as f: + return self._parseASVersion(f.read()) + + @staticmethod + def _parseASVersion(apj:str) -> str: + result = re.search(' str: + # Retrieve the value of a parameter defined in the configuration's Hardware.hw file. + hardwareFile = xmlAsFile(os.path.join(self.physicalPath, config, 'Hardware.hw')) + element = hardwareFile.find("Module","Parameter[@ID='" + paramName + "']") + if not element is None: + attributes = element.attrib + return attributes['Value'] + else: + return '' + + def setHardwareParameter(self, config, paramName, paramValue): + # Write a value to a specified parameter in the configuration's Hardware.hw file. + hardwareFile = xmlAsFile(os.path.join(self.physicalPath, config, 'Hardware.hw')) + try: + attributes = hardwareFile.find("Module","Parameter[@ID='" + paramName + "']").attrib + attributes['Value'] = paramValue + hardwareFile.write() + except: + # Getting here means the element to write to doesn't exist. It needs to be created. + # Set up the element that needs to be created. + attributes = {} + attributes['ID'] = paramName + attributes['Value'] = paramValue + element = ET.Element('Parameter', attrib=attributes) + # Create a parent map to determine the PLC node where the element will be added. + parent_map = {c: p for p in hardwareFile.package.iter() for c in p} + # Use the ConfigurationID which should (?) always be there... + config_element = hardwareFile.find("Module","Parameter[@ID='ConfigurationID']") + for key, value in parent_map.items(): + if config_element == key: + parent = value + # Now find the parent element, and append the new parameter. + parent_element = hardwareFile.find("Module[@Name='" + parent.attrib["Name"] + "']") + parent_element.append(element) + hardwareFile.write() + return + + def _getConfigs(self, physicalPath: str) -> List[BuildConfig]: + '''Get list of build configurations from physical directory''' + physical = Package(os.path.join(self.physicalPath, 'Physical.pkg')) + objects = physical.findall('Objects', 'Object') + configurations = [] + for config in objects: + if config.get('Type', '').lower() == 'configuration': + path = os.path.join(physicalPath, config.text) + configurations.append(BuildConfig(name=config.text, path=path, hardware=getHardwareFolderFromConfig(path))) + configurations[-1].type = getConfigType(configurations[-1]) + return configurations + +class Package(xmlAsFile): + '''TODO: Maybe if doesn't exist, create one''' + def __init__(self, path: str, new_pkg=False): + if(os.path.isdir(path)): + path = os.path.join(path, 'Package.pkg') + if (new_pkg): + package_element = ET.Element('Package') + package_element.set('xmlns', 'http://br-automation.co.at/AS/Package') + objects_element = ET.SubElement(package_element, 'Objects') + tree = ET.ElementTree(package_element) + if (new_pkg): + super().__init__(path, tree) + else: + super().__init__(path) + + def synchPackageFile(self): + # TODO: Does not handle references + + items = [i for i in os.listdir(self.dirPath)] + + # TODO: update package with directory + objsText = {} + + # Remove items not in dir from pkg + for element in self.objects: + if element.text not in items: + self._removePkgObject(element.text) + else: + objsText[element.text] = element + + # Add items in dir to pkg + for item in items: + if item == os.path.split(self.path)[1]: continue # Ignore pkg file + if item not in objsText: + self._addPkgObject(path=os.path.join(self.dirPath, item)) + + self.write() + + return self + + def addObject(self, path, reference=False): + '''Copy file or folder to package and directory''' + name = os.path.basename(path) + newPath = os.path.join(self.dirPath, name) + if(os.path.dirname(path) != self.dirPath and not reference): + # If object is not in dir, add it + if os.path.isfile(path): + shutil.copyfile(path, newPath) + else: + shutil.copytree(path, newPath) + return self._addPkgObject(newPath) + + def addEmptyPackage(self, name): + # Create the package (i.e. just the folder itself). + full_path = self.dirPath + '/' + name + os.mkdir(full_path) + # Add the newly created package to its parent's .pkg. + self._addPkgObject(full_path) + # Create the .pkg for this new package. + newPackage = Package(full_path, True) + newPackage.write() + return newPackage + + def removeObject(self, name): + '''Remove file or folder from package and directory''' + path_to_remove = os.path.join(self.dirPath, name) + # Check to see if dealing with file or dir. + if(os.path.isdir(path_to_remove)): + shutil.rmtree(path_to_remove) + elif(os.path.isfile(path_to_remove)): + os.remove(path_to_remove) + # Remove the entry from the .pkg file. + self._removePkgObject(name) + return + + def _removePkgObject(self, name): + for child in self.objects: + if (child.text == name): + self.objects.remove(child) + self.write() + + def _addPkgObject(self, path: str, reference=False, element: ET.Element = None) -> ET.Element: + ''' + Add element to objects list in package file + + If no element is specified one will be created from path + ''' + if(element is None): + # If no element use provided path + element = self._createElement(path, reference=reference) + obj = self.find('Objects') + obj.append(element) + self.write() + return element + + @staticmethod + def _createElement(path: str, reference=False) -> ET.Element: + if path is None: raise FileNotFoundError(path) + # Create the element from path to be added + attributes = {} + if reference: attributes['Reference'] = True + attributes['Type'] = getPkgType(path) + if attributes['Type'] == 'Library': + attributes['Language'] = getLibraryType(path) + if attributes['Type'] == 'Program': + attributes['Language'] = getProgramType(path) + + element = ET.Element('Object', attrib=attributes) + if reference: + element.text = os.path.abspath(path) + else: + element.text = os.path.basename(path) + element.tail = "\n" #+2*" " Just stick with newline for now + return element + + @property + def objects(self): + return self.find('Objects') + + @property + def objectList(self): + return self.findall('Objects', 'Object') + +class Task(xmlAsFile): + def __init__(self, path: str): + if(os.path.isdir(path)): + self.path = path + if('ANSIC.prg' in os.listdir(path)): + self.type = 'ANSIC' + super().__init__(os.path.join(path, 'ANSIC.prg')) + elif('IEC.prg' in os.listdir(path)): + self.type = 'IEC' + super().__init__(os.path.join(path, 'IEC.prg')) + else: + self.type = None + +class SwDeploymentTable(xmlAsFile): + def __init__(self, path: str): + if(os.path.isfile(path)): + self.path = path + super().__init__(path) + # Look for any missing TaskClass tags, and add them in at the right locations. + # First check to see if target task class exists. + for i in range(8): + tc = self.find(f"TaskClass[@Name='Cyclic#{i+1}']") + if (tc == None): + # TC doesn't exist yet, so create it. + tc = self._addRootLevelElement('TaskClass', i, { "Name": f"Cyclic#{i+1}" }) + # Look for the Libraries tag, and add it in there if it's missing. + lib = self.find('Libraries') + if (lib == None): + lib = self._addRootLevelElement('Libraries') + self.read() + + def deployLibrary(self, libraryFolder, library, attributes = {}): + obj = self.find('Libraries') + # Check to see if that library already exists. + for lib in self.libraries: + if (lib.lower() == library.lower()): + return + # Library isn't in there yet, so let's add it. + element = self._createLibraryElement(libraryFolder, library, attributeOverrides = attributes) + obj.append(element) + self.write() + + def deployTask(self, taskFolder, taskName, taskClass): + # First get a handle on the target task class. + cyclicName = "Cyclic#" + [s for s in taskClass if s.isdigit()][0] + tc = self.find(f"TaskClass[@Name='{cyclicName}']") + # Now check to see if the task has already been deployed here (if so, skip deployment). + preexistingTask = self.find(f"TaskClass[@Name='{cyclicName}']","Task[@Name='" + taskName[:10] + "']") + if(preexistingTask is not None): + return + # Task isn't in there yet, so let's add it. + element = self._createTaskElement(taskFolder, taskName) + tc.append(element) + self.write() + + def _createLibraryElement(self, libraryFolder, name, memory: str = 'UserROM', attributeOverrides = {}) -> ET.Element: + language = getLibraryType(os.path.join(libraryFolder, name)) + splitPath = os.path.split(libraryFolder) + parentFolder = splitPath[-1] + # Create the element from the provided arguments. + attributes = {} + attributes['Name'] = name + source = ('Libraries', parentFolder, name, 'lby') + attributes['Source'] = '.'.join(source) + attributes['Memory'] = memory + attributes['Language'] = language + attributes['Debugging'] = 'true' + for attributeName in attributeOverrides: + attributes[attributeName] = attributeOverrides[attributeName] + element = ET.Element('LibraryObject', attrib=attributes) + element.tail = "\n" #+2*" " Just stick with newline for now + return element + + def _createTaskElement(self, taskFolder, taskName, memory: str = 'UserROM') -> ET.Element: + task = Task(os.path.join('Logical', taskFolder, taskName)) + language = task.type + # Split the path, and add to it, since cpu.sw expects a '.' separated path. + splitPath = os.path.normpath(taskFolder).split(os.sep) + splitPath.append(taskName) + splitPath.append('prg') + # Create the element from the provided arguments. + attributes = {} + attributes['Name'] = taskName[:10] # Only taking the first 10 characters of name, because AS expects the truncation + attributes['Source'] = '.'.join(splitPath) + attributes['Memory'] = memory + attributes['Language'] = language + attributes['Debugging'] = 'true' + element = ET.Element('Task', attrib=attributes) + element.tail = "\n" #+2*" " Just stick with newline for now + return element + + def _addLibrariesElement(self): + self._addRootLevelElement('Libraries') + self.read() + obj = self.find('Libraries') + return obj + + def _addRootLevelElement(self, name, index = None, attributes = {}): + element = ET.Element(name, attrib=attributes) + element.tail = "\n" + if (index is None): + self.root.append(element) + else: + self.root.insert(index, element) + self.write() + + @property + def libraries(self) -> List: + libraryList = [] + for element in self.findall('Libraries', 'LibraryObject'): + libraryList.append(element.get('Name', 'Unknown')) + return libraryList + +class CpuConfig(xmlAsFile): + def __init__(self, path: str): + if(os.path.isfile(path)): + self.path = path + super().__init__(path) + self.buildElement = self.find('Configuration', 'Build') + self.arElement = self.find('Configuration', 'AutomationRuntime') + + def getGccVersion(self): + if 'GccVersion' in self.buildElement.attrib: + return(self.buildElement.attrib['GccVersion']) + else: + return None + + def setGccVersion(self, value): + self.buildElement.attrib['GccVersion'] = value + self.write() + + def getPreBuildStep(self): + if 'PreBuildStep' in self.buildElement.attrib: + return(self.buildElement.attrib['PreBuildStep']) + else: + return None + + def setPreBuildStep(self, value): + self.buildElement.attrib['PreBuildStep'] = value + self.write() + + def getArVersion(self): + if 'Version' in self.arElement.attrib: + return(self.arElement.attrib['Version']) + else: + return None + + def setArVersion(self, value): + self.arElement.attrib['Version'] = value + self.write() + + # Define accessible properties. + gccVersion = property(getGccVersion, setGccVersion) + preBuildStep = property(getPreBuildStep, setPreBuildStep) + arVersion = property(getArVersion, setArVersion) + +# TODO: Remove +def getConfigs(physicalPath: str) -> List[BuildConfig]: + '''*Deprecated* - Get list of build configurations from physical directory''' + # TODO: Remove Fn + logging.warning('Function getConfigs Deprecated') + package = readXmlFile(os.path.join(physicalPath, 'Physical.pkg')) + objects = getPkgObjectList(package) + configurations = [] + for config in objects: + if config.get('Type', '').lower() == 'configuration': + path = os.path.join(physicalPath, config.text) + configurations.append(BuildConfig(name=config.text, path=path, hardware=getHardwareFolderFromConfig(path))) + configurations[-1].type = getConfigType(configurations[-1]) + return configurations +# TODO: Move to Build Config Class +def getConfigType(config: BuildConfig) -> str: + '''Gets the config type based on cpu''' + # TODO: Move these constants to package scope + sg4arm = 'sg4_arm' + sg4 = 'sg4' + cpu = { + 'x20cp04': sg4arm, + 'x20cp13': sg4, + 'x20cp14': sg4, + 'x20cp3': sg4, + 'apc': sg4, + '5pc': sg4, + } + + for key, value in cpu.items(): # iter on both keys and values + if config.hardware.lower().startswith(key): + return value + + return sg4 +# TODO: Needed by Library and Package Class. Maybe leave as function +def getLibraryType(path: str) -> str: + if os.path.exists(os.path.join(path, 'ANSIC.lby')): + return 'ANSIC' + elif os.path.exists(os.path.join(path, 'IEC.lby')): + return 'IEC' + elif os.path.exists(os.path.join(path, 'Binary.lby')): + return 'Binary' + + return 'None' +# TODO: Keep with getLibraryType Fn. Maybe leave as function +def getProgramType(path: str) -> str: + if os.path.exists(os.path.join(path, 'ANSIC.prg')): + return 'ANSIC' + elif os.path.exists(os.path.join(path, 'IEC.prg')): + return 'IEC' + elif os.path.exists(os.path.join(path, 'Binary.prg')): + return 'Binary' + + return 'None' +# TODO: Keep with getLibraryType Fn. Maybe leave as function +def getPkgType(path: str): + if os.path.exists(path) == False: raise FileNotFoundError(path) + + # Could be a : + # Package (a folder) + # File (myTask.var) + # Program (a folder with a .prg) + # Library (a folder with a .lby) + + if os.path.isdir(path): + # Check inside dir to find type + if getLibraryType(path) != 'None': return 'Library' + if getProgramType(path) != 'None': return 'Program' + return 'Package' # TODO: maybe check to make sure it has a .pkg file? + elif os.path.isfile(path): + return 'File' +# TODO: Remove +def collectBinaryLibrary(lib: Library, buildFolder, dest, buildName:List[BuildConfig]=None) -> None: + '''*Deprecated* - Copies all files for a binary library into dest''' + # TODO: Remove Fn + logging.warning('Function collectBinaryLibrary Deprecated') + + packageFileName = lib.type + '.lby' + + # buildPaths["source"] + builds = {} + # builds + for build in buildName: + if builds.get(build.type) is None: + builds[build.type] = build + + collectSourceLibrary(lib.dirPath, dest, ['*.c','*.st']) + + if builds.get("sg4") != None: + collectConfigBinary(buildFolder, builds["sg4"], lib.name, os.path.join(dest, 'SG4')) # Collect SG4 Intel + if builds.get("sg4_arm") != None: + collectConfigBinary(buildFolder, builds["sg4_arm"], lib.name, os.path.join(dest, 'SG4', 'Arm')) # Collect SG4 ARM + + # TODO: Support SG3 and lower + + os.rename(os.path.join(dest, packageFileName), os.path.join(dest, 'Binary.lby')) + newLib = Library(os.path.join(dest, 'Binary.lby')) + newLib.root.set('SubType','Binary') + newLib.synchronize() + # updateLibraryFile(os.path.join(dest, 'Binary.lby')) + + return +# TODO: Remove +def getASNamespaceFormatted(package: ET.ElementTree) -> str: + '''*Deprecated*''' + logging.warning('Function getASNamespaceFormatted Deprecated') + ns = getASNamespace(package) + if ns != '': + ns = '{' + ns + '}' + return ns +# TODO: Remove +def getASNamespace(package: ET.ElementTree) -> str: + '''*Deprecated* - Get Automation Studio's namespace for xml files''' + logging.warning('Function getASNamespace Deprecated') + ns = package.getroot().tag.split('}') + if ns[0][0] == '{' : + ns = ns[0][1:] + else: + ns = '' + + return ns + # return 'http://br-automation.co.at/AS/Package' + # return 'http://br-automation.co.at/AS/Physical' +# TODO: Remove +def indentXml(elem: ET.Element, level=0): + '''*Deprecated* - Indent Element and sub elements''' + logging.warning('Function indentXml Deprecated') + i = "\n" + level*" " + if len(elem): + if not elem.text or not elem.text.strip(): + elem.text = i + " " + if not elem.tail or not elem.tail.strip(): + elem.tail = i + for elem in elem: + indentXml(elem, level+1) + if not elem.tail or not elem.tail.strip(): + elem.tail = i + else: + if level and (not elem.tail or not elem.tail.strip()): + elem.tail = i +# TODO: Remove +def readXmlFile(file: str) -> ET.ElementTree: + '''*Deprecated* - Reads library, or package file into xml tree''' + logging.warning('Function readXmlFile Deprecated') + # nsUnformatted = getASNamespace() + # ET.register_namespace('', nsUnformatted) # TODO: This is a ET global effect + # ET.register_namespace('', 'http://br-automation.co.at/AS/Package') # TODO: This is a ET global effect + + # Load File && Parse XML + package = ET.parse(file) + return package +# TODO: Remove +def writeXmlFile(file: str, package: ET.ElementTree): + '''*Deprecated* - Writes xml tree to library, hardware, or package file''' + logging.warning('Function writeXmlFile Deprecated') + # TODO: This loses the . This shouldn't cause any issues though + # This can be solved by extracting xml stuff with file writing (function that returns xml as string) then modify and write that + + ns = getASNamespace(package) + ET.register_namespace('', ns) # TODO: This is a ET global effect + indentXml(package.getroot()) # When we add items indent gets messed up + package.write(file, xml_declaration=True, encoding='utf-8', method='xml') + return +# TODO: Remove +def updatePackageFile(file: str, output:str=None) -> None: + '''*Deprecated* see Package.synchPackageFile - Updates package file with files in directory''' + # TODO: Does not handle references + # TODO: Remove Fn + logging.warning('Funcion updatePackageFile Deprecated') + + if(os.path.isfile(file)): + directory = os.path.split(file)[0] + + package = readXmlFile(file) + objs = getPkgObjectList(package) + items = [i for i in os.listdir(directory)] + + # TODO: update package with directory + objsText = {} + + for element in objs: + if element.text not in items: + removePkgObject(package, element) + else: + objsText[element.text] = element + + for item in items: + if item == os.path.split(file)[1]: continue + if item not in objsText: + addPkgObject(package, path=os.path.join(directory, item)) + + if output is None: output = file + writeXmlFile(output, package) + return package +# TODO: Remove +def getPkgObjectList(package: ET.ElementTree) -> List[ET.Element]: + '''*Deprecated* - Returns objects list from package file''' + # TODO: Remove Fn + logging.warning('Function getPkgObjectList Deprectated') + nameSpace = getASNamespaceFormatted(package) + root = package.getroot() + objs = root.findall('./' + nameSpace + 'Objects/' + nameSpace + 'Object') + return objs +# TODO: Remove +def removePkgObject(package: ET.ElementTree, element: ET.Element): + '''*Deprecated* - Remove element from objects list in package file''' + # TODO: Remove Fn + logging.warning('Function removePkgObject Deprectated') + nameSpace = getASNamespaceFormatted(package) + root = package.getroot() + obj = root.find('./' + nameSpace + 'Objects') + obj.remove(element) + return +# TODO: Remove +def addPkgObject(package: ET.ElementTree, element: ET.Element = None, path: str = None) -> ET.Element: + ''' + *Deprecated* - + Add element to objects list in package file + + If no element is specified one will be created from path + ''' + # TODO: Remove Fn + logging.warning('Function addPkgObject Deprectated') + if(element is None): + # If no element use provided path + element = createPkgElement(path) + + nameSpace = getASNamespaceFormatted(package) + root = package.getroot() + obj = root.find('./' + nameSpace + 'Objects') + obj.append(element) + return element +# TODO: Remove +def createPkgElement(path: str, reference=False) -> ET.Element: + '''*Deprecated*''' + if path is None: raise FileNotFoundError(path) + # Create the element from path to be added + attributes = {} + if reference: attributes['Reference'] = True + attributes['Type'] = getPkgType(path) + if attributes['Type'] == 'Library': + attributes['Language'] = getLibraryType(path) + if attributes['Type'] == 'Program': + attributes['Language'] = getProgramType(path) + + element = ET.Element('Object', attrib=attributes) + if reference: + element.text = os.path.abspath(path) + else: + element.text = os.path.basename(path) + element.tail = "\n" #+2*" " Just stick with newline for now + return element +# TODO: Remove +def collectSourceLibrary(sourceFolder: Union[str], dest: Union[str], excludes=None) -> None: + '''*Deprecated* - Copies all files for a source library except excludes into dest''' + # TODO: Remove Fn + logging.warning('Function collectSourceLibrary Deprecated') + if excludes is None: excludes = [] + + # TODO: This errors if directory already exists + # This function just doesn't support that + # dir_util.copy_tree() is an option but it might not support ignore + shutil.copytree(sourceFolder, dest, ignore=shutil.ignore_patterns(*excludes)) + return +# TODO: Remove +def collectConfigBinary(tempPath: str, config: BuildConfig, libraryName: str, dest) -> None: + '''*Deprecated* - Collects all binary files associated with a HW Config''' + # TODO: Remove Fn + logging.warning('Function collectConfigBinary Deprecated') + pathlib.Path(dest).mkdir(parents=True, exist_ok=True) # Create directory if it does not exist + + shutil.copy2(os.path.join(tempPath, 'Objects', config.name, config.hardware, libraryName + '.br'), dest) # Library.br + shutil.copy2(os.path.join(tempPath, 'Includes', libraryName + '.h'), dest) # Library.h + shutil.copy2(os.path.join(tempPath, 'Archives', config.name, config.hardware, 'lib' + libraryName + '.a'), dest) # libLibrary.a + return +# TODO: Remove +def getHardwareFolderFromConfig(configPath): + '''*Deprecated* - Gets hardware folder name from path to configuration folder''' + + # TODO: We are assuming that there is only one hardware folder under config + # This may be a safe assumption but i am not sure + hardware = [d for d in os.listdir(configPath) if os.path.isdir(os.path.join(configPath, d))][0] + + return hardware + +def toDict(obj, classkey=None): + if isinstance(obj, dict): + data = {} + for (k, v) in obj.items(): + data[k] = toDict(v, classkey) + return data + elif hasattr(obj, "_ast"): + return toDict(obj._ast()) + elif hasattr(obj, "__iter__") and not isinstance(obj, str): + return [toDict(v, classkey) for v in obj] + elif hasattr(obj, "__dict__"): + data = dict([(key, toDict(value, classkey)) + for key, value in obj.__dict__.items() + if not callable(value) and not key.startswith('_')]) + if classkey is not None and hasattr(obj, "__class__"): + data[classkey] = obj.__class__.__name__ + return data + else: + return obj + +def main(): + pathlib.Path('Test/Exports').mkdir(parents=True, exist_ok=True) # Create directory if it does not exist + + sandbox = Project('C:\\Projects\\Path\\To\\Project') + print(toDict(sandbox.buildConfigs)) + + # input("Press Enter to continue...") + return + +if __name__ == "__main__": + # Set up color coding + kernel32 = ctypes.windll.kernel32 + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + + formatter = '[%(asctime)s] p%(process)s {%(pathname)s:%(lineno)d} %(levelname)s - %(message)s','%m-%d %H:%M:%S' + logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) + main() \ No newline at end of file diff --git a/src/ASPython/CHANGELOG.md b/src/ASPython/CHANGELOG.md new file mode 100644 index 0000000..48dfca5 --- /dev/null +++ b/src/ASPython/CHANGELOG.md @@ -0,0 +1,27 @@ +# Change log + +- 0.1.1 - Add support for including user partition and HMI files in Simulator.tar.gz + +- 0.1.0 - Release (Synchronize and consolidate all script versions) + +- 0.0.2 - Release + +- 0.0.1.9 - Fix binary library exports including cpp files + +- 0.0.1.8 - Fix relative project paths failing during builds + - Refactor code and deprecate some functions + +- 0.0.1.7 - Renamed package to ASTools + - Fix logging would sometimes produce invalid characters + +- 0.0.1.6 - Add colors to output + - Add build.log for AS results + - Fix some bugs related to AS builds + +- 0.0.1.5 - Fix binary library exports .lby file still containing some .c and .st files + +- 0.0.1.4 - Add support for AS4.X instead of just 4.5 + +- 0.0.1.3 - Add build option 'None' + +- 0.0.1 - Initial version \ No newline at end of file diff --git a/src/ASPython/CmdLineARSim.py b/src/ASPython/CmdLineARSim.py new file mode 100644 index 0000000..40148a6 --- /dev/null +++ b/src/ASPython/CmdLineARSim.py @@ -0,0 +1,118 @@ +''' + * File: CmdLineARSim.py + * Copyright (c) 2023 Loupe + * https://loupe.team + * + * This file is part of ASPython, licensed under the MIT License. +''' +""" +@title CmdLineARSim +@description This python script takes in command line arguments +and creates an ARSIm package for a given AS project + +0.1.0 - Synchronize all script versions +0.0.4 - Improve failed build error message +0.0.3 - Add support for multiple configurations +0.0.2 - Fix ARSim structure not created if buildMode is None +0.0.1 - Initial release +""" + +# Python Modules +import argparse +import ctypes +import logging +import sys +import os +import shutil +import tarfile + +# External Modules +import ASTools +import _version + +def main(): + + buildStatus = None + + # Parse arguments from the command line + parser = argparse.ArgumentParser(description='Build and start ARSim an AS project via command line.') + parser.add_argument('project', type=str, help='AS project you want to build') + parser.add_argument('-c','--configuration', nargs='+', type=str, help='AS configuration you want to build') + parser.add_argument('-bm', '--buildMode', type=str, help='AS build mode you want executed', default='None', choices=['Rebuild', 'Build','BuildAndTransfer', 'BuildAndCreateCompactFlash', 'None']) + parser.add_argument('-ss', '--startSim', action='store_true', help='Option to have ARSim start after ARSim creation') + parser.add_argument('-uf', '--userFiles', type=str, help='Path to the folder containing user files to get included with simulator') + parser.add_argument('-hf', '--hmiFiles', type=str, help='Path to the folder containing HMI files to get included with simulator') + parser.add_argument('-l', '--logLevel', type=str.upper, help='Log level', choices=['DEBUG','INFO','WARNING', 'ERROR'], default='') + parser.add_argument('-v','--version', action='version', version='%(prog)s {version}'.format(version=_version.__version__)) + args = parser.parse_args() + + # Allow setting log level via command line + if(args.logLevel): + lognum = getattr(logging, args.logLevel) + if not isinstance(lognum, int): + raise ValueError('Invalid log level: %s' % args.logLevel) + logging.getLogger().setLevel(level=lognum) + + # Save parsed information in to variables. eARSim. + logging.debug('%s', args) + logging.debug('The project to be built is: %s', args.project) + logging.debug('The configuration(s) to be built is: %s', args.configuration) + logging.debug('Build mode: %s', args.buildMode) + logging.debug('Start simulation when creation is complete: %s', args.startSim) + + project = ASTools.Project(args.project) + + if args.buildMode != 'None': + for config in args.configuration: + buildStatus = project.build(config, buildMode=args.buildMode, simulation=True) + + if buildStatus.returncode > ASTools.ASReturnCodes['Warnings']: + sys.exit('Build failed for config {config}') + else: + logging.debug('Building of %s Complete!', config) + + for config in args.configuration: + # Determine target destination for the PIP (will be in the format /Temp/PIP//) + destination = os.path.join(project.tempPath, 'SIM', config) + + # Create SIM folder if it doesn't exist + if not os.path.isdir(os.path.join(project.tempPath, 'SIM')): + os.mkdir(os.path.join(project.tempPath, 'SIM')) + + # Delete the entirety of the SIM Config folder so that old PIPs don't get used later. + if os.path.isdir(destination): + shutil.rmtree(destination) + + # Recreate the SIM Config folder. + os.mkdir(destination) + + # Create the SIM (will just be loose files at this point). + project.createSim(config, destination=destination, startSim=args.startSim) + + # Add custom directory with user partition data if configured. + if args.userFiles != '': + userPath = os.path.join(destination, 'ARSimUser') + shutil.copytree(args.userFiles, userPath) + + # Add custom directory with HMI data if configured. + if args.userFiles != '': + hmiPath = os.path.join(destination, 'HMI') + shutil.copytree(args.hmiFiles, hmiPath, ignore=shutil.ignore_patterns('node_modules')) + + # Zip up the PIP files into a .tar archive. + os.chdir(destination) + tf = tarfile.open('Simulator.tar.gz', mode='w:gz', format=tarfile.PAX_FORMAT) + for item in os.listdir(): + tf.add(item) + tf.close() + + sys.exit(0) + +if __name__ == "__main__": + + # Configure colored logger + logging.basicConfig(stream=sys.stderr, level=logging.INFO) + kernel32 = ctypes.windll.kernel32 + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + + main() diff --git a/src/ASPython/CmdLineBuild.py b/src/ASPython/CmdLineBuild.py new file mode 100644 index 0000000..7759199 --- /dev/null +++ b/src/ASPython/CmdLineBuild.py @@ -0,0 +1,113 @@ +''' + * File: CmdLineBuild.py + * Copyright (c) 2023 Loupe + * https://loupe.team + * + * This file is part of ASPython, licensed under the MIT License. +''' +""" +@title CmdLineExportBuild +@description This python script takes in command line arguments +and build given configurations for an AS project + +0.1.0 - Synchronize all script versions +0.0.3 - Improve failed build error message +0.0.2 - Slight changes to debug messages +0.0.1 - Initial release +""" + +# Python Modules +import argparse +import logging +import sys +import ctypes +import os +import tarfile +import shutil + +# External Modules +import ASTools +import _version + +def main(): + # Parse arguments from the command line + parser = argparse.ArgumentParser(description='Build an AS project with command line arguments.') + parser.add_argument('project', type=str, help='Path to AS project you want to build') + parser.add_argument('-c','--configuration', nargs='+', type=str, help='AS configuration(s) you want to build') + parser.add_argument('-bm','--buildMode', type=str, help='Type of build in AS', default='Build', choices=['Rebuild', 'Build','BuildAndTransfer', 'BuildAndCreateCompactFlash', 'None']) + parser.add_argument('-sim','--simulation', action='store_true', help='Should be built for simulation') + parser.add_argument('-pip', action='store_true', help='Generate a PIP after the build completes') + parser.add_argument('-l', '--logLevel', type=str.upper, help='Log level', choices=['DEBUG','INFO','WARNING', 'ERROR'], default='') + parser.add_argument('-v', '--version', action='version', version='%(prog)s {version}'.format(version=_version.__version__)) + args = parser.parse_args() + + + # Allow setting log level via command line + if(args.logLevel): + lognum = getattr(logging, args.logLevel) + if not isinstance(lognum, int): + raise ValueError('Invalid log level: %s' % args.logLevel) + logging.getLogger().setLevel(level=lognum) + + # Save parsed information in to variables. + logging.debug('The project to be built is: %s', args.project) + logging.debug('The project configuration(s) to be build is: %s', args.configuration) + logging.debug('The project build mode is: %s', args.buildMode) + logging.debug('The project will be built for simulation: %s', args.simulation) + logging.debug('The log level will be: %s', args.logLevel) + if args.pip: + logging.debug('Pip will be created') + + + # Build the project + project = ASTools.Project(os.path.abspath(args.project)) + + for config in args.configuration: + + # if not args.simulation: + # # If there is a simulation parameter defined in the XML, set it to 0 (i.e. disable sim before the build) + # if project.getHardwareParameter(config, 'Simulation') != '': + # project.setHardwareParameter(config, 'Simulation', '0') + + buildStatus = project.build(config, buildMode=args.buildMode, simulation=args.simulation) + + if buildStatus.returncode > ASTools.ASReturnCodes['Warnings']: + sys.exit('Build failed for config {config}') + elif args.pip: + # Determine target destination for the PIP (will be in the format /Temp/PIP//) + destination = f"{project.tempPath}/PIP/{config}" + + # Create Pip folder if it doesn't exist + if not os.path.isdir(f"{project.tempPath}/PIP"): + os.mkdir(f"{project.tempPath}/PIP") + + # Delete the entirety of the PIP Config folder so that old PIPs don't get used later. + if os.path.isdir(destination): + shutil.rmtree(destination) + + # Recreate the PIP Config folder. + os.mkdir(destination) + + # Create the PIP (will just be loose files at this point). + project.createPIP(config, destination) + + # Zip up the PIP files into a .tar archive. + os.chdir(destination) + tf = tarfile.open('Installer.tar.gz', mode='w:gz', format=tarfile.USTAR_FORMAT) + for item in os.listdir(): + tf.add(item) + tf.close() + else: + logging.debug('Building of %s Complete!', config) + + + sys.exit(0) + +if __name__ == "__main__": + + # Configure colored logger + logging.basicConfig(stream=sys.stderr, level=logging.INFO) + kernel32 = ctypes.windll.kernel32 + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + + main() diff --git a/src/ASPython/CmdLineCreateInstaller.py b/src/ASPython/CmdLineCreateInstaller.py new file mode 100644 index 0000000..5998eaa --- /dev/null +++ b/src/ASPython/CmdLineCreateInstaller.py @@ -0,0 +1,125 @@ +''' + * File: CmdLineCreateInstaller.py + * Copyright (c) 2023 Loupe + * https://loupe.team + * + * This file is part of ASPython, licensed under the MIT License. +''' +""" +@title CmdLineCreateInstaller +@description This python script takes in command line arguments +and generates an ISS-based installer + +0.1.0 - Synchronize all script versions +0.0.1 - Initial release +""" + +# Python Modules +import argparse +import logging +import sys +import subprocess +import uuid +from typing import Dict, Tuple, Sequence, Union, List, Optional + +import _version + +# TODO: Error handling when required pars aren't passed in. + +def main(): + + # Parse arguments from the command line + parser = argparse.ArgumentParser(description='Generate an Inno installer') + # High-level application information. + parser.add_argument('script', type=str, help='Name of the iss script to compile') + parser.add_argument('-o', '--output', type=str, help='Destination folder where the installer is placed') + parser.add_argument('-an', '--appName', type=str, help='Name of the app to create') + parser.add_argument('-av', '--appVersion', type=str, help='Version of the app to create', default='1.0.0') + parser.add_argument('-ap', '--appPublisher', type=str, help='Name of the app publisher', default='Loupe') + parser.add_argument('-au', '--appUrl', type=str, help='URL of the app publisher', default='https://loupe.team') + # Simulation assets. + parser.add_argument('-sd', '--simDir', type=str, help='Directory where Simulation assets are located') + # User Partition assets. + parser.add_argument('-ud', '--userDir', type=str, help='Directory where User Partition assets are located') + parser.add_argument('-jb', '--junctionBatch', type=str, help='Name of the Junction Batch file', default='ConnectFileDevice.bat') + # HMI assets. + parser.add_argument('-hd', '--hmiDir', type=str, help='Directory where HMI assets are located') + parser.add_argument('-he', '--hmiExe', type=str, help='Name of the HMI EXE file') + # General script utilities. + parser.add_argument('-l', '--logLevel', type=str.upper, help='Log level', choices=['DEBUG','INFO','WARNING', 'ERROR'], default='') + parser.add_argument('-v', '--version', action='version', version='%(prog)s {version}'.format(version=_version.__version__)) + args = parser.parse_args() + + # Allow setting log level via command line + if(args.logLevel): + lognum = getattr(logging, args.logLevel) + if not isinstance(lognum, int): + raise ValueError('Invalid log level: %s' % args.logLevel) + logging.getLogger().setLevel(level=lognum) + + # Generate the unique GUID that Inno expects for each app build. + GUID = generateGUID() + + # Compile the app, which produces the installer. + compileInstaller(args, GUID) + + sys.exit(0) + +def generateGUID(): + GUID = uuid.uuid4() + return '{{' + str(GUID) + '}' + +def compileInstaller(args, GUID): + + command = [] + + # Add the call to the iscc executable (this compiles the .iss script). + command.append("C:\Program Files (x86)\Inno Setup 6\iscc") + + # Add the name of the script to compile. + command.append(args.script) + + # Pass in general app parameters. + command.append(f"/O{args.output}") + command.append(f"/dAppName={args.appName}") + command.append(f"/dAppVersion={args.appVersion}") + command.append(f"/dAppPublisher={args.appPublisher}") + command.append(f"/dAppUrl={args.appUrl}") + command.append(f"/dAppGUID={GUID}") + + # Pass in parameters related to simulation if it's required. + if args.simDir: + command.append("/dIncludeSimulator=yes") + command.append(f"/dSimulationDirectory={args.simDir}") + else: + command.append("/dIncludeSimulator=no") + + # Pass in parameters related to User Partition if it's required. + if args.userDir: + command.append("/dIncludeUserPartition=yes") + command.append(f"/dUserPartitionDirectory={args.userDir}") + command.append(f"/dJunctionBatchFilename={args.junctionBatch}") + else: + command.append("/dIncludeUserPartition=no") + + # Pass in parameters related to HMI. + if args.hmiDir: + command.append("/dIncludeHmi=yes") + command.append(f"/dHmiDirectory={args.hmiDir}") + command.append(f"/dHmiExeName={args.hmiExe}") + else: + command.append("/dIncludeHmi=no") + + # Force quiet compilation if debug isn't set. + if args.logLevel != 'DEBUG': + command.append("/Qp") + + # Execute the process, and retrieve the process object for further processing. + logging.debug(command) + process = subprocess.run(command, encoding="utf-8", errors='replace', shell=True) + + return + +if __name__ == "__main__": + + main() diff --git a/src/ASPython/CmdLineDeployLibraries.py b/src/ASPython/CmdLineDeployLibraries.py new file mode 100644 index 0000000..3ab96b4 --- /dev/null +++ b/src/ASPython/CmdLineDeployLibraries.py @@ -0,0 +1,74 @@ +''' + * File: CmdLineDeployLibraries.py + * Copyright (c) 2023 Loupe + * https://loupe.team + * + * This file is part of ASPython, licensed under the MIT License. +''' +""" +@title CmdLineDeployLibraries +@description This python script deploys Loupe libraries +to a specified cpu.sw file. + +0.1.0 - Synchronize all script versions +0.0.1 - Initial release +""" + +# Python Modules +import argparse +import logging +import sys +import ctypes +import os +import tarfile +import shutil + +# External Modules +import ASTools +import _version + +def main(): + # Parse arguments from the command line + parser = argparse.ArgumentParser(description='Build an AS project with command line arguments.') + parser.add_argument('-d', '--deploymentFile', type=str, help='Path to the cpu.sw file') + parser.add_argument('-lf', '--libraryFolder', type=str, help='Path to the folder that holds the libraries') + parser.add_argument('-lib', '--libraries', nargs='+', type=str, help='Libraries to deploy') + parser.add_argument('-l', '--logLevel', type=str.upper, help='Log level', choices=['DEBUG','INFO','WARNING', 'ERROR'], default='') + parser.add_argument('-v', '--version', action='version', version='%(prog)s {version}'.format(version=_version.__version__)) + args = parser.parse_args() + + + # Allow setting log level via command line + if(args.logLevel): + lognum = getattr(logging, args.logLevel) + if not isinstance(lognum, int): + raise ValueError('Invalid log level: %s' % args.logLevel) + logging.getLogger().setLevel(level=lognum) + + # Save parsed information in to variables. + logging.debug('The file to be updated is: %s', args.deploymentFile) + logging.debug('The libraries to be deployed are: %s', args.libraries) + logging.debug('The log level will be: %s', args.logLevel) + + # Retrieve the deployment table. + deploymentTable = ASTools.SwDeploymentTable(args.deploymentFile) + # Deploy the required libraries. If no libraries are specified, this means deploy everything in the library folder. + if (not args.libraries): + for library in os.listdir(args.libraryFolder): + # Wait! Don't deploy the Package.pkg as a library, that would make no sense! + if (library != 'Package.pkg'): + deploymentTable.deployLibrary(args.libraryFolder, library) + else: + for library in args.libraries: + deploymentTable.deployLibrary(args.libraryFolder, library) + + sys.exit(0) + +if __name__ == "__main__": + + # Configure colored logger + logging.basicConfig(stream=sys.stderr, level=logging.INFO) + kernel32 = ctypes.windll.kernel32 + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + + main() diff --git a/src/ASPython/CmdLineExportLib.py b/src/ASPython/CmdLineExportLib.py new file mode 100644 index 0000000..8e20fa7 --- /dev/null +++ b/src/ASPython/CmdLineExportLib.py @@ -0,0 +1,121 @@ +""" +@title CmdLineExportLib +@description This python script takes in command line arguments +and export libraries from an AS project to a specified destination + +0.0.4 - Improve error handling of failed builds +0.0.3 - Slight changes to debug messages +0.0.2 - ??? +0.0.1 - Initial release +""" + +# Python Modules +import argparse +import ctypes +import logging +import os.path +import shutil +import sys +import subprocess +from typing import Dict, Tuple, Sequence, Union, List, Optional + +# External Modules +import ASTools +import _version + +def main(): + + buildStatus = None + libBuildConfig:List[ASTools.Project.buildConfigs] = [] + + # Parse arguments from the command line + parser = argparse.ArgumentParser(description='Export libraries from an AS project with command line arguments.') + parser.add_argument('project', type=str, help='Path to AS project') + parser.add_argument('-dest','--destination', type=str, help='Destination path for exported libraries') + parser.add_argument('-c','--configuration', nargs='+', type=str, help='AS configuration') + parser.add_argument('-wl', '--whitelist', type=str, nargs='+', help='Desired libraries (trumps the blacklist)', default='') + parser.add_argument('-bl', '--blacklist', type=str, nargs='+', help='Ignored libraries (use glob style pattern: *myLibName*)', default='') + parser.add_argument('-o', '--overwrite', action='store_true', help='Option to have previously exported libraries overwritten') + parser.add_argument('-source','--sourceFile', action='store_true', help='Option to have libraries exported as source') + parser.add_argument('-bm','--buildMode', type=str, help='Type of build in AS', default='None', choices=['Rebuild', 'Build','BuildAndTransfer', 'BuildAndCreateCompactFlash', 'None']) + parser.add_argument('-l', '--logLevel', type=str.upper, help='Log level input is case insensitive', choices=['DEBUG','INFO','WARNING', 'ERROR'], default='') + parser.add_argument('-iv', '--includeVersion', action='store_true', help='Option to have version number included in the folder structure') + parser.add_argument('-v', '--version', action='version', version='%(prog)s {version}'.format(version=_version.__version__)) + args = parser.parse_args() + + # Allow setting log level via command line + if(args.logLevel): + lognum = getattr(logging, args.logLevel) + if not isinstance(lognum, int): + raise ValueError('Invalid log level: %s' % args.logLevel) + logging.getLogger().setLevel(level=lognum) + + # Log arguments for debug + logging.debug('The project to be built is: %s', args.project) + logging.debug('The project configuration(s) to be build is: %s', args.configuration) + logging.debug('Overwrite? %s', args.overwrite) + logging.debug('Source? %s', args.sourceFile) + logging.debug('Version Included? %s', args.includeVersion) + logging.debug('Built before exporting? %s', args.buildMode) + logging.debug('Libraries whitelist: %s', args.whitelist) + logging.debug('Libraries blacklist: %s', args.blacklist) + + if args.destination == None: + args.destination = os.path.join(os.path.dirname(args.project), '..', 'Exports') + + logging.debug('Export destination: %s', args.destination) + + project = ASTools.Project(args.project) + + # TODO: This section of config names to BuildConfig list can probably be improved (next ~85 lines) + # Using something like for each config name project.getConfigByName + for buildConfig in project.buildConfigs: + if args.configuration != None: + if buildConfig.name in args.configuration: + libBuildConfig.append(buildConfig) + + if not len(libBuildConfig): + logging.error('\033[31mNot a configration in specified project: %s\033[0m', str(args.configuration)) + sys.exit('Configuration passed in is not part of AS project') + + libBuildConfigNames:List[str] = [config.name for config in libBuildConfig] + + for name in args.configuration: + if name not in libBuildConfigNames: + logging.error('Configuration name does not exist in project: %s', name) + + if args.buildMode != 'None': + for config in args.configuration: + buildStatus = project.build(config, buildMode=args.buildMode, simulation=False) + + if buildStatus.returncode > ASTools.ASReturnCodes['Warnings']: + sys.exit('Build failed for config {config}') + else: + logging.debug('Building of %s Complete!', config) + + if args.buildMode == 'None' or buildStatus.returncode <= ASTools.ASReturnCodes['Errors']: + results = project.exportLibraries(args.destination, overwrite=args.overwrite, buildConfigs=libBuildConfig, blacklist=args.blacklist, whitelist=args.whitelist, binary= not args.sourceFile, includeVersion= args.includeVersion ) + + # TODO: This handling needs to be improved but first requires improvements in the return info of exportLibraries + for result in results.failed: + logging.error('\033[31mFailed to export %s to %s because %s\033[0m', result.name, result.path, result.exception) + # Remove libraries that failed exports + try: + shutil.rmtree(result.path, onerror=ASTools.Library._rmtreeOnError) + except FileNotFoundError as identifier: + logging.debug('Failed to delete fail export lib, does not exist: %s', result.path) + except: + logging.exception('\033[31mFailed to delete: %s, because %s\033[0m', result.path, sys.exc_info()[0]) + + logging.info('Export Complete!') + sys.exit(0) + + +if __name__ == "__main__": + + # Configure colored logger + logging.basicConfig(stream=sys.stderr, level=logging.INFO) + kernel32 = ctypes.windll.kernel32 + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + + main() diff --git a/src/ASPython/CmdLineGetSafetyCrc.py b/src/ASPython/CmdLineGetSafetyCrc.py new file mode 100644 index 0000000..cf39c31 --- /dev/null +++ b/src/ASPython/CmdLineGetSafetyCrc.py @@ -0,0 +1,61 @@ +''' + * File: CmdLineGetSafetyCrc.py + * Copyright (c) 2023 Loupe + * https://loupe.team + * + * This file is part of ASPython, licensed under the MIT License. +''' +""" +@title CmdLineGetVersion +@description This python script takes in command line arguments +and retrieves the CRC of the specified safe application + +0.1.0 - Synchronize all script versions +0.0.1 - Initial release +""" + +# Python Modules +import argparse +import os.path +import shutil +import sys +import re +from typing import Dict, Tuple, Sequence, Union, List, Optional + +# External Modules +import ASTools +import _version + +def main(): + + # Parse arguments from the command line + parser = argparse.ArgumentParser(description='Retrieve the CRC for an SafeApplication.') + parser.add_argument('project', type=str, help='Path to AS project') + parser.add_argument('-c','--configuration', nargs='+', type=str, help='AS configuration(s)') + parser.add_argument('-sa','--safeApp', type=str, help='Location of the safe application binaries') + parser.add_argument('-v', '--version', action='version', version='%(prog)s {version}'.format(version=_version.__version__)) + args = parser.parse_args() + + project = ASTools.Project(args.project) + + # Find the name of the PLC folder (the one that's right under the configuration name folder). + configurationDirectory = os.path.join(project.dirPath, 'Physical', args.configuration[0]) + plcDirectory = [name for name in os.listdir(configurationDirectory) if os.path.isdir(os.path.join(configurationDirectory, name))] + + # Truncate extension off of SfApp. + splitSafetyApp = args.safeApp.split('.') + + # Create the full relative path to the obscure file. + relativePath = os.path.join('Physical', args.configuration[0], plcDirectory[0], 'MappSafety', splitSafetyApp[0], 'C', 'PLC', 'R', 'CPU', 'CPU.ini') + + # And retrieve the CRC value. + safetyCrc = project.getIniValue(relativePath, 'CRC', 'PROJECT') + + sys.stdout.write(safetyCrc) + + sys.exit(0) + + +if __name__ == "__main__": + + main() diff --git a/src/ASPython/CmdLineGetVersion.py b/src/ASPython/CmdLineGetVersion.py new file mode 100644 index 0000000..f0eed57 --- /dev/null +++ b/src/ASPython/CmdLineGetVersion.py @@ -0,0 +1,63 @@ +''' + * File: CmdLineGetVersion.py + * Copyright (c) 2023 Loupe + * https://loupe.team + * + * This file is part of ASPython, licensed under the MIT License. +''' +""" +@title CmdLineGetVersion +@description This python script takes in command line arguments +and retrieves the current build version of an AS project + +0.1.0 - Synchronize all script versions +0.0.1 - Initial release +""" + +# Python Modules +import argparse +import os.path +import shutil +import sys +import re +from typing import Dict, Tuple, Sequence, Union, List, Optional + +# External Modules +import ASTools +import _version + +def main(): + + # Parse arguments from the command line + parser = argparse.ArgumentParser(description='Retrieve the versionId for an AS project.') + parser.add_argument('project', type=str, help='Path to AS project') + parser.add_argument('-bi','--buildInfo', type=str, help='Location of the buildInfo .var file') + parser.add_argument('--semver', dest='semVer', action='store_true', help='Request the version back in Semantic Version format') + parser.add_argument('-v', '--version', action='version', version='%(prog)s {version}'.format(version=_version.__version__)) + args = parser.parse_args() + + project = ASTools.Project(args.project) + + versionId = project.getConstantValue(args.buildInfo, 'versionId') + + if (args.semVer): + # The version needs to be in the format w.x.y.z. So we try to extract that from the versionId string. + # Expecting the output of 'git describe --tags --all', which looks like this: v0.1.2-685-gad6e288 + try: + match = re.search('(\d+\.\d+\.\d+).*-(\d+)-.*', versionId) + versionId = match.group(1) + if (match.group(2) != ''): + versionId = versionId + '.' + match.group(2) + else: + versionId = versionId + '.0' + except: + versionId = '0.0.0.0' + + sys.stdout.write(versionId) + + sys.exit(0) + + +if __name__ == "__main__": + + main() diff --git a/src/ASPython/CmdLinePackageHmi.py b/src/ASPython/CmdLinePackageHmi.py new file mode 100644 index 0000000..f162564 --- /dev/null +++ b/src/ASPython/CmdLinePackageHmi.py @@ -0,0 +1,119 @@ +''' + * File: CmdLinePackageHmi.py + * Copyright (c) 2023 Loupe + * https://loupe.team + * + * This file is part of ASPython, licensed under the MIT License. +''' +""" +@title CmdLinePackageHmi +@description This python script takes in command line arguments +and packages a webHMI-based HMI + +0.1.0 - Synchronize all script versions +0.0.1 - Initial release +""" + +# Python Modules +import argparse +import logging +import sys +import subprocess +import uuid +import os +import json +import re +from typing import Dict, Tuple, Sequence, Union, List, Optional + +import _version + +# TODO: add capabilities for zipping up HMI. +# TODO: error check the parameters. + +def main(): + + # Parse arguments from the command line + parser = argparse.ArgumentParser(description='Package up a webHMI-based HMI') + # High-level application information. + parser.add_argument('-s', '--source', type=str, help='Source folder where the HMI files are located (i.e. where main package.json is located)', default='C:/Projects/Publisher/Project/HMIApp/Electron') + parser.add_argument('-o', '--output', type=str, help='Destination folder where packaged files are placed') + parser.add_argument('-an', '--appName', type=str, help='Name of the app to package') + parser.add_argument('-av', '--appVersion', type=str, help='Version of the app to create', default='1.0.0') + parser.add_argument('-ap', '--appPublisher', type=str, help='Name of the app publisher', default='Loupe') + parser.add_argument('--installElectronPackager', dest='installElectronPackager', action='store_true', help='Install electron-packager before attempting to package HMI') + # General script utilities. + parser.add_argument('-l', '--logLevel', type=str.upper, help='Log level', choices=['DEBUG','INFO','WARNING', 'ERROR'], default='') + parser.add_argument('-v', '--version', action='version', version='%(prog)s {version}'.format(version=_version.__version__)) + args = parser.parse_args() + + # Allow setting log level via command line + if(args.logLevel): + lognum = getattr(logging, args.logLevel) + if not isinstance(lognum, int): + raise ValueError('Invalid log level: %s' % args.logLevel) + logging.getLogger().setLevel(level=lognum) + + # Install all npm dependencies from source folder and source/public folder. + installDependencies(args.source) + try: + installDependencies(args.source + '/public') + except: + logging.info('No public sub-folder found, skipping its dependency installation') + + # Install electron-packager if specified. + if (args.installElectronPackager): + installElectronPackager() + + # Update the version in the package.json. + appSemanticVersion = updateAppVersion(args.source, args.appVersion) + + # Call electron-packager to package up the HMI. + packageHMI(args.source, args.appName, args.output, args.appPublisher, appSemanticVersion) + + # Zip up the packaged artifacts. + #zipHMI(args) + + sys.exit(0) + +def installDependencies(source): + # cd into the right folder. + os.chdir(source) + # Check to see if this folder has a package.json in it. + if not 'package.json' in os.listdir('.'): + logging.info('The source directory does not contain a package.json, skipping install') + else: + # Install all local npm dependencies. + subprocess.run('npm install', encoding='utf-8', errors='replace', shell=True) + return + +def installElectronPackager(): + # Install the electron-packager module globally. + subprocess.run('npm install electron-packager -g', encoding='utf-8', errors='replace', shell=True) + return + +def updateAppVersion(source, version): + with open(source + '/package.json', 'r+') as f: + data = json.load(f) + data['version'] = version + f.seek(0) # <--- should reset file position to the beginning. + json.dump(data, f, indent=4) + f.truncate() # remove remaining part + return version + +def packageHMI(source, appName, output, appPublisher, appVersion): + command = [] + command.append('electron-packager') + command.append(source) + command.append(appName) + command.append('--platform=win32') + command.append('--arch=x64') + command.append(f'--out={output}') + command.append('--overwrite') + command.append(f'--win32metadata.CompanyName="{appPublisher}"') + command.append(f'--win32metadata.FileDescription="Build #{appVersion}"') + logging.info(command) + subprocess.run(command, encoding='utf-8', shell=True) + +if __name__ == "__main__": + + main() diff --git a/src/ASPython/CmdLineRunUnitTests.py b/src/ASPython/CmdLineRunUnitTests.py new file mode 100644 index 0000000..aacfde1 --- /dev/null +++ b/src/ASPython/CmdLineRunUnitTests.py @@ -0,0 +1,69 @@ +''' + * File: CmdLineRunUnitTests.py + * Copyright (c) 2023 Loupe + * https://loupe.team + * + * This file is part of ASPython, licensed under the MIT License. +''' +""" +@title CmdLineRunUnitTests +@description This python script takes in command line arguments +and runs a series of unit tests against a live server + +0.0.1 - Initial release +""" + +# Python Modules +import argparse +import ctypes +import logging +import sys +import os +import shutil + +# External Modules +import UnitTestTools +import _version + +def main(): + + # Parse arguments from the command line + parser = argparse.ArgumentParser(description='Run unit tests via command line.') + parser.add_argument('host', type=str, help='IP address of the PLC running the tests') + parser.add_argument('-d', '--destination', type=str, help='Destination directory where test results should get placed') + parser.add_argument('-a', '--all', action='store_true', help='Run all available tests') + parser.add_argument('-l', '--logLevel', type=str.upper, help='Log level', choices=['DEBUG','INFO','WARNING', 'ERROR'], default='') + parser.add_argument('-v','--version', action='version', version='%(prog)s {version}'.format(version=_version.__version__)) + args = parser.parse_args() + + # Allow setting log level via command line + if(args.logLevel): + lognum = getattr(logging, args.logLevel) + if not isinstance(lognum, int): + raise ValueError('Invalid log level: %s' % args.logLevel) + logging.getLogger().setLevel(level=lognum) + + # Save parsed information in to variables. eARSim. + logging.debug('%s', args) + logging.debug('The host to be tested is: %s', args.host) + + logging.info('Querying test server to retrive list of available tests') + testServer = UnitTestTools.UnitTestServer(args.host, args.destination) + + if testServer.connected: + for testSuite in testServer.testSuites: + logging.info(f'Running test suite {testSuite["device"]}') + testServer.runTest(testSuite['device']) + else: + logging.error("Could not connect to the test server") + + sys.exit(0) + +if __name__ == "__main__": + + # Configure colored logger + logging.basicConfig(stream=sys.stderr, level=logging.INFO) + kernel32 = ctypes.windll.kernel32 + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + + main() diff --git a/src/ASPython/ColorCodedLog.py b/src/ASPython/ColorCodedLog.py new file mode 100644 index 0000000..d53e4e9 --- /dev/null +++ b/src/ASPython/ColorCodedLog.py @@ -0,0 +1,189 @@ +''' + * File: ColorCodedLog.py + * Copyright (c) 2023 Loupe + * https://loupe.team + * + * This file is part of ASPython, licensed under the MIT License. +''' +# Python Modules +import logging +import sys +from typing import Optional, Union + +def InitializeLogger(logName:str="main", logLevel:Union[int, str, None]=None, logStringFormat:Optional[str]=None, infoColor:Optional[str]=None, debugColor:Optional[str]=None, warningColor:Optional[str]=None, errorColor:Optional[str]=None, criticalColor:Optional[str]=None, disableColors:Optional[bool]=False): + """Function for Initalizing a logger with a custom format""" + + # Find logger with given name. if it does not exist it will create one + logger = logging.getLogger(logName) + + # Set logging level + if logLevel: logger.setLevel(logLevel) + + # For loop through handlers to see if one already exists + customHandler = None + for handler in logger.handlers: + if isinstance(handler.formatter, CustomFormatter): + customHandler = handler + logFormatter = handler.formatter + break + + # Create custom handler if one does not exist + if customHandler is None: + customHandler = logging.StreamHandler(sys.stderr) + customHandler.setLevel(logging.DEBUG) + logFormatter = CustomFormatter() + customHandler.setFormatter(logFormatter) + logger.addHandler(customHandler) + + + if logStringFormat: logFormatter.msgFormat = logStringFormat + + # Disable colors by removing the color from the format string + if disableColors: + logFormatter.debug = "" + logFormatter.info = "" + logFormatter.warning = "" + logFormatter.error = "" + logFormatter.critical = "" + else: + # Only apply color if argument was passed in + if debugColor: logFormatter.debug = debugColor + if infoColor: logFormatter.info = debugColor + if warningColor: logFormatter.warning = debugColor + if errorColor: logFormatter.error = debugColor + if criticalColor: logFormatter.critical = debugColor + + return logger + +class CustomFormatter(logging.Formatter): + """Logging Formatter to add colors and count warning / errors""" + + # Default values + defaultDebug = "\x1b[38;21m" + defaultInfo = "\x1b[38;21m" + defaultWarning = "\x1b[33;21m" + defaultError = "\x1b[31;21m" + defaultCritical = "\x1b[31;1m" + defaultReset = "\x1b[0m" + defaultMsgFormat = "%(asctime)s - %(name)s - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)" + + def __init__(self): + self._debug = CustomFormatter.defaultDebug + self._info = CustomFormatter.defaultInfo + self._warning = CustomFormatter.defaultWarning + self._error = CustomFormatter.defaultError + self._critical = CustomFormatter.defaultCritical + self._msgFormat = CustomFormatter.defaultMsgFormat + self._reset = CustomFormatter.defaultReset + self.generate() + + # Make setter regenterate formatDict for each property + @property + def debug(self): + return self._debug + + @debug.setter + def debug(self, value): + self._debug = value + self.generate() + return self._debug + + @property + def info(self): + return self._info + + @info.setter + def info(self, value): + self._info = value + self.generate() + return self._info + + @property + def warning(self): + return self._warning + + @warning.setter + def warning(self, value): + self._warning = value + self.generate() + return self._warning + + @property + def error(self): + return self._error + + @error.setter + def error(self, value): + self._error = value + self.generate() + return self._error + + @property + def critical(self): + return self._critical + + @critical.setter + def critical(self, value): + self._critical = value + self.generate() + return self._critical + + @property + def msgFormat(self): + return self._msgFormat + + @msgFormat.setter + def msgFormat(self, value): + self._msgFormat = value + self.generate() + return self._msgFormat + + @property + def reset(self): + return self._reset + + @reset.setter + def reset(self, value): + self._reset = value + self.generate() + return self._reset + + def generate(self): + self._formatDict = { + logging.DEBUG: self.debug + self.msgFormat + self.reset, + logging.INFO: self.info + self.msgFormat + self.reset, + logging.WARNING: self.warning + self.msgFormat + self.reset, + logging.ERROR: self.error + self.msgFormat + self.reset, + logging.CRITICAL: self.critical + self.msgFormat + self.reset + } + return self + + def format(self, record): + log_fmt = self._formatDict.get(record.levelno) + formatter = logging.Formatter(log_fmt) + return formatter.format(record) + +def main(): + + logger = InitializeLogger(logLevel="DEBUG") + logger.info('info message') + logger.debug('debug message') + logger.warning('warning message') + logger.error('error message') + + logger2 = InitializeLogger(debugColor="\x1b[31;21m") + logger2.debug('debug message red') + + logger = InitializeLogger(disableColors=True) + logger.info('info message') + logger.debug('debug message') + logger.warning('warning message') + logger.error('error message') + + logger2 = InitializeLogger(debugColor="\x1b[31;21m") + logger2.debug('debug message red') + + +if __name__ == "__main__": + main() + diff --git a/src/ASPython/ExportLibraries.py b/src/ASPython/ExportLibraries.py new file mode 100644 index 0000000..ea3818e --- /dev/null +++ b/src/ASPython/ExportLibraries.py @@ -0,0 +1,94 @@ +''' + * File: ExportLibraries.py + * Copyright (c) 2023 Loupe + * https://loupe.team + * + * This file is part of ASPython, licensed under the MIT License. +''' +""" +@title ExportLibraries +@description This file parses parameters from a file and uses them to +export libraries from an AS project +""" + +import ASTools +import logging +import os.path +import shutil +import json +import sys +import copy +import ctypes + +from _version import __version__ + +# TODO: Better support command line arguments +# TODO: Add option to support user input + +if __name__ == "__main__": + default = { + "projectPath": "", + "buildMode": "Rebuild", + "Configs": [], + "exportPath": "", + "versionSubFolders": False, + "ignoreLibraries": [] + } + paramFileName = 'ExportLibrariesParam.json' + # Set up logging and color-coding + logging.basicConfig(stream=sys.stderr, level=logging.INFO) + kernel32 = ctypes.windll.kernel32 + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + + # Write default params if none exist + if not os.path.exists(paramFileName): + logging.info('Parameter file: %s, not found. Creating file.', paramFileName) + with open(paramFileName, 'x') as f: + f.write(json.dumps(default, indent=2)) + input("Please modify %s with desired params. Press Enter to continue..." % paramFileName) + + try: + data = copy.deepcopy(default) # Use deep copy instead of copy so that we don't modify default's non primitive values + + # Read parameters + with open(paramFileName) as param: + data.update(json.load(param)) + + # Parse project + project = ASTools.Project(data.get('projectPath')) + + if not data.get('Configs'): + data['Configs'] = project.buildConfigNames + + # Update parameters with missing keys + with open(paramFileName, 'w') as param: + param.write(json.dumps(data, indent=2)) + + # Build project + if data.get('buildMode') != 'None': + process = project.build(*data.get('Configs'), buildMode=data.get('buildMode')) + logging.info('Project batch build complete, code %d', process.returncode) + if process.returncode > ASTools.ASReturnCodes["Warnings"]: + sys.exit(process.returncode) + + # Export + results = project.exportLibraries(data.get('exportPath'), overwrite=True, ignores=data.get('ignoreLibraries'), includeVersion=data.get('versionSubFolders')) + for result in results.failed: + logging.error('\033[31mFailed to export %s to %s because %s\033[0m', result.name, result.path, result.exception) + # Remove libraries that failed exports + try: + shutil.rmtree(result.path, onerror=ASTools.Library._rmtreeOnError) + except FileNotFoundError as identifier: + logging.debug('Failed to delete fail export lib, does not exist: %s', result.path) + except: + logging.exception('Failed to delete: %s, because %s', result.path, sys.exc_info()[0]) + + logging.info('Export Complete!') + + except: + logging.exception('Error occurred: %s', sys.exc_info()[0]) + + pass + + sys.exit(0) + \ No newline at end of file diff --git a/src/ASPython/Files/DisconnectFileDevice.bat b/src/ASPython/Files/DisconnectFileDevice.bat new file mode 100644 index 0000000..308ae69 --- /dev/null +++ b/src/ASPython/Files/DisconnectFileDevice.bat @@ -0,0 +1 @@ +rmdir "C:\ARSimUser\%1" \ No newline at end of file diff --git a/src/ASPython/Files/InnoInstaller.iss b/src/ASPython/Files/InnoInstaller.iss new file mode 100644 index 0000000..cc819c9 --- /dev/null +++ b/src/ASPython/Files/InnoInstaller.iss @@ -0,0 +1,118 @@ +;TODO: +;Get the 'launch now' working (checkbox at the end of install) +;Figure out how to disconnect the file device upon uninstall. + +;######################################################## +; General (common) fields +;######################################################## +[Setup] +AppId={#AppGUID} +AppName={#AppName} +AppVersion={#AppVersion} +AppPublisher={#AppPublisher} +AppPublisherURL={#AppURL} +AppSupportURL={#AppURL} +DefaultDirName={commonpf64}\{#AppPublisher}\{#AppName} +DefaultGroupName={#AppPublisher}\{#AppName} +AllowNoIcons=yes +DisableDirPage=yes +OutputBaseFilename={#AppName}_Setup_{#AppVersion} +Compression=lzma +SolidCompression=yes +AlwaysShowDirOnReadyPage=yes +AlwaysShowGroupOnReadyPage=yes +DisableWelcomePage=no +;TODO: look into these directives: +;WizardImageFile=myimage.bmp +;SetupIconFile= +;BackColor=clBlue +;BackColor2=clBlack +;Custom icons? + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[UninstallDelete] +Type: filesandordirs; Name: "{app}" + + +;######################################################## +; ARsim simulator fields +;######################################################## +#if IncludeSimulator == "yes" + + #define AppExeName "ar000loader.exe" + + [Components] + Name: "Simulator"; Description: "ARsim simulator"; Types: full compact custom; + + [Files] + Source: {#SimulationDirectory}\*; DestDir: "{app}\ARsim"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: Simulator; + + [Icons] + ;Start Menu shortcuts + Name: "{group}\{#AppName} Simulator"; Filename: "{app}\ARsim\{#AppExeName}"; Components: Simulator + ;Desktop shortcuts + Name: "{commondesktop}\{#AppName}\{#AppName} Simulator"; Filename: "{app}\ARsim\{#AppExeName}"; Tasks: desktopicon + + [Run] + ;Start up ARsim if checked to do so + Filename: "{app}\ARsim\{#AppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(AppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent; Components: Simulator; + + [Registry] + ;Need to write to the registry to run ar000loader as admin, otherwise the ARsim will not boot in Program Files/(x86) + ;Write to win7 64bit reg only if running windows7 64bit + Root: "HKLM64"; Subkey: "SOFTWARE\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers\"; ValueType: String; ValueName: "{app}\ARsim\ar000loader.exe"; ValueData: "RUNASADMIN"; Flags: uninsdeletekeyifempty uninsdeletevalue; MinVersion: 6.1.7601; Check: IsWin64; Components: Simulator; + ;Write to win7 32bit reg only if running windows7 32bit + Root: "HKLM32"; Subkey: "SOFTWARE\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers\"; ValueType: string; ValueName: "{app}\ARsim\ar000loader.exe"; ValueData: "RUNASADMIN"; Flags: uninsdeletekeyifempty uninsdeletevalue; MinVersion: 6.1.7601; Check: NOT IsWin64; Components: Simulator; + +#endif + + +;######################################################## +; HMI fields +;######################################################## +#if IncludeHmi == "yes" + + [Components] + Name: "HMI"; Description: "webHMI user interface"; Types: full custom; + + [Files] + Source: {#HMIDirectory}\*; DestDir: "{app}\HMI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: HMI; + + [Icons] + ;Start Menu shortcuts + Name: "{group}\{#AppName} HMI"; Filename: "{app}\HMI\{#HMIExeName}"; Components: HMI + ;Desktop shortcuts + Name: "{commondesktop}\{#AppName}\{#AppName} HMI"; Filename: "{app}\HMI\{#HMIExeName}"; Tasks: desktopicon + +#endif + + +;######################################################## +; User partition fields +;######################################################## +#if IncludeUserPartition == "yes" + + [Components] + Name: "UserPartition"; Description: "Recipe data"; Types: full custom; + + [Files] + Source: {#UserPartitionDirectory}\*; DestDir: "{app}\UserPartition"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: UserPartition; + Source: {#UserPartitionDirectory}\{#JunctionBatchFilename}; DestDir:"{app}\UserPartition"; Components: UserPartition; Permissions: everyone-full; + Source: DisconnectFileDevice.bat; Destdir: "{app}"; Components: UserPartition; + + [Run] + ;Create Folder Junction in User Partition (C:\ARSimUser) and link it to Application's User Partition (C:\Program Files\Publisher\AppName\User Partition\Config) + Filename: "{app}\UserPartition\{#JunctionBatchFilename}"; Components: UserPartition; + + ; [UninstallRun] + ; ;Remove the symlink to user partition that was created. + ; Filename: "{app}\DisconnectFileDevice.bat"; Parameters:"{#AppPublisher}-{#AppName}"; Components: UserPartition; + +#endif + + diff --git a/src/ASPython/Files/favicon.ico b/src/ASPython/Files/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..418c43705b33f5454a93f006380594437d5544dd GIT binary patch literal 4286 zcmchaJ8KkC7>3VgH5>4V1cqpUu5PraASPyx7u-?>g?2X;<0=~gT$jieja_@rMa398D z3v|E;yo9_wsC^9Hz)!Hh{k;WiAWsj8_B(XEKj-)fn1L$vgOP`aM(t#LH=*|gzQPbB zv78=?B(=|z`%bj2uS;NGJcT}}kLB{PgQU*u`kJcOwiXV6dX3SJhmTxjJ*S29*)??% z)NRyu-L;R>qLqiMTx4$hG%c(%+l)guBz^9E|4W-DwP@yHBWKQnxv$h(HUB!!Ve4xK z?t$ZwMVsceX!J16QTMAau)o%#`PVuQyQezO_4)~pLaRRKbkop-wekVXWlqOnEu@9# zY?J7_ukHhLWeq*+y;0wLOp7C&u#fDk2Vnm{hrN)+gWBeD?94F@uGNn1AxMj){w8Q( z%_Q}g#Y3X)J*aICYk2}3hgS9HIoq6vE~uY>?RaRWoy_CO#oQP8O^ZF8u;zT0I0;!i zI6eoV{=O4!=e&LK3I-r8Iyo^0vyjz8AJ;sCq|Y0*UDI9L?W0z)R1ZTWwZ7b=+^f`2 z?mN+TZ)+!(;-R0!Rfxc~(pt7JvTAclEwXxWUf%)N4xf$vwHEC2cJG^}e??XgKAW8Z zpVjm-3`^DK(pt3Z!SOc=;Jo=?U2`ST%7c5h&xRH7**vdE^WZy(&nyx6ZjxuzdzeGW zcdse1-X4MLY~G=VQE+c`trqKi1RVR72t8QaH^4o^cX8j5GDke$6UWsul%NQOI6fER zUm=`cV6C3-2tCzeC`ZMKQdF#zB0uG7De9RoMFR_e@W03W*rre^RYRpb9|n8OVXioA Oh~o)#DiEm$X#WO3Xhenp literal 0 HcmV?d00001 diff --git a/src/ASPython/InstallUpgrades.py b/src/ASPython/InstallUpgrades.py new file mode 100644 index 0000000..c23607b --- /dev/null +++ b/src/ASPython/InstallUpgrades.py @@ -0,0 +1,128 @@ +''' + * File: InstallUpgrades.py + * Copyright (c) 2023 Loupe + * https://loupe.team + * + * This file is part of ASPython, licensed under the MIT License. +''' +""" +@title InstallUpgrades +@description This python script takes in command line arguments +and installs all AS upgrades in the specified directory +""" + +#Python Modules +import argparse +import logging +import sys +import ctypes +import os +import subprocess +# import psutil # This is a third party library + +from _version import __version__ + +# Sample call for this script: +# python InstallUpgrades.py "C:/Temp/downloading" -brp "C:\BrAutomation" -asp "C:\BrAutomation\AS49" -l INFO + +# def getService(name): + +# service = None +# try: +# service = psutil.win_service_get(name) +# service = service.as_dict() +# except Exception as ex: +# print(str(ex)) +# return service + +def installBRUpgrade(upgrade:str, brPath:str, asPath:str): + commandLine = [] + commandLine.append(upgrade) + commandLine.append('-G=' + brPath) + commandLine.append('-V=' + asPath) + commandLine.append('-R=Y') + + # Execute the process, and retrieve the process object. + logging.info('Started installing upgrade ' + upgrade) + logging.debug(commandLine) + process = subprocess.run(commandLine) + + if process.returncode == 0: + logging.info('Finished install upgrade ' + upgrade) + else: + logging.error('Error while installing upgrade ' + upgrade + ' (return code = ' + process.returncode + ')') + + return process.returncode + +def main(): + #parse arguments from the command line + parser = argparse.ArgumentParser(description='Install AS upgrades') + parser.add_argument('upgradePath', type=str, help='Path to single upgrade or a folder containing upgrades') + parser.add_argument('-brp','--brpath', type=str, help='Global AS install path') + parser.add_argument('-asp','--aspath', type=str, help='AS install path for the desired AS version') + parser.add_argument('-l', '--logLevel', type=str.upper, help='Log level', choices=['DEBUG','INFO','WARNING', 'ERROR'], default='') + parser.add_argument('-v', '--version', action='version', version='%(prog)s {version}'.format(version=__version__)) + args = parser.parse_args() + + # Allow setting log level via command line + if(args.logLevel): + lognum = getattr(logging, args.logLevel) + if not isinstance(lognum, int): + raise ValueError('Invalid log level: %s' % args.logLevel) + logging.getLogger().setLevel(level=lognum) + + #save parsed information in to variables. + logging.debug('The upgrades Path is: %s', args.upgradePath) + logging.debug('The global AS install path is: %s', args.brpath) + logging.debug('The local AS install path is: %s', args.aspath) + logging.debug('The log level will be: %s', args.logLevel) + + try: + is_admin = os.getuid() == 0 + except AttributeError: + is_admin = ctypes.windll.shell32.IsUserAnAdmin() != 0 + + + + # service = getService('BrUpgrSrvAS45') + # print(service) + + # upgradeServiceStatus = os.system('BR.AS.UpgradeService sshd status') + # logging.debug('Upgrade status %i', upgradeServiceStatus) + # upgradeServiceStatus = os.system('systemctl is-active --quiet BrUpgrSrvAS45') + # logging.debug('Upgrade status %i', upgradeServiceStatus) + + logging.debug('Terminal is admin: %i', is_admin) + + if not is_admin: + logging.error('Admin privileges required. Open terminal with as Administrator') + sys.exit(1) + + + + if os.path.isdir(args.upgradePath): + # Move into upgrade folder. + os.chdir(args.upgradePath) + for upgrade in os.listdir(): + # If the item is a .exe file, try to install it. + if os.path.isfile(upgrade) and upgrade.lower().endswith('.exe'): + installBRUpgrade(upgrade, args.brpath, args.aspath) + + elif os.path.isfile(args.upgradePath) and args.upgradePath.lower().endswith('.exe'): + os.chdir(os.path.dirname(args.upgradePath)) # We do this to match the case above + installBRUpgrade(args.upgradePath, args.brpath, args.aspath) + + else: + logging.error('Path provided neither an upgrade or a directory: ' + args.upgradePath) + sys.exit(args.upgradePath + ' is not a valid AS upgrade') + + sys.exit(0) + +if __name__ == "__main__": + + #configure colored logger + logging.basicConfig(stream=sys.stderr, level=logging.INFO) + kernel32 = ctypes.windll.kernel32 + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + + main() diff --git a/src/ASPython/LICENSE b/src/ASPython/LICENSE new file mode 100644 index 0000000..e39672f --- /dev/null +++ b/src/ASPython/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Loupe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/src/ASPython/README.md b/src/ASPython/README.md new file mode 100644 index 0000000..424ae5c --- /dev/null +++ b/src/ASPython/README.md @@ -0,0 +1,26 @@ +# Info +Tool is provided by Loupe +https://loupe.team +info@loupe.team +1-800-240-7042 + +# Description + +This repo provides Python tooling for interacting with Automation Studio projects in a programmatic way. + +The core capabilities live in the [ASTools](./ASTools.py) script, which contains classes that represent the various levels of an Automation Studio project. Although these can be directly imported into a user script, the more common usage pattern is to wrap this functionality in a user-facing Python script that is intended to be called from the command line directly (with argparse support for argument parsing). Each of these wrapper scripts begin with the `CmdLine` prefix, and their capabilities are briefly described below: +- [CmdLineARSim.py](CmdLineARSim.py): create an ARsim package for a given AS project. With the options to build the project, include user files, and start the simulator. +- [CmdLineBuild.py](CmdLineBuild.py): build one or more configurations in an AS project. +- [CmdLineCreateInstaller.py](CmdLineCreateInstaller.py): generate a portable ISS-based Windows installer that includes all required files to run ARsim. +- [CmdLineDeployLibraries.py](CmdLineDeployLibraries.py): deploy Automation Studio libraries to a specific cpu.sw file. +- [CmdLineExportLib.py](CmdLineExportLib.py): export Automation Studio libraries into shareable binary or source formats. +- [CmdLineGetSafetyCrc.py](CmdLineGetSafetyCrc.py): retrieve the CRC of the specified B&R Safe Application in a project. +- [CmdLineGetVersion.py](CmdLineGetVersion.py): retrieve the build version of an AS project. +- [CmdLinePackageHmi.py](CmdLinePackageHmi.py): package a webHMI-based HMI for distribution. +- [CmdLineRunUnitTests.py](CmdLineRunUnitTests.py): run the unit tests that are defined in the Automation Studio project. Note that this wrapper uses the [UnitTestTools.py](UnitTestTools.py) backend script. + +For a more detailed look at each script's API, please call the script with the `-h` argument. For example, `python CmdLineBuild.py -h`. + +# Licensing + +This project is licensed under the [MIT License](LICENSE). diff --git a/src/ASPython/UI.py b/src/ASPython/UI.py new file mode 100644 index 0000000..8a62c85 --- /dev/null +++ b/src/ASPython/UI.py @@ -0,0 +1,282 @@ +''' + * File: UI.py + * Copyright (c) 2023 Loupe + * https://loupe.team + * + * This file is part of ASPython, licensed under the MIT License. +''' +import tkinter.filedialog # This NEEDS to come before tkinter? At least if you have an 'as' after tkinter +import tkinter +import ASTools + + +class Application(tkinter.Frame): + def say_hi(self): + print("hi there, everyone!") + + def createWidgets(self): + self.columnconfigure(0, weight=1) + # self.rowconfigure(2, weight=1) + self.rowconfigure(4, weight=1) + + # Create a frame for the canvas with non-zero row&column weights + frame_canvas = tkinter.Frame(self) + frame_canvas.grid(row=4, column=0, columnspan=3, pady=(5, 0), sticky='ew') + + self.libraryScrollFrame = ScrollFrame(self) + self.libraryScrollFrame.grid(row=4, columnspan=3, sticky=tkinter.W+tkinter.E) + + self.libraryWidget = ChecklistBox(self.libraryScrollFrame.viewPort, bg="grey") + self.libraryWidget.pack(fill=tkinter.X) + + self.QUIT = tkinter.Button(self) + self.QUIT["text"] = "QUIT" + self.QUIT["fg"] = "red" + self.QUIT["command"] = self.quit + + # self.QUIT.pack({"side": "left"}) + self.QUIT.grid(column=0, row=0) + + self.hi_there = tkinter.Button(self) + self.hi_there["text"] = "Select Project", + self.hi_there["command"] = self.getProject + # self.hi_there.bind("", lambda self, event: self.setColor(event.widget, "red")) + # self.hi_there.pack({"side": "left"}) + self.hi_there.grid(column=1, row=0) + + configLabel = tkinter.Label(self) + configLabel["text"] = "Configurations: " + configLabel.grid(row=1, columnspan=3) + + + self.configScrollFrame = ScrollFrame(self) + self.configScrollFrame.grid(row=2, columnspan=3, ipady=10, sticky=tkinter.W+tkinter.E) + + self.configWidget = ChecklistBox(self.configScrollFrame.viewPort, bg="grey") + self.configWidget.pack(fill=tkinter.X) + + configLabel = tkinter.Label(self) + configLabel["text"] = "Libraries: " + configLabel.grid(row=3, columnspan=1) + + configCheckAll = tkinter.Button(self) + configCheckAll["text"] = "✓" + configCheckAll.grid(row=3, column=1) + configUnCheckAll = tkinter.Button(self) + configUnCheckAll["text"] = "❌" + configUnCheckAll.grid(row=3, column=2) + + # self.libraryWidget = ChecklistBox(self, bg="grey") + # self.libraryWidget.pack(fill=tkinter.X, side=tkinter.TOP) + # self.libraryWidget.grid(row=4, columnspan=3, ipady=10, sticky=tkinter.W+tkinter.E) + + configCheckAll["command"] = lambda : self.libraryWidget.setAll(True) + configUnCheckAll["command"] = lambda : self.libraryWidget.setAll(0) + + self.buildButton = tkinter.Button(self) + self.buildButton["text"] = "Build" + self.buildButton["command"] = self.buildProject + self.buildButton.grid(row=5, column=0, sticky=tkinter.W+tkinter.E) + + self.exportButton = tkinter.Button(self) + self.exportButton["text"] = "Export Libs" + self.exportButton["command"] = self.exportLibraries + self.exportButton.grid(row=5, column=1, columnspan=2, pady=10, padx=10, sticky=tkinter.W+tkinter.E) + + + + + def setColor(self, widget, color): + widget["activeforeground"] = color + + def __init__(self, master=None): + self.projectPath = '' + self.project:ASTools.Project = None + tkinter.Frame.__init__(self, master) + self.pack(fill='both', expand=True) + self.createWidgets() + + self.bind("", self.on_resize) + self.height = self.winfo_reqheight() + self.width = self.winfo_reqwidth() + + def on_resize(self,event): + # determine the ratio of old width/height to new width/height + wscale = float(event.width)/self.width + hscale = float(event.height)/self.height + self.width = event.width + self.height = event.height + # resize the canvas + self.config(width=self.width, height=self.height) + # rescale all the objects tagged with the "all" tag + # self.scale("all",0,0,wscale,hscale) + + def getProject(self): + potentialPath:str = tkinter.filedialog.askopenfilename(filetypes=[("AS Project","*.apj")]) + if not potentialPath: return + + self.busy(True) + try: + self.projectPath = potentialPath + print(self.projectPath) + self.project = ASTools.Project(self.projectPath) + + self.configWidget.addItems(self.projectConfigs()) + self.configWidget.setAll(0) + + self.libraryWidget.addItems(self.projectLibraries()) + self.libraryWidget.setAll(0) + + print(self.projectLibraries()) + print(self.projectConfigs()) + + finally: + self.busy(False) + + def projectLibraries(self): + if not self.project: raise AttributeError() + + libNames:list = [] + + for lib in self.project.libraries: + libNames.append(lib.name) + + return libNames + + def projectConfigs(self): + if not self.project: raise AttributeError() + + return self.project.buildConfigNames + + def buildProject(self): + self.busy(True) + try: + status = self.project.build(*self.configWidget.getCheckedItems()) + if status.returncode < ASTools.ASReturnCodes["Errors"]: + self.buildButton["text"] = "Build " + "✓" + else: + self.buildButton["text"] = "Build " + "❌" + finally: + self.busy(False) + + + def exportLibraries(self): + dest:str = tkinter.filedialog.askdirectory() + if not dest: return + + + buildConfigs = [] + for configName in self.configWidget.getCheckedItems(): + buildConfigs.append(configName) + + if len(buildConfigs) == 0: raise IndexError() + + ignoreLibraries = list(set(self.projectLibraries()).difference(self.libraryWidget.getCheckedItems())) + ignoreLibraries = ['*{0}*'.format(element) for element in ignoreLibraries] + + status = self.project.exportLibraries(dest, overwrite=True, buildConfigs=buildConfigs, ignores=ignoreLibraries, includeVersion=True) + + if len(status.failed) == 0: + self.exportButton["text"] = "Export Libs " + "✓" + else: + self.exportButton["text"] = "Export Libs " + "❌" + + def busy(self, setValue:bool) -> bool: + if(setValue != None): + self._busy = setValue + + if(setValue): + self.config(cursor='wait') + else: + self.config(cursor='') + self.update() + return self._busy + + +# ************************ +# Scrollable Frame Class +# ************************ +class ScrollFrame(tkinter.Frame): + def __init__(self, parent, **kwargs): + super().__init__(parent, **kwargs) # create a frame (self) + self.config(height=100) + + self.canvas = tkinter.Canvas(self, borderwidth=0, background="#ffffff") #place canvas on self + self.viewPort = tkinter.Frame(self.canvas, background="#ffffff") #place a frame on the canvas, this frame will hold the child widgets + self.vsb = tkinter.Scrollbar(self, orient="vertical", command=self.canvas.yview) #place a scrollbar on self + self.canvas.configure(yscrollcommand=self.vsb.set) #attach scrollbar action to scroll of canvas + + self.vsb.pack(side="right", fill="y") #pack scrollbar to right of self + self.canvas.pack(side="left", fill="both", expand=True) #pack canvas to left of self and expand to fil + self.canvas_window = self.canvas.create_window((4,4), window=self.viewPort, anchor="nw", #add view port frame to canvas + tags="self.viewPort") + + self.viewPort.bind("", self.onFrameConfigure) #bind an event whenever the size of the viewPort frame changes. + self.canvas.bind("", self.onCanvasConfigure) #bind an event whenever the size of the viewPort frame changes. + + self.onFrameConfigure(None) #perform an initial stretch on render, otherwise the scroll region has a tiny border until the first resize + + def onFrameConfigure(self, event): + '''Reset the scroll region to encompass the inner frame''' + self.canvas.configure(scrollregion=self.canvas.bbox("all")) #whenever the size of the frame changes, alter the scroll region respectively. + + def onCanvasConfigure(self, event): + '''Reset the canvas window to encompass inner frame when required''' + canvas_width = event.width + self.canvas.itemconfig(self.canvas_window, width = canvas_width) #whenever the size of the canvas changes alter the window region respectively. + + +class ChecklistBox(tkinter.Frame): + def __init__(self, parent, choices=None, **kwargs): + tkinter.Frame.__init__(self, parent, **kwargs) + + self.vars = [] + self.items = [] + + if choices: + self.addItems(choices) + + def clearItems(self): + for item in self.items: + item.pack_forget() + + self.items.clear() + self.vars.clear() + + def setAll(self, value): + for i, var in enumerate(self.vars): + if value: + var.set(self.items[i].cget('text')) + else: + var.set('') + + def addItems(self, choices): + bg = self.cget("background") + for choice in choices: + var = tkinter.StringVar(value=choice) + cb = tkinter.Checkbutton(self, var=var, text=choice, + onvalue=choice, offvalue="", + anchor="w", width=20, background=bg, + relief="flat", highlightthickness=0 + ) + cb.pack(side="top", fill="x", anchor="w") + self.vars.append(var) + self.items.append(cb) + + def getCheckedItems(self): + values = [] + for var in self.vars: + value = var.get() + if value: + values.append(value) + return values + + + +if __name__ == "__main__": + # filediag = tkinter.filedialog.FileDialog(root) + root = tkinter.Tk() + root.title("AS Tools") + app = Application(master=root) + app.mainloop() + root.destroy() diff --git a/src/ASPython/UnitTestTools.py b/src/ASPython/UnitTestTools.py new file mode 100644 index 0000000..085607f --- /dev/null +++ b/src/ASPython/UnitTestTools.py @@ -0,0 +1,43 @@ +''' + * File: UnitTestTools.py + * Copyright (c) 2023 Loupe + * https://loupe.team + * + * This file is part of ASPython, licensed under the MIT License. +''' +''' +UnitTest Tools + +This package contains tools for running unit tests. +''' + +import os +import requests +import subprocess +import logging + +class UnitTestServer(): + def __init__(self, host = 'http://127.0.0.1', destination = './TestResults'): + self._host = host + self._destination = destination + self.connected = False + # Retrieve list of tests. + try: + r = requests.get(url = self._host + '/WsTest/?', params = {}) + if r.status_code == 200: + data = r.json() + self.testSuites = data['itemList'] + self.connected = True + else: + logging.error(f'Received HTTP response {r.status_code} from the test server') + except Exception as e: + logging.error(f'Exception occurred while connecting to the test server ({e})') + + def runTest(self, name): + for testSuite in self.testSuites: + if testSuite['device'] == name: + r = requests.get(url = self._host + '/WsTest/' + name, params = {}) + if r.status_code == 200: + f = open(f'{os.path.join(self._destination, name)}.xml', 'w') + f.write(r.text) + f.close() \ No newline at end of file diff --git a/src/ASPython/_version.py b/src/ASPython/_version.py new file mode 100644 index 0000000..c12f34c --- /dev/null +++ b/src/ASPython/_version.py @@ -0,0 +1 @@ +__version__ = '0.1.1' \ No newline at end of file diff --git a/src/LPM.cmd b/src/LPM.cmd new file mode 100644 index 0000000..989ecfe --- /dev/null +++ b/src/LPM.cmd @@ -0,0 +1 @@ +@python "%~dp0\LPM.py" %* diff --git a/src/LPM.py b/src/LPM.py new file mode 100644 index 0000000..7d18c28 --- /dev/null +++ b/src/LPM.py @@ -0,0 +1,1103 @@ +''' + * File: LPM.py + * Copyright (c) 2023 Loupe + * https://loupe.team + * + * This file is part of LPM, licensed under the MIT License. +''' +''' +LPM +This is a lightweight wrapper around NPM that processes Loupe packages. +''' + +__version__ = '1.0.0' +__author__ = 'Andrew Musser' + +# Python modules +import os.path +import json +import shutil +import subprocess +import sys +import re +import argparse +import logging +import ctypes +import time +import requests + +# External Modules +from ASPython import ASTools + +def main(): + + # Extract the name. Probably could just hard-code this to 'LPM'. + prog_base = os.path.basename(sys.argv[0]) + prog_split = os.path.splitext(prog_base) + prog_name = prog_split[0].upper() + + # Parse arguments from the command line + parser = argparse.ArgumentParser(description='A lightweight wrapper around NPM for Automation Studio dependency management', prog=prog_name) + parser.add_argument('cmd', type=str, help='LPM command to execute (init, install, uninstall, list, etc)', default='') + parser.add_argument('packages', type=str, nargs='*', help='the packages to be acted upon', default='') + parser.add_argument('-s', '--silent', action='store_true', help='Execute commands silently with default values and no operator prompts') + parser.add_argument('-prj', '--asproject', action='store_true', help='Force LPM to treat current directory as AS project') + parser.add_argument('-lib', '--aslibrary', action='store_true', help='Force LPM to treat current directory as AS library') + parser.add_argument('-src', '--source', action='store_true', help='Use source code for libraries instead of binaries') + parser.add_argument('-nc', '--nocolor', action='store_true', help='Dont color the output - to avoid dependency on termcolor') + parser.add_argument('-t', '--token', type=str, help='Personal Access Token required for silent login') + parser.add_argument('-v', '--version', action='version', version='%(prog)s: ' + __version__) + args = parser.parse_args() + + # Handle output coloring configuration. + if (not args.nocolor): + from termcolor import colored, cprint + else: + # If the colors are skipped, then define dummy versions of termcolor functions. + def colored(text, color): + return text + def cprint(text, color): + print(text) + + # Prepend the @loupeteam prefix to all package names, and split any @version suffixes off into separate struct. + packages = [] + packageVersions = [] + if(args.packages): + for item in args.packages: + # Force package name to lowercase, by convention + item = item.lower() + # If the @ character is present, it means there's a version specifier. + if('@' in item): + splitItem = item.split('@') + packages.append('@loupeteam/' + splitItem[0]) + packageVersions.append(splitItem[1]) + # Othersie, version string is empty. + else: + packages.append('@loupeteam/' + item) + packageVersions.append('') + + # Authenticate with a custom personal access token. + if(args.cmd == 'login'): + if not args.silent: + cprint('Please follow the prompts below to log in using valid Github credentials.', 'yellow') + token = input(colored('? ', 'green') + 'Enter your personal access token: ') + login(token) + else: + login(args.token) + if isAuthenticated(): + print(colored(f'New credentials for {getAuthenticatedUser()} successfully stored.', 'green')) + else: + print(colored('Error: invalid credentials. Please try again.', 'red')) + + # Remove all local files related to LPM. + elif(args.cmd == 'delete'): + print('Removing LPM content from the local project...') + deleteProject() + print(colored('All done!', 'green'), 'Thanks for using LPM.') + print(colored('Note: LPM-installed content within your AS project folder has not been removed.', 'yellow')) + + # Report on the overall status of LPM. + elif(args.cmd == 'status'): + print('Retrieving status...') + # Check to see if we're logged in globally. + if(isAuthenticated()): + print('-> Logged in: ' + colored('Yes', 'green')) + print('-> Current user: ' + colored(getAuthenticatedUser(), 'green')) + else: + print('-> Logged in: ' + colored('No', 'yellow')) + # Check to see if we're initialized with a package.json file. + if os.path.exists('package.json'): + packageType = getPackageType('.') + # Now print out status message based on packageType + if(packageType == 'project'): + print('-> Local directory status: ' + colored('Initialized with Automation Studio project', 'green')) + elif(packageType == 'library'): + print('-> Local directory status: ' + colored('Initialized with local library', 'green')) + elif((packageType == 'program') | (packageType == 'package')): + print('-> Local directory status: ' + colored('Initialized with local program', 'green')) + else: + print('-> Local directory status: ' + colored('Initialized as stand-alone package manager', 'green')) + else: + print('-> Local directory status: ' + colored('Not initialized for use with lpm', 'yellow')) + + # Check to see if the user is logged in. + elif(not isAuthenticated()): + cprint('No credentials found. Please call lpm login before attempting other operations.', 'yellow') + + # Process commands that require authentication. + else: + + # Logout of the Github registry. + if(args.cmd == 'logout'): + print('Logging out...') + logout() + print(colored('Successfully logged out.', 'green')) + + # View information about a package. + elif(args.cmd == 'view') | (args.cmd == 'info'): + if (len(args.packages) > 1): + options = args.packages[1:] + getInfo(packages[0], options) + elif (len(args.packages) > 0): + options = [] + getInfo(packages[0], options) + else: + print(colored('Please provide the name of one package.', 'yellow')) + + # View a list of all available packages. + elif(args.cmd == 'viewall'): + printLoupePackageList() + + # Retrieve the type of a package (or directory). + elif(args.cmd == 'type'): + if (len(packages) > 1): + print(colored('This command is only supported with a single package.', 'yellow')) + else: + print(getPackageType(args.packages[0])) + + # Open up documentation page for the library. + elif(args.cmd == 'docs'): + if (len(packages) > 0): + print('Opening up documentation for ' + ', '.join(packages) + '...') + openDocumentation(packages) + print(colored('Documentation is now up in your browser.', 'green')) + else: + print(colored('Please provide the name of at least one package.', 'yellow')) + + # Open up the project in Automation Studio. + elif(args.cmd == 'as'): + project = ASTools.Project('.') + cmd = [] + print('Opening Automation Studio...') + asBin = os.path.join(ASTools.getASPath(project.ASVersion), 'pg.exe') + if(not os.path.isfile(asBin)): + cprint(f'Project was last opened with {project.ASVersion}, but that version is not installed. Trying to open with AS410 instead...', 'yellow') + # If that version isn't installed, try something else. Just hard-coding 410 for now because it's what I have installed! + asBin = os.path.join(ASTools.getASPath('AS410'), 'pg.exe') + cmd.append(asBin) + cmd.append('\"' + os.path.join(os.getcwd(), f'{project.name}.apj') + '\"') + executeAndContinue(cmd) + time.sleep(3) + cprint('Wait for it...', 'yellow') + time.sleep(3) + cprint('Still working...', 'yellow') + time.sleep(3) + cprint('Almost done...', 'yellow') + time.sleep(3) + cprint('Good to go!', 'green') + + # Set up the local directory for package management. + elif(args.cmd == 'init'): + as_project = False + as_library = False + + # Check to see if the current directory is an AS project. + try: + project = ASTools.Project('.') + as_project = True + except: + as_project = False + + # Check to see if the current directory is an AS library. + try: + library = ASTools.Library('.') + as_library = True + except: + as_library = False + + # Handle AS project. + if (as_project) or (args.asproject): + print('Automation Studio project found, initializing package manager...') + initializeProject() + arg_folder = False + if(not args.silent): + text = input(colored('? ', 'green') + colored('Would you like to initialize LPM with the existing Loupe libraries in your Automation Studio project?', 'yellow') + ' (y/N) ') + if (text.lower() == 'y') | (text.lower() == 'yes'): + arg_folder = importLibraries() + configureProject(args) + cprint('Your Automation Studio project is now ready to be used with LPM.', 'green') + if (arg_folder): + cprint('Note that you will need to manually remove the _ARG folder to avoid library conflicts.', 'yellow') + + # Handle AS library. + elif (as_library) or (args.aslibrary): + # Prepare a library for publication. + name = os.path.basename(os.getcwd()) + packageData = {} + # Check for existing package.json, and extract important info. + if(os.path.exists('package.json')): + print('Found an existing package.json file, extracting its relevant contents...') + packageData = getPackageManifestField('package.json', ['lpm']) + print('Creating package.json for the ' + colored(f'{name}', 'yellow') + ' library...') + createLibraryManifest(name, packageData) + print(colored('The package.json was successfully created.', 'green')) + + else: + if(not args.silent): + text = input(colored('? ', 'green') + colored('No Automation Studio project found. Would you like to initialize this directory with a starter AS project?', 'yellow') + ' (Y/n) ') + else: + text = 'n' + if (text.lower() == 'n') | (text.lower() == 'no'): + initializeProject() + print(colored('This directory has been initialized as a stand-alone package manager.', 'green')) + else: + initializeProject() + starterProject = ['@loupeteam/starterasproject49'] + installPackages(starterProject, ['']) + syncPackages(starterProject) + configureProject(args) + print(colored('Your local directory is now ready to be used with LPM.', 'green')) + + # Require that the init command be run before other commands are allowed. + elif(not os.path.exists('package.json')): + cprint('Local directory not initialized. Please run lpm init before attempting other operations.', 'yellow') + + elif(args.cmd == 'configure'): + configureProject(args) + + elif(args.cmd == 'debug'): + libraries = readLoupeLibraryList() + print(libraries) + + # Install one or more packages (and their dependencies). + elif(args.cmd == 'install'): + # Handle default scenario where sources are not requested (i.e. we're installing binary packages). + if (not args.source): + if (len(packages) > 0): + print('Installing ' + ', '.join(packages) + '...') + try: + installPackages(packages, packageVersions) + except: + cprint('Error while attempting to install package(s).', 'yellow') + return + else: + print('Installing all dependencies...') + try: + installPackages(packages, packageVersions) + except: + cprint('Error while attempting to install package(s).', 'yellow') + return + # Move packages from the node_modules folder into the project/main directory. + syncPackages(getAllDependencies(packages)) + else: # Handle request to install source code instead. + if (len(packages) > 0): + print('Cloning ' + ', '.join(args.packages) + '...') + try: + # First check to see if there is an AS project locally. + project = ASTools.Project('.') + librariesPkg = ASTools.Package('./Logical/Libraries') + try: + # Check for existing Loupe folder. + loupePkg = ASTools.Package('./Logical/Libraries/Loupe') + except: + print('Loupe folder not found, creating it...') + # Add the Loupe package back in there. + loupePkg = librariesPkg.addEmptyPackage('Loupe') + for package in args.packages: + # Extract version info if included (this would show up after the '@' character, i.e. 'mylib@3.0.4') + # TODO: this check is now redundant with a version check done at the top of this file, so this should get refactored... + if package.find('@') > -1: + splitPackage = package.split('@') + packageName = splitPackage[0] + packageVersion = splitPackage[1] + installSource(packageName, packageVersion, loupePkg) + else: + packageName = package + installSource(packageName, '', loupePkg) + # Next, install any dependencies of this source library. + sourceDependencies = getSourceDependencies(os.path.join('.', 'Logical', 'Libraries', 'Loupe', packageName)) + if (len(sourceDependencies) > 0): + # TODO: add support for getting the correct version of these dependencies. + installPackages(sourceDependencies, [''] * len(sourceDependencies)) + # Aaand sync those dependencies. + syncPackages(getAllDependencies(sourceDependencies)) + except: + cprint('Error while attempting to install source code.', 'yellow') + cprint(sys.exc_info()) + # Deploy relevant objects to cpu.sw. + # First check project-level settings to see if deployment is configured. + deploymentConfigs = getPackageManifestField('package.json', ['lpmConfig', 'deploymentConfigs']) + if (deploymentConfigs is not None): + print('Deploying ' + ', '.join(packages) + ' to the following configurations: ' + ', '.join(deploymentConfigs)) + for config in deploymentConfigs: + if(not args.source): + deployPackages(config, getAllDependencies(packages)) + else: + # TODO: this split below may not be necessary, TBD. + # For case of source, deploy the source first. + deployPackages(config, packages) + # Then deploy all of its dependencies. + deployPackages(config, sourceDependencies) + cprint('Operation completed successfully.', 'green') + for package in packages: + packageManifestPath = os.path.join('node_modules', package, 'package.json') + if(os.path.exists(packageManifestPath)): + packageType = getPackageManifestField(packageManifestPath, ['lpm', 'type']) + if(packageType == 'project'): + return + if (deploymentConfigs is None): + cprint("Note that the installed packages have not been deployed.", "yellow") + cprint("You will need to do this manually, or you can configure deployment targets with the 'lpm configure' command.", 'yellow') + + # Uninstall one or more packages. + elif(args.cmd == 'uninstall'): + if (len(packages) > 0): + print('Uninstalling ' + ', '.join(packages) + '...') + try: + uninstallPackages(packages) + except: + cprint('Error while attempting to uninstall package(s).', 'yellow') + return + syncPackages(getAllDependencies(packages)) + cprint('Operation completed successfully.', 'green') + else: + print(colored('Please provide the name of at least one package.', 'yellow')) + + # Open git client for a specific source library. + elif(args.cmd == 'git'): + # Retrieve configured Git client. + gitClient = getPackageManifestField('package.json', ['lpmConfig', 'gitClient']) + if(gitClient == ''): + cprint("No Git client configured (please run lpm configure)", "yellow") + else: + print(f'Opening {gitClient} for these packages: ' + ', '.join(args.packages)) + for package in packages: + cmd = [] + if(gitClient == 'GitExtensions'): + cmd.append('gitex.cmd') + cmd.append('openrepo') + cmd.append('\"' + os.path.join(os.getcwd(), 'Logical', 'Libraries', 'Loupe', os.path.split(package)[1]) + '\"') + executeAndContinue(cmd) + else: + cprint(f"We don't support {gitClient}, are you kidding?", "yellow") + + # Publish a binary library. + elif(args.cmd == 'publish'): + # Introspect the package.json to verify that the package name has the right scope prefix (i.e. @loupeteam). + data = getPackageManifestData('package.json') + if data['name'].find('@loupeteam') != 0: + cprint('Error: the package name must include the @loupeteam scope prefix.', 'yellow') + else: + try: + # Introspect the package.json to verify that the 'repository' field is present. + repoUrl = data['repository']['url'] # This triggers an exception if it doesn't exist + if (args.silent): + text = 'y' + else: + text = input(colored('? ', 'green') + 'Are you sure you want to publish ' + colored(data['name'], 'yellow') + ' version ' + colored(data['version'], 'yellow') + '? (y/N) ') + if (text.lower() == 'y') | (text.lower() == 'yes'): + try: + # Clean up the local directory before publishing. + # If there's a Jenkinsfile present, remove it. + if(os.path.isfile('./Jenkinsfile')): + os.remove('./Jenkinsfile') + # Publish the package. + execute(['npm' ,'publish'], True) + cprint('Successfully published ' + data['name'] + ' version ' + data['version'] + '.', 'green') + except: + cprint("Error while publishing. Please check the detailed error message above.", 'yellow') + except: + cprint('Error: the package.json file must include a repository parameter', 'yellow') + cprint('For example:', 'yellow') + print('"repository": {') + print(' "type": "git",') + print(' "url": "https://github.com/loupeteam/piper"') + print('}') + + # Fetch the list of all dependencies for the root level, or one of its direct package dependencies. + elif((args.cmd == 'list') and (len(args.packages) == 0)): + cmd = [] + cmd.append('npm list') + cmd.append('-all') + executeStandard(cmd) + + # In all other cases, just pass the command and list of packages straight through to NPM. + else: + runGenericNpmCmd(args.cmd, packages) + cprint('Operation completed', 'green') + + return + +def isAuthenticated(): + command = [] + command.append('npm whoami') + command.append('--registry=https://npm.pkg.github.com') + result = executeAndReturnCode(command) + if result > 0: + return False + else: + return True + +def getAuthenticatedUser(): + command = [] + command.append('npm whoami') + command.append('--registry=https://npm.pkg.github.com') + # Catch the special case where it's the loupe-devops-admin, as this should be converted to a different user name. + user = executeAndReturnStdOut(command) + if (user == 'loupe-devops-admin'): + return 'default-user' + else: + return user + +def getLocalToken(): + # Search in the local .npmrc file for this token. + f = open(os.path.join(os.path.expanduser('~'), '.npmrc'), 'r') + text = f.readlines() + return re.search("_authToken=(.+)", "\n".join(text)).group(1) + +# Bootstrap the project with required files. +def initializeProject(): + # Check to see if package.json already exists (and don't overwrite if it does). + if os.path.exists('package.json'): + print('Package.json already exists, skipping creation') + else: + # Set up the package.json file. + execute(['npm' ,'init', '-y'], True) + print('Created package.json file') + +# Grab a list of existing libraries in the Loupe folder. +def importLibraries(): + arg_folder = False + try: + loupePkg = ASTools.Package('./Logical/Libraries/Loupe') + except: + err = sys.exc_info() + print('Loupe folder not found, trying _ARG...') + try: + loupePkg = ASTools.Package('./Logical/Libraries/_ARG') + arg_folder = True + except: + print('No existing Loupe libraries found.') + return + # Read the list of existing packages. + packages_to_import = [] + package_versions_to_import = [] + for library in loupePkg.objects: + packages_to_import.append('@loupeteam/' + library.text) + package_versions_to_import.append(library.version) + # Install them. + try: + print('Importing ' + ', '.join(packages_to_import) + '...') + installPackages(packages_to_import, package_versions_to_import) + syncPackages(packages_to_import) + except: + print(colored('An error occurred while importing the libraries.', 'yellow')) + return + return arg_folder + # # Now delete the ARG folder if it was in there. + # librariesPkg = ASTools.Package('./Logical/Libraries') + # librariesPkg.removeObject('_ARG') + +# Login silently by manually creating a the .npmrc file in the user directory. +def login(token): + f = open(os.path.join(os.path.expanduser('~'), '.npmrc'), 'w') + text = [] + text.append('@loupeteam:registry=https://npm.pkg.github.com') + text.append('//npm.pkg.github.com/:_authToken=' + token) + f.write('\n'.join(text)) + f.close() + +# Logout of the Github registry. +def logout(): + # If there's a local .npmrc file, remove it. + if(os.path.exists('./.npmrc')): + os.remove('./.npmrc') + # And perform the npm logout to globally logout as well. + command = [] + command.append('npm logout') + command.append('--scope=@loupeteam') + command.append('--registry=https://npm.pkg.github.com') + executeStandard(command) + +# Remove all LPM references from the project. +def deleteProject(): + if (os.path.isfile('./.npmrc')): + os.remove('./.npmrc') + if (os.path.isfile('./package.json')): + os.remove('./package.json') + if (os.path.isdir('./node_modules/')): + shutil.rmtree('./node_modules/') + if (os.path.isfile('./package-lock.json')): + os.remove('./package-lock.json') + +def configureProject(args): + try: + project = ASTools.Project('.') + except: + print('Configuration options are only supported at the root level of a project') + return + deploymentConfigs = getPackageManifestField('package.json', ['lpmConfig', 'deploymentConfigs']) + if(deploymentConfigs == None): deploymentConfigs = [] + if (args.nocolor) or (args.silent): + print('Support for interactice prompts is disabled. Default values will be assigned to the package.json file.') + print('All configurations in the project are being assigned as deployment targets: ' + ' '.join(project.buildConfigNames)) + setPackageManifestField('package.json', 'deploymentConfigs', project.buildConfigNames) + else: + from termcolor import colored, cprint + from InquirerPy import inquirer, get_style + from InquirerPy.base.control import Choice + + # Config question #1: Deployment configurations. + configOptions = [] + for config in project.buildConfigNames: + configOptions.append(Choice(config, enabled=(config in deploymentConfigs))) + print(colored("?", "green") + colored(" Which AS configuration(s) would you like future packages deployed to?", "yellow")) + configs = inquirer.checkbox( + message="(press 'space' to toggle one, 'a' to select all, 't' to toggle all)", + style=get_style({"questionmark": "#00ff00", "pointer": "#ffff00"}), + choices=configOptions, + cycle=False, + keybindings={ + "toggle-all-true": [{ "key": "a"}], + "toggle-all-false": [{ "key": "t" }] + } + ).execute() + setPackageManifestField('package.json', 'deploymentConfigs', configs) + if (len(configs) > 0): + cprint("The following configurations were successfully added to the deployment list: " + ", ".join(configs), "green") + else: + cprint("No configurations used for deployments", "green") + + # Config question #2: Configure a Git client. + # Check to see if there already is a configuration, and if so display that. + gitClient = getPackageManifestField('package.json', ['lpmConfig', 'gitClient']) + if(gitClient == None): gitClient = '' + availableClients = ['GitExtensions', 'GitKraken', 'SourceTree', ''] # Note that these are available but not all supported (= + configOptions = [] + for client in availableClients: + if(client == ''): + configOptions.append(Choice(client, name='None', enabled=(client == gitClient))) + else: + configOptions.append(Choice(client, enabled=(client == gitClient))) + print(colored("?", "green") + colored(" Which Git client would you like to use to introspect source libraries?", "yellow")) + selectedClient = inquirer.select( + message="(press 'enter' to select)", + style=get_style({"questionmark": "#00ff00", "pointer": "#ffff00"}), + choices=configOptions, + cycle=False + ).execute() + setPackageManifestField('package.json', 'gitClient', selectedClient) + if (selectedClient != ''): + cprint(f"{selectedClient} has been configured as the preferred Git client", "green") + else: + cprint("No Git client selected", "green") + +def getPackageType(path): + packageType = None + # Check to see if this directory has a package.json file. + manifestFilePath = os.path.join(path, 'package.json') + if os.path.exists(manifestFilePath): + # First check for the lpm->type metadata. + packageType = getPackageManifestField(manifestFilePath, ['lpm', 'type']) + # If the field is not present, or the file is not present, then search manually for an indicative file extension. + if (packageType == None): + try: + project = ASTools.Project(path) + packageType = 'project' + except: + try: + library = ASTools.Library(path) + packageType = 'library' + except: + try: + package = ASTools.Package(path) + packageType = 'program' + except: + # Getting here means no matches were found. + packageType = None + if packageType == None: + return 'undefined' + else: + return packageType + +# Perform the NPM install. +def installPackages(packages, packageVersions): + command = [] + command.append('npm install') + # force packages names to lowercase + packages = [package.lower() for package in packages] + for (item, version) in zip(packages, packageVersions): + if(version != ''): + command.append(f'{item}@{version}') + else: + command.append(item) + execute(command, False) + +# Perform the NPM uninstall. +def uninstallPackages(packages): + command = [] + command.append('npm uninstall') + for item in packages: + command.append(item) + execute(command, False) + +# Install source library by cloning into Logical / Libraries / Loupe folder. +def installSource(package, version, loupePkg): + # Package names are forced to lower case by convention + package = package.lower() + # The assumption here is that the repo has all of its assets at the root level. + # Check to make sure this library isn't already in there. + if (os.path.isdir(os.path.join('.', 'Logical', 'Libraries', 'Loupe', package))): + raise Exception("Library folder already exists") + return + try: + # Record the current directory. + cwd = os.getcwd() + # Grab the required credentials for inline cloning. + username = getAuthenticatedUser() + password = getLocalToken() + # Clone the repo into the target directory. + libraryPath = os.path.join('.', 'Logical', 'Libraries', 'Loupe', package) + command1 = [] + command1.append('git clone') + command1.append(f'https://{username}:{password}@github.com/loupeteam/{package}') + command1.append(libraryPath) + execute(command1, False) + # And then checkout the correct commit if it's been specified. + if (version != ''): + os.chdir(os.path.join('.', libraryPath)) + command1b = [] + command1b.append('git checkout') + command1b.append(version) + execute(command1b, False) + os.chdir(cwd) + # Now add the new directory to the parent's .pkg. + loupePkg._addPkgObject(libraryPath) + + except: + raise Exception("Error cloning the repo") + +def openDocumentation(packages): + command = [] + command.append('npm docs') + for item in packages: + command.append(item) + execute(command, False) + +def getInfo(package, options): + command = [] + command.append('npm view') + command.append(package) + for item in options: + command.append(item) + execute(command, False) + +# Create a list of Loupe libraries that are currently in the AS project. +def readLoupeLibraryList(): + libraryList = [] + try: + loupePkg = ASTools.Package(os.path.join('.', 'Logical', 'Libraries', 'Loupe')) + for element in loupePkg.objects: + libraryName = element.text + lib = ASTools.Library(os.path.join('.', 'Logical', 'Libraries', 'Loupe', libraryName)) + libraryList.append(lib.name + '@' + lib.version) + except: + # No Loupe folder, so the list should be null. + return + return libraryList + +# Retrieve a deep list of all dependencies of the specified packages. +# This is recursive logic that hurts my brain, but seems to work. +def getAllDependencies(packages): + dependencies = [] + for package in packages: + # Introspect the package.json for this file. Find its 'lpm/type' field. + packageManifest = os.path.join('node_modules', package, 'package.json') + packageType = getPackageManifestField(packageManifest, ['lpm', 'type']) + # When dealing with an HMI project, don't parse through its dependencies recursively! That gets deep real fast. + if(packageType == 'hmi-project'): + return packages + dependencies.append(package) + # If package.json exists, get dependencies from there. + if(os.path.exists(os.path.join('node_modules', package, 'package.json'))): + dependencyData = getPackageManifestField(os.path.join('node_modules', package, 'package.json'), ['dependencies']) + if(dependencyData == None): + dependencyData = [] + # If there is no package.json for this package, assume it's a source library, and that it's already sync'd + # to the Logical View under Libraries / Loupe. + else: + # Strip it of its @loupeteam prefix. + splitPackage = os.path.split(package)[1] + dependencyData = getSourceDependencies(os.path.join('.', 'Logical', 'Libraries', 'Loupe', splitPackage)) + print('Source dependencies: ') + print(dependencyData) + localDependencies = [] + for item in dependencyData: + localDependencies.append(item) + nestedDependencies = getAllDependencies(localDependencies) + for item in nestedDependencies: + dependencies.append(item) + # Before returning the list, sanitize it by removing duplicates. + sanitizedDependencies = [] + for dep in dependencies: + lowerdep = dep.lower() + if lowerdep not in sanitizedDependencies: + sanitizedDependencies.append( lowerdep ) + return sanitizedDependencies + +def getSourceDependencies(libraryPath): + sourceLibrary = ASTools.Library(libraryPath) + dependencyNames = [] + # Install binary dependencies for this library + for dependency in sourceLibrary.dependencies: + # First check to see if it's a custom Loupe lib (if not, ignore it) + command = [] + command.append('npm view') + command.append(f'@loupeteam/{dependency.name}') + result = executeAndReturnCode(command) + if result == 0: + print('Dependency found: ' + f'@loupeteam/{dependency.name.lower()}') + # Add this dependency to our list. + dependencyNames.append(f'@loupeteam/{dependency.name}'.lower()) + return dependencyNames + +# Synchronize a package from the node_modules folder into the appropriate directory. +def syncPackages(packages): + try: + # First check to see if we're in an AS project root directory. + project = ASTools.Project('.') + except: + project = None + for package in packages: + # Introspect the package.json for this file. Find its 'lpm' section. + packageManifest = os.path.join('node_modules', package, 'package.json') + packageType = getPackageManifestField(packageManifest, ['lpm', 'type']) + # Do something different based on package type. + if(packageType == 'project'): + # Copy starter project into root directory. + shutil.copytree(os.path.join('node_modules', package), '.', dirs_exist_ok=True, ignore=shutil.ignore_patterns('package.json')) + + if(packageType == 'hmi-project'): + # Copy starter project into root directory. + shutil.copytree(os.path.join('node_modules', package), '.', dirs_exist_ok=True) + + elif(project == None): + # Skip sync'ing of other types (packages or libraries) if we're not in a project. + pass + + elif((packageType == 'program') | (packageType == 'package')): + packageDestination = getPackageManifestField(packageManifest, ['lpm', 'logical', 'destination']) + # If an explicit destination exists, use that. Otherwise default to Logical root. + if packageDestination != None: + destination = os.path.join('Logical', packageDestination) + # Now create the packages in this path that doesn't exist. + createPackageTree(destination) + else: + destination = 'Logical' + # Find the module(s) in node_modules, and sync it/them. + for module in os.listdir(os.path.join('node_modules', '@loupeteam')): + if (os.path.join('@loupeteam', module) == os.path.normpath(package)): + # Get a handle on the folder destination. + destinationPkg = ASTools.Package(destination) + # Create a list of filtered objects that don't get copied over. + filter = ['package.pkg', 'license', 'readme.md', 'package.json', 'changelog.md'] + # Loop through all contents in the source directory and copy them over one by one. + for item in os.listdir(os.path.join('node_modules', '@loupeteam', module)): + if (item.lower() not in filter): + # If the item already exists, delete it. + destinationItem = os.path.join(destination, item) + if os.path.exists(destinationItem): + destinationPkg.removeObject(item) + destinationPkg.addObject(os.path.join('node_modules', package, item)) + + elif(packageType == 'library') or (packageType == None): + packageDestination = getPackageManifestField(packageManifest, ['lpm', 'logical', 'destination']) + # If an explicit destination exists, use that. Otherwise default to Logical root. + if packageDestination != None: + destination = os.path.join('Logical', packageDestination) + else: + destination = os.path.join('Logical', 'Libraries', 'Loupe') + # Now create the packages in this path that doesn't exist. + createPackageTree(destination) + # Find the module(s) in node_modules, and sync it/them. + for module in os.listdir(os.path.join('node_modules', '@loupeteam')): + if (os.path.join('@loupeteam', module) == os.path.normpath(package)): + # Get a handle on the library's parent folder. + parentPkg = ASTools.Package(destination) + # If the library already exists, delete it. + libraryPath = os.path.join(destination, module) + if os.path.isdir(libraryPath): + parentPkg.removeObject(module) + parentPkg.addObject(os.path.join('node_modules', package)) + +def deployPackages(config, packages): + # Figure out where the deployment table is for this configuration. + configPath = os.path.join('Physical', config) + cpuFolderName = [x for x in os.listdir(configPath) if os.path.isdir(os.path.join(configPath, x))] + deploymentTable = ASTools.SwDeploymentTable(os.path.join('Physical', config, cpuFolderName[0], 'cpu.sw')) + configPackage = ASTools.CpuConfig(os.path.join('Physical', config, cpuFolderName[0], 'cpu.pkg')) + for package in packages: + # Check if the package.json exists in node_modules - if it doesn't, then assume that it is a source library. + if(os.path.exists(os.path.join('node_modules', package, 'package.json'))): + # Introspect the package.json for this package. Find its 'lpm' section. + packageManifest = os.path.join('node_modules', package, 'package.json') + packageType = getPackageManifestField(packageManifest, ['lpm', 'type']) + + # Do something different based on package type. + if(packageType == 'library') or (packageType == None): + libraryLocation = getPackageManifestField(packageManifest, ['lpm', 'logical', 'destination']) + if (libraryLocation == None): + libraryLocation = os.path.join('Libraries', 'Loupe') + libraryAttributes = getLibraryAttributes(packageManifest, config) + # Deploy the required library. + deploymentTable.deployLibrary(os.path.join('Logical', libraryLocation), os.path.split(package)[1], libraryAttributes) + + elif((packageType == 'program') | (packageType == 'package')): + cpuDeployment = getPackageManifestField(packageManifest, ['lpm', 'physical', 'cpu']) + taskLocation = getPackageManifestField(packageManifest, ['lpm', 'logical', 'destination']) + # First deploy all configured tasks. + if cpuDeployment != None: + for item in cpuDeployment: + deploymentTable.deployTask(taskLocation, item['source'], item['destination']) + # Next perform additional configuration changes. + # Set the pre-build step if it exists. + preBuildCommand = getPackageManifestField(packageManifest, ['lpm', 'physical', 'configuration', 'preBuildStep']) + if (preBuildCommand != None): + configPackage.setPreBuildStep(preBuildCommand) + + # No package.json is present in node_modules - so it's a source library. + else: + # Introspect the package.json for this package. Find its 'lpm' section. + packageManifest = os.path.join('Logical', 'Libraries', 'Loupe', os.path.split(package)[1], 'package.json') + libraryAttributes = getLibraryAttributes(packageManifest, config) + libraryLocation = os.path.join('Libraries', 'Loupe') + # Deploy the required library. + deploymentTable.deployLibrary(os.path.join('Logical', libraryLocation), os.path.split(package)[1], libraryAttributes) + +def getLibraryAttributes(packageManifest, config): + libraryCpus = getPackageManifestField(packageManifest, ['lpm', 'physical', 'cpu']) + try: + if (type(libraryCpus) == list): + for cpu in libraryCpus: + try: + if(cpu['config'].lower() == config.lower()): + return cpu['attributes'] + except Exception as e: + return cpu['attributes'] + else: + try: + return libraryCpus['attributes'] + except Exception as e: + return {} + except Exception as e: + return {} + return {} + +def createPackageTree(packages: list): + # Retrieve this as a list of folders for creation. + normalizedDestination = os.path.normpath(packages) + packageList = normalizedDestination.split(os.sep) + for i in range(len(packageList)): + try: + # Check for package existence. + pkg = ASTools.Package(os.path.join(*packageList[:i+1])) + except: + # Package does not exist, so create it. + # First retrieve handle of its parent package. + parentPkg = ASTools.Package(os.path.join(*packageList[:i])) + pkg = parentPkg.addEmptyPackage(packageList[i]) + +def createLibraryManifest(package, lpmConfig): + library = ASTools.Library('.') + # Create dependencies dictionary for this library + dependency_dict = {} + for dependency in library.dependencies: + # First check to see if it's a custom Loupe lib (if not, ignore it) + cmd = [] + cmd.append('npm view') + cmd.append(f'@loupeteam/{dependency.name}') + result = executeAndReturnCode(cmd) + if result == 0: + version = [] + if dependency.minVersion != '': + version.append(f'>={library._formatVersionString(dependency.minVersion)}') + if dependency.maxVersion != '': + version.append(f'<={library._formatVersionString(dependency.maxVersion)}') + if len(version) == 0: + version.append('*') + dependency_dict.update({f'@loupeteam/{dependency.name.lower()}':' '.join(version)}) + # Make sure there's a top level description available. + if (library.description == ''): + description = f"Loupe's {package.lower()} library for Automation Runtime" + else: + description = library.description + # Set the homepage to point to the styleguide + homepage = f'https://loupeteam.github.io/LoupeDocs/libraries/{package.lower()}.html' + # Ensure the lpmConfig has the proper type set. + try: + if lpmConfig['type'] != 'library': + lpmConfig = { 'type': 'library' } + except Exception: + lpmConfig = { 'type': 'library' } + # Create dictionary that will hold all values for the package.json file + manifest_dict = { + 'name': f'@loupeteam/{package.lower()}', + 'version': library._formatVersionString(library.version), + 'description': description, + 'homepage': homepage, + 'scripts': {}, + 'keywords': [], + 'author': 'Loupe', + 'license': 'MIT', + 'repository': { + 'type': 'git', + 'url': 'https://github.com/loupeteam/' + package + }, + 'lpm': lpmConfig, + 'dependencies': dependency_dict + } + # Convert to JSON and create the file + manifest_json = json.dumps(manifest_dict, indent=2) + f = open('.\package.json', 'w') + f.write(manifest_json) + f.close() + return + +def getPackageManifestData(manifest): + f = open(manifest, 'r+', encoding='utf-8') + data = json.load(f) + f.close() + return data + +def getPackageManifestField(manifest, fieldPath: list): + data = getPackageManifestData(manifest) + try: + for item in fieldPath: + data = data[item] + return data + except: + return None + +def setPackageManifestField(manifest, fieldName, fieldData): + readFile = open(manifest, 'r+') + data = json.load(readFile) + readFile.close() + # If the lpmConfig key isn't in there yet, add it first. + if(not "lpmConfig" in data): + data["lpmConfig"] = {} + data["lpmConfig"][fieldName] = fieldData + jsonData = json.dumps(data, indent=2) + writeFile = open(manifest, 'w') + writeFile.write(jsonData) + writeFile.close() + +def printLoupePackageList(): + print("Retrieving package data...") + (error, data) = getLoupePackageListData() + + if not error: + packages_sorted = sorted(data, key=lambda x: x["name"]) + for package in packages_sorted: + if package['repository']['description'] is None: + package['repository']['description'] = " " + name_col_width = max(len(package["name"]) for package in packages_sorted) + 2 + version_col_width = 12 + lastmod_col_width = 14 + description_col_width = max(len(package['repository']['description']) for package in packages_sorted) + print( "NAME".ljust(name_col_width) + + "VERSIONS".ljust(version_col_width) + + "LASTUPDATED".ljust(lastmod_col_width) + + "DESCRIPTION".ljust(description_col_width)) + print( "----".ljust(name_col_width) + + "--------".ljust(version_col_width) + + "-----------".ljust(lastmod_col_width) + + "-----------".ljust(description_col_width)) + for package in packages_sorted: + print( package["name"].ljust(name_col_width) + + str(package["version_count"]).ljust(version_col_width) + + package["updated_at"][:10].ljust(lastmod_col_width) + + package['repository']['description'].ljust(description_col_width)) + else: + print(f"Unable to print package list: {error}") + +# Fetches data using GitHub API (See https://docs.github.com/en/rest/packages?apiVersion=2022-11-28#list-packages-for-an-organization) +# Returns (error, data) tuple, where error is None if all OK and data is a list of package dictionaries (see GitHub's schema) +def getLoupePackageListData(): + token = getLocalToken() + headers = { 'Authorization': f'Bearer {token}', + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28' } + organization = 'loupeteam' + page = 1 + per_page = 100 + all_packages_gathered = False + all_packages = [] + + while not all_packages_gathered: + params = { 'package_type': 'npm', + 'page': str(page), + 'per_page': str(per_page) } + r = requests.get(f'https://api.github.com/orgs/{organization}/packages', headers=headers, params=params, timeout=5) + if r.status_code != 200: + error = "Status code not OK. Code: " + r.status_code + "\n" + r.text + return (error, []) # Early return + retrieved_packages = json.loads(r.content) + all_packages += retrieved_packages + all_packages_gathered = len(retrieved_packages) < per_page # All gathered once there are fewer results than full amount + page += 1 + + print(f"Retrieved {len(all_packages)} packages total. See below for detailed information.") + return (None, all_packages) + +# Run a generic NPM command on the specified packages. +def runGenericNpmCmd(cmd, packages): + command = [] + command.append('npm') + command.append(cmd) + for item in packages: + command.append(item) + execute(command, False) + +# Execute a generic batch script command. +def execute(cmd, quiet): + #process = subprocess.Popen(' '.join(cmd), encoding="utf-8", errors='replace', shell=True) + process = subprocess.Popen(' '.join(cmd), stdout=subprocess.PIPE, encoding="utf-8", errors='replace', shell=True) + while process.returncode == None: + rawStdOut = process.stdout.readline() + #rawStdErr = process.stderr.readline() + strippedStdOut = rawStdOut.rstrip() + #strippedStdErr = rawStdErr#.rstrip() + if (not quiet): + if (strippedStdOut != ''): + print(strippedStdOut) + # if (strippedStdErr != ''): + # cprint(strippedStdErr, 'red') + process.poll() + if (process.returncode != 0): + raise Exception('Error during process execution') + +def executeStandard(cmd): + process = subprocess.Popen(' '.join(cmd), encoding="utf-8", errors='replace', shell=True) + while process.returncode == None: + process.poll() + if (process.returncode != 0): + raise Exception('Error during process execution') + +def executeAndContinue(cmd): + process = subprocess.Popen(' '.join(cmd), encoding="utf-8", errors='replace', shell=True) + return + +def executeAndReturnCode(cmd): + process = subprocess.Popen(' '.join(cmd), encoding="utf-8", stdout=subprocess.PIPE, stderr=subprocess.PIPE, errors='replace', shell=True) + while process.returncode == None: + process.poll() + return process.returncode + +def executeAndReturnStdOut(cmd): + process = subprocess.Popen(' '.join(cmd), stdout=subprocess.PIPE, encoding="utf-8", errors='replace', shell=True) + std_out = '' + while process.returncode == None: + rawStdOut = process.stdout.readline() + strippedStdOut = rawStdOut.rstrip() + std_out = std_out + strippedStdOut + process.poll() + return std_out + +if __name__ == "__main__": + + # Configure colored logger + logging.basicConfig(stream=sys.stderr, level=logging.INFO) + kernel32 = ctypes.windll.kernel32 + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + + main() \ No newline at end of file diff --git a/utils/BuildInstaller.bat b/utils/BuildInstaller.bat new file mode 100644 index 0000000..ec90a07 --- /dev/null +++ b/utils/BuildInstaller.bat @@ -0,0 +1 @@ +iscc ./Setup.iss \ No newline at end of file diff --git a/utils/Setup.iss b/utils/Setup.iss new file mode 100644 index 0000000..8ba5371 --- /dev/null +++ b/utils/Setup.iss @@ -0,0 +1,125 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + +#define MyAppName "LPM" +#define MyAppVersion "1.0.0" +#define MyAppPublisher "Loupe" +#define MyAppURL "https://loupe.team/" +#define MyAppExeName "LPM.cmd" + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId={{EE61D73D-8F6F-4D57-BED7-6FFB0F6C488A} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +;AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={autopf}\{#MyAppName} +ChangesAssociations=yes +DisableProgramGroupPage=yes +LicenseFile=..\LICENSE +InfoBeforeFile=..\docs\PREINSTALL.txt +InfoAfterFile=..\docs\POSTINSTALL.txt +; Uncomment the following line to run in non administrative install mode (install for current user only.) +;PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog +OutputDir=..\build +OutputBaseFilename=LPM-Setup +SetupIconFile=..\files\favicon.ico +Compression=lzma +SolidCompression=yes +WizardStyle=modern +AlwaysRestart=yes + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Files] +Source: "..\src\*.py"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "..\src\LPM.cmd"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\requirements.txt"; DestDir: "{app}"; Flags: ignoreversion +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Registry] +Root: HKLM; Subkey: "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"; ValueType: expandsz; ValueName: "Path"; ValueData: "{olddata};{app}"; + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait skipifsilent +Filename: "{cmd}"; Parameters: "/C pip install -r ""{app}\requirements.txt"""; Description: "Installing Python dependencies..."; StatusMsg: "Installing dependencies..."; Check: PythonAndPipExist() + +[Code] + +{ ///////////////////////////////////////////////////////////////////// } +function PythonAndPipExist(): Boolean; +var + ErrorCode: Integer; +begin + if Exec('python', '--version', '', SW_HIDE, ewWaitUntilTerminated, ErrorCode) and + Exec('pip', '--version', '', SW_HIDE, ewWaitUntilTerminated, ErrorCode) then + Result := True + else + Result := False; +end; + +{ ///////////////////////////////////////////////////////////////////// } +function GetUninstallString(): String; +var + sUnInstPath: String; + sUnInstallString: String; +begin + sUnInstPath := ExpandConstant('Software\Microsoft\Windows\CurrentVersion\Uninstall\{#emit SetupSetting("AppId")}_is1'); + sUnInstallString := ''; + if not RegQueryStringValue(HKLM, sUnInstPath, 'UninstallString', sUnInstallString) then + RegQueryStringValue(HKCU, sUnInstPath, 'UninstallString', sUnInstallString); + Result := sUnInstallString; +end; + + +{ ///////////////////////////////////////////////////////////////////// } +function IsUpgrade(): Boolean; +begin + Result := (GetUninstallString() <> ''); +end; + + +{ ///////////////////////////////////////////////////////////////////// } +function UnInstallOldVersion(): Integer; +var + sUnInstallString: String; + iResultCode: Integer; +begin +{ Return Values: } +{ 1 - uninstall string is empty } +{ 2 - error executing the UnInstallString } +{ 3 - successfully executed the UnInstallString } + + { default return value } + Result := 0; + + { get the uninstall string of the old app } + sUnInstallString := GetUninstallString(); + if sUnInstallString <> '' then begin + sUnInstallString := RemoveQuotes(sUnInstallString); + if Exec(sUnInstallString, '/SILENT /NORESTART /SUPPRESSMSGBOXES','', SW_HIDE, ewWaitUntilTerminated, iResultCode) then + Result := 3 + else + Result := 2; + end else + Result := 1; +end; + +{ ///////////////////////////////////////////////////////////////////// } +procedure CurStepChanged(CurStep: TSetupStep); +begin + if (CurStep=ssInstall) then + begin + if (IsUpgrade()) then + begin + UnInstallOldVersion(); + end; + end; +end; \ No newline at end of file