Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions aspython/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
getAsPathType,
convertAsPathToWinPath,
convertWinPathToAsPath,
resolveReferencePath,
findProjectRoot,
getLibraryPathInPackage,
getLibraryType,
getProgramType,
Expand Down Expand Up @@ -50,6 +52,8 @@
"getAsPathType",
"convertAsPathToWinPath",
"convertWinPathToAsPath",
"resolveReferencePath",
"findProjectRoot",
"getLibraryPathInPackage",
"getLibraryType",
"getProgramType",
Expand Down
37 changes: 37 additions & 0 deletions aspython/config.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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 ``<Object>`` entries listed under this Cpu.pkg's ``<Objects>``."""
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
Comment on lines +48 to +75
20 changes: 20 additions & 0 deletions aspython/deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<Task>`` 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'))]
Comment on lines +110 to +128
51 changes: 51 additions & 0 deletions aspython/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<libraryName>.lby`` within a Libraries package, resolving references."""
# Local import to avoid a circular package <-> paths dependency at import time.
Expand Down
43 changes: 43 additions & 0 deletions aspython/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment on lines +218 to +257

def getConstantValue(self, filePath: str, varName: str):
fullFilePath = os.path.join(self.dirPath, filePath)
with open(fullFilePath, "r") as f:
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
84 changes: 84 additions & 0 deletions tests/test_config_sw.py
Original file line number Diff line number Diff line change
@@ -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(
'<?xml version="1.0" encoding="utf-8"?>\n'
f'<Cpu xmlns="{NS}">\n'
f' <Objects>\n{object_xml}\n </Objects>\n'
'</Cpu>\n'
)
return str(pkg)


def test_local_sw_path(tmp_path):
cpu_dir = tmp_path / 'Cfg' / 'Cpu'
pkg = _write_cpu_pkg(
cpu_dir,
' <Object Type="File" Description="Software configuration">Cpu.sw</Object>',
)
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,
' <Object Type="File" Description="Software configuration" '
'Reference="true">..\\..\\CfgA\\Cpu\\Cpu.sw</Object>',
)
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,
' <Object Type="File" Description="Software configuration" '
'Reference="true">Physical\\CfgA\\Cpu\\Cpu.sw</Object>',
)
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('<Project/>')
cpu_dir = tmp_path / 'Physical' / 'CfgB' / 'Cpu'
pkg = _write_cpu_pkg(
cpu_dir,
' <Object Type="File" Description="Software configuration" '
'Reference="true">Physical\\CfgA\\Cpu\\Cpu.sw</Object>',
)
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,
' <Object Type="File" Description="Software configuration">Cpu.sw</Object>',
)
cfg = CpuConfig(pkg)
# The referenced Cpu.sw does not exist on disk.
assert cfg.getSwDeploymentTable() is None
49 changes: 49 additions & 0 deletions tests/test_config_traversal.py
Original file line number Diff line number Diff line change
@@ -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') == []
17 changes: 17 additions & 0 deletions tests/test_cpu_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading