diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7d0635a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: [main, master] + tags: ['v*'] + pull_request: + branches: [main, master] + +jobs: + test: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.11', '3.12'] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package + dev extras + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Lint (ruff) + run: ruff check aspython tests + continue-on-error: true + + - name: Run tests + run: pytest -q + + build-exe: + needs: test + if: startsWith(github.ref, 'refs/tags/v') + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install package + dev extras + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Build single-file executable + run: pyinstaller packaging/aspython.spec --noconfirm + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: aspython-windows + path: dist/aspython.exe + + - name: Attach to GitHub release + uses: softprops/action-gh-release@v2 + with: + files: dist/aspython.exe diff --git a/.gitignore b/.gitignore index b6e4761..dd9b3d1 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,8 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +tests/AsProject/* +!tests/AsProject/package.json # Translations *.mo diff --git a/ASCncConfig.py b/ASCncConfig.py index 49ad558..e8e64c8 100644 --- a/ASCncConfig.py +++ b/ASCncConfig.py @@ -2,43 +2,20 @@ * 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 -''' +"""Backwards-compatibility shim — use ``aspython.cnc`` instead.""" +import warnings as _warnings -import os.path -# import xml.etree.ElementTree as ET -import lxml.etree as ET +from aspython.cnc import listOfProcs # noqa: F401 __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() +_warnings.warn( + "Importing from 'ASCncConfig' is deprecated; use 'aspython.cnc' instead.", + DeprecationWarning, + stacklevel=2, +) +__all__ = ["listOfProcs"] diff --git a/ASTools.py b/ASTools.py index 389044c..410cb46 100644 --- a/ASTools.py +++ b/ASTools.py @@ -2,1680 +2,65 @@ * 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' -} - -# Candidate base directories where B&R Automation Studio may be installed. -# AS <= 4.x defaults to C:\BrAutomation. AS 6 changed the default to -# C:\Program Files (x86)\BRAutomation (note: no space in "BRAutomation"). -_AS_BASE_CANDIDATES = [ - "C:\\BrAutomation", - "C:\\Program Files (x86)\\BRAutomation", - "C:\\Program Files\\BRAutomation", +"""Backwards-compatibility shim. + +Historically all of ASPython lived in this single file. The implementation now lives in the +``aspython`` package; this module re-exports every previously public name and emits a +``DeprecationWarning`` on import. New code should ``import aspython`` (or +``from aspython import Project``) instead. +""" +import warnings as _warnings + +from aspython import ( # noqa: F401 + ASReturnCodes, + PVIReturnCodeText, + LibExportInfo, + ProjectExportInfo, + Dependency, + BuildConfig, + getASPath, + getASBuildPath, + getPVITransferPath, + getActualPathFromLogicalPath, + getAsPathType, + convertAsPathToWinPath, + convertWinPathToAsPath, + getLibraryPathInPackage, + getLibraryType, + getProgramType, + getPkgType, + xmlAsFile, + Library, + Package, + Task, + SwDeploymentTable, + CpuConfig, + ASProjetGetConfigs, + batchBuildAsProject, + buildASProject, + CreateARSimStructure, + Project, + toDict, +) + +_warnings.warn( + "Importing from 'ASTools' is deprecated; use 'aspython' instead " + "(e.g. 'from aspython import Project').", + DeprecationWarning, + stacklevel=2, +) + +__all__ = [ + "ASReturnCodes", "PVIReturnCodeText", + "LibExportInfo", "ProjectExportInfo", "Dependency", "BuildConfig", + "getASPath", "getASBuildPath", "getPVITransferPath", + "getActualPathFromLogicalPath", "getAsPathType", + "convertAsPathToWinPath", "convertWinPathToAsPath", + "getLibraryPathInPackage", "getLibraryType", "getProgramType", "getPkgType", + "xmlAsFile", "Library", "Package", "Task", "SwDeploymentTable", "CpuConfig", + "ASProjetGetConfigs", "batchBuildAsProject", "buildASProject", + "CreateARSimStructure", "Project", "toDict", ] - -def _findASBase(version:str='') -> str: - """Return the base BrAutomation directory. - - If a specific version is provided, prefer a base that actually contains - a folder for that version. Otherwise return the first base that exists. - Falls back to the legacy default 'C:\\BrAutomation' if none are found. - """ - if version and version.lower() != 'base': - for base in _AS_BASE_CANDIDATES: - if os.path.isdir(os.path.join(base, version.upper())): - return base - for base in _AS_BASE_CANDIDATES: - if os.path.isdir(base): - return base - return _AS_BASE_CANDIDATES[0] - -def getASPath(version:str) -> str: - base = _findASBase(version) - 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', buildRUCPackage=True, 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, buildRUCPackage=buildRUCPackage, 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', buildRUCPackage=True, 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') - - if buildRUCPackage: - 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'): - path = convertAsPathToWinPath(item.text) - # Since relative paths are relative to project root, we need to convert them to absolute paths. - if path.startswith('.'): - path = os.path.abspath(os.path.join(os.path.dirname(self.path), path)) - lib = Library(path) - 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', buildRUCPackage=True, 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, buildRUCPackage, 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('= 6: - version = result[0] - else: - version = ''.join(result[0:2]) - return 'AS' + version - - def getHardwareParameter(self, config, paramName) -> 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 = {} - attributes['Type'] = getPkgType(path) - if attributes['Type'] == 'Library': - attributes['Language'] = getLibraryType(path) - if attributes['Type'] == 'Program': - attributes['Language'] = getProgramType(path) - if reference: - attributes['Reference'] = "true" - - element = ET.Element('Object', attrib=attributes) - if reference: - # Note: From empirical testing in AS, the path to a referenced library must be relative, if the source is somewhere within the "Logical" folder - - if os.path.isabs(path): - element.text = os.path.abspath(path) - else: - element.text = os.path.normpath(os.path.join('\\', 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 str(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: - lbyPath = getLibraryPathInPackage(libraryFolder, name) - language = getLibraryType(lbyPath) - 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: - actualTaskFolderPath = getActualPathFromLogicalPath(taskFolder) - prgPath = os.path.join(actualTaskFolderPath, taskName) - task = Task(prgPath) - 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) - for i, part in enumerate(splitPath): - if part.lower() == "logical": - splitPath = splitPath[i+1:] # For task element, path must be relative to Logical folder, - break # so if "logical" found, get the path parts that follow - 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 - -# Retrieve the path to the library (location of .lby), given a package and the library name -# Provides abstraction, because the library may be a reference in a different directory -def getLibraryPathInPackage(libraryPackagePath, libraryName): - asPackage = Package(libraryPackagePath) - - for object in asPackage.objectList: - if object.attrib.get('Reference', 'false') == 'true' and libraryName.lower() in object.text.lower(): - # Library not in folder (is referenced) - return convertAsPathToWinPath(object.text) - - elif object.text.lower() == libraryName.lower(): - # library is in folder (not referenced) - return os.path.join(libraryPackagePath, libraryName) - - return None - -# Gets actual path for the "Logical Path", as viewed in AS -# Handles the situation in which "Reference" packages exist in path chain -def getActualPathFromLogicalPath(logicalPath): - splitPath = os.path.normpath(logicalPath).split(os.sep) - if splitPath[0] == "": - splitPath = splitPath[1:] - currentPath = "." - for step in splitPath: - if step.lower() in [s.lower() for s in os.listdir(currentPath)]: - currentPath = os.path.join(currentPath, step) - elif 'package.pkg' in [s.lower() for s in os.listdir(currentPath)]: - currentAsPackage = Package(currentPath) - found = False - for object in currentAsPackage.objectList: - if object.attrib.get('Reference', '') == 'true' and step in object.text: - currentPath = convertAsPathToWinPath(object.text) - found = True - if not found: - return None - else: - return None - return currentPath - - -# Get AsPath Type -# Returns "relative", "absolute", or None -# Nuances of AS Paths: -# - if the path begins with '\' or '..' it is a relative path. In Windows paths, starting with '\' is interpretated as an absolute path from the C: drive -# - Paths cannot begin with '.', though '\.\some\path' is respected -def getAsPathType(path): - if path[0] == '\\' or path[0:2] == "..": - # starts with backlash or "..", means path is relative to location of .apj - return "relative" - elif path[0] == '/' or path[0:2] == "C:": - # starts with fwdslash or "C:", means path is absolute - return "absolute" - else: - return None - -# Convert AS Path to Windows Path -# AS Paths (e.g. paths to "Referenced" files/folders in a package.pkg file) have a slightly different syntax than Windows, thus conversion is necessary -def convertAsPathToWinPath(asPath): - if getAsPathType(asPath) == 'relative': - return '.' + os.path.join(os.sep, os.path.normpath(asPath)) # Add '.' so os.path interptrets as relative path - else: - # path is absolute or unidentified - return os.path.normpath(asPath) - -# Convert Windows Path to AS Path -# (see description notes for convertAsPathToWinPath()) -def convertWinPathToAsPath(winPath): - if os.path.isabs(winPath): - # path is absolute - return os.path.normpath(winPath) - else: - # path is relative - return os.path.join('\\', os.path.normpath(winPath)) - - -# 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 = convertWinPathToAsPath(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/CHANGELOG.md b/CHANGELOG.md index 5c4a716..1acadd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Change log +- 0.3.0 - Major maintainability refactor + - Split `ASTools.py` into a proper `aspython` package (`project`, `library`, `package`, `task`, `deployment`, `config`, `build`, `simulation`, `paths`, `models`, `xml_base`, `returncodes`, `utils`, `installer`, `hmi`, `unittests`, `upgrades`, `cnc`, `logging_setup`) + - Unified the nine `CmdLine*.py` scripts behind a single `aspython` CLI with subcommands (`build`, `arsim`, `export-libs`, `deploy-libs`, `safety-crc`, `version`, `installer`, `package-hmi`, `run-tests`); legacy `CmdLine*.py` scripts kept as deprecation shims + - Added `pyproject.toml` with console_scripts entry point, dev extras, and a PyInstaller spec under `packaging/` + - Added `tests/` (pytest) covering imports, dataclass models, path helpers, and CLI dispatch + - Added GitHub Actions CI for Windows + - Backwards-compatible: `import ASTools` and `python CmdLineXxx.py …` still work and emit `DeprecationWarning` + - 0.2.2 - Adapt SW task element's path to be relative to Logical - 0.2.1 - Fix logical path to actual path functions diff --git a/CmdLineARSim.py b/CmdLineARSim.py index 6451a34..4b3a151 100644 --- a/CmdLineARSim.py +++ b/CmdLineARSim.py @@ -2,119 +2,23 @@ * 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 +"""Backwards-compatibility shim — delegates to ``aspython arsim``.""" import sys -import os -import shutil -import tarfile - -# External Modules -import ASTools -import _version - -def main(): - - buildStatus = None +import warnings - # 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', default='') - parser.add_argument('-hf', '--hmiFiles', type=str, help='Path to the folder containing HMI files to get included with simulator', default='') - 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() +from aspython.cli.main import main as _aspython_main - # 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) - logging.debug('User files: %s', args.userFiles) - logging.debug('HMI files: %s', args.hmiFiles) - - 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) +def main(): + warnings.warn( + "CmdLineARSim.py is deprecated; use 'aspython arsim' instead.", + DeprecationWarning, stacklevel=2, + ) + sys.exit(_aspython_main(['arsim', *sys.argv[1:]])) - # 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.hmiFiles != '': - 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/CmdLineBuild.py b/CmdLineBuild.py index b164064..c78f099 100644 --- a/CmdLineBuild.py +++ b/CmdLineBuild.py @@ -2,114 +2,26 @@ * 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 +"""Backwards-compatibility shim — delegates to ``aspython build``. -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 +Prefer ``aspython build -c `` going forward. """ - -# 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('-rp','--buildRUCPackage', action='store_false', help='Should RUCPackage be built') - 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) +import warnings - # 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 RUCPackage will be built: %s', args.buildRUCPackage) - 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') +from aspython.cli.main import main as _aspython_main - # 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, buildRUCPackage=args.buildRUCPackage, 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) +def main(): + warnings.warn( + "CmdLineBuild.py is deprecated; use 'aspython build' instead.", + DeprecationWarning, stacklevel=2, + ) + sys.exit(_aspython_main(['build', *sys.argv[1:]])) - - 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/CmdLineCreateInstaller.py b/CmdLineCreateInstaller.py index 5998eaa..428133d 100644 --- a/CmdLineCreateInstaller.py +++ b/CmdLineCreateInstaller.py @@ -2,124 +2,23 @@ * 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 +"""Backwards-compatibility shim — delegates to ``aspython installer``.""" import sys -import subprocess -import uuid -from typing import Dict, Tuple, Sequence, Union, List, Optional +import warnings -import _version +from aspython.cli.main import main as _aspython_main -# TODO: Error handling when required pars aren't passed in. def main(): + warnings.warn( + "CmdLineCreateInstaller.py is deprecated; use 'aspython installer' instead.", + DeprecationWarning, stacklevel=2, + ) + sys.exit(_aspython_main(['installer', *sys.argv[1:]])) - # 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/CmdLineDeployLibraries.py b/CmdLineDeployLibraries.py index 3ab96b4..de45429 100644 --- a/CmdLineDeployLibraries.py +++ b/CmdLineDeployLibraries.py @@ -2,73 +2,23 @@ * 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 +"""Backwards-compatibility shim — delegates to ``aspython deploy-libs``.""" import sys -import ctypes -import os -import tarfile -import shutil +import warnings -# 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() +from aspython.cli.main import main as _aspython_main - # 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) +def main(): + warnings.warn( + "CmdLineDeployLibraries.py is deprecated; use 'aspython deploy-libs' instead.", + DeprecationWarning, stacklevel=2, + ) + sys.exit(_aspython_main(['deploy-libs', *sys.argv[1:]])) - # 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/CmdLineExportLib.py b/CmdLineExportLib.py index 8e20fa7..ca86aff 100644 --- a/CmdLineExportLib.py +++ b/CmdLineExportLib.py @@ -1,121 +1,24 @@ -""" -@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 +''' + * File: CmdLineExportLib.py + * Copyright (c) 2023 Loupe + * https://loupe.team + * + * This file is part of ASPython, licensed under the MIT License. +''' +"""Backwards-compatibility shim — delegates to ``aspython export-libs``.""" 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() +import warnings - # 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) +from aspython.cli.main import main as _aspython_main - # 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) +def main(): + warnings.warn( + "CmdLineExportLib.py is deprecated; use 'aspython export-libs' instead.", + DeprecationWarning, stacklevel=2, + ) + sys.exit(_aspython_main(['export-libs', *sys.argv[1:]])) 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/CmdLineGetSafetyCrc.py b/CmdLineGetSafetyCrc.py index cf39c31..a7effb2 100644 --- a/CmdLineGetSafetyCrc.py +++ b/CmdLineGetSafetyCrc.py @@ -2,60 +2,23 @@ * 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 +"""Backwards-compatibility shim — delegates to ``aspython safety-crc``.""" 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) +import warnings - # 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))] +from aspython.cli.main import main as _aspython_main - # 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) +def main(): + warnings.warn( + "CmdLineGetSafetyCrc.py is deprecated; use 'aspython safety-crc' instead.", + DeprecationWarning, stacklevel=2, + ) + sys.exit(_aspython_main(['safety-crc', *sys.argv[1:]])) if __name__ == "__main__": - main() diff --git a/CmdLineGetVersion.py b/CmdLineGetVersion.py index f0eed57..4337730 100644 --- a/CmdLineGetVersion.py +++ b/CmdLineGetVersion.py @@ -2,62 +2,23 @@ * 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 +"""Backwards-compatibility shim — delegates to ``aspython version``.""" import sys -import re -from typing import Dict, Tuple, Sequence, Union, List, Optional - -# External Modules -import ASTools -import _version - -def main(): +import warnings - # 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) +from aspython.cli.main import main as _aspython_main - 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) +def main(): + warnings.warn( + "CmdLineGetVersion.py is deprecated; use 'aspython version' instead.", + DeprecationWarning, stacklevel=2, + ) + sys.exit(_aspython_main(['version', *sys.argv[1:]])) if __name__ == "__main__": - main() diff --git a/CmdLinePackageHmi.py b/CmdLinePackageHmi.py index 7cbee3c..71e1d01 100644 --- a/CmdLinePackageHmi.py +++ b/CmdLinePackageHmi.py @@ -2,118 +2,23 @@ * 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 Loupe UX-based HMI - -0.1.0 - Synchronize all script versions -0.0.1 - Initial release -""" - -# Python Modules -import argparse -import logging +"""Backwards-compatibility shim — delegates to ``aspython package-hmi``.""" import sys -import subprocess -import uuid -import os -import json -import re -from typing import Dict, Tuple, Sequence, Union, List, Optional +import warnings -import _version +from aspython.cli.main import main as _aspython_main -# TODO: add capabilities for zipping up HMI. -# TODO: error check the parameters. def main(): + warnings.warn( + "CmdLinePackageHmi.py is deprecated; use 'aspython package-hmi' instead.", + DeprecationWarning, stacklevel=2, + ) + sys.exit(_aspython_main(['package-hmi', *sys.argv[1:]])) - # Parse arguments from the command line - parser = argparse.ArgumentParser(description='Package up a Loupe UX-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/CmdLineRunUnitTests.py b/CmdLineRunUnitTests.py index aacfde1..18c525a 100644 --- a/CmdLineRunUnitTests.py +++ b/CmdLineRunUnitTests.py @@ -2,68 +2,23 @@ * 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 +"""Backwards-compatibility shim — delegates to ``aspython run-tests``.""" import sys -import os -import shutil +import warnings -# External Modules -import UnitTestTools -import _version - -def main(): +from aspython.cli.main import main as _aspython_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) +def main(): + warnings.warn( + "CmdLineRunUnitTests.py is deprecated; use 'aspython run-tests' instead.", + DeprecationWarning, stacklevel=2, + ) + sys.exit(_aspython_main(['run-tests', *sys.argv[1:]])) - 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/ColorCodedLog.py b/ColorCodedLog.py index d53e4e9..6b09c27 100644 --- a/ColorCodedLog.py +++ b/ColorCodedLog.py @@ -2,32 +2,40 @@ * File: ColorCodedLog.py * Copyright (c) 2023 Loupe * https://loupe.team - * + * * This file is part of ASPython, licensed under the MIT License. ''' -# Python Modules +"""Backwards-compatibility shim. + +The old ``InitializeLogger`` / ``CustomFormatter`` API was unused inside the project, but is +preserved here. New code should call ``aspython.logging_setup.setup_logging`` instead. +""" +import warnings as _warnings + +# The original implementation lived only in this module and was never imported by other +# ASPython files; preserve verbatim under a sub-module name so external callers still work. import logging import sys -from typing import Optional, Union +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) +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): + logger = logging.getLogger(logName) + if logLevel: + logger.setLevel(logLevel) - # For loop through handlers to see if one already exists customHandler = None + logFormatter = 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) @@ -35,10 +43,9 @@ def InitializeLogger(logName:str="main", logLevel:Union[int, str, None]=None, lo customHandler.setFormatter(logFormatter) logger.addHandler(customHandler) - - if logStringFormat: logFormatter.msgFormat = logStringFormat + if logStringFormat: + logFormatter.msgFormat = logStringFormat - # Disable colors by removing the color from the format string if disableColors: logFormatter.debug = "" logFormatter.info = "" @@ -46,38 +53,39 @@ def InitializeLogger(logName:str="main", logLevel:Union[int, str, None]=None, lo 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 + 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 +class CustomFormatter(logging.Formatter): defaultDebug = "\x1b[38;21m" defaultInfo = "\x1b[38;21m" defaultWarning = "\x1b[33;21m" defaultError = "\x1b[31;21m" defaultCritical = "\x1b[31;1m" - defaultReset = "\x1b[0m" + 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._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 @@ -86,7 +94,6 @@ def debug(self): def debug(self, value): self._debug = value self.generate() - return self._debug @property def info(self): @@ -96,7 +103,6 @@ def info(self): def info(self, value): self._info = value self.generate() - return self._info @property def warning(self): @@ -106,7 +112,6 @@ def warning(self): def warning(self, value): self._warning = value self.generate() - return self._warning @property def error(self): @@ -116,7 +121,6 @@ def error(self): def error(self, value): self._error = value self.generate() - return self._error @property def critical(self): @@ -126,7 +130,6 @@ def critical(self): def critical(self, value): self._critical = value self.generate() - return self._critical @property def msgFormat(self): @@ -136,7 +139,6 @@ def msgFormat(self): def msgFormat(self, value): self._msgFormat = value self.generate() - return self._msgFormat @property def reset(self): @@ -146,7 +148,6 @@ def reset(self): def reset(self, value): self._reset = value self.generate() - return self._reset def generate(self): self._formatDict = { @@ -154,36 +155,19 @@ def generate(self): 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 + 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') + return logging.Formatter(log_fmt).format(record) - 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') +_warnings.warn( + "Importing from 'ColorCodedLog' is deprecated; use 'aspython.logging_setup.setup_logging' instead.", + DeprecationWarning, + stacklevel=2, +) -if __name__ == "__main__": - main() - +__all__ = ["InitializeLogger", "CustomFormatter"] diff --git a/ExportLibraries.py b/ExportLibraries.py index ea3818e..495ede9 100644 --- a/ExportLibraries.py +++ b/ExportLibraries.py @@ -2,93 +2,25 @@ * 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 +"""Deprecated parameter-file driven export — superseded by ``aspython export-libs``.""" 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) +import warnings - 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)) +def main(): + warnings.warn( + "ExportLibraries.py is deprecated; use 'aspython export-libs' instead.", + DeprecationWarning, stacklevel=2, + ) + sys.stderr.write( + "ExportLibraries.py has been removed. Use 'aspython export-libs' (or the legacy " + "CmdLineExportLib.py shim) instead.\n" + ) + sys.exit(1) - # 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 +if __name__ == "__main__": + main() diff --git a/InstallUpgrades.py b/InstallUpgrades.py index 11c940f..f2a052b 100644 --- a/InstallUpgrades.py +++ b/InstallUpgrades.py @@ -2,147 +2,73 @@ * 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 -""" +"""Standalone CLI: install AS upgrades from a folder of installer .exe files. -#Python Modules +The library helper ``installBRUpgrade`` lives in ``aspython.upgrades`` and is re-exported +here for backwards compatibility. +""" import argparse -import logging -import sys import ctypes +import logging 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=') - commandLine.append('"' + brPath + '"') - - if brPath in asPath: - commandLine.append('-V=') - commandLine.append('"' + asPath + '"') - else: - commandLine.append('-V=') - commandLine.append('"' + brPath + '\\' + asPath + '"') - commandLine.append('-R') - # commandLine.append('Y') +import sys - # Execute the process, and retrieve the process object. - logging.info('Started installing upgrade ' + upgrade) - logging.info(commandLine) +from aspython._version import __version__ +from aspython.logging_setup import add_log_level_argument, setup_logging +from aspython.upgrades import installBRUpgrade # noqa: F401 (re-export) - process = subprocess.run(' '.join(commandLine), shell=False, capture_output=True) - # returncode = os.system(' '.join(commandLine)) - # process = subprocess.CompletedProcess(' '.join(commandLine), returncode) - - if process.returncode == 0: - logging.info('Finished install upgrade ' + upgrade) - else: - logging.error('Error while installing upgrade ' + upgrade + ' (return code = ' + str(process.returncode) + ')') - logging.debug('stderr: ' + str(process.stderr)) - logging.debug('stdout: ' + str(process.stdout)) - - 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', default='C:\\BrAutomation') - parser.add_argument('-asp','--aspath', type=str, help='AS install path for the desired AS version') - parser.add_argument('-r', '--recursive', action='store_true', help='Recursively search for upgrades in the upgrade path') - 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__)) + parser.add_argument('upgradePath', type=str, + help='Path to single upgrade or a folder containing upgrades') + parser.add_argument('-brp', '--brpath', type=str, default='C:\\BrAutomation', + help='Global AS install path') + parser.add_argument('-asp', '--aspath', type=str, + help='AS install path for the desired AS version') + parser.add_argument('-r', '--recursive', action='store_true', + help='Recursively search for upgrades in the upgrade path') + add_log_level_argument(parser) + parser.add_argument('-v', '--version', action='version', + version=f'%(prog)s {__version__}') args = parser.parse_args() + setup_logging(args.logLevel) - # 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) + logging.debug('upgrades path: %s | brpath: %s | aspath: %s', args.upgradePath, args.brpath, args.aspath) 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') + logging.error('Admin privileges required. Open terminal as Administrator') sys.exit(1) - - if os.path.isdir(args.upgradePath): - # Move into upgrade folder. os.chdir(args.upgradePath) if args.recursive: - for root, dirs, files in os.walk(args.upgradePath): + for root, _dirs, files in os.walk(args.upgradePath): for upgrade in files: if upgrade.lower().endswith('.exe'): installBRUpgrade(os.path.join(root, upgrade), args.brpath, args.aspath) else: 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'): + 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 + os.chdir(os.path.dirname(args.upgradePath)) installBRUpgrade(args.upgradePath, args.brpath, args.aspath) - else: - logging.error('Path provided neither an upgrade or a directory: ' + args.upgradePath) + logging.error('Path provided is neither an upgrade nor 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) +if __name__ == "__main__": main() diff --git a/README.md b/README.md index ff5ecf3..7dffe0b 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,342 @@ -# 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 Loupe UX-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 +# ASPython + +Python toolkit for programmatic interaction with B&R Automation Studio projects. + +Provided by [Loupe](https://loupe.team) · info@loupe.team · 1-800-240-7042 + +--- + +## Overview + +ASPython (`aspython`) is a Python package that provides both a **command-line interface** and a **Python API** for automating common Automation Studio workflows: building configurations, managing libraries, creating simulators, packaging HMIs, running unit tests, and more. + +--- + +## Requirements + +- Python 3.8+ +- B&R Automation Studio installed (required for build/sim operations) +- Windows (Automation Studio is Windows-only) + +--- + +## Installation + +Install from source in editable mode (recommended for development): + +```bash +pip install -e . +``` + +Install with optional CNC support (requires `lxml`): + +```bash +pip install -e .[cnc] +``` + +Install with all development dependencies (testing, linting, packaging): + +```bash +pip install -e .[dev] +``` + +After installation the `aspython` command is available on your PATH. + +--- + +## CLI Reference + +``` +aspython [options] +aspython --help +aspython --help +``` + +Global flags available on every subcommand: + +| Flag | Description | +|------|-------------| +| `-l`, `--logLevel` | Log verbosity: `DEBUG`, `INFO`, `WARNING`, `ERROR` (default: `WARNING`) | +| `-v`, `--version` | Print the installed version and exit | + +### `aspython build` + +Build one or more configurations of an AS project. + +``` +aspython build -c [ ...] [options] +``` + +| Argument | Description | +|----------|-------------| +| `project` | Path to the AS project | +| `-c`, `--configuration` | One or more AS configuration names to build (required) | +| `-bm`, `--buildMode` | Build mode: `Build` (default), `Rebuild`, `BuildAndTransfer`, `BuildAndCreateCompactFlash`, `None` | +| `-rp`, `--buildRUCPackage` | Disable building the RUC package | +| `-sim`, `--simulation` | Build for simulation | +| `-pip` | Generate a PIP (`.tar.gz`) after build completes | + +### `aspython arsim` + +Build (optionally) and create an ARsim package for an AS project. + +``` +aspython arsim -c [ ...] [options] +``` + +| Argument | Description | +|----------|-------------| +| `project` | Path to the AS project | +| `-c`, `--configuration` | One or more AS configuration names (required) | +| `-bm`, `--buildMode` | Build before packaging: `None` (default, skip build), `Build`, `Rebuild`, etc. | +| `-ss`, `--startSim` | Start ARsim after creating the package | +| `-uf`, `--userFiles` | Path to a folder of user partition files to include | +| `-hf`, `--hmiFiles` | Path to a folder of HMI files to include | + +Output is written to `/Temp/SIM//Simulator.tar.gz`. + +### `aspython export-libs` + +Export libraries from an AS project in binary or source format. + +``` +aspython export-libs -c [ ...] [options] +``` + +| Argument | Description | +|----------|-------------| +| `project` | Path to the AS project | +| `-c`, `--configuration` | One or more AS configuration names (required) | +| `-dest`, `--destination` | Export destination path (default: `../Exports` relative to project) | +| `-wl`, `--whitelist` | Export only these libraries (overrides blacklist) | +| `-bl`, `--blacklist` | Skip libraries matching these glob patterns | +| `-o`, `--overwrite` | Overwrite previously-exported libraries | +| `-source`, `--sourceFile` | Export as source instead of binary | +| `-bm`, `--buildMode` | Build before exporting (default: `None`) | +| `-iv`, `--includeVersion` | Include version number in the folder structure | + +### `aspython deploy-libs` + +Deploy libraries into a cpu.sw deployment table. + +``` +aspython deploy-libs -d -lf [options] +``` + +| Argument | Description | +|----------|-------------| +| `-d`, `--deploymentFile` | Path to the `cpu.sw` file (required) | +| `-lf`, `--libraryFolder` | Folder containing the libraries to deploy (required) | +| `-lib`, `--libraries` | Specific library names to deploy (default: all libraries in the folder) | + +### `aspython safety-crc` + +Retrieve the CRC of a B&R Safe Application from a built project. + +``` +aspython safety-crc -c -sa +``` + +| Argument | Description | +|----------|-------------| +| `project` | Path to the AS project | +| `-c`, `--configuration` | AS configuration name (required) | +| `-sa`, `--safeApp` | Safe application name (e.g. `MySafeApp.SafAPP`) (required) | + +Prints the CRC value to stdout. + +### `aspython version` + +Read a project's build version from a `.var` file. + +``` +aspython version -bi [options] +``` + +| Argument | Description | +|----------|-------------| +| `project` | Path to the AS project | +| `-bi`, `--buildInfo` | Path to the buildInfo `.var` file (required) | +| `--semver` | Return the version in Semantic Version format | + +Prints the version string to stdout. + +### `aspython installer` + +Generate a Windows installer (`.exe`) from an Inno Setup `.iss` script. + +``` +aspython installer -o -an [options] +``` + +| Argument | Description | +|----------|-------------| +| `script` | Path to the `.iss` script (required) | +| `-o`, `--output` | Destination folder for the compiled installer (required) | +| `-an`, `--appName` | Application name (required) | +| `-av`, `--appVersion` | Application version (default: `1.0.0`) | +| `-ap`, `--appPublisher` | Publisher name (default: `Loupe`) | +| `-au`, `--appUrl` | Publisher URL (default: `https://loupe.team`) | +| `-sd`, `--simDir` | Directory containing simulation assets | +| `-ud`, `--userDir` | Directory containing user partition assets | +| `-jb`, `--junctionBatch` | Junction batch filename (default: `ConnectFileDevice.bat`) | +| `-hd`, `--hmiDir` | Directory containing HMI assets | +| `-he`, `--hmiExe` | HMI executable filename | + +### `aspython package-hmi` + +Package a Loupe UX-based HMI using electron-packager. + +``` +aspython package-hmi -s -o -an [options] +``` + +| Argument | Description | +|----------|-------------| +| `-s`, `--source` | Source folder containing the HMI `package.json` (required) | +| `-o`, `--output` | Destination folder for packaged files (required) | +| `-an`, `--appName` | Application name (required) | +| `-av`, `--appVersion` | Application version (default: `1.0.0`) | +| `-ap`, `--appPublisher` | Publisher name (default: `Loupe`) | +| `--installElectronPackager` | Install electron-packager before packaging | + +### `aspython run-tests` + +Run unit tests against a PLC test server and write JUnit-style XML results. + +``` +aspython run-tests -d [options] +``` + +| Argument | Description | +|----------|-------------| +| `host` | IP address of the PLC running the test server (required) | +| `-d`, `--destination` | Directory to write test result XML files (required) | +| `-a`, `--all` | Run all available tests | + +--- + +## Python API + +The package exposes a public API that can be imported directly: + +```python +from aspython import Project, Library, Package, BuildConfig +``` + +### `Project` + +The main entry point for working with an AS project. + +```python +project = Project('/path/to/MyProject') + +# Build a configuration +result = project.build('MyConfig', buildMode='Build') + +# Export libraries +results = project.exportLibraries('/path/to/exports', buildConfigs=[...]) + +# Create an ARsim package +project.createSim('MyConfig', destination='/path/to/sim') + +# Create a PIP +project.createPIP('MyConfig', '/path/to/pip') + +# Read a constant from a .var file +version = project.getConstantValue('path/to/buildInfo.var', 'versionId') + +# Read a value from an .ini file +crc = project.getIniValue('relative/path/to/CPU.ini', 'CRC', 'PROJECT') +``` + +Key properties: + +| Property | Description | +|----------|-------------| +| `project.dirPath` | Absolute path to the project directory | +| `project.tempPath` | Absolute path to the project's `Temp` directory | +| `project.buildConfigs` | List of `BuildConfig` objects for each AS configuration | + +### `Library` + +Represents an Automation Studio library. + +```python +from aspython import Library +lib = Library('/path/to/MyLibrary') +``` + +### `Package` + +Represents an AS logical package (`.pkg` file). + +### `BuildConfig` + +Represents a build configuration within a project. + +```python +for config in project.buildConfigs: + print(config.name) +``` + +### `SwDeploymentTable` + +Read and modify a `cpu.sw` software deployment table. + +```python +from aspython import SwDeploymentTable +table = SwDeploymentTable('/path/to/cpu.sw') +table.deployLibrary('/path/to/libs', 'MyLibrary') +``` + +### Path utilities + +```python +from aspython import ( + getASPath, + getASBuildPath, + convertAsPathToWinPath, + convertWinPathToAsPath, +) +``` + +--- + +## Legacy compatibility + +The legacy `CmdLine*.py` scripts and the `import ASTools` pattern continue to work as deprecation shims. Prefer the `aspython` CLI and package API for new code. + +| Legacy | Current | +|--------|---------| +| `python CmdLineBuild.py` | `aspython build` | +| `python CmdLineARSim.py` | `aspython arsim` | +| `python CmdLineExportLib.py` | `aspython export-libs` | +| `python CmdLineDeployLibraries.py` | `aspython deploy-libs` | +| `python CmdLineGetSafetyCrc.py` | `aspython safety-crc` | +| `python CmdLineGetVersion.py` | `aspython version` | +| `python CmdLineCreateInstaller.py` | `aspython installer` | +| `python CmdLinePackageHmi.py` | `aspython package-hmi` | +| `python CmdLineRunUnitTests.py` | `aspython run-tests` | + +--- + +## Development + +Run the test suite: + +```bash +pytest +``` + +Lint: + +```bash +ruff check . +``` + +--- + +## Licensing This project is licensed under the [MIT License](LICENSE). diff --git a/UnitTestTools.py b/UnitTestTools.py index 085607f..6403222 100644 --- a/UnitTestTools.py +++ b/UnitTestTools.py @@ -2,42 +2,18 @@ * 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. -''' +"""Backwards-compatibility shim — use ``aspython.unittests`` instead.""" +import warnings as _warnings -import os -import requests -import subprocess -import logging +from aspython.unittests import UnitTestServer # noqa: F401 -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})') +_warnings.warn( + "Importing from 'UnitTestTools' is deprecated; use 'aspython.unittests' instead.", + DeprecationWarning, + stacklevel=2, +) - 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 +__all__ = ["UnitTestServer"] diff --git a/_version.py b/_version.py index 9dd16a3..cb7857a 100644 --- a/_version.py +++ b/_version.py @@ -1 +1,2 @@ -__version__ = '0.2.2' \ No newline at end of file +"""Backwards-compatibility shim — the canonical version lives in ``aspython._version``.""" +from aspython._version import __version__ # noqa: F401 diff --git a/aspython/__init__.py b/aspython/__init__.py new file mode 100644 index 0000000..36f0a59 --- /dev/null +++ b/aspython/__init__.py @@ -0,0 +1,69 @@ +"""ASPython - Python toolkit for B&R Automation Studio projects. + +Public API is re-exported here for convenience:: + + from aspython import Project, Library, Package, BuildConfig +""" +from ._version import __version__ +from .returncodes import ASReturnCodes, PVIReturnCodeText +from .models import LibExportInfo, ProjectExportInfo, Dependency, BuildConfig +from .paths import ( + getASPath, + getASBuildPath, + getPVITransferPath, + getActualPathFromLogicalPath, + getAsPathType, + convertAsPathToWinPath, + convertWinPathToAsPath, + getLibraryPathInPackage, + getLibraryType, + getProgramType, + getPkgType, +) +from .xml_base import xmlAsFile +from .library import Library +from .package import Package +from .task import Task +from .deployment import SwDeploymentTable +from .config import CpuConfig +from .build import ( + ASProjetGetConfigs, + batchBuildAsProject, + buildASProject, +) +from .simulation import CreateARSimStructure +from .project import Project +from .utils import toDict + +__all__ = [ + "__version__", + "ASReturnCodes", + "PVIReturnCodeText", + "LibExportInfo", + "ProjectExportInfo", + "Dependency", + "BuildConfig", + "getASPath", + "getASBuildPath", + "getPVITransferPath", + "getActualPathFromLogicalPath", + "getAsPathType", + "convertAsPathToWinPath", + "convertWinPathToAsPath", + "getLibraryPathInPackage", + "getLibraryType", + "getProgramType", + "getPkgType", + "xmlAsFile", + "Library", + "Package", + "Task", + "SwDeploymentTable", + "CpuConfig", + "ASProjetGetConfigs", + "batchBuildAsProject", + "buildASProject", + "CreateARSimStructure", + "Project", + "toDict", +] diff --git a/aspython/__main__.py b/aspython/__main__.py new file mode 100644 index 0000000..32e38fb --- /dev/null +++ b/aspython/__main__.py @@ -0,0 +1,8 @@ +"""Allow ``python -m aspython`` to invoke the CLI.""" +import sys + +from .cli.main import main + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/aspython/_version.py b/aspython/_version.py new file mode 100644 index 0000000..0404d81 --- /dev/null +++ b/aspython/_version.py @@ -0,0 +1 @@ +__version__ = '0.3.0' diff --git a/aspython/build.py b/aspython/build.py new file mode 100644 index 0000000..e7a8b24 --- /dev/null +++ b/aspython/build.py @@ -0,0 +1,110 @@ +"""Build orchestration: invoking ``BR.AS.Build.exe`` for AS projects.""" +import logging +import os.path +import re +import subprocess +from typing import Union + +from .returncodes import ASReturnCodes + + +def ASProjetGetConfigs(project: str): + if os.path.isfile(project): + project = os.path.split(project)[0] + project = os.path.join(project, 'Physical') + return [d for d in os.listdir(project) if os.path.isdir(os.path.join(project, d))] + + +def batchBuildAsProject( + project, + ASPath: str, + configurations=None, + buildMode: str = 'Build', + buildRUCPackage: bool = True, + tempPath: str = '', + logPath: str = '', + binaryPath: str = '', + simulation: bool = False, + additionalArg: Union[str, list, tuple, None] = None, +) -> subprocess.CompletedProcess: + if configurations is None: + configurations = [] + completedProcess = None + for config in configurations: + completedProcess = buildASProject( + project, ASPath, + configuration=config, buildMode=buildMode, buildRUCPackage=buildRUCPackage, + tempPath=tempPath, logPath=logPath, binaryPath=binaryPath, + simulation=simulation, additionalArg=additionalArg, + ) + if completedProcess.returncode > ASReturnCodes["Warnings"]: + logging.info(f'Build for configuration {config} has completed with errors, see DEBUG logging for details') + return completedProcess + logging.info(f'Build for configuration {config} has completed without errors, see DEBUG logging for details') + return completedProcess + + +def buildASProject( + project, + ASPath: str, + configuration: str = '', + buildMode: str = 'Build', + buildRUCPackage: bool = True, + tempPath: str = '', + binaryPath: str = '', + logPath: str = '', + simulation: bool = False, + additionalArg: Union[str, list, tuple, None] = None, +) -> subprocess.CompletedProcess: + commandLine = [ASPath, '"' + os.path.abspath(project) + '"'] + + if configuration: + commandLine.extend(['-c', configuration]) + + if buildMode: + commandLine.extend(['-buildMode', buildMode]) + if buildMode.capitalize() == 'Rebuild': + commandLine.append('-all') + + if tempPath: + commandLine.extend(['-t', tempPath]) + + if binaryPath: + commandLine.extend(['-o', binaryPath]) + + if simulation: + commandLine.append('-simulation') + + if buildRUCPackage: + commandLine.append('-buildRUCPackage') + + if additionalArg: + if isinstance(additionalArg, str): + commandLine.append(additionalArg) + elif isinstance(additionalArg, (list, tuple)): + commandLine.extend(additionalArg) + + logging.info(f'Starting build for configuration {configuration}...') + logging.debug(commandLine) + process = subprocess.Popen(commandLine, stdout=subprocess.PIPE, encoding="utf-8", errors='replace') + + log_file = os.path.join(logPath, "build.log") + logging.info("Recording build log here: " + log_file) + + with open(log_file, "w", encoding='utf-8') as f: + while process.returncode is None: + raw = process.stdout.readline() + data = raw.rstrip() + f.write(raw) + if data != "": + warningMatch = re.search('warning [0-9]*:', data) + errorMatch = re.search('error [0-9]*:', data) + if warningMatch is not None: + logging.warning("\033[32m" + data + "\033[0m") + elif errorMatch is not None: + logging.error("\033[31m" + data + "\033[0m") + else: + logging.debug(data) + process.poll() + + return subprocess.CompletedProcess(commandLine, process.returncode) diff --git a/aspython/cli/__init__.py b/aspython/cli/__init__.py new file mode 100644 index 0000000..f6b1eb8 --- /dev/null +++ b/aspython/cli/__init__.py @@ -0,0 +1 @@ +"""Unified ``aspython`` command-line interface.""" diff --git a/aspython/cli/arsim.py b/aspython/cli/arsim.py new file mode 100644 index 0000000..e2c5f35 --- /dev/null +++ b/aspython/cli/arsim.py @@ -0,0 +1,67 @@ +"""``aspython arsim`` subcommand.""" +import logging +import os +import shutil +import sys +import tarfile + +from .. import Project +from ..returncodes import ASReturnCodes + + +SUBCOMMAND = 'arsim' +HELP = 'Build (optional) and create an ARsim package for an AS project.' + + +def add_subparser(subparsers): + p = subparsers.add_parser(SUBCOMMAND, help=HELP, description=HELP) + p.add_argument('project', type=str, help='AS project you want to build') + p.add_argument('-c', '--configuration', nargs='+', type=str, required=True, + help='AS configuration(s) you want to build') + p.add_argument('-bm', '--buildMode', type=str, default='None', + choices=['Rebuild', 'Build', 'BuildAndTransfer', 'BuildAndCreateCompactFlash', 'None']) + p.add_argument('-ss', '--startSim', action='store_true', + help='Start ARsim after creation') + p.add_argument('-uf', '--userFiles', type=str, default='', + help='Path to folder with user partition files to include in the simulator') + p.add_argument('-hf', '--hmiFiles', type=str, default='', + help='Path to folder with HMI files to include in the simulator') + p.set_defaults(func=run) + return p + + +def run(args) -> int: + logging.debug('args: %s', args) + project = Project(args.project) + + if args.buildMode != 'None': + for config in args.configuration: + buildStatus = project.build(config, buildMode=args.buildMode, simulation=True) + if buildStatus.returncode > ASReturnCodes['Warnings']: + sys.exit(f'Build failed for config {config}') + logging.debug('Building of %s Complete!', config) + + for config in args.configuration: + destination = os.path.join(project.tempPath, 'SIM', config) + sim_root = os.path.join(project.tempPath, 'SIM') + if not os.path.isdir(sim_root): + os.mkdir(sim_root) + if os.path.isdir(destination): + shutil.rmtree(destination) + os.mkdir(destination) + + project.createSim(config, destination=destination, startSim=args.startSim) + + if args.userFiles != '': + shutil.copytree(args.userFiles, os.path.join(destination, 'ARSimUser')) + if args.hmiFiles != '': + shutil.copytree(args.hmiFiles, os.path.join(destination, 'HMI'), + ignore=shutil.ignore_patterns('node_modules')) + + 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() + + return 0 diff --git a/aspython/cli/build.py b/aspython/cli/build.py new file mode 100644 index 0000000..a887171 --- /dev/null +++ b/aspython/cli/build.py @@ -0,0 +1,68 @@ +"""``aspython build`` subcommand.""" +import logging +import os +import shutil +import sys +import tarfile + +from .. import Project +from ..returncodes import ASReturnCodes + + +SUBCOMMAND = 'build' +HELP = 'Build one or more configurations of an AS project.' + + +def add_subparser(subparsers): + p = subparsers.add_parser(SUBCOMMAND, help=HELP, description=HELP) + p.add_argument('project', type=str, help='Path to AS project you want to build') + p.add_argument('-c', '--configuration', nargs='+', type=str, required=True, + help='AS configuration(s) you want to build') + p.add_argument('-bm', '--buildMode', type=str, default='Build', + choices=['Rebuild', 'Build', 'BuildAndTransfer', 'BuildAndCreateCompactFlash', 'None'], + help='Type of build in AS') + p.add_argument('-rp', '--buildRUCPackage', action='store_false', + help='Disable building the RUCPackage') + p.add_argument('-sim', '--simulation', action='store_true', + help='Build for simulation') + p.add_argument('-pip', action='store_true', + help='Generate a PIP after the build completes') + p.set_defaults(func=run) + return p + + +def run(args) -> int: + logging.debug('The project to be built is: %s', args.project) + logging.debug('Config(s): %s, mode: %s, RUC: %s, sim: %s', + args.configuration, args.buildMode, args.buildRUCPackage, args.simulation) + + project = Project(os.path.abspath(args.project)) + + for config in args.configuration: + buildStatus = project.build( + config, buildMode=args.buildMode, + buildRUCPackage=args.buildRUCPackage, simulation=args.simulation, + ) + + if buildStatus.returncode > ASReturnCodes['Warnings']: + sys.exit(f'Build failed for config {config}') + + if args.pip: + destination = os.path.join(project.tempPath, 'PIP', config) + pip_root = os.path.join(project.tempPath, 'PIP') + if not os.path.isdir(pip_root): + os.mkdir(pip_root) + if os.path.isdir(destination): + shutil.rmtree(destination) + os.mkdir(destination) + project.createPIP(config, destination) + + 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) + + return 0 diff --git a/aspython/cli/deploy_libs.py b/aspython/cli/deploy_libs.py new file mode 100644 index 0000000..57326ff --- /dev/null +++ b/aspython/cli/deploy_libs.py @@ -0,0 +1,34 @@ +"""``aspython deploy-libs`` subcommand.""" +import logging +import os + +from .. import SwDeploymentTable + + +SUBCOMMAND = 'deploy-libs' +HELP = 'Deploy libraries to a cpu.sw deployment table.' + + +def add_subparser(subparsers): + p = subparsers.add_parser(SUBCOMMAND, help=HELP, description=HELP) + p.add_argument('-d', '--deploymentFile', type=str, required=True, + help='Path to the cpu.sw file') + p.add_argument('-lf', '--libraryFolder', type=str, required=True, + help='Path to the folder that holds the libraries') + p.add_argument('-lib', '--libraries', nargs='+', type=str, + help='Libraries to deploy (default: every library in the folder)') + p.set_defaults(func=run) + return p + + +def run(args) -> int: + logging.debug('Updating: %s | libs: %s', args.deploymentFile, args.libraries) + deploymentTable = SwDeploymentTable(args.deploymentFile) + if not args.libraries: + for library in os.listdir(args.libraryFolder): + if library != 'Package.pkg': + deploymentTable.deployLibrary(args.libraryFolder, library) + else: + for library in args.libraries: + deploymentTable.deployLibrary(args.libraryFolder, library) + return 0 diff --git a/aspython/cli/export_libs.py b/aspython/cli/export_libs.py new file mode 100644 index 0000000..5926fc1 --- /dev/null +++ b/aspython/cli/export_libs.py @@ -0,0 +1,92 @@ +"""``aspython export-libs`` subcommand.""" +import logging +import os.path +import shutil +import sys +from typing import List + +from .. import Project, Library +from ..returncodes import ASReturnCodes + + +SUBCOMMAND = 'export-libs' +HELP = 'Export libraries from an AS project (binary or source).' + + +def add_subparser(subparsers): + p = subparsers.add_parser(SUBCOMMAND, help=HELP, description=HELP) + p.add_argument('project', type=str, help='Path to AS project') + p.add_argument('-dest', '--destination', type=str, + help='Destination path for exported libraries') + p.add_argument('-c', '--configuration', nargs='+', type=str, required=True, + help='AS configuration(s)') + p.add_argument('-wl', '--whitelist', type=str, nargs='+', default=[], + help='Desired libraries (trumps the blacklist)') + p.add_argument('-bl', '--blacklist', type=str, nargs='+', default=[], + help='Ignored libraries (glob style: *myLibName*)') + p.add_argument('-o', '--overwrite', action='store_true', + help='Overwrite previously-exported libraries') + p.add_argument('-source', '--sourceFile', action='store_true', + help='Export libraries as source') + p.add_argument('-bm', '--buildMode', type=str, default='None', + choices=['Rebuild', 'Build', 'BuildAndTransfer', 'BuildAndCreateCompactFlash', 'None'], + help='Type of build in AS') + p.add_argument('-iv', '--includeVersion', action='store_true', + help='Include version number in folder structure') + p.set_defaults(func=run) + return p + + +def run(args) -> int: + buildStatus = None + libBuildConfig: List = [] + + logging.debug('Project: %s | configs: %s | overwrite=%s | source=%s | iv=%s | bm=%s', + args.project, args.configuration, args.overwrite, args.sourceFile, + args.includeVersion, args.buildMode) + logging.debug('whitelist=%s blacklist=%s', args.whitelist, args.blacklist) + + if args.destination is None: + args.destination = os.path.join(os.path.dirname(args.project), '..', 'Exports') + logging.debug('Export destination: %s', args.destination) + + project = Project(args.project) + + for buildConfig in project.buildConfigs: + if args.configuration is not None and buildConfig.name in args.configuration: + libBuildConfig.append(buildConfig) + + if not libBuildConfig: + logging.error('\033[31mNot a configuration in specified project: %s\033[0m', str(args.configuration)) + sys.exit('Configuration passed in is not part of AS project') + + libBuildConfigNames = [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 > ASReturnCodes['Warnings']: + sys.exit(f'Build failed for config {config}') + logging.debug('Building of %s Complete!', config) + + if args.buildMode == 'None' or buildStatus.returncode <= 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, + ) + for result in results.failed: + logging.error('\033[31mFailed to export %s to %s because %s\033[0m', + result.name, result.path, result.exception) + try: + shutil.rmtree(result.path, onerror=Library._rmtreeOnError) + except FileNotFoundError: + logging.debug('Failed to delete failed export lib, does not exist: %s', result.path) + except Exception: + logging.exception('Failed to delete: %s', result.path) + + logging.info('Export Complete!') + return 0 diff --git a/aspython/cli/installer.py b/aspython/cli/installer.py new file mode 100644 index 0000000..da12382 --- /dev/null +++ b/aspython/cli/installer.py @@ -0,0 +1,38 @@ +"""``aspython installer`` subcommand — generate an Inno Setup installer.""" +from ..installer import compileInstaller, generateGUID + + +SUBCOMMAND = 'installer' +HELP = 'Generate an Inno Setup installer (.exe) from an .iss script.' + + +def add_subparser(subparsers): + p = subparsers.add_parser(SUBCOMMAND, help=HELP, description=HELP) + p.add_argument('script', type=str, help='Name of the iss script to compile') + p.add_argument('-o', '--output', type=str, required=True, + help='Destination folder for the installer') + p.add_argument('-an', '--appName', type=str, required=True, + help='Name of the app to create') + p.add_argument('-av', '--appVersion', type=str, default='1.0.0', + help='Version of the app to create') + p.add_argument('-ap', '--appPublisher', type=str, default='Loupe', + help='Name of the app publisher') + p.add_argument('-au', '--appUrl', type=str, default='https://loupe.team', + help='URL of the app publisher') + p.add_argument('-sd', '--simDir', type=str, + help='Directory where Simulation assets are located') + p.add_argument('-ud', '--userDir', type=str, + help='Directory where User Partition assets are located') + p.add_argument('-jb', '--junctionBatch', type=str, default='ConnectFileDevice.bat', + help='Name of the Junction Batch file') + p.add_argument('-hd', '--hmiDir', type=str, + help='Directory where HMI assets are located') + p.add_argument('-he', '--hmiExe', type=str, help='Name of the HMI EXE file') + p.set_defaults(func=run) + return p + + +def run(args) -> int: + GUID = generateGUID() + compileInstaller(args, GUID) + return 0 diff --git a/aspython/cli/main.py b/aspython/cli/main.py new file mode 100644 index 0000000..8129ebb --- /dev/null +++ b/aspython/cli/main.py @@ -0,0 +1,72 @@ +"""Root ``aspython`` CLI entry point. + +Usage:: + + aspython [options...] + aspython --help + aspython --help +""" +import argparse +import sys +from typing import List, Optional + +from .. import __version__ +from ..logging_setup import add_log_level_argument, setup_logging + +from . import ( + arsim as _arsim, + build as _build, + deploy_libs as _deploy_libs, + export_libs as _export_libs, + installer as _installer, + package_hmi as _package_hmi, + run_tests as _run_tests, + safety_crc as _safety_crc, + version as _version_cmd, +) + + +SUBCOMMAND_MODULES = ( + _build, + _arsim, + _export_libs, + _deploy_libs, + _safety_crc, + _version_cmd, + _installer, + _package_hmi, + _run_tests, +) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog='aspython', + description='Python toolkit for B&R Automation Studio projects.', + ) + parser.add_argument('-v', '--version', action='version', + version=f'%(prog)s {__version__}') + add_log_level_argument(parser) + + subparsers = parser.add_subparsers(dest='command', metavar='') + subparsers.required = True + + for module in SUBCOMMAND_MODULES: + sub = module.add_subparser(subparsers) + # Mirror the per-script flags from the legacy CmdLine*.py wrappers so users can + # write either ``aspython -l DEBUG `` or ``aspython -l DEBUG``. + add_log_level_argument(sub) + + return parser + + +def main(argv: Optional[List[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + setup_logging(args.logLevel) + rc = args.func(args) + return rc if isinstance(rc, int) else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/aspython/cli/package_hmi.py b/aspython/cli/package_hmi.py new file mode 100644 index 0000000..09adc3c --- /dev/null +++ b/aspython/cli/package_hmi.py @@ -0,0 +1,41 @@ +"""``aspython package-hmi`` subcommand — package a Loupe UX HMI.""" +import logging + +from ..hmi import installDependencies, installElectronPackager, packageHMI, updateAppVersion + + +SUBCOMMAND = 'package-hmi' +HELP = 'Package a Loupe UX-based HMI via electron-packager.' + + +def add_subparser(subparsers): + p = subparsers.add_parser(SUBCOMMAND, help=HELP, description=HELP) + p.add_argument('-s', '--source', type=str, required=True, + help='Source folder where the HMI files (main package.json) are located') + p.add_argument('-o', '--output', type=str, required=True, + help='Destination folder for the packaged files') + p.add_argument('-an', '--appName', type=str, required=True, + help='Name of the app to package') + p.add_argument('-av', '--appVersion', type=str, default='1.0.0', + help='Version of the app to create') + p.add_argument('-ap', '--appPublisher', type=str, default='Loupe', + help='Name of the app publisher') + p.add_argument('--installElectronPackager', dest='installElectronPackager', action='store_true', + help='Install electron-packager before packaging') + p.set_defaults(func=run) + return p + + +def run(args) -> int: + installDependencies(args.source) + try: + installDependencies(args.source + '/public') + except Exception: + logging.info('No public sub-folder found, skipping its dependency installation') + + if args.installElectronPackager: + installElectronPackager() + + appSemanticVersion = updateAppVersion(args.source, args.appVersion) + packageHMI(args.source, args.appName, args.output, args.appPublisher, appSemanticVersion) + return 0 diff --git a/aspython/cli/run_tests.py b/aspython/cli/run_tests.py new file mode 100644 index 0000000..df57d6b --- /dev/null +++ b/aspython/cli/run_tests.py @@ -0,0 +1,34 @@ +"""``aspython run-tests`` subcommand — query an on-PLC unit test server.""" +import logging + +from ..unittests import UnitTestServer + + +SUBCOMMAND = 'run-tests' +HELP = 'Run unit tests against a PLC test server.' + + +def add_subparser(subparsers): + p = subparsers.add_parser(SUBCOMMAND, help=HELP, description=HELP) + p.add_argument('host', type=str, help='IP address of the PLC running the tests') + p.add_argument('-d', '--destination', type=str, required=True, + help='Destination directory for the test result XML files') + p.add_argument('-a', '--all', action='store_true', + help='Run all available tests') + p.set_defaults(func=run) + return p + + +def run(args) -> int: + logging.debug('args: %s', args) + logging.info('Querying test server to retrieve list of available tests') + + testServer = UnitTestServer(args.host, args.destination) + if not testServer.connected: + logging.error('Could not connect to the test server') + return 1 + + for testSuite in testServer.testSuites: + logging.info(f'Running test suite {testSuite["device"]}') + testServer.runTest(testSuite['device']) + return 0 diff --git a/aspython/cli/safety_crc.py b/aspython/cli/safety_crc.py new file mode 100644 index 0000000..764319d --- /dev/null +++ b/aspython/cli/safety_crc.py @@ -0,0 +1,34 @@ +"""``aspython safety-crc`` subcommand.""" +import os.path +import sys + +from .. import Project + + +SUBCOMMAND = 'safety-crc' +HELP = 'Retrieve the CRC value of a B&R Safe Application.' + + +def add_subparser(subparsers): + p = subparsers.add_parser(SUBCOMMAND, help=HELP, description=HELP) + p.add_argument('project', type=str, help='Path to AS project') + p.add_argument('-c', '--configuration', nargs='+', type=str, required=True, + help='AS configuration(s)') + p.add_argument('-sa', '--safeApp', type=str, required=True, + help='Location of the safe application binaries') + p.set_defaults(func=run) + return p + + +def run(args) -> int: + project = Project(args.project) + + 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))] + + splitSafetyApp = args.safeApp.split('.') + relativePath = os.path.join('Physical', args.configuration[0], plcDirectory[0], + 'MappSafety', splitSafetyApp[0], 'C', 'PLC', 'R', 'CPU', 'CPU.ini') + sys.stdout.write(project.getIniValue(relativePath, 'CRC', 'PROJECT')) + return 0 diff --git a/aspython/cli/version.py b/aspython/cli/version.py new file mode 100644 index 0000000..9fad104 --- /dev/null +++ b/aspython/cli/version.py @@ -0,0 +1,39 @@ +"""``aspython version`` subcommand — read project build version from a .var file.""" +import re +import sys + +from .. import Project + + +SUBCOMMAND = 'version' +HELP = "Retrieve a project's build version (versionId) from a .var file." + + +def add_subparser(subparsers): + p = subparsers.add_parser(SUBCOMMAND, help=HELP, description=HELP) + p.add_argument('project', type=str, help='Path to AS project') + p.add_argument('-bi', '--buildInfo', type=str, required=True, + help='Location of the buildInfo .var file') + p.add_argument('--semver', dest='semVer', action='store_true', + help='Return the version in Semantic Version format') + p.set_defaults(func=run) + return p + + +def run(args) -> int: + project = Project(args.project) + versionId = project.getConstantValue(args.buildInfo, 'versionId') + + if args.semVer: + try: + match = re.search(r'(\d+\.\d+\.\d+).*-(\d+)-.*', versionId) + versionId = match.group(1) + if match.group(2) != '': + versionId = versionId + '.' + match.group(2) + else: + versionId = versionId + '.0' + except Exception: + versionId = '0.0.0.0' + + sys.stdout.write(versionId) + return 0 diff --git a/aspython/cnc.py b/aspython/cnc.py new file mode 100644 index 0000000..45fe055 --- /dev/null +++ b/aspython/cnc.py @@ -0,0 +1,22 @@ +"""AS CNC configuration helpers (requires the optional ``lxml`` dependency).""" + +try: # pragma: no cover - import guard + import lxml.etree as ET +except ModuleNotFoundError: # pragma: no cover - import guard + ET = None + + +def listOfProcs(tree, include_comments: bool = False): + if ET is None: + raise ModuleNotFoundError( + "aspython.cnc requires the optional 'lxml' dependency. Install it with " + "'pip install lxml'." + ) + 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: + procs.append(child.getprevious()) + procs.append(child) + return procs diff --git a/aspython/config.py b/aspython/config.py new file mode 100644 index 0000000..5e1f034 --- /dev/null +++ b/aspython/config.py @@ -0,0 +1,38 @@ +"""B&R AS CPU configuration (Cpu.pkg / Configuration).""" +import os.path + +from .xml_base import xmlAsFile + + +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): + return self.buildElement.attrib.get('GccVersion') + + def setGccVersion(self, value): + self.buildElement.attrib['GccVersion'] = value + self.write() + + def getPreBuildStep(self): + return self.buildElement.attrib.get('PreBuildStep') + + def setPreBuildStep(self, value): + self.buildElement.attrib['PreBuildStep'] = value + self.write() + + def getArVersion(self): + return self.arElement.attrib.get('Version') + + def setArVersion(self, value): + self.arElement.attrib['Version'] = value + self.write() + + gccVersion = property(getGccVersion, setGccVersion) + preBuildStep = property(getPreBuildStep, setPreBuildStep) + arVersion = property(getArVersion, setArVersion) diff --git a/aspython/deployment.py b/aspython/deployment.py new file mode 100644 index 0000000..b3b9b83 --- /dev/null +++ b/aspython/deployment.py @@ -0,0 +1,108 @@ +"""B&R AS software deployment table (cpu.sw).""" +import os +import os.path +import xml.etree.ElementTree as ET +from typing import List + +from .paths import getActualPathFromLogicalPath, getLibraryPathInPackage, getLibraryType +from .task import Task +from .xml_base import xmlAsFile + + +class SwDeploymentTable(xmlAsFile): + def __init__(self, path: str): + if os.path.isfile(path): + self.path = path + super().__init__(path) + for i in range(8): + tc = self.find(f"TaskClass[@Name='Cyclic#{i+1}']") + if tc is None: + tc = self._addRootLevelElement('TaskClass', i, {"Name": f"Cyclic#{i+1}"}) + lib = self.find('Libraries') + if lib is None: + lib = self._addRootLevelElement('Libraries') + self.read() + + def deployLibrary(self, libraryFolder, library, attributes=None): + if attributes is None: + attributes = {} + obj = self.find('Libraries') + for lib in self.libraries: + if lib.lower() == library.lower(): + return + element = self._createLibraryElement(libraryFolder, library, attributeOverrides=attributes) + obj.append(element) + self.write() + + def deployTask(self, taskFolder, taskName, taskClass): + cyclicName = "Cyclic#" + [s for s in str(taskClass) if s.isdigit()][0] + tc = self.find(f"TaskClass[@Name='{cyclicName}']") + preexistingTask = self.find(f"TaskClass[@Name='{cyclicName}']", "Task[@Name='" + taskName[:10] + "']") + if preexistingTask is not None: + return + element = self._createTaskElement(taskFolder, taskName) + tc.append(element) + self.write() + + def _createLibraryElement(self, libraryFolder, name, memory: str = 'UserROM', attributeOverrides=None) -> ET.Element: + if attributeOverrides is None: + attributeOverrides = {} + lbyPath = getLibraryPathInPackage(libraryFolder, name) + language = getLibraryType(lbyPath) + splitPath = os.path.split(libraryFolder) + parentFolder = splitPath[-1] + attributes = { + 'Name': name, + 'Source': '.'.join(('Libraries', parentFolder, name, 'lby')), + 'Memory': memory, + 'Language': language, + 'Debugging': 'true', + } + for attributeName in attributeOverrides: + attributes[attributeName] = attributeOverrides[attributeName] + element = ET.Element('LibraryObject', attrib=attributes) + element.tail = "\n" + return element + + def _createTaskElement(self, taskFolder, taskName, memory: str = 'UserROM') -> ET.Element: + actualTaskFolderPath = getActualPathFromLogicalPath(taskFolder) + prgPath = os.path.join(actualTaskFolderPath, taskName) + task = Task(prgPath) + language = task.type + splitPath = os.path.normpath(taskFolder).split(os.sep) + for i, part in enumerate(splitPath): + if part.lower() == "logical": + splitPath = splitPath[i + 1:] + break + splitPath.append(taskName) + splitPath.append('prg') + attributes = { + 'Name': taskName[:10], + 'Source': '.'.join(splitPath), + 'Memory': memory, + 'Language': language, + 'Debugging': 'true', + } + element = ET.Element('Task', attrib=attributes) + element.tail = "\n" + return element + + def _addLibrariesElement(self): + self._addRootLevelElement('Libraries') + self.read() + return self.find('Libraries') + + def _addRootLevelElement(self, name, index=None, attributes=None): + if attributes is 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: + return [element.get('Name', 'Unknown') for element in self.findall('Libraries', 'LibraryObject')] diff --git a/aspython/hmi.py b/aspython/hmi.py new file mode 100644 index 0000000..e6e4836 --- /dev/null +++ b/aspython/hmi.py @@ -0,0 +1,43 @@ +"""HMI packaging via electron-packager.""" +import json +import logging +import os +import subprocess + + +def installDependencies(source: str) -> None: + os.chdir(source) + if 'package.json' not in os.listdir('.'): + logging.info('The source directory does not contain a package.json, skipping install') + else: + subprocess.run('npm install', encoding='utf-8', errors='replace', shell=True) + + +def installElectronPackager() -> None: + subprocess.run('npm install electron-packager -g', encoding='utf-8', errors='replace', shell=True) + + +def updateAppVersion(source: str, version: str) -> str: + with open(source + '/package.json', 'r+') as f: + data = json.load(f) + data['version'] = version + f.seek(0) + json.dump(data, f, indent=4) + f.truncate() + return version + + +def packageHMI(source: str, appName: str, output: str, appPublisher: str, appVersion: str) -> None: + command = [ + 'electron-packager', + source, + appName, + '--platform=win32', + '--arch=x64', + f'--out={output}', + '--overwrite', + f'--win32metadata.CompanyName={appPublisher}', + f'--win32metadata.FileDescription=Build #{appVersion}', + ] + logging.info(command) + subprocess.run(command, encoding='utf-8', shell=False) diff --git a/aspython/installer.py b/aspython/installer.py new file mode 100644 index 0000000..154b371 --- /dev/null +++ b/aspython/installer.py @@ -0,0 +1,56 @@ +"""Inno Setup (.iss) installer compilation.""" +import logging +import subprocess +import uuid + + +ISCC_PATH = r"C:\Program Files (x86)\Inno Setup 6\iscc" + + +def generateGUID() -> str: + return '{{' + str(uuid.uuid4()) + '}' + + +def compileInstaller(args, GUID: str) -> subprocess.CompletedProcess: + """Compile an Inno Setup script using ``iscc``. + + ``args`` is the parsed argparse Namespace from ``aspython installer`` (or the legacy + ``CmdLineCreateInstaller.py``). All assembly of /d-defines from app/sim/user/HMI options + happens here. + """ + command = [ + ISCC_PATH, + args.script, + f"/O{args.output}", + f"/dAppName={args.appName}", + f"/dAppVersion={args.appVersion}", + f"/dAppPublisher={args.appPublisher}", + f"/dAppUrl={args.appUrl}", + f"/dAppGUID={GUID}", + ] + + if args.simDir: + command.append("/dIncludeSimulator=yes") + command.append(f"/dSimulationDirectory={args.simDir}") + else: + command.append("/dIncludeSimulator=no") + + if args.userDir: + command.append("/dIncludeUserPartition=yes") + command.append(f"/dUserPartitionDirectory={args.userDir}") + command.append(f"/dJunctionBatchFilename={args.junctionBatch}") + else: + command.append("/dIncludeUserPartition=no") + + if args.hmiDir: + command.append("/dIncludeHmi=yes") + command.append(f"/dHmiDirectory={args.hmiDir}") + command.append(f"/dHmiExeName={args.hmiExe}") + else: + command.append("/dIncludeHmi=no") + + if getattr(args, 'logLevel', '') != 'DEBUG': + command.append("/Qp") + + logging.debug(command) + return subprocess.run(command, encoding="utf-8", errors='replace', shell=True) diff --git a/aspython/library.py b/aspython/library.py new file mode 100644 index 0000000..6581d7f --- /dev/null +++ b/aspython/library.py @@ -0,0 +1,248 @@ +"""B&R AS Library (.lby) representation.""" +import logging +import os +import os.path +import pathlib +import shutil +import xml.etree.ElementTree as ET +from typing import List, Union + +from .models import BuildConfig, Dependency, LibExportInfo +from .paths import getLibraryType, getProgramType, getPkgType +from .xml_base import xmlAsFile + + +class Library(xmlAsFile): + '''Represents a single ``.lby`` library 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)) + self._dependencies: List[Dependency] = [] + super().__init__(path) + self._xmlTag = self._getXmlTag(self.package) + self._xmlTagChild = self._xmlTag[:-1] + + @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( + name=element.get('ObjectName', 'Unknown'), + minVersion=element.get('FromVersion', ''), + maxVersion=element.get('ToVersion', ''), + ) + ) + return self._dependencies + + @property + def dependencyNames(self) -> List[str]: + return [dep.name for dep in self.dependencies] + + @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): + for path in paths: + if not os.path.isfile(path) and not os.path.isdir(path): + raise FileNotFoundError(path) + name = os.path.split(path)[1] + newPath = os.path.join(self.dirPath, 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): + deps_container = self.find('Dependencies') + if deps_container is None: + deps_container = ET.SubElement(self.root, 'Dependencies') + for dependent in dependency: + if not isinstance(dependent, Dependency): + raise TypeError('Expected Dependency class got', type(dependent)) + deps_container.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) + 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 + items = list(os.listdir(self.dirPath)) + usedItems = [] + toRemove = [] + + for obj in objects: + if obj.text not in items: + 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'): + self._addObjectElement(os.path.join(self.dirPath, item)) + + self.write() + + def _convertXmlTag(self, fromTag: str, toTag: str): + childTag = toTag[:-1] + for elem in self.findall(fromTag): + elem.tag = self.nameSpaceFormatted + toTag + for child in elem: + child.tag = self.nameSpaceFormatted + childTag + if toTag == 'Objects': + 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' + builds = {} + for build in buildConfigs: + if builds.get(build.type) is None: + builds[build.type] = build + + self._collectSourceLibrary( + self.dirPath, dest, + ['.c', '.st', '.cpp', '.git', '.vscode', '.gitignore', 'jenkinsfile', 'CMakeLists.txt'], + True, + ) + + if builds.get("sg4") is not None: + self._collectConfigBinary(buildFolder, builds["sg4"], self.name, os.path.join(dest, 'SG4')) + if builds.get("sg4_arm") is not None: + self._collectConfigBinary(buildFolder, builds["sg4_arm"], self.name, os.path.join(dest, 'SG4', 'Arm')) + + 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() + + @staticmethod + def _formatVersionString(version: str) -> str: + return '.'.join(str(int(x)) for x in version.split(sep='.')) + + @staticmethod + def _createPkgElement(path: str, tag: str) -> ET.Element: + 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" + return element + + @staticmethod + def _createDependencyElement(dependency: Dependency): + 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' + + @staticmethod + def _rmtreeOnError(func, path, exc_info): + '''Error handler for ``shutil.rmtree`` that retries on access errors.''' + import stat + if not os.access(path, os.W_OK): + 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.''' + if excludes is None: + excludes = [] + + def _ignorePatterns(path, names): + ignores = [] + for name in names: + for item in excludes: + if name.lower().endswith(item.lower()): + ignores.append(name) + if ignoreFolders and os.path.isdir(os.path.join(path, name)): + ignores.append(name) + return ignores + + shutil.copytree(sourceFolder, dest, ignore=_ignorePatterns) + + @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) + shutil.copy2(os.path.join(tempPath, 'Objects', config.name, config.hardware, libraryName + '.br'), dest) + shutil.copy2(os.path.join(tempPath, 'Includes', libraryName + '.h'), dest) + shutil.copy2(os.path.join(tempPath, 'Archives', config.name, config.hardware, 'lib' + libraryName + '.a'), dest) + + @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) + validExtensions = ['fun', 'lby', 'var', 'typ', 'md'] + for item in os.listdir(sourceFolder): + extension = item.split('.')[-1] + if extension in validExtensions: + shutil.copy(os.path.join(sourceFolder, item), dest) diff --git a/aspython/logging_setup.py b/aspython/logging_setup.py new file mode 100644 index 0000000..c242077 --- /dev/null +++ b/aspython/logging_setup.py @@ -0,0 +1,42 @@ +"""Centralised logging + console-mode setup for ASPython CLIs. + +Replaces the duplicated ``logging.basicConfig`` + ``ctypes.windll.kernel32`` calls in every +``CmdLine*.py`` script. On non-Windows the console-mode call is a no-op. +""" +import ctypes +import logging +import sys +from typing import Optional + + +_LOG_LEVELS = ('DEBUG', 'INFO', 'WARNING', 'ERROR') + + +def _enable_windows_ansi() -> None: + if sys.platform != 'win32': + return + try: + kernel32 = ctypes.windll.kernel32 + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + except Exception: + # Some environments (e.g. running under non-conhost terminals) may not support this. + pass + + +def setup_logging(level: Optional[str] = None, default_level: int = logging.INFO) -> None: + """Configure the root logger and enable ANSI escape sequences on Windows consoles.""" + logging.basicConfig(stream=sys.stderr, level=default_level) + if level: + upper = level.upper() + if upper not in _LOG_LEVELS: + raise ValueError(f'Invalid log level: {level}') + logging.getLogger().setLevel(getattr(logging, upper)) + _enable_windows_ansi() + + +def add_log_level_argument(parser) -> None: + """Add the standard ``-l/--logLevel`` flag used by every legacy ``CmdLine*`` script.""" + parser.add_argument( + '-l', '--logLevel', type=str.upper, + help='Log level', choices=list(_LOG_LEVELS), default='', + ) diff --git a/aspython/models.py b/aspython/models.py new file mode 100644 index 0000000..9ec8542 --- /dev/null +++ b/aspython/models.py @@ -0,0 +1,58 @@ +"""Domain value objects (build configurations, library export results, dependencies).""" +from dataclasses import dataclass, field +from typing import List, Optional + + +@dataclass +class LibExportInfo: + name: str + path: str + exception: Optional[BaseException] = None + lib: Optional[object] = None + + +@dataclass +class ProjectExportInfo: + _success: List[LibExportInfo] = field(default_factory=list) + _failed: List[LibExportInfo] = field(default_factory=list) + + def addLibInfo(self, libInfo: LibExportInfo) -> None: + if libInfo.exception is None: + self._success.append(libInfo) + else: + self._failed.append(libInfo) + + def extend(self, *exportInfo: "ProjectExportInfo") -> None: + 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 + + +@dataclass +class Dependency: + name: str + minVersion: str = '' + maxVersion: str = '' + + +@dataclass +class BuildConfig: + name: str + path: str = '' + type: str = 'sg4' + hardware: str = '' + + # Backwards compat: original constructor signature used kwarg `typ`. + def __init__(self, name: str, path: str = '', typ: str = 'sg4', hardware: str = '') -> None: + self.name = name + self.path = path + self.type = typ + self.hardware = hardware diff --git a/aspython/package.py b/aspython/package.py new file mode 100644 index 0000000..c9cc80f --- /dev/null +++ b/aspython/package.py @@ -0,0 +1,116 @@ +"""B&R AS Package (Package.pkg) representation.""" +import os +import os.path +import shutil +import xml.etree.ElementTree as ET + +from .paths import getLibraryType, getProgramType, getPkgType +from .xml_base import xmlAsFile + + +class Package(xmlAsFile): + def __init__(self, path: str, new_pkg: bool = 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') + ET.SubElement(package_element, 'Objects') + tree = ET.ElementTree(package_element) + if new_pkg: + super().__init__(path, tree) + else: + super().__init__(path) + + def synchPackageFile(self): + items = list(os.listdir(self.dirPath)) + objsText = {} + + for element in self.objects: + if element.text not in items: + self._removePkgObject(element.text) + else: + objsText[element.text] = element + + for item in items: + if item == os.path.split(self.path)[1]: + continue + 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 os.path.isfile(path): + shutil.copyfile(path, newPath) + else: + shutil.copytree(path, newPath) + return self._addPkgObject(newPath) + + def addEmptyPackage(self, name): + full_path = self.dirPath + '/' + name + os.mkdir(full_path) + self._addPkgObject(full_path) + 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) + if os.path.isdir(path_to_remove): + shutil.rmtree(path_to_remove) + elif os.path.isfile(path_to_remove): + os.remove(path_to_remove) + self._removePkgObject(name) + + 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: bool = False, element: ET.Element = None) -> ET.Element: + if element is None: + element = self._createElement(path, reference=reference) + obj = self.find('Objects') + obj.append(element) + self.write() + return element + + @staticmethod + def _createElement(path: str, reference: bool = False) -> ET.Element: + if path is None: + raise FileNotFoundError(path) + attributes = {} + attributes['Type'] = getPkgType(path) + if attributes['Type'] == 'Library': + attributes['Language'] = getLibraryType(path) + if attributes['Type'] == 'Program': + attributes['Language'] = getProgramType(path) + if reference: + attributes['Reference'] = "true" + + element = ET.Element('Object', attrib=attributes) + if reference: + if os.path.isabs(path): + element.text = os.path.abspath(path) + else: + element.text = os.path.normpath(os.path.join('\\', path)) + else: + element.text = os.path.basename(path) + element.tail = "\n" + return element + + @property + def objects(self): + return self.find('Objects') + + @property + def objectList(self): + return self.findall('Objects', 'Object') diff --git a/aspython/paths.py b/aspython/paths.py new file mode 100644 index 0000000..43d8478 --- /dev/null +++ b/aspython/paths.py @@ -0,0 +1,173 @@ +"""B&R Automation Studio path helpers and AS-style path conversions.""" +import os.path +from typing import Optional + + +# Candidate base directories where B&R Automation Studio may be installed. +# AS <= 4.x defaults to C:\BrAutomation. AS 6 changed the default to +# C:\Program Files (x86)\BRAutomation (note: no space in "BRAutomation"). +_AS_BASE_CANDIDATES = [ + "C:\\BrAutomation", + "C:\\Program Files (x86)\\BRAutomation", + "C:\\Program Files\\BRAutomation", +] + + +def _findASBase(version: str = '') -> str: + """Return the base BrAutomation directory. + + If a specific version is provided, prefer a base that actually contains + a folder for that version. Otherwise return the first base that exists. + Falls back to the legacy default 'C:\\BrAutomation' if none are found. + """ + if version and version.lower() != 'base': + for base in _AS_BASE_CANDIDATES: + if os.path.isdir(os.path.join(base, version.upper())): + return base + for base in _AS_BASE_CANDIDATES: + if os.path.isdir(base): + return base + return _AS_BASE_CANDIDATES[0] + + +def getASPath(version: str) -> str: + base = _findASBase(version) + if version.lower() == 'base': + return base + return os.path.join(base, version.upper(), 'Bin-en') + + +def getASBuildPath(version: str) -> str: + if version.lower() == 'base': + return getASPath('base') + 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 getLibraryType(path: str) -> str: + if os.path.exists(os.path.join(path, 'ANSIC.lby')): + return 'ANSIC' + if os.path.exists(os.path.join(path, 'IEC.lby')): + return 'IEC' + if os.path.exists(os.path.join(path, 'Binary.lby')): + return 'Binary' + return 'None' + + +def getProgramType(path: str) -> str: + if os.path.exists(os.path.join(path, 'ANSIC.prg')): + return 'ANSIC' + if os.path.exists(os.path.join(path, 'IEC.prg')): + return 'IEC' + if os.path.exists(os.path.join(path, 'Binary.prg')): + return 'Binary' + return 'None' + + +def getPkgType(path: str) -> Optional[str]: + if not os.path.exists(path): + raise FileNotFoundError(path) + if os.path.isdir(path): + if getLibraryType(path) != 'None': + return 'Library' + if getProgramType(path) != 'None': + return 'Program' + return 'Package' + if os.path.isfile(path): + return 'File' + return None + + +def getAsPathType(path: str) -> Optional[str]: + """Returns 'relative', 'absolute', or None for an AS-style path string.""" + if not path: + return None + if path[0] == '\\' or path[0:2] == "..": + return "relative" + if path[0] == '/' or path[0:2] == "C:": + return "absolute" + return None + + +def convertAsPathToWinPath(asPath: str) -> str: + if getAsPathType(asPath) == 'relative': + return '.' + os.path.join(os.sep, os.path.normpath(asPath)) + return os.path.normpath(asPath) + + +def convertWinPathToAsPath(winPath: str) -> str: + if os.path.isabs(winPath): + return os.path.normpath(winPath) + return os.path.join('\\', os.path.normpath(winPath)) + + +def getLibraryPathInPackage(libraryPackagePath: str, libraryName: str) -> Optional[str]: + """Return the path to ``.lby`` within a Libraries package, resolving references.""" + # Local import to avoid a circular package <-> paths dependency at import time. + from .package import Package + + asPackage = Package(libraryPackagePath) + for obj in asPackage.objectList: + if obj.attrib.get('Reference', 'false') == 'true' and libraryName.lower() in obj.text.lower(): + return convertAsPathToWinPath(obj.text) + if obj.text.lower() == libraryName.lower(): + return os.path.join(libraryPackagePath, libraryName) + return None + + +def getActualPathFromLogicalPath(logicalPath: str) -> Optional[str]: + """Resolve an AS 'Logical' path on disk, walking through reference packages.""" + from .package import Package + + splitPath = os.path.normpath(logicalPath).split(os.sep) + if splitPath[0] == "": + splitPath = splitPath[1:] + currentPath = "." + for step in splitPath: + if step.lower() in [s.lower() for s in os.listdir(currentPath)]: + currentPath = os.path.join(currentPath, step) + elif 'package.pkg' in [s.lower() for s in os.listdir(currentPath)]: + currentAsPackage = Package(currentPath) + found = False + for obj in currentAsPackage.objectList: + if obj.attrib.get('Reference', '') == 'true' and step in obj.text: + currentPath = convertAsPathToWinPath(obj.text) + found = True + if not found: + return None + else: + return None + return currentPath + + +def getHardwareFolderFromConfig(configPath: str) -> str: + """Get the hardware folder name from a path to a configuration folder. + + Assumes there is a single hardware sub-folder under the configuration. + """ + return [d for d in os.listdir(configPath) if os.path.isdir(os.path.join(configPath, d))][0] + + +# Type-detection cpu prefix table (was buried inside getConfigType in the legacy module). +_SG4_ARM = 'sg4_arm' +_SG4 = 'sg4' +_CPU_TYPE_MAP = { + 'x20cp04': _SG4_ARM, + 'x20cp13': _SG4, + 'x20cp14': _SG4, + 'x20cp3': _SG4, + 'apc': _SG4, + '5pc': _SG4, +} + + +def getConfigType(config) -> str: + """Returns 'sg4' or 'sg4_arm' based on the hardware folder name.""" + for key, value in _CPU_TYPE_MAP.items(): + if config.hardware.lower().startswith(key): + return value + return _SG4 diff --git a/aspython/project.py b/aspython/project.py new file mode 100644 index 0000000..7395ce1 --- /dev/null +++ b/aspython/project.py @@ -0,0 +1,293 @@ +"""B&R AS Project (.apj) representation.""" +import configparser +import fnmatch +import logging +import os +import os.path +import re +import subprocess +import xml.etree.ElementTree as ET +from typing import List, Union + +from .build import batchBuildAsProject +from .library import Library +from .models import BuildConfig, ProjectExportInfo +from .package import Package +from .paths import ( + convertAsPathToWinPath, + getASBuildPath, + getConfigType, + getHardwareFolderFromConfig, + getPVITransferPath, +) +from .returncodes import PVIReturnCodeText +from .simulation import CreateARSimStructure +from .xml_base import xmlAsFile + + +class Project(xmlAsFile): + def __init__(self, path: str): + if os.path.isdir(path): + projectFile = [f for f in os.listdir(path) if f.endswith('.apj')][0] + path = os.path.join(path, projectFile) + + super().__init__(path) + + self.name = os.path.basename(os.path.splitext(path)[0]) + 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() + + 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'): + try: + lib = Library(os.path.join(root, name)) + self.libraries.append(lib) + except Exception: + pass + if name.endswith('.pkg'): + package = Package(os.path.join(root, name)) + objects = package.findall('Objects', 'Object') + for item in objects: + if (item.get('Type', '').lower() == 'library') and (item.get('Reference', '').lower() == 'true'): + path = convertAsPathToWinPath(item.text) + if path.startswith('.'): + path = os.path.abspath(os.path.join(os.path.dirname(self.path), path)) + lib = Library(path) + self.libraries.append(lib) + return self + + def exportLibraries(self, dest, overwrite=False, buildConfigs: List[BuildConfig] = None, + blacklist: list = None, whitelist: list = None, + binary: bool = True, includeVersion: bool = False) -> ProjectExportInfo: + if buildConfigs is None: + buildConfigs = self.buildConfigs + if whitelist is None: + whitelist = [] + if blacklist is None: + blacklist = [] + + exportLibs = [] + if len(whitelist) > 0: + whitelist = [el.lower() for el in whitelist] + for lib in self.libraries: + if lib.name.lower() in whitelist: + exportLibs.append(lib) + elif len(blacklist) > 0: + 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: + logging.info('Exporting ' + lib.name + '...') + result = lib.export(dest, self.tempPath, 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: bool = True, + includeVersion: bool = False, withDependencies: bool = True) -> ProjectExportInfo: + exportInfo = ProjectExportInfo() + if withDependencies: + 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: str = 'Build', buildRUCPackage: bool = True, + tempPath: str = '', binaryPath: str = '', simulation: bool = False, + additionalArgs: Union[str, list, tuple, None] = None): + for configName in configNames: + simulation_status = self.getHardwareParameter(configName, 'Simulation') + if simulation_status == '': + self.setHardwareParameter(configName, 'Simulation', str(int(simulation))) + elif bool(int(simulation_status)) != simulation: + self.setHardwareParameter(configName, 'Simulation', str(int(simulation))) + + return batchBuildAsProject( + self.path, getASBuildPath(self.ASVersion), configNames, buildMode, buildRUCPackage, + tempPath=tempPath, logPath=self.dirPath, binaryPath=binaryPath, simulation=simulation, + additionalArg=additionalArgs, + ) + + def createPIP(self, configName, destination): + logging.info(f'Creating PIP at {destination}') + pviVersion = self.ASVersion.replace('AS', '', 1) + pviVersion = 'V' + pviVersion[:1] + '.' + pviVersion[1:] + + config = self.getConfigByName(configName) + RUCPackagePath = os.path.join(self.binaryPath, config.name, config.hardware, 'RUCPackage', 'RUCPackage.zip') + + RUCFolderPath = os.path.dirname(RUCPackagePath) + RUCPilPath = os.path.join(RUCFolderPath, 'CreatePIP.pil') + with open(RUCPilPath, 'w+') as f: + f.write( + f'CreatePIP "{RUCPackagePath}", ' + f'"InstallMode=ForceReboot InstallRestriction=AllowPartitioning KeepPVValues=0 ' + f'ExecuteInitExit=1 IgnoreVersion=1", "Default", "SupportLegacyAR=0", ' + f'"DestinationDirectory={destination}"' + ) + + arguments = [ + os.path.join(getPVITransferPath(pviVersion), 'PVITransfer.exe'), + '-automatic', '-silent', RUCPilPath, '-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}: ' + f'{PVIReturnCodeText.get(process.returncode, "Unknown")}' + ) + return process + + def createArsim(self, *configNames, destination=None, startSim: bool = False): + '''*Deprecated* - see ``createSim``.''' + if destination is None: + destination = self.path + return self.createSim(*configNames, destination=destination, startSim=startSim) + + def createSim(self, *configNames, destination, startSim: bool = 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, + ) + + def startSim(self, configName: str, build: bool = False): + pass + + def getLibraryByName(self, libName: str): + for lib in self.libraries: + if lib.name == libName: + return lib + return None + + def getLibrariesByName(self, libNames: List[str]) -> List[Library]: + return [lib for lib in self.libraries if lib.name in libNames] + + def getConfigByName(self, configName: str) -> BuildConfig: + return next(i for i in self.buildConfigs if i.name == configName) + + def getConstantValue(self, filePath: str, varName: str): + fullFilePath = os.path.join(self.dirPath, filePath) + with open(fullFilePath, "r") as f: + fileContents = f.read() + return re.search(varName + ".*'(.*)'", fileContents).group(1) + + def getIniValue(self, filePath: str, sectionName: str, keyName: str): + 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]: + return [config.name for config in self.buildConfigs] + + @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('= 6: + version = result[0] + else: + version = ''.join(result[0:2]) + return 'AS' + version + + def getHardwareParameter(self, config, paramName) -> str: + hardwareFile = xmlAsFile(os.path.join(self.physicalPath, config, 'Hardware.hw')) + element = hardwareFile.find("Module", "Parameter[@ID='" + paramName + "']") + if element is not None: + return element.attrib['Value'] + return '' + + def setHardwareParameter(self, config, paramName, paramValue): + 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 Exception: + attributes = {'ID': paramName, 'Value': paramValue} + element = ET.Element('Parameter', attrib=attributes) + parent_map = {c: p for p in hardwareFile.package.iter() for c in p} + config_element = hardwareFile.find("Module", "Parameter[@ID='ConfigurationID']") + for key, value in parent_map.items(): + if config_element == key: + parent = value + parent_element = hardwareFile.find("Module[@Name='" + parent.attrib["Name"] + "']") + parent_element.append(element) + hardwareFile.write() + return + + def _getConfigs(self, physicalPath: str) -> List[BuildConfig]: + physical = Package(os.path.join(self.physicalPath, 'Physical.pkg')) + objects = physical.findall('Objects', 'Object') + configurations: List[BuildConfig] = [] + 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 diff --git a/aspython/returncodes.py b/aspython/returncodes.py new file mode 100644 index 0000000..e702ee1 --- /dev/null +++ b/aspython/returncodes.py @@ -0,0 +1,34 @@ +"""B&R Automation Studio build / PVI Transfer return codes.""" + +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', +} diff --git a/aspython/simulation.py b/aspython/simulation.py new file mode 100644 index 0000000..627299f --- /dev/null +++ b/aspython/simulation.py @@ -0,0 +1,43 @@ +"""ARsim structure creation via ``PVITransfer.exe``.""" +import logging +import os.path +import subprocess + +from .paths import getPVITransferPath +from .returncodes import PVIReturnCodeText + + +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 startSim: + f.write('Connection "/IF=TCPIP /SA=1", "/DA=2 /DAIP=127.0.0.1 /REPO=11160", "WT=120"') + + arguments = [ + os.path.join(getPVITransferPath(version), 'PVITransfer.exe'), + '-silent', + RUCPil, + ] + logging.info('PVI version: ' + version) + logging.debug(arguments) + process = subprocess.run(arguments) + + logging.debug(process) + if process.returncode == 0: + logging.debug('ARSim created') + if startSim: + # silent / autoclose mode does not support starting arsim, so launch it ourselves. + 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}: ' + f'{PVIReturnCodeText.get(process.returncode, "Unknown")}' + ) + return process diff --git a/aspython/task.py b/aspython/task.py new file mode 100644 index 0000000..7771c2b --- /dev/null +++ b/aspython/task.py @@ -0,0 +1,19 @@ +"""B&R AS Task (.prg) representation.""" +import os +import os.path + +from .xml_base import xmlAsFile + + +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 diff --git a/aspython/unittests.py b/aspython/unittests.py new file mode 100644 index 0000000..16f1d41 --- /dev/null +++ b/aspython/unittests.py @@ -0,0 +1,31 @@ +"""HTTP client for the on-PLC unit-test server.""" +import logging +import os.path + +import requests + + +class UnitTestServer: + def __init__(self, host: str = 'http://127.0.0.1', destination: str = './TestResults'): + self._host = host + self._destination = destination + self.connected = False + self.testSuites = [] + 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: str): + for testSuite in self.testSuites: + if testSuite['device'] == name: + r = requests.get(url=self._host + '/WsTest/' + name, params={}) + if r.status_code == 200: + with open(f'{os.path.join(self._destination, name)}.xml', 'w') as f: + f.write(r.text) diff --git a/aspython/upgrades.py b/aspython/upgrades.py new file mode 100644 index 0000000..fd57062 --- /dev/null +++ b/aspython/upgrades.py @@ -0,0 +1,26 @@ +"""B&R AS upgrade installer helpers.""" +import logging +import subprocess + + +def installBRUpgrade(upgrade: str, brPath: str, asPath: str) -> int: + commandLine = [upgrade, '-G=', '"' + brPath + '"'] + if brPath in asPath: + commandLine.extend(['-V=', '"' + asPath + '"']) + else: + commandLine.extend(['-V=', '"' + brPath + '\\' + asPath + '"']) + commandLine.append('-R') + + logging.info('Started installing upgrade ' + upgrade) + logging.info(commandLine) + + process = subprocess.run(' '.join(commandLine), shell=False, capture_output=True) + if process.returncode == 0: + logging.info('Finished install upgrade ' + upgrade) + else: + logging.error('Error while installing upgrade ' + upgrade + + ' (return code = ' + str(process.returncode) + ')') + logging.debug('stderr: ' + str(process.stderr)) + logging.debug('stdout: ' + str(process.stdout)) + + return process.returncode diff --git a/aspython/utils.py b/aspython/utils.py new file mode 100644 index 0000000..29a18f1 --- /dev/null +++ b/aspython/utils.py @@ -0,0 +1,20 @@ +"""Misc utilities.""" + + +def toDict(obj, classkey=None): + if isinstance(obj, dict): + return {k: toDict(v, classkey) for k, v in obj.items()} + if hasattr(obj, "_ast"): + return toDict(obj._ast()) + if hasattr(obj, "__iter__") and not isinstance(obj, str): + return [toDict(v, classkey) for v in obj] + if hasattr(obj, "__dict__"): + data = { + 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 + return obj diff --git a/aspython/xml_base.py b/aspython/xml_base.py new file mode 100644 index 0000000..b41c831 --- /dev/null +++ b/aspython/xml_base.py @@ -0,0 +1,106 @@ +"""Base ``xmlAsFile`` class for reading/writing AS XML files (.lby/.pkg/.apj/...).""" +import os.path +import xml.etree.ElementTree as ET +from typing import List, Optional + + +class xmlAsFile: + def __init__(self, path: str, new_data: Optional[ET.ElementTree] = None): + self.path = path + if new_data is None: + self.read() + else: + 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 processing instruction. + ns = self._getASNamespace(self.package) + ET.register_namespace('', ns) # TODO: This is an ET global effect. + self._indentXml(self.package.getroot()) + 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 (debug-only).''' + ns = self._getASNamespace(self.package) + return ns.split('/')[-1] + + @staticmethod + def _indentXml(elem: ET.Element, level: int = 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: # noqa: B020 - mirror legacy semantics; tail-fix uses last child below. + 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 + + @staticmethod + def _getASNamespaceFormatted(package: ET.ElementTree) -> str: + '''Get Automation Studio's namespace formatted as ElementTree expects.''' + ns = xmlAsFile._getASNamespace(package) + if ns != '': + ns = '{' + ns + '}' + return ns diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d8a799d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "aspython" +description = "Python toolkit for programmatic interaction with B&R Automation Studio projects" +readme = "README.md" +license = "MIT" +requires-python = ">=3.8" +authors = [{ name = "Loupe", email = "info@loupe.team" }] +dynamic = ["version"] +dependencies = [ + "requests", + "lxml" +] + +[project.optional-dependencies] +cnc = [ + "lxml", +] +dev = [ + "pytest>=7", + "ruff", + "pyinstaller", +] + +[project.scripts] +aspython = "aspython.cli.main:main" + +[project.urls] +Homepage = "https://loupe.team" +Repository = "https://github.com/loupeteam/ASPython" + +[tool.setuptools] +packages = ["aspython", "aspython.cli"] +include-package-data = true + +[tool.setuptools.dynamic] +version = { attr = "aspython._version.__version__" } + +[tool.ruff] +line-length = 120 +target-version = "py38" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/AsProject/package.json b/tests/AsProject/package.json new file mode 100644 index 0000000..d8dda8a --- /dev/null +++ b/tests/AsProject/package.json @@ -0,0 +1,24 @@ +{ + "name": "asproject", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "@loupeteam/firstinitprog": "^1.0.0", + "@loupeteam/librarybuilderproject": "^1.0.0", + "@loupeteam/stringext": "^1.0.0" + }, + "lpmConfig": { + "deploymentConfigs": [ + "Intel", + "ARM" + ], + "gitClient": "GitExtensions" + } +} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5c2544e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,73 @@ +import subprocess +import sys +from pathlib import Path + +import pytest + +# Make the repo-root packages importable when running ``pytest`` without an editable install. +_repo_root = Path(__file__).resolve().parent.parent +if str(_repo_root) not in sys.path: + sys.path.insert(0, str(_repo_root)) + +AS_PROJECT = Path(__file__).parent / 'AsProject' + +# Packages to install individually — lpm only deploys when a specific package +# is named; a plain ``lpm install`` (no args) downloads to node_modules but +# does not deploy project files. +_LPM_PACKAGES = [ + 'librarybuilderproject@1.0.0', + 'firstinitprog', + 'stringext', +] + + +def _run_lpm(*args, **kwargs): + """Run an lpm command. shell=True is required on Windows (.cmd file).""" + subprocess.run(' '.join(['lpm', *args]), check=True, shell=True, **kwargs) + + +def pytest_configure(config): + """Generate the AsProject fixture if it hasn't been set up yet.""" + if (AS_PROJECT / 'AsProject.apj').exists(): + return + + AS_PROJECT.mkdir(exist_ok=True) + sys.stderr.write('\nAsProject not found — generating via lpm...\n') + + try: + _run_lpm('init', '--silent', cwd=AS_PROJECT) + for package in _LPM_PACKAGES: + sys.stderr.write(f' lpm install {package}\n') + _run_lpm('install', package, '--silent', cwd=AS_PROJECT) + # _run_lpm('install', '--silent', cwd=AS_PROJECT) # See comment above about plain "lpm install" + except FileNotFoundError: + sys.stderr.write( + 'ERROR: lpm not found.\n' + 'Install it from: https://loupeteam.github.io/LoupeDocs/tools/lpm.html\n' + 'AsProject-dependent tests will fail.\n' + ) + except subprocess.CalledProcessError as exc: + sys.stderr.write( + f'ERROR: lpm setup failed (exit {exc.returncode}).\n' + 'AsProject-dependent tests will fail.\n' + ) + + +# Files whose tests all require a fully-generated AsProject (lpm). +_AS_PROJECT_DEPENDENT = { + 'test_asproject.py', + 'test_build.py', + 'test_cpu_config.py', + 'test_deployment.py', + 'test_library.py', +} + + +def pytest_collection_modifyitems(config, items): + """Skip AsProject-dependent tests when the fixture project is not set up.""" + if (AS_PROJECT / 'AsProject.apj').exists(): + return + skip = pytest.mark.skip(reason='AsProject not available (lpm not installed)') + for item in items: + if Path(item.fspath).name in _AS_PROJECT_DEPENDENT: + item.add_marker(skip) diff --git a/tests/test_asproject.py b/tests/test_asproject.py new file mode 100644 index 0000000..aa549d8 --- /dev/null +++ b/tests/test_asproject.py @@ -0,0 +1,119 @@ +"""Tests against the checked-in AsProject fixture. + +These tests exercise Project's file-reading capabilities and do not require +Automation Studio to be installed. +""" +import shutil +from pathlib import Path + +import pytest + +from aspython import Project +from aspython.models import BuildConfig + +AS_PROJECT = Path(__file__).parent / 'AsProject' + + +@pytest.fixture(scope='module') +def project(): + return Project(str(AS_PROJECT)) + + +@pytest.fixture() +def project_copy(tmp_path): + """A writable copy of AsProject for tests that modify files.""" + dest = tmp_path / 'AsProject' + shutil.copytree(AS_PROJECT, dest, ignore=shutil.ignore_patterns('node_modules', 'Binaries')) + return Project(str(dest)) + + +# --------------------------------------------------------------------------- +# Project metadata +# --------------------------------------------------------------------------- + +def test_project_name(project): + assert project.name == 'AsProject' + + +def test_project_as_version(project): + assert project.ASVersion == 'AS6' + + +def test_project_paths_exist(project): + assert Path(project.sourcePath).is_dir() + assert Path(project.physicalPath).is_dir() + + +# --------------------------------------------------------------------------- +# Build configurations +# --------------------------------------------------------------------------- + +def test_build_config_names(project): + assert set(project.buildConfigNames) == {'ARM', 'Intel'} + + +def test_build_configs_are_build_config_instances(project): + for config in project.buildConfigs: + assert isinstance(config, BuildConfig) + + +def test_get_config_by_name(project): + config = project.getConfigByName('Intel') + assert config.name == 'Intel' + + +def test_get_config_by_name_unknown_raises(project): + with pytest.raises(StopIteration): + project.getConfigByName('DoesNotExist') + + +# --------------------------------------------------------------------------- +# Hardware parameters +# --------------------------------------------------------------------------- + +def test_get_hardware_parameter_existing(project): + assert project.getHardwareParameter('Intel', 'ConfigurationID') == 'AsProject_Intel' + + +def test_get_hardware_parameter_missing_returns_empty(project): + assert project.getHardwareParameter('Intel', 'NonExistentParam') == '' + + +def test_set_hardware_parameter_roundtrip(project_copy): + original = project_copy.getHardwareParameter('Intel', 'UserPartitionSize') + project_copy.setHardwareParameter('Intel', 'UserPartitionSize', '999') + assert project_copy.getHardwareParameter('Intel', 'UserPartitionSize') == '999' + project_copy.setHardwareParameter('Intel', 'UserPartitionSize', original) + assert project_copy.getHardwareParameter('Intel', 'UserPartitionSize') == original + + +# --------------------------------------------------------------------------- +# Library cache +# --------------------------------------------------------------------------- + +KNOWN_LOUPE_LIBS = ['errorlib', 'hmitools', 'logthat', 'stringext', 'vartools'] + + +def test_libraries_are_cached(project): + assert len(project.libraries) > 0 + + +def test_known_loupe_libraries_present(project): + names = {lib.name for lib in project.libraries} + for lib_name in KNOWN_LOUPE_LIBS: + assert lib_name in names, f'{lib_name} not found in cached libraries' + + +def test_get_library_by_name(project): + lib = project.getLibraryByName('errorlib') + assert lib is not None + assert lib.name == 'errorlib' + + +def test_get_library_by_name_unknown_returns_none(project): + assert project.getLibraryByName('totally_fake_lib') is None + + +def test_get_libraries_by_name(project): + libs = project.getLibrariesByName(['errorlib', 'stringext']) + assert {lib.name for lib in libs} == {'errorlib', 'stringext'} diff --git a/tests/test_build.py b/tests/test_build.py new file mode 100644 index 0000000..b531db7 --- /dev/null +++ b/tests/test_build.py @@ -0,0 +1,38 @@ +"""Integration test: build the test AsProject using aspython. + +Requires B&R Automation Studio to be installed. Skipped automatically when +the AS build executable cannot be found. +""" +import os +from pathlib import Path + +import pytest + +from aspython import Project +from aspython.paths import getASBuildPath +from aspython.returncodes import ASReturnCodes + +AS_PROJECT = Path(__file__).parent / 'AsProject' +CONFIGURATION = 'Intel' + + +def _as_installed() -> bool: + if not (AS_PROJECT / 'AsProject.apj').exists(): + return False + project = Project(str(AS_PROJECT)) + build_exe = getASBuildPath(project.ASVersion) + return os.path.isfile(build_exe) + + +pytestmark = pytest.mark.skipif( + not _as_installed(), + reason='Automation Studio not installed', +) + + +def test_build_starter_config(): + project = Project(str(AS_PROJECT)) + result = project.build(CONFIGURATION, buildMode='Build') + assert result.returncode <= ASReturnCodes['Warnings'], ( + f'Build failed with return code {result.returncode}' + ) diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py new file mode 100644 index 0000000..f0c8c4f --- /dev/null +++ b/tests/test_cli_smoke.py @@ -0,0 +1,65 @@ +"""CLI smoke tests: every subcommand wires up and ``--help`` exits 0.""" +import subprocess +import sys + +import pytest + + +SUBCOMMANDS = [ + 'build', + 'arsim', + 'export-libs', + 'deploy-libs', + 'safety-crc', + 'version', + 'installer', + 'package-hmi', + 'run-tests', +] + + +def _run(args): + return subprocess.run( + [sys.executable, '-m', 'aspython', *args], + capture_output=True, text=True, + ) + + +def test_root_help(): + proc = _run(['--help']) + assert proc.returncode == 0 + for sub in SUBCOMMANDS: + assert sub in proc.stdout + + +def test_root_version(): + from aspython import __version__ + proc = _run(['--version']) + assert proc.returncode == 0 + assert __version__ in proc.stdout + + +@pytest.mark.parametrize('sub', SUBCOMMANDS) +def test_subcommand_help(sub): + proc = _run([sub, '--help']) + assert proc.returncode == 0, proc.stderr + assert sub in proc.stdout or 'usage' in proc.stdout.lower() + + +def test_unknown_subcommand_fails(): + proc = _run(['totally-not-a-real-command']) + assert proc.returncode != 0 + + +def test_legacy_astools_shim_imports(): + """The deprecated ``ASTools`` re-export must still expose Project & friends.""" + proc = subprocess.run( + [sys.executable, '-W', 'ignore::DeprecationWarning', '-c', + 'import ASTools; ' + 'assert hasattr(ASTools, "Project"); ' + 'assert hasattr(ASTools, "Library"); ' + 'assert hasattr(ASTools, "buildASProject"); ' + 'assert hasattr(ASTools, "ASReturnCodes")'], + capture_output=True, text=True, + ) + assert proc.returncode == 0, proc.stderr diff --git a/tests/test_cpu_config.py b/tests/test_cpu_config.py new file mode 100644 index 0000000..9534b8f --- /dev/null +++ b/tests/test_cpu_config.py @@ -0,0 +1,67 @@ +"""Tests for CpuConfig against the Cpu.pkg in the AsProject fixture.""" +import shutil +from pathlib import Path + +import pytest + +from aspython import CpuConfig + +AS_PROJECT = Path(__file__).parent / 'AsProject' +CPU_PKG = AS_PROJECT / 'Physical' / 'Intel' / '5APC4100_TGL1_000' / 'Cpu.pkg' + + +@pytest.fixture(scope='module') +def cpu_config(): + return CpuConfig(str(CPU_PKG)) + + +@pytest.fixture() +def cpu_config_copy(tmp_path): + dest = tmp_path / 'Cpu.pkg' + shutil.copy(CPU_PKG, dest) + return CpuConfig(str(dest)) + + +# --------------------------------------------------------------------------- +# Reading +# --------------------------------------------------------------------------- + +def test_gcc_version(cpu_config): + assert cpu_config.gccVersion == '11.3.0' + + +def test_ar_version(cpu_config): + assert cpu_config.arVersion == '6.6.2' + + +def test_pre_build_step_missing_returns_none(cpu_config_copy): + del cpu_config_copy.buildElement.attrib['PreBuildStep'] + assert cpu_config_copy.preBuildStep is None + + +# --------------------------------------------------------------------------- +# Write roundtrips +# --------------------------------------------------------------------------- + +def test_set_gcc_version_roundtrip(cpu_config_copy): + cpu_config_copy.gccVersion = '12.0.0' + assert cpu_config_copy.gccVersion == '12.0.0' + + reloaded = CpuConfig(cpu_config_copy.path) + assert reloaded.gccVersion == '12.0.0' + + +def test_set_ar_version_roundtrip(cpu_config_copy): + cpu_config_copy.arVersion = '6.7.0' + assert cpu_config_copy.arVersion == '6.7.0' + + reloaded = CpuConfig(cpu_config_copy.path) + assert reloaded.arVersion == '6.7.0' + + +def test_set_pre_build_step_roundtrip(cpu_config_copy): + cpu_config_copy.preBuildStep = 'echo building' + assert cpu_config_copy.preBuildStep == 'echo building' + + reloaded = CpuConfig(cpu_config_copy.path) + assert reloaded.preBuildStep == 'echo building' diff --git a/tests/test_deployment.py b/tests/test_deployment.py new file mode 100644 index 0000000..e1bff3c --- /dev/null +++ b/tests/test_deployment.py @@ -0,0 +1,70 @@ +"""Tests for SwDeploymentTable against the cpu.sw in the AsProject fixture.""" +import shutil +from pathlib import Path + +import pytest + +from aspython import SwDeploymentTable + +AS_PROJECT = Path(__file__).parent / 'AsProject' +CPU_SW = AS_PROJECT / 'Physical' / 'Intel' / '5APC4100_TGL1_000' / 'Cpu.sw' +# deployLibrary expects the parent package folder (the one containing Package.pkg), +# not the specific library subfolder. +LOUPE_LIBS_PATH = AS_PROJECT / 'Logical' / 'Libraries' / 'Loupe' + + +@pytest.fixture(scope='module') +def deployment(): + return SwDeploymentTable(str(CPU_SW)) + + +@pytest.fixture() +def deployment_copy(tmp_path): + dest = tmp_path / 'Cpu.sw' + shutil.copy(CPU_SW, dest) + return SwDeploymentTable(str(dest)) + + +# --------------------------------------------------------------------------- +# Reading the existing table +# --------------------------------------------------------------------------- + +def test_libraries_list_is_non_empty(deployment): + assert len(deployment.libraries) > 0 + + +def test_known_as_libraries_present(deployment): + libs = deployment.libraries + assert 'ArEventLog' in libs + assert 'AsBrStr' in libs + assert 'standard' in libs + + +def test_task_classes_exist(deployment): + for i in range(1, 9): + assert deployment.find(f"TaskClass[@Name='Cyclic#{i}']") is not None + + +# --------------------------------------------------------------------------- +# deployLibrary +# --------------------------------------------------------------------------- + +def test_deploy_library_adds_entry(deployment_copy): + deployment_copy.deployLibrary(str(LOUPE_LIBS_PATH), 'errorlib') + # New elements lack the AS namespace until re-read from disk. + reloaded = SwDeploymentTable(deployment_copy.path) + assert 'errorlib' in reloaded.libraries + + +def test_deploy_library_is_idempotent(deployment_copy): + deployment_copy.deployLibrary(str(LOUPE_LIBS_PATH), 'stringext') + count_before = deployment_copy.libraries.count('stringext') + deployment_copy.deployLibrary(str(LOUPE_LIBS_PATH), 'stringext') + assert deployment_copy.libraries.count('stringext') == count_before + + +def test_deploy_library_persists_to_disk(deployment_copy): + deployment_copy.deployLibrary(str(LOUPE_LIBS_PATH), 'vartools') + + reloaded = SwDeploymentTable(deployment_copy.path) + assert 'vartools' in reloaded.libraries diff --git a/tests/test_library.py b/tests/test_library.py new file mode 100644 index 0000000..209e037 --- /dev/null +++ b/tests/test_library.py @@ -0,0 +1,96 @@ +"""Tests for the Library class against real .lby files in the AsProject fixture.""" +import shutil +from pathlib import Path + +import pytest + +from aspython import Library +from aspython.models import Dependency + +AS_PROJECT = Path(__file__).parent / 'AsProject' +ERRORLIB_PATH = AS_PROJECT / 'Logical' / 'Libraries' / 'Loupe' / 'errorlib' + + +@pytest.fixture(scope='module') +def errorlib(): + return Library(str(ERRORLIB_PATH)) + + +@pytest.fixture() +def errorlib_copy(tmp_path): + dest = tmp_path / 'errorlib' + shutil.copytree(ERRORLIB_PATH, dest) + return Library(str(dest)) + + +# --------------------------------------------------------------------------- +# Basic metadata +# --------------------------------------------------------------------------- + +def test_library_name(errorlib): + assert errorlib.name == 'errorlib' + + +def test_library_version(errorlib): + assert errorlib.version == '1.0.0' + + +def test_library_type_is_binary(errorlib): + assert errorlib.type.lower() == 'binary' + + +# --------------------------------------------------------------------------- +# File list +# --------------------------------------------------------------------------- + +def test_file_list_is_non_empty(errorlib): + assert len(errorlib.fileList) > 0 + + +def test_file_list_contains_known_files(errorlib): + names = {el.text for el in errorlib.fileList} + assert 'ErrorLib.typ' in names + assert 'ErrorLib.fun' in names + assert 'ErrorLib.var' in names + + +# --------------------------------------------------------------------------- +# Dependencies +# --------------------------------------------------------------------------- + +def test_dependency_names_non_empty(errorlib): + assert len(errorlib.dependencyNames) > 0 + + +def test_known_dependencies_present(errorlib): + names = errorlib.dependencyNames + assert 'sys_lib' in names + assert 'AsBrStr' in names + + +def test_dependencies_are_dependency_instances(errorlib): + for dep in errorlib.dependencies: + assert isinstance(dep, Dependency) + + +def test_dependency_with_version_range(errorlib): + dep = next(d for d in errorlib.dependencies if d.name == 'HMITools') + assert dep.minVersion == '1.0.0' + + +# --------------------------------------------------------------------------- +# Mutation: addDependency +# --------------------------------------------------------------------------- + +def test_add_dependency_roundtrip(errorlib_copy): + new_dep = Dependency(name='TestLib', minVersion='2.0.0', maxVersion='3.0.0') + errorlib_copy.addDependency(new_dep) + # New elements lack the AS namespace until written and re-read, so persist first. + errorlib_copy.write() + reloaded = Library(errorlib_copy.path) + assert 'TestLib' in reloaded.dependencyNames + + +def test_add_dependency_wrong_type_raises(errorlib_copy): + with pytest.raises(TypeError): + errorlib_copy.addDependency('not_a_dependency') diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..3decf20 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,43 @@ +"""Tests for the dataclass models.""" +from aspython.models import BuildConfig, Dependency, LibExportInfo, ProjectExportInfo + + +def test_build_config_defaults(): + bc = BuildConfig('Cfg1') + assert bc.name == 'Cfg1' + assert bc.type == 'sg4' + assert bc.hardware == '' + assert bc.path == '' + + +def test_build_config_legacy_kwargs(): + bc = BuildConfig('Cfg1', path='/p', typ='sg4_arm', hardware='x20cp04') + assert bc.type == 'sg4_arm' + assert bc.path == '/p' + + +def test_dependency(): + d = Dependency('LoupeUI', minVersion='1.0', maxVersion='2.0') + assert d.name == 'LoupeUI' + assert d.minVersion == '1.0' + assert d.maxVersion == '2.0' + + +def test_export_info_partition(): + info = ProjectExportInfo() + info.addLibInfo(LibExportInfo('a', '/a')) + info.addLibInfo(LibExportInfo('b', '/b', exception=RuntimeError('boom'))) + assert len(info.success) == 1 + assert len(info.failed) == 1 + assert info.success[0].name == 'a' + assert info.failed[0].name == 'b' + + +def test_export_info_extend(): + a = ProjectExportInfo() + a.addLibInfo(LibExportInfo('a', '/a')) + b = ProjectExportInfo() + b.addLibInfo(LibExportInfo('b', '/b', exception=ValueError())) + a.extend(b) + assert len(a.success) == 1 + assert len(a.failed) == 1 diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..a6e9292 --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,75 @@ +"""Tests for pure helpers in aspython.paths.""" +import os +from unittest.mock import patch + +from aspython.paths import ( + _findASBase, + convertAsPathToWinPath, + convertWinPathToAsPath, + getASBuildPath, + getASPath, + getAsPathType, + getPVITransferPath, +) + + +def test_get_as_path_base(): + # Without any AS install present, falls back to the legacy default. + with patch('aspython.paths.os.path.isdir', return_value=False): + assert getASPath('base') == 'C:\\BrAutomation' + + +def test_get_as_path_versioned(): + with patch('aspython.paths.os.path.isdir', return_value=False): + p = getASPath('AS49') + assert p.endswith(os.path.join('AS49', 'Bin-en')) + assert p.startswith('C:\\BrAutomation') + + +def test_find_as_base_prefers_match(): + """When AS6 lives under Program Files, _findASBase should pick it.""" + program_files = 'C:\\Program Files (x86)\\BRAutomation' + + def _fake_isdir(path: str) -> bool: + # Pretend only the Program Files base exists, and only it has AS6. + if path == program_files: + return True + if path == os.path.join(program_files, 'AS6'): + return True + return False + + with patch('aspython.paths.os.path.isdir', side_effect=_fake_isdir): + assert _findASBase('AS6') == program_files + + +def test_find_as_base_legacy_fallback(): + with patch('aspython.paths.os.path.isdir', return_value=False): + assert _findASBase('AS49') == 'C:\\BrAutomation' + + +def test_get_as_build_path(): + assert getASBuildPath('AS49').endswith('BR.AS.Build.exe') + assert getASBuildPath('base') == getASPath('base') + + +def test_get_pvi_transfer_path(): + p = getPVITransferPath('V4.9') + assert 'PVI' in p + assert p.endswith('PVITransfer') + + +def test_as_path_type_relative(): + assert getAsPathType('\\Logical\\Foo') == 'relative' + assert getAsPathType('..\\Foo') == 'relative' + + +def test_as_path_type_absolute(): + assert getAsPathType('C:\\Foo') == 'absolute' + assert getAsPathType('/usr/local') == 'absolute' + + +def test_path_round_trip_relative(): + win = convertAsPathToWinPath('\\Logical\\Foo') + assert win.startswith('.') + back = convertWinPathToAsPath(win) + assert back.startswith('\\') diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 0000000..d1f54d6 --- /dev/null +++ b/tests/test_project.py @@ -0,0 +1,17 @@ +"""Targeted tests for aspython.project helpers.""" +from aspython.project import Project + + +def test_parse_as_version_legacy(): + apj = '\n\n' + assert Project._parseASVersion(apj) == 'AS49' + + +def test_parse_as_version_as6_uses_major_only(): + apj = '\n\n' + assert Project._parseASVersion(apj) == 'AS6' + + +def test_parse_as_version_as6_minor_still_major_only(): + apj = '\n\n' + assert Project._parseASVersion(apj) == 'AS6' diff --git a/tests/test_smoke_imports.py b/tests/test_smoke_imports.py new file mode 100644 index 0000000..40bcb11 --- /dev/null +++ b/tests/test_smoke_imports.py @@ -0,0 +1,57 @@ +"""Smoke tests: every aspython module imports cleanly and exposes its public API.""" +import importlib + +import pytest + + +SUBMODULES = [ + 'aspython', + 'aspython._version', + 'aspython.returncodes', + 'aspython.models', + 'aspython.paths', + 'aspython.xml_base', + 'aspython.library', + 'aspython.package', + 'aspython.task', + 'aspython.deployment', + 'aspython.config', + 'aspython.build', + 'aspython.simulation', + 'aspython.project', + 'aspython.utils', + 'aspython.cnc', + 'aspython.unittests', + 'aspython.upgrades', + 'aspython.installer', + 'aspython.hmi', + 'aspython.logging_setup', + 'aspython.cli.main', + 'aspython.cli.build', + 'aspython.cli.arsim', + 'aspython.cli.export_libs', + 'aspython.cli.deploy_libs', + 'aspython.cli.safety_crc', + 'aspython.cli.version', + 'aspython.cli.installer', + 'aspython.cli.package_hmi', + 'aspython.cli.run_tests', +] + + +@pytest.mark.parametrize('mod', SUBMODULES) +def test_module_imports(mod): + importlib.import_module(mod) + + +def test_public_api_surface(): + import aspython + expected = { + 'Project', 'Library', 'Package', 'Task', 'SwDeploymentTable', 'CpuConfig', + 'BuildConfig', 'Dependency', 'LibExportInfo', 'ProjectExportInfo', + 'ASReturnCodes', 'PVIReturnCodeText', 'xmlAsFile', + 'buildASProject', 'batchBuildAsProject', 'ASProjetGetConfigs', + 'CreateARSimStructure', 'toDict', + } + missing = expected - set(dir(aspython)) + assert not missing, f'missing public symbols: {missing}'