diff --git a/aspython/__init__.py b/aspython/__init__.py index 36f0a59..c1520f0 100644 --- a/aspython/__init__.py +++ b/aspython/__init__.py @@ -15,6 +15,8 @@ getAsPathType, convertAsPathToWinPath, convertWinPathToAsPath, + resolveReferencePath, + findProjectRoot, getLibraryPathInPackage, getLibraryType, getProgramType, @@ -50,6 +52,8 @@ "getAsPathType", "convertAsPathToWinPath", "convertWinPathToAsPath", + "resolveReferencePath", + "findProjectRoot", "getLibraryPathInPackage", "getLibraryType", "getProgramType", diff --git a/aspython/config.py b/aspython/config.py index 5e1f034..e3873a3 100644 --- a/aspython/config.py +++ b/aspython/config.py @@ -1,6 +1,9 @@ """B&R AS CPU configuration (Cpu.pkg / Configuration).""" import os.path +from typing import List, Optional +from .deployment import SwDeploymentTable +from .paths import findProjectRoot, resolveReferencePath from .xml_base import xmlAsFile @@ -36,3 +39,37 @@ def setArVersion(self, value): gccVersion = property(getGccVersion, setGccVersion) preBuildStep = property(getPreBuildStep, setPreBuildStep) arVersion = property(getArVersion, setArVersion) + + @property + def objectList(self) -> List: + """The ```` entries listed under this Cpu.pkg's ````.""" + return self.findall('Objects', 'Object') + + def getSoftwareConfigPath(self, projectRoot: Optional[str] = None) -> Optional[str]: + """Resolve this CPU's software-configuration (``.sw``) file on disk. + + The ``.sw`` may be a file owned by this CPU folder, or a reference to + another configuration's ``.sw`` (common when several configurations + share one software deployment). References are resolved relative to the + project root, which is auto-detected from the package location when + *projectRoot* is not given. + """ + if projectRoot is None: + projectRoot = findProjectRoot(self.path) or self.dirPath + for obj in self.objectList: + text = (obj.text or '').strip() + if not text.lower().endswith('.sw'): + continue + if obj.attrib.get('Reference', 'false').lower() == 'true': + return resolveReferencePath(text, self.dirPath, projectRoot) + return os.path.normpath(os.path.join(self.dirPath, text)) + # Fall back to the conventional local file name. + local = os.path.join(self.dirPath, 'Cpu.sw') + return os.path.normpath(local) if os.path.isfile(local) else None + + def getSwDeploymentTable(self, projectRoot: Optional[str] = None) -> Optional[SwDeploymentTable]: + """Return the resolved :class:`SwDeploymentTable`, or ``None`` if absent.""" + swPath = self.getSoftwareConfigPath(projectRoot) + if swPath and os.path.isfile(swPath): + return SwDeploymentTable(swPath) + return None diff --git a/aspython/deployment.py b/aspython/deployment.py index b3b9b83..f2f0340 100644 --- a/aspython/deployment.py +++ b/aspython/deployment.py @@ -106,3 +106,23 @@ def _addRootLevelElement(self, name, index=None, attributes=None): @property def libraries(self) -> List: return [element.get('Name', 'Unknown') for element in self.findall('Libraries', 'LibraryObject')] + + @property + def taskElements(self) -> List[ET.Element]: + """Every ```` deployed in this table, across all task classes.""" + tasks: List[ET.Element] = [] + for tc in self.findall('TaskClass'): + tasks.extend(tc.findall(self.nameSpaceFormatted + 'Task')) + return tasks + + @property + def taskNames(self) -> List[str]: + return [t.get('Name', '') for t in self.taskElements] + + @property + def taskSources(self) -> List[str]: + """The ``Source`` of every deployed task (e.g. ``Pkg.Sub.Name.prg``). + + Tasks with an empty ``Source`` (e.g. binary-only HMI tasks) are omitted. + """ + return [src for t in self.taskElements if (src := t.get('Source'))] diff --git a/aspython/paths.py b/aspython/paths.py index 43d8478..f341079 100644 --- a/aspython/paths.py +++ b/aspython/paths.py @@ -105,6 +105,57 @@ def convertWinPathToAsPath(winPath: str) -> str: return os.path.join('\\', os.path.normpath(winPath)) +def resolveReferencePath(refText: Optional[str], baseDir: str, projectRoot: str) -> str: + """Resolve a package ``Reference="true"`` object's text to a Windows path. + + AS stores reference targets in a few shapes: + + * absolute (``C:\\...``) -> used as-is + * project-root relative with a leading ``\\`` (``\\Logical\\...``) + * project-root relative with no leading slash (``Physical\\Cfg\\Cpu.sw``) + * file relative, starting with ``..`` (``..\\..\\Other\\Cpu.sw``) + + *baseDir* is the directory holding the referencing package; *projectRoot* + is the AS project directory (the one containing the ``.apj``). The returned + path is normalized but not required to exist. + """ + if refText is None: + return '' + refText = refText.strip() + if not refText: + return '' + native = refText.replace('\\', os.sep).replace('/', os.sep) + # A drive-qualified (``C:\\...``) or UNC (``\\\\server\\share``) path is the + # only truly absolute form. A bare leading separator is project-root + # relative in AS -- and ``os.path.isabs`` disagrees about that across Python + # versions (ntpath treated ``\\foo`` as absolute before 3.13), so test the + # drive explicitly instead of relying on it. + drive = os.path.splitdrive(native)[0] + if drive or getAsPathType(refText) == 'absolute': + return os.path.normpath(native) + if native.startswith('..'): + return os.path.normpath(os.path.join(baseDir, native)) + # Leading-slash and plain forms are both project-root relative in AS. + return os.path.normpath(os.path.join(projectRoot, native.lstrip(os.sep))) + + +def findProjectRoot(startPath: str) -> Optional[str]: + """Walk up from *startPath* to the AS project dir (the one with a ``.apj``).""" + current = os.path.abspath(startPath) + if os.path.isfile(current): + current = os.path.dirname(current) + while True: + try: + if any(f.lower().endswith('.apj') for f in os.listdir(current)): + return current + except OSError: + return None + parent = os.path.dirname(current) + if parent == current: + return None + current = parent + + 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. diff --git a/aspython/project.py b/aspython/project.py index 7395ce1..e961b9b 100644 --- a/aspython/project.py +++ b/aspython/project.py @@ -10,6 +10,8 @@ from typing import List, Union from .build import batchBuildAsProject +from .config import CpuConfig +from .deployment import SwDeploymentTable from .library import Library from .models import BuildConfig, ProjectExportInfo from .package import Package @@ -213,6 +215,47 @@ def getLibrariesByName(self, libNames: List[str]) -> List[Library]: def getConfigByName(self, configName: str) -> BuildConfig: return next(i for i in self.buildConfigs if i.name == configName) + def getConfigCpus(self, configName: str) -> List[CpuConfig]: + """Return a :class:`CpuConfig` for each CPU deployed in *configName*. + + The CPUs are read from the configuration's ``Config.pkg`` (objects of + ``Type="Cpu"``), so this works regardless of the hardware folder name + and supports configurations that contain more than one CPU. + """ + configDir = os.path.join(self.physicalPath, configName) + configPkg = os.path.join(configDir, 'Config.pkg') + cpus: List[CpuConfig] = [] + if not os.path.isfile(configPkg): + return cpus + for obj in Package(configPkg).objectList: + if obj.get('Type', '').lower() != 'cpu': + continue + cpuPkg = os.path.join(configDir, (obj.text or '').strip(), 'Cpu.pkg') + if os.path.isfile(cpuPkg): + cpus.append(CpuConfig(cpuPkg)) + return cpus + + def getConfigSwTables(self, configName: str) -> List[SwDeploymentTable]: + """Return every software-deployment table (``.sw``) used by *configName*. + + One table is returned per CPU. References to another configuration's + ``.sw`` are resolved transparently, so a configuration that merely + points at a shared deployment still yields its real task/library list. + """ + tables: List[SwDeploymentTable] = [] + for cpu in self.getConfigCpus(configName): + table = cpu.getSwDeploymentTable(self.dirPath) + if table is not None: + tables.append(table) + return tables + + def getConfigTaskSources(self, configName: str) -> List[str]: + """Return the ``Source`` of every task deployed by *configName*'s CPUs.""" + sources: List[str] = [] + for table in self.getConfigSwTables(configName): + sources.extend(table.taskSources) + return sources + def getConstantValue(self, filePath: str, varName: str): fullFilePath = os.path.join(self.dirPath, filePath) with open(fullFilePath, "r") as f: diff --git a/tests/conftest.py b/tests/conftest.py index b12a7a6..87de1d8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,6 +56,7 @@ def pytest_configure(config): _AS_PROJECT_DEPENDENT = { 'test_asproject.py', 'test_build.py', + 'test_config_traversal.py', 'test_cpu_config.py', 'test_deployment.py', 'test_library.py', diff --git a/tests/test_config_sw.py b/tests/test_config_sw.py new file mode 100644 index 0000000..4feb97e --- /dev/null +++ b/tests/test_config_sw.py @@ -0,0 +1,84 @@ +"""Hermetic tests for CpuConfig software-configuration (.sw) resolution. + +These build a throwaway ``Cpu.pkg`` on disk so the reference-resolution logic +(local file, ``Reference="true"`` with a ``..``-relative or project-root-relative +target) is exercised without needing the lpm-generated AsProject fixture. +""" +import os + +from aspython import CpuConfig + +NS = 'http://br-automation.co.at/AS/Package' + + +def _write_cpu_pkg(directory, object_xml: str) -> str: + directory.mkdir(parents=True, exist_ok=True) + pkg = directory / 'Cpu.pkg' + pkg.write_text( + '\n' + f'\n' + f' \n{object_xml}\n \n' + '\n' + ) + return str(pkg) + + +def test_local_sw_path(tmp_path): + cpu_dir = tmp_path / 'Cfg' / 'Cpu' + pkg = _write_cpu_pkg( + cpu_dir, + ' Cpu.sw', + ) + cfg = CpuConfig(pkg) + assert cfg.getSoftwareConfigPath() == os.path.join(str(cpu_dir), 'Cpu.sw') + + +def test_reference_dotdot_relative(tmp_path): + cpu_dir = tmp_path / 'Physical' / 'CfgB' / 'Cpu' + pkg = _write_cpu_pkg( + cpu_dir, + ' ..\\..\\CfgA\\Cpu\\Cpu.sw', + ) + cfg = CpuConfig(pkg) + expected = os.path.normpath( + os.path.join(str(cpu_dir), '..', '..', 'CfgA', 'Cpu', 'Cpu.sw')) + assert cfg.getSoftwareConfigPath() == expected + + +def test_reference_project_root_relative(tmp_path): + cpu_dir = tmp_path / 'Physical' / 'CfgB' / 'Cpu' + pkg = _write_cpu_pkg( + cpu_dir, + ' Physical\\CfgA\\Cpu\\Cpu.sw', + ) + cfg = CpuConfig(pkg) + resolved = cfg.getSoftwareConfigPath(projectRoot=str(tmp_path)) + assert resolved == os.path.join( + str(tmp_path), 'Physical', 'CfgA', 'Cpu', 'Cpu.sw') + + +def test_reference_auto_detects_project_root_via_apj(tmp_path): + (tmp_path / 'MyProj.apj').write_text('') + cpu_dir = tmp_path / 'Physical' / 'CfgB' / 'Cpu' + pkg = _write_cpu_pkg( + cpu_dir, + ' Physical\\CfgA\\Cpu\\Cpu.sw', + ) + cfg = CpuConfig(pkg) + # No projectRoot passed: it should be discovered from the .apj location. + assert cfg.getSoftwareConfigPath() == os.path.join( + str(tmp_path), 'Physical', 'CfgA', 'Cpu', 'Cpu.sw') + + +def test_sw_deployment_table_none_when_missing(tmp_path): + cpu_dir = tmp_path / 'Cfg' / 'Cpu' + pkg = _write_cpu_pkg( + cpu_dir, + ' Cpu.sw', + ) + cfg = CpuConfig(pkg) + # The referenced Cpu.sw does not exist on disk. + assert cfg.getSwDeploymentTable() is None diff --git a/tests/test_config_traversal.py b/tests/test_config_traversal.py new file mode 100644 index 0000000..11c2a7f --- /dev/null +++ b/tests/test_config_traversal.py @@ -0,0 +1,49 @@ +"""End-to-end tests for Project configuration traversal against the AsProject +fixture: Config.pkg -> Cpu.pkg -> .sw, including task-source collection. + +Skipped automatically when the lpm-generated AsProject is unavailable (see +conftest.py); exercised in CI where the fixture is generated. +""" +from pathlib import Path + +import pytest + +from aspython import CpuConfig, SwDeploymentTable +from aspython.project import Project + +AS_PROJECT = Path(__file__).parent / 'AsProject' +CONFIG = 'Intel' + + +@pytest.fixture(scope='module') +def project(): + return Project(str(AS_PROJECT)) + + +def test_get_config_cpus(project): + cpus = project.getConfigCpus(CONFIG) + assert len(cpus) >= 1 + assert all(isinstance(c, CpuConfig) for c in cpus) + + +def test_get_config_sw_tables(project): + tables = project.getConfigSwTables(CONFIG) + assert len(tables) >= 1 + assert all(isinstance(t, SwDeploymentTable) for t in tables) + # The deployment tables carry the real library list. + assert any('standard' in t.libraries for t in tables) + + +def test_get_config_task_sources(project): + sources = project.getConfigTaskSources(CONFIG) + assert isinstance(sources, list) + assert all(isinstance(s, str) and s for s in sources) + # Should equal the concatenation of each table's taskSources. + expected = [s for t in project.getConfigSwTables(CONFIG) for s in t.taskSources] + assert sources == expected + + +def test_unknown_config_yields_nothing(project): + assert project.getConfigCpus('NoSuchConfig') == [] + assert project.getConfigSwTables('NoSuchConfig') == [] + assert project.getConfigTaskSources('NoSuchConfig') == [] diff --git a/tests/test_cpu_config.py b/tests/test_cpu_config.py index 9534b8f..e0f32cd 100644 --- a/tests/test_cpu_config.py +++ b/tests/test_cpu_config.py @@ -39,6 +39,23 @@ def test_pre_build_step_missing_returns_none(cpu_config_copy): assert cpu_config_copy.preBuildStep is None +# --------------------------------------------------------------------------- +# Software-configuration (.sw) resolution +# --------------------------------------------------------------------------- + +def test_software_config_path_resolves_existing_sw(cpu_config): + sw = cpu_config.getSoftwareConfigPath() + assert sw is not None + assert sw.lower().endswith('.sw') + assert Path(sw).is_file() + + +def test_sw_deployment_table_is_loaded(cpu_config): + table = cpu_config.getSwDeploymentTable() + assert table is not None + assert 'standard' in table.libraries + + # --------------------------------------------------------------------------- # Write roundtrips # --------------------------------------------------------------------------- diff --git a/tests/test_deployment.py b/tests/test_deployment.py index e1bff3c..c13e32b 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -45,6 +45,28 @@ def test_task_classes_exist(deployment): assert deployment.find(f"TaskClass[@Name='Cyclic#{i}']") is not None +# --------------------------------------------------------------------------- +# Task enumeration (taskElements / taskNames / taskSources) +# --------------------------------------------------------------------------- + +def test_task_elements_non_empty(deployment): + assert len(deployment.taskElements) > 0 + + +def test_task_names_align_with_elements(deployment): + assert len(deployment.taskNames) == len(deployment.taskElements) + assert all(isinstance(n, str) for n in deployment.taskNames) + + +def test_task_sources_are_truthy_and_bounded(deployment): + sources = deployment.taskSources + # Every reported source is a non-empty string ... + assert all(s for s in sources) + # ... and tasks with an empty Source are excluded, so sources cannot + # outnumber the tasks themselves. + assert len(sources) <= len(deployment.taskElements) + + # --------------------------------------------------------------------------- # deployLibrary # --------------------------------------------------------------------------- diff --git a/tests/test_paths.py b/tests/test_paths.py index a6e9292..35a0896 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -6,10 +6,12 @@ _findASBase, convertAsPathToWinPath, convertWinPathToAsPath, + findProjectRoot, getASBuildPath, getASPath, getAsPathType, getPVITransferPath, + resolveReferencePath, ) @@ -73,3 +75,54 @@ def test_path_round_trip_relative(): assert win.startswith('.') back = convertWinPathToAsPath(win) assert back.startswith('\\') + + +# --------------------------------------------------------------------------- +# resolveReferencePath -- resolving Cpu.pkg/Package.pkg Reference="true" targets +# --------------------------------------------------------------------------- + +ROOT = os.path.join('C:\\', 'proj') +BASE = os.path.join(ROOT, 'Physical', 'CfgB', 'Cpu') + + +def test_resolve_reference_project_root_relative_no_slash(): + # The shape seen in real cross-config .sw references. + ref = 'Physical\\CfgA\\Cpu\\Cpu.sw' + assert resolveReferencePath(ref, BASE, ROOT) == \ + os.path.join(ROOT, 'Physical', 'CfgA', 'Cpu', 'Cpu.sw') + + +def test_resolve_reference_leading_slash_is_project_root(): + ref = '\\Physical\\CfgA\\Cpu\\Cpu.sw' + assert resolveReferencePath(ref, BASE, ROOT) == \ + os.path.join(ROOT, 'Physical', 'CfgA', 'Cpu', 'Cpu.sw') + + +def test_resolve_reference_dotdot_is_base_relative(): + ref = '..\\..\\CfgA\\Cpu\\Cpu.sw' + assert resolveReferencePath(ref, BASE, ROOT) == \ + os.path.normpath(os.path.join(BASE, ref)) + + +def test_resolve_reference_absolute_kept(): + ref = 'C:\\elsewhere\\Cpu.sw' + assert resolveReferencePath(ref, BASE, ROOT) == os.path.normpath('C:\\elsewhere\\Cpu.sw') + + +def test_resolve_reference_empty(): + assert resolveReferencePath('', BASE, ROOT) == '' + assert resolveReferencePath(None, BASE, ROOT) == '' + + +def test_find_project_root(tmp_path): + proj = tmp_path / 'MyProj' + deep = proj / 'Physical' / 'CfgA' / 'Cpu' + deep.mkdir(parents=True) + (proj / 'MyProj.apj').write_text('') + assert findProjectRoot(str(deep)) == str(proj) + + +def test_find_project_root_none_when_absent(tmp_path): + deep = tmp_path / 'nope' / 'deeper' + deep.mkdir(parents=True) + assert findProjectRoot(str(deep)) is None