pysdccc 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pysdccc/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """Python wrapper to the SDCcc tool for testing SDC devices."""
2
+
3
+ from pysdccc._common import DEFAULT_STORAGE_DIRECTORY
4
+ from pysdccc._download import adownload, ais_downloaded, download, is_downloaded
5
+ from pysdccc._result_parser import TestCase, TestSuite
6
+ from pysdccc._runner import (
7
+ SdcccRunner,
8
+ SdcccRunnerAsync,
9
+ check_requirements,
10
+ )
11
+
12
+ __version__ = '0.1.0'
13
+
14
+ __all__ = [
15
+ 'DEFAULT_STORAGE_DIRECTORY',
16
+ 'SdcccRunner',
17
+ 'SdcccRunnerAsync',
18
+ 'TestCase',
19
+ 'TestSuite',
20
+ 'adownload',
21
+ 'ais_downloaded',
22
+ 'check_requirements',
23
+ 'download',
24
+ 'is_downloaded',
25
+ ]
pysdccc/_cli.py ADDED
@@ -0,0 +1,145 @@
1
+ """Command line interface."""
2
+
3
+ import contextlib
4
+ import io
5
+ import pathlib
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import zipfile
10
+
11
+ import httpx
12
+
13
+ from pysdccc import _common, _download
14
+
15
+ try:
16
+ import click
17
+ except ImportError as import_error:
18
+ msg = 'Cli not installed. Please install "pysdccc[cli]" package.'
19
+ raise ImportError(msg) from import_error
20
+
21
+
22
+ class UrlType(click.ParamType):
23
+ """Url type."""
24
+
25
+ name = 'url'
26
+
27
+ def convert(self, value: str, param: click.Parameter | None, ctx: click.Context | None) -> httpx.URL:
28
+ """Convert value to url."""
29
+ try:
30
+ return httpx.URL(value)
31
+ except Exception as e: # noqa: BLE001
32
+ self.fail(f'{value!r} is not a valid url: {e}', param, ctx)
33
+
34
+
35
+ URL = UrlType()
36
+
37
+
38
+ class ProxyType(click.ParamType):
39
+ """Proxy type."""
40
+
41
+ name = 'proxy'
42
+
43
+ def convert(self, value: str, param: click.Parameter | None, ctx: click.Context | None) -> httpx.Proxy | None:
44
+ """Convert value to proxy."""
45
+ try:
46
+ return httpx.Proxy(value)
47
+ except Exception as e: # noqa: BLE001
48
+ self.fail(f'{value!r} is not a valid proxy: {e}', param, ctx)
49
+
50
+
51
+ PROXY = ProxyType()
52
+
53
+
54
+ def _download_to_stream(url: httpx.URL, stream: io.IOBase, proxy: httpx.Proxy | None = None) -> None:
55
+ with _download.open_download_stream(url, proxy) as response:
56
+ total = response.headers.get('Content-Length')
57
+ with click.progressbar(
58
+ response.iter_bytes(),
59
+ length=int(total) if total is not None else None,
60
+ label='Downloading',
61
+ show_eta=True,
62
+ width=0,
63
+ ) as progress:
64
+ num_bytes_downloaded = response.num_bytes_downloaded
65
+ for chunk in progress:
66
+ stream.write(chunk)
67
+ progress.update(response.num_bytes_downloaded - num_bytes_downloaded)
68
+ num_bytes_downloaded = response.num_bytes_downloaded
69
+
70
+
71
+ def download(url: httpx.URL, output: pathlib.Path, proxy: httpx.Proxy | None = None):
72
+ """Download and extract the executable from an url."""
73
+ click.echo(f'Downloading SDCcc from {url}.')
74
+ with tempfile.NamedTemporaryFile('wb', suffix='.zip', delete=False) as temporary_file:
75
+ _download_to_stream(url, temporary_file, proxy=proxy) # type: ignore[arg-type]
76
+ click.echo(f'Extracting SDCcc to {output}.')
77
+ with (
78
+ zipfile.ZipFile(temporary_file.name) as f,
79
+ click.progressbar(f.infolist(), label='Extracting', width=0) as progress,
80
+ ):
81
+ for member in progress:
82
+ f.extract(member, output)
83
+
84
+
85
+ @click.group(help='Manage SDCcc installation.')
86
+ @click.version_option(message='%(version)s')
87
+ def cli():
88
+ pass
89
+
90
+
91
+ @click.command(
92
+ short_help='Install the SDCcc executable from the specified URL. Releases can be found at https://github.com/Draegerwerk/SDCcc/releases.',
93
+ )
94
+ @click.argument('url', type=URL)
95
+ @click.option('--proxy', help='Proxy server to use for the download.', type=PROXY)
96
+ @click.pass_context
97
+ def install(ctx: click.Context, url: httpx.URL, proxy: httpx.Proxy | None):
98
+ """Download the specified version from the default URL to a temporary directory.
99
+
100
+ This function downloads the SDCcc executable from the given URL to a temporary directory.
101
+ It optionally uses a proxy and a specified timeout for the download operation.
102
+ The downloaded file is extracted to a local path determined by the version string in the URL.
103
+
104
+ :param ctx: context from click
105
+ :param url: The parsed URL from which to download the executable.
106
+ :param proxy: Optional proxy to be used for the download.
107
+ """
108
+ ctx.invoke(uninstall)
109
+ try:
110
+ download(url, _common.DEFAULT_STORAGE_DIRECTORY, proxy)
111
+ except Exception as e:
112
+ msg = f'Failed to download and extract SDCcc from {url}: {e}.'
113
+ raise click.ClickException(msg) from e
114
+
115
+
116
+ @click.command(short_help='Uninstall the SDCcc executable by removing the directory.')
117
+ def uninstall():
118
+ """Uninstall the SDCcc executable.
119
+
120
+ This function removes the SDCcc executable from the directory.
121
+ """
122
+ import shutil
123
+
124
+ with contextlib.suppress(FileNotFoundError):
125
+ shutil.rmtree(_common.DEFAULT_STORAGE_DIRECTORY)
126
+
127
+
128
+ cli.add_command(install)
129
+ cli.add_command(uninstall)
130
+
131
+
132
+ def sdccc():
133
+ try:
134
+ sdccc_exe = _common.get_exe_path(_common.DEFAULT_STORAGE_DIRECTORY)
135
+ subprocess.run( # noqa: S603
136
+ [sdccc_exe, *sys.argv[1:]],
137
+ check=True,
138
+ cwd=sdccc_exe.parent,
139
+ )
140
+ except FileNotFoundError as e:
141
+ click.ClickException("SDCcc is not installed. Please install using 'pysdccc install <url>'.").show()
142
+ raise SystemExit(1) from e
143
+ except subprocess.CalledProcessError as e:
144
+ click.echo(e, err=True)
145
+ raise SystemExit(e.returncode) from e
pysdccc/_common.py ADDED
@@ -0,0 +1,57 @@
1
+ """Contains commonly used constants and functions."""
2
+
3
+ import locale
4
+ import os
5
+ import pathlib
6
+ import sys
7
+ from collections.abc import Iterable
8
+
9
+ DEFAULT_STORAGE_DIRECTORY = pathlib.Path(__file__).parent.joinpath('_sdccc')
10
+ """Default directory to store the downloaded SDCcc versions."""
11
+
12
+ PATH_TYPE = str | os.PathLike[str]
13
+
14
+ ENCODING = 'utf-8' if sys.flags.utf8_mode else locale.getencoding()
15
+
16
+ SINGLE_CMD_TYPE = str | int | bool | pathlib.Path
17
+ CMD_TYPE = SINGLE_CMD_TYPE | Iterable[str | int | pathlib.Path] | None
18
+
19
+
20
+ def build_command(*args: str, **kwargs: CMD_TYPE) -> list[str]:
21
+ """Build the command string from the arguments and keyword arguments."""
22
+ command = list(args)
23
+ for arg, value in kwargs.items():
24
+ if isinstance(value, SINGLE_CMD_TYPE):
25
+ if value is True:
26
+ command.append(f'--{arg}')
27
+ elif value is False:
28
+ continue # ignore False flags
29
+ else:
30
+ command.append(f'--{arg}')
31
+ command.append(str(value))
32
+ elif isinstance(value, Iterable) and not isinstance(value, dict | bytes):
33
+ for item in value:
34
+ command.append(f'--{arg}')
35
+ command.append(str(item))
36
+ elif value is not None:
37
+ err = f'Unsupported value type: {type(value)}'
38
+ raise TypeError(err)
39
+ return command
40
+
41
+
42
+ def get_exe_path(local_path: pathlib.Path) -> pathlib.Path:
43
+ """Get the path to the SDCcc executable.
44
+
45
+ This function searches the specified local path for the SDCcc executable file. It expects exactly one executable
46
+ file matching the pattern "sdccc-*.exe" to be present in the directory. If no such file or more than one file is
47
+ found, a FileNotFoundError is raised.
48
+
49
+ :param local_path: The local path where the SDCcc executable is expected to be found.
50
+ :return: The path to the SDCcc executable file.
51
+ :raises FileNotFoundError: If no executable file or more than one executable file is found in the specified path.
52
+ """
53
+ files = [f for f in local_path.glob('*.exe') if f.is_file()]
54
+ if len(files) != 1:
55
+ msg = f'Expected a single executable file, got {files} in path {local_path}'
56
+ raise FileNotFoundError(msg)
57
+ return files[0]
pysdccc/_download.py ADDED
@@ -0,0 +1,119 @@
1
+ """Everything needed for downloading SDCcc."""
2
+
3
+ import contextlib
4
+ import logging
5
+ import pathlib
6
+ import subprocess
7
+ import tempfile
8
+ import zipfile
9
+ from collections.abc import AsyncGenerator, Generator
10
+
11
+ import httpx
12
+
13
+ from pysdccc import _common, _runner
14
+
15
+ logger = logging.getLogger('pysdccc.download')
16
+
17
+
18
+ @contextlib.contextmanager
19
+ def open_download_stream(
20
+ url: httpx.URL, proxy: httpx.Proxy | None = None, timeout: float | None = None
21
+ ) -> Generator[httpx.Response, None, None]:
22
+ """Open a stream from which SDCcc can be downloaded chunk by chunk."""
23
+ with httpx.stream('GET', url, follow_redirects=True, proxy=proxy, timeout=timeout) as response:
24
+ response.raise_for_status()
25
+ yield response
26
+
27
+
28
+ def download(
29
+ url: httpx.URL | str,
30
+ proxy: httpx.Proxy | None = None,
31
+ output: pathlib.Path | None = None,
32
+ timeout: float | None = None,
33
+ ) -> pathlib.Path:
34
+ """Download and extract the specified version from the URL.
35
+
36
+ :param url: The parsed URL from which to download the executable.
37
+ :param proxy: Optional proxy to be used for the download.
38
+ :param output: The path to the directory where the downloaded executable will be extracted. If None,
39
+ `DEFAULT_STORAGE_DIRECTORY` is used.
40
+ :param timeout: Optional timeout in seconds for the download.
41
+ :return: Path to the executable.
42
+ """
43
+ url = httpx.URL(url)
44
+ logger.info('Downloading SDCcc from %s.', url)
45
+ with (
46
+ tempfile.NamedTemporaryFile('wb', suffix='.zip', delete=False) as temporary_file,
47
+ open_download_stream(url, proxy, timeout) as response,
48
+ ):
49
+ for chunk in response.iter_bytes():
50
+ temporary_file.write(chunk)
51
+ output = output or _common.DEFAULT_STORAGE_DIRECTORY
52
+ logger.info('Extracting SDCcc to %s.', output)
53
+ with zipfile.ZipFile(temporary_file.name) as f:
54
+ f.extractall(output)
55
+ return _common.get_exe_path(output)
56
+
57
+
58
+ def is_downloaded(version: str) -> bool:
59
+ """Check if the SDCcc version is already downloaded.
60
+
61
+ This function checks if the SDCcc executable is already downloaded.
62
+
63
+ :return: True if the executable is already downloaded, False otherwise.
64
+ """
65
+ try:
66
+ return _runner.SdcccRunner(pathlib.Path().absolute()).get_version() == version
67
+ except (FileNotFoundError, subprocess.CalledProcessError):
68
+ return False
69
+
70
+
71
+ @contextlib.asynccontextmanager
72
+ async def aopen_download_stream(
73
+ url: httpx.URL,
74
+ proxy: httpx.Proxy | None = None,
75
+ ) -> AsyncGenerator[httpx.Response, None]:
76
+ """Open a stream from which SDCcc can be downloaded chunk by chunk."""
77
+ client = httpx.AsyncClient(follow_redirects=True, proxy=proxy)
78
+ async with client.stream('GET', url) as response:
79
+ response.raise_for_status()
80
+ yield response
81
+
82
+
83
+ async def adownload(
84
+ url: httpx.URL | str,
85
+ proxy: httpx.Proxy | None = None,
86
+ output: pathlib.Path | None = None,
87
+ ) -> pathlib.Path:
88
+ """Download and extract the specified version from the URL.
89
+
90
+ :param url: The parsed URL from which to download the executable.
91
+ :param proxy: Optional proxy to be used for the download.
92
+ :param output: The path to the directory where the downloaded executable will be extracted. If None,
93
+ `DEFAULT_STORAGE_DIRECTORY` is used.
94
+ :return: Path to the executable.
95
+ """
96
+ url = httpx.URL(url)
97
+ logger.info('Downloading SDCcc from %s.', url)
98
+ with tempfile.NamedTemporaryFile('wb', suffix='.zip', delete=False) as temporary_file:
99
+ async with aopen_download_stream(url, proxy=proxy) as response:
100
+ async for chunk in response.aiter_bytes():
101
+ temporary_file.write(chunk)
102
+ output = output or _common.DEFAULT_STORAGE_DIRECTORY
103
+ logger.info('Extracting SDCcc to %s.', output)
104
+ with zipfile.ZipFile(temporary_file.name) as f:
105
+ f.extractall(output)
106
+ return _common.get_exe_path(output)
107
+
108
+
109
+ async def ais_downloaded(version: str) -> bool:
110
+ """Check if the SDCcc version is already downloaded.
111
+
112
+ This function checks if the SDCcc executable is already downloaded.
113
+
114
+ :return: True if the executable is already downloaded, False otherwise.
115
+ """
116
+ try:
117
+ return await _runner.SdcccRunnerAsync(pathlib.Path().absolute()).get_version() == version
118
+ except (FileNotFoundError, subprocess.CalledProcessError):
119
+ return False
@@ -0,0 +1,130 @@
1
+ """Parser for the JUnit XML test results provided by SDCcc including custom elements.
2
+
3
+ This module provides classes to parse and handle JUnit XML test results with custom elements specific to SDCcc.
4
+ It includes custom elements for test identifiers and descriptions, as well as custom test case and test suite classes.
5
+
6
+ Classes
7
+ -------
8
+
9
+ TestIdentifierElement
10
+ Represents the test identifier element in the JUnit XML.
11
+ TestDescriptionElement
12
+ Represents the test description element in the JUnit XML.
13
+ TestCase
14
+ Represents a test case with custom elements.
15
+ TestSuite
16
+ Represents a test suite with custom test cases.
17
+
18
+ Usage
19
+ -----
20
+
21
+ .. code-block:: python
22
+
23
+ from result_parser import TestSuite
24
+
25
+ # Parse a test suite from a file
26
+ test_suite = TestSuite.from_file("path/to/test_results.xml")
27
+
28
+ # Iterate over test cases in the test suite
29
+ for test_case in test_suite:
30
+ print(test_case.test_identifier)
31
+ print(test_case.test_description)
32
+ """
33
+
34
+ import pathlib
35
+ import typing
36
+
37
+ from junitparser import junitparser
38
+
39
+
40
+ class TestIdentifierElement(junitparser.Element):
41
+ """Element containing the test identifier.
42
+
43
+ This class represents a custom XML element for the test identifier in the JUnit XML test results.
44
+ It extends the `junitparser.Element` class and provides a property to access the text content of the element.
45
+ """
46
+
47
+ __test__ = False
48
+ _tag = 'test-identifier'
49
+
50
+ @property
51
+ def text(self) -> str | None:
52
+ """Retrieve the text content of the XML element.
53
+
54
+ :return: The text content of the XML element, or None if the element has no text.
55
+ """
56
+ return self._elem.text
57
+
58
+
59
+ class TestDescriptionElement(junitparser.Element):
60
+ """Element containing the test description.
61
+
62
+ This class represents a custom XML element for the test description in the JUnit XML test results.
63
+ It extends the `junitparser.Element` class and provides a property to access the text content of the element.
64
+ """
65
+
66
+ __test__ = False
67
+ _tag = 'test-description'
68
+
69
+ @property
70
+ def text(self) -> str | None:
71
+ """Retrieve the text content of the XML element.
72
+
73
+ :return: The text content of the XML element, or None if the element has no text.
74
+ """
75
+ return self._elem.text
76
+
77
+
78
+ class TestCase(junitparser.TestCase):
79
+ """Test case containing custom elements.
80
+
81
+ This class extends the `junitparser.TestCase` class to include custom elements such as test identifier and test
82
+ description. It provides properties to access these custom elements.
83
+ """
84
+
85
+ @property
86
+ def test_identifier(self) -> str | None:
87
+ """Links a test case with a unique test identifier.
88
+
89
+ :return: The unique identifier for the test case.
90
+ """
91
+ elem = self.child(TestIdentifierElement)
92
+ return elem.text if elem is not None else None
93
+
94
+ @property
95
+ def test_description(self) -> str | None:
96
+ """Description of the test case itself, i.e. what is tested and how.
97
+
98
+ :return: The description of the test case.
99
+ """
100
+ elem = self.child(TestDescriptionElement)
101
+ return elem.text if elem is not None else None
102
+
103
+
104
+ class TestSuite(junitparser.TestSuite):
105
+ """Test suite containing custom test cases.
106
+
107
+ This class extends the `junitparser.TestSuite` class to include custom test cases with additional elements.
108
+ It provides methods to iterate over the test cases and parse a test suite from a file.
109
+ """
110
+
111
+ def __iter__(self) -> typing.Iterator[TestCase]: # pyright: ignore [reportIncompatibleMethodOverride]
112
+ for elem in super().__iter__():
113
+ yield TestCase.fromelem(elem) # type: ignore[no-untyped-call]
114
+
115
+ @classmethod
116
+ def from_file(cls, file: pathlib.Path | str) -> 'TestSuite':
117
+ """Parse a test suite from a given file.
118
+
119
+ This method reads a JUnit XML file and parses it into a `TestSuite` object containing custom elements.
120
+
121
+ :param file: The path to the test suite file to be parsed. Can be a `pathlib.Path` object or a string.
122
+ :return: A `TestSuite` object containing the parsed test cases with custom elements.
123
+ :raises ValueError: If the parsed file does not contain a `TestSuite` object.
124
+ :raises FileNotFoundError: If the file does not exist.
125
+ """
126
+ suite = junitparser.JUnitXml.fromfile(str(file))
127
+ if not isinstance(suite, junitparser.TestSuite):
128
+ msg = f'Expected class {junitparser.TestSuite}, got {type(suite)}'
129
+ raise TypeError(msg)
130
+ return cls.fromelem(suite) # type: ignore[no-untyped-call,no-any-return]
pysdccc/_runner.py ADDED
@@ -0,0 +1,311 @@
1
+ """Implements the runner for the SDCcc executable.
2
+
3
+ This module provides the `SdcccRunner` class to manage and execute the SDCcc tests. It handles the configuration,
4
+ requirements, and execution of the SDCcc executable, as well as parsing the test results.
5
+
6
+ Classes
7
+ -------
8
+
9
+ SdcccRunner
10
+ Runner for the SDCcc tests.
11
+
12
+ Functions
13
+ ---------
14
+
15
+ check_requirements(provided: dict[str, dict[str, bool]], available: dict[str, dict[str, bool]]) -> None
16
+ Check if the provided requirements are supported by the available requirements.
17
+
18
+ Usage
19
+ -----
20
+
21
+ .. code-block:: python
22
+
23
+ from pysdccc import SdcccRunner
24
+ import pathlib
25
+
26
+ # Initialize the runner with the path to the SDCcc executable and the test run directory
27
+ runner = SdcccRunner(
28
+ exe=pathlib.Path("/absolute/path/to/sdccc-executable"),
29
+ test_run_dir=pathlib.Path("/absolute/path/to/test-run-directory")
30
+ )
31
+
32
+ # Load the default configuration
33
+ config = runner.get_config()
34
+
35
+ # Load the default requirements
36
+ requirements = runner.get_requirements()
37
+
38
+ # Check user-provided requirements against the SDCcc provided requirements
39
+ runner.check_requirements(pathlib.Path("/absolute/path/to/user-requirements.toml"))
40
+
41
+ # Run the SDCcc executable with the specified configuration and requirements
42
+ exit_code, direct_result, invariant_result = runner.run(
43
+ config=pathlib.Path("/absolute/path/to/config.toml"),
44
+ requirements=pathlib.Path("/absolute/path/to/requirements.toml"),
45
+ timeout=3600 # 1 hour timeout
46
+ )
47
+ """
48
+
49
+ import pathlib
50
+ import subprocess
51
+ import sys
52
+ import tomllib
53
+ import typing
54
+
55
+ import anyio
56
+
57
+ from pysdccc import _common
58
+ from pysdccc._result_parser import TestSuite
59
+
60
+ DIRECT_TEST_RESULT_FILE_NAME = 'TEST-SDCcc_direct.xml'
61
+ INVARIANT_TEST_RESULT_FILE_NAME = 'TEST-SDCcc_invariant.xml'
62
+
63
+
64
+ def check_requirements(provided: dict[str, dict[str, bool]], available: dict[str, dict[str, bool]]) -> None:
65
+ """Check if the provided requirements are supported by the available requirements.
66
+
67
+ This function verifies that all the requirements specified in the `provided` dictionary are supported by the
68
+ requirements in the `available` dictionary. If any requirement in `provided` is not found in `available`, a KeyError
69
+ is raised.
70
+
71
+ :param provided: A dictionary of provided requirements to be verified. The keys are standard names, and the values
72
+ are dictionaries where the keys are requirement IDs and the values are booleans indicating whether
73
+ the requirement is enabled.
74
+ :param available: A dictionary of available requirements provided by SDCcc. The keys are standard names, and the
75
+ values are dictionaries where the keys are requirement IDs and the values are booleans indicating
76
+ whether the requirement is enabled.
77
+ :raise KeyError: If a standard or requirement provided by the user is not found in the SDCcc provided requirements.
78
+ """
79
+ for standard, requirements in provided.items():
80
+ if standard not in available:
81
+ msg = f'Unsupported standard "{standard}". Supported standards are "{list(available)}"'
82
+ raise KeyError(msg)
83
+ provided_enabled = [req for req, enabled in requirements.items() if enabled]
84
+ available_enabled = [a for a, enabled in available[standard].items() if enabled]
85
+ for req in provided_enabled:
86
+ if req not in available_enabled:
87
+ msg = f'Requirement id "{standard}.{req}" not found'
88
+ raise KeyError(msg)
89
+
90
+
91
+ class _BaseRunner:
92
+ """Runner for the SDCcc tests.
93
+
94
+ This class provides methods to manage and execute the SDCcc tests. It handles the configuration, requirements,
95
+ and execution of the SDCcc executable, as well as parsing the test results.
96
+ """
97
+
98
+ def __init__(self, test_run_dir: _common.PATH_TYPE, exe: _common.PATH_TYPE | None = None):
99
+ """Initialize the SdcccRunner object.
100
+
101
+ :param exe: The path to the SDCcc executable. Must be an absolute path.
102
+ :param test_run_dir: The path to the directory where the test run results are to be stored. Must be an absolute
103
+ path.
104
+ :raises ValueError: If the provided paths are not absolute.
105
+ """
106
+ try:
107
+ self.exe = (
108
+ pathlib.Path(exe)
109
+ if exe is not None
110
+ else _common.get_exe_path(_common.DEFAULT_STORAGE_DIRECTORY).absolute()
111
+ )
112
+ except FileNotFoundError as e:
113
+ msg = 'Have you downloaded SDCcc?'
114
+ raise FileNotFoundError(msg) from e
115
+ if not self.exe.is_absolute():
116
+ msg = f'Path to executable must be absolute but is {self.exe}'
117
+ raise ValueError(msg)
118
+ if not self.exe.is_file():
119
+ msg = f'No executable found under {self.exe}'
120
+ raise FileNotFoundError(msg)
121
+ self.test_run_dir = pathlib.Path(test_run_dir)
122
+ if not self.test_run_dir.is_absolute():
123
+ msg = f'Path to test run directory must be absolute but is {self.test_run_dir}'
124
+ raise ValueError(msg)
125
+ if not self.test_run_dir.is_dir():
126
+ msg = f'Test run directory "{self.test_run_dir}" is not a directory or does not exist'
127
+ raise ValueError(msg)
128
+
129
+ def get_config(self) -> dict[str, typing.Any]:
130
+ """Get the default configuration.
131
+
132
+ This method loads the default configuration from the SDCcc executable's directory.
133
+
134
+ :return: A dictionary containing the configuration data.
135
+ """
136
+ return tomllib.loads(self.exe.parent.joinpath('configuration').joinpath('config.toml').read_text())
137
+
138
+ def get_requirements(self) -> dict[str, dict[str, bool]]:
139
+ """Get the default requirements.
140
+
141
+ This method loads the default requirements from the SDCcc executable's directory.
142
+
143
+ :return: A dictionary containing the requirements data.
144
+ """
145
+ return tomllib.loads(self.exe.parent.joinpath('configuration').joinpath('test_configuration.toml').read_text())
146
+
147
+ def get_test_parameter(self) -> dict[str, typing.Any]:
148
+ """Get the default test parameter.
149
+
150
+ This method loads the default test parameters from the SDCcc executable's directory.
151
+
152
+ :return: A dictionary containing the test parameter data.
153
+ """
154
+ return tomllib.loads(self.exe.parent.joinpath('configuration').joinpath('test_parameter.toml').read_text())
155
+
156
+ def check_requirements(self, path: pathlib.Path) -> None:
157
+ """Check the requirements from the given file against the requirements provided by the SDCcc version.
158
+
159
+ This method verifies that all the requirements specified in the user's requirements file are supported by the
160
+ requirements provided by the SDCcc version. If any requirement is not found, a KeyError is raised.
161
+
162
+ :param path: The path to the user's requirements file.
163
+ :raises KeyError: If a standard or requirement provided by the user is not found in the SDCcc provided
164
+ requirements.
165
+ """
166
+ sdccc_provided_requirements = self.get_requirements()
167
+ user_provided_requirements = tomllib.loads(path.read_text())
168
+ check_requirements(user_provided_requirements, sdccc_provided_requirements)
169
+
170
+ def _get_result(self, file_name: str) -> TestSuite | None:
171
+ """Get the parsed results of the test run.
172
+
173
+ This method reads the direct and invariant test result files from the test run directory and returns them
174
+ as TestSuite objects.
175
+
176
+ :return: A tuple containing the parsed direct and invariant test results as TestSuite objects.
177
+ """
178
+ test_result_dir = self.test_run_dir.joinpath(file_name)
179
+ if not test_result_dir.exists():
180
+ return None
181
+ return TestSuite.from_file(test_result_dir)
182
+
183
+ def _prepare_command(
184
+ self,
185
+ *args: str,
186
+ config: pathlib.Path,
187
+ requirements: pathlib.Path,
188
+ **kwargs: _common.CMD_TYPE,
189
+ ) -> list[str]:
190
+ if not config.is_absolute():
191
+ msg = 'Path to config file must be absolute'
192
+ raise ValueError(msg)
193
+ if not requirements.is_absolute():
194
+ msg = 'Path to requirements file must be absolute'
195
+ raise ValueError(msg)
196
+ if list(self.test_run_dir.iterdir()):
197
+ msg = f'{self.test_run_dir} is not empty'
198
+ raise ValueError(msg)
199
+
200
+ kwargs['no_subdirectories'] = 'true'
201
+ kwargs['test_run_directory'] = self.test_run_dir
202
+ kwargs['config'] = config
203
+ kwargs['testconfig'] = requirements
204
+ return _common.build_command(*args, **kwargs)
205
+
206
+
207
+ class SdcccRunner(_BaseRunner):
208
+ """Synchronous runner for SDCcc."""
209
+
210
+ def run(
211
+ self,
212
+ *,
213
+ config: _common.PATH_TYPE,
214
+ requirements: _common.PATH_TYPE,
215
+ timeout: float | None = None,
216
+ **kwargs: _common.CMD_TYPE,
217
+ ) -> tuple[int, TestSuite | None, TestSuite | None]:
218
+ """Run the SDCcc executable using the specified configuration and requirements.
219
+
220
+ This method executes the SDCcc executable with the provided configuration and requirements files,
221
+ and additional command line arguments. It logs the stdout and stderr of the process and waits for the
222
+ process to complete or timeout.
223
+ Checkout more parameter under https://github.com/draegerwerk/sdccc?tab=readme-ov-file#running-sdccc
224
+
225
+ :param config: The path to the configuration file. Must be an absolute path.
226
+ :param requirements: The path to the requirements file. Must be an absolute path.
227
+ :param timeout: The timeout in seconds for the SDCcc process. If None, wait indefinitely.
228
+ :param kwargs: Additional command line arguments to be passed to the SDCcc executable.
229
+ :return: A tuple containing the returncode of the SDCcc process, parsed direct and invariant test results as
230
+ TestSuite objects.
231
+ :raises ValueError: If the provided paths are not absolute.
232
+ :raises subprocess.TimeoutExpired: If the process is running longer than the timeout.
233
+ """
234
+ command = self._prepare_command(
235
+ str(self.exe),
236
+ config=pathlib.Path(config),
237
+ requirements=pathlib.Path(requirements),
238
+ **kwargs,
239
+ )
240
+ try:
241
+ return_code = subprocess.run(command, timeout=timeout, check=True, cwd=self.exe.parent).returncode # noqa: S603
242
+ except subprocess.CalledProcessError as e:
243
+ return_code = e.returncode
244
+ return (
245
+ return_code,
246
+ self._get_result(DIRECT_TEST_RESULT_FILE_NAME),
247
+ self._get_result(INVARIANT_TEST_RESULT_FILE_NAME),
248
+ )
249
+
250
+ def get_version(self) -> str:
251
+ """Get the version of the SDCcc executable."""
252
+ # use capture_output = True to get stdout and stderr instead of check_output which only collects stdout
253
+ process = subprocess.run([str(self.exe), '--version'], check=True, capture_output=True, cwd=self.exe.parent) # noqa: S603
254
+ return process.stdout.decode(_common.ENCODING).strip()
255
+
256
+
257
+ class SdcccRunnerAsync(_BaseRunner):
258
+ """Asynchronous runner for SDCcc."""
259
+
260
+ def __init__(self, test_run_dir: _common.PATH_TYPE, exe: _common.PATH_TYPE | None = None):
261
+ """Initialize the SdcccRunnerAsync object.
262
+
263
+ :param exe: The path to the SDCcc executable. Must be an absolute path.
264
+ :param test_run_dir: The path to the directory where the test run results are to be stored. Must be an absolute
265
+ path.
266
+ :raises ValueError: If the provided paths are not absolute.
267
+ """
268
+ super().__init__(test_run_dir=test_run_dir, exe=exe)
269
+
270
+ async def run(
271
+ self,
272
+ *,
273
+ config: _common.PATH_TYPE,
274
+ requirements: _common.PATH_TYPE,
275
+ **kwargs: _common.CMD_TYPE,
276
+ ) -> tuple[int, TestSuite | None, TestSuite | None]:
277
+ """Run the SDCcc executable using the specified configuration and requirements.
278
+
279
+ This method executes the SDCcc executable with the provided configuration and requirements files,
280
+ and additional command line arguments. It logs the stdout and stderr of the process and waits for the
281
+ process to complete or timeout.
282
+ Checkout more parameter under https://github.com/draegerwerk/sdccc?tab=readme-ov-file#running-sdccc
283
+
284
+ :param config: The path to the configuration file. Must be an absolute path.
285
+ :param requirements: The path to the requirements file. Must be an absolute path.
286
+ :param kwargs: Additional command line arguments to be passed to the SDCcc executable.
287
+ :return: A tuple containing the returncode of the SDCcc process, parsed direct and invariant test results as
288
+ TestSuite objects.
289
+ :raises ValueError: If the provided paths are not absolute.
290
+ :raises TimeoutError: If the process is running longer than the timeout.
291
+ """
292
+ command = self._prepare_command(
293
+ str(self.exe), config=pathlib.Path(config), requirements=pathlib.Path(requirements), **kwargs
294
+ )
295
+ try:
296
+ return_code = (
297
+ await anyio.run_process(command, check=True, cwd=self.exe.parent, stdout=sys.stdout, stderr=sys.stderr)
298
+ ).returncode
299
+ except subprocess.CalledProcessError as e:
300
+ return_code = e.returncode
301
+
302
+ return (
303
+ return_code,
304
+ self._get_result(DIRECT_TEST_RESULT_FILE_NAME),
305
+ self._get_result(INVARIANT_TEST_RESULT_FILE_NAME),
306
+ )
307
+
308
+ async def get_version(self) -> str | None:
309
+ """Get the version of the SDCcc executable."""
310
+ result = await anyio.run_process([self.exe, '--version'], check=True, cwd=self.exe.parent)
311
+ return result.stdout.decode(_common.ENCODING).strip() if result.stdout is not None else None
pysdccc/py.typed ADDED
File without changes
@@ -0,0 +1,245 @@
1
+ Metadata-Version: 2.4
2
+ Name: pysdccc
3
+ Version: 0.1.0
4
+ Summary: Python wrapper for SDCcc test tool
5
+ Project-URL: Homepage, https://github.com/Draegerwerk/pysdccc
6
+ Project-URL: Bug Tracker, https://github.com/Draegerwerk/pysdccc/issues
7
+ Project-URL: Changelog, https://github.com/Draegerwerk/pysdccc/blob/main/CHANGELOG.md
8
+ Project-URL: Repository, https://github.com/Draegerwerk/pysdccc.git
9
+ Author-email: Leon Budnick <leon.budnick@draeger.com>
10
+ License: MIT License
11
+
12
+ Copyright (c) 2024 Dräger
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: SDCcc,sdc,test,wrapper
33
+ Classifier: Development Status :: 5 - Production/Stable
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Operating System :: OS Independent
36
+ Classifier: Programming Language :: Python :: 3
37
+ Requires-Python: <3.14,>=3.11
38
+ Requires-Dist: anyio>=4.7.0
39
+ Requires-Dist: httpx>=0.28.0
40
+ Requires-Dist: junitparser>=3.1.2
41
+ Provides-Extra: cli
42
+ Requires-Dist: click>=8.0; extra == 'cli'
43
+ Description-Content-Type: text/markdown
44
+
45
+ # pysdccc
46
+
47
+ This python packages provides a convenient way to execute the [SDCcc test suite](https://github.com/Draegerwerk/sdccc/).
48
+
49
+ This wrapper is only compatible with SDCcc versions later than [internal-baseline-001](https://github.com/Draegerwerk/SDCcc/releases/tag/internal-baseline-001).
50
+
51
+ ## Installation
52
+
53
+ Download from pypi using `pip install pysdccc`. There is also an option to use this via the command line by installing `pysdccc[cli]`.
54
+
55
+ ### Development
56
+
57
+ For this open source project the [Contributor License Agreement](Contributor_License_Agreement.md) governs all relevant activities and your contributions. By contributing to the project you agree to be bound by this Agreement and to licence your work accordingly.
58
+
59
+ 1. clone the repository
60
+ 2. install dependencies, e.g. with [`uv sync --dev --all-extras`](https://docs.astral.sh/uv/reference/cli/#uv-sync)
61
+
62
+ ## Usage
63
+
64
+ ### Quick start
65
+
66
+ ```python
67
+ import pathlib
68
+ import subprocess
69
+
70
+ import pysdccc
71
+
72
+
73
+ def main():
74
+ if not pysdccc.is_downloaded("my-specific-version"):
75
+ pysdccc.download("https://url/to/sdccc.zip")
76
+
77
+ runner = pysdccc.SdcccRunner(
78
+ pathlib.Path("/path/to/sdccc/result/directory"),
79
+ )
80
+
81
+ try:
82
+ # https://github.com/Draegerwerk/SDCcc/?tab=readme-ov-file#exit-codes
83
+ return_code, direct_result, invariant_result = runner.run(
84
+ config=pathlib.Path("/path/to/configuration/file.toml"),
85
+ requirements=pathlib.Path("/path/to/requirements/file.toml"),
86
+ )
87
+ except subprocess.TimeoutExpired:
88
+ print("Timeout occurred")
89
+ return
90
+
91
+ if direct_result is None or invariant_result is None:
92
+ print("No result file available")
93
+ return
94
+
95
+ for test_case in direct_result + invariant_result:
96
+ print(f"{test_case.name}: {test_case.is_passed}")
97
+ ```
98
+ If you look for an async version
99
+
100
+ ```python
101
+ import pathlib
102
+
103
+ import pysdccc
104
+
105
+
106
+ async def main():
107
+ if not await pysdccc.ais_downloaded("my-specific-version"):
108
+ await pysdccc.adownload("https://url/to/sdccc.zip")
109
+
110
+ runner = pysdccc.SdcccRunnerAsync(
111
+ pathlib.Path("/path/to/sdccc/result/directory"),
112
+ )
113
+
114
+ # https://github.com/Draegerwerk/SDCcc/?tab=readme-ov-file#exit-codes
115
+ return_code, direct_result, invariant_result = await runner.run(
116
+ config=pathlib.Path("/path/to/configuration/file.toml"),
117
+ requirements=pathlib.Path("/path/to/requirements/file.toml"),
118
+ )
119
+
120
+ # checkout example from above ...
121
+ ```
122
+
123
+ ### Download an SDCcc executable
124
+
125
+ Check out `pysdccc.download` or `pysdccc.adownload`. If the command line interface of `pysdccc` is installed, the zipped source of SDCcc can be installed using this command: `pysdccc install https://url/to/sdccc.zip`
126
+
127
+ ### Create configuration file
128
+
129
+ Configure the test consumer. Check the [test consumer configuration](https://github.com/Draegerwerk/SDCcc/?tab=readme-ov-file#test-consumer-configuration) for more information.
130
+
131
+ ```python
132
+ import pathlib
133
+
134
+ import toml # has to be installed by the user
135
+
136
+ import pysdccc
137
+
138
+ config = {
139
+ 'SDCcc': {
140
+ ... # add all relevant config parameter
141
+ }
142
+ }
143
+ config_path = pathlib.Path('/path/to/configuration/file.toml')
144
+ config_path.write_text(toml.dumps(config))
145
+
146
+ runner = pysdccc.SdcccRunner(
147
+ pathlib.Path('/path/to/sdccc/result/directory'),
148
+ )
149
+
150
+ runner.run(
151
+ config=config_path,
152
+ requirements=pathlib.Path('/path/to/requirements/file.toml'),
153
+ )
154
+
155
+ # or if you have already downloaded SDCcc
156
+ config = runner.get_config() # load default configuration
157
+ config['SDCcc']['Consumer']['DeviceEpr'] = "urn:uuid:12345678-1234-1234-1234-123456789012" # e.g. change device epr
158
+ # save and run as above
159
+ ```
160
+
161
+ ### Create requirements file
162
+
163
+ Enable or disable specific requirements. Check the [test requirements](https://github.com/Draegerwerk/SDCcc/?tab=readme-ov-file#enabling-tests) for more information.
164
+
165
+ ```python
166
+ import pathlib
167
+
168
+ import toml # has to be installed by the user
169
+
170
+ import pysdccc
171
+
172
+ requirements = {
173
+ # the standard name is the key, the requirement from the standard is the value
174
+ 'BICEPS': {
175
+ ... # add all requirements to be tested
176
+ }
177
+ }
178
+ requirements_path = pathlib.Path('/path/to/requirement/file.toml')
179
+ requirements_path.write_text(toml.dumps(requirements))
180
+
181
+ runner = pysdccc.SdcccRunner(
182
+ pathlib.Path('/path/to/sdccc/result/directory'),
183
+ )
184
+ # optionally, check whether you did not add a requirement that is not available
185
+ runner.check_requirements(requirements_path)
186
+ runner.run(
187
+ config=pathlib.Path('/path/to/configuration/file.toml'),
188
+ requirements=requirements_path,
189
+ )
190
+
191
+ # or, if you have already downloaded SDCcc
192
+ requirements = runner.get_requirements() # load default configuration
193
+ requirements['BICEPS']['R0033'] = False # e.g. disable biceps R0033
194
+ # save and run as above
195
+ ```
196
+
197
+ ### Create test parameter configuration
198
+
199
+ Some tests require individual parameters. Check the [test parameter configuration](https://github.com/Draegerwerk/SDCcc/?tab=readme-ov-file#test-parameter-configuration) for more information.
200
+
201
+ ```python
202
+ import pathlib
203
+
204
+ import toml # has to be installed by the user
205
+
206
+ import pysdccc
207
+
208
+ testparameter_config = {
209
+ 'TestParameter': {
210
+ ...
211
+ }
212
+ }
213
+ testparameter_config_path = pathlib.Path('/path/to/test_parameter/file.toml')
214
+ testparameter_config_path.write_text(toml.dumps(testparameter_config))
215
+
216
+ runner = pysdccc.SdcccRunner(
217
+ pathlib.Path('/path/to/sdccc/result/directory'),
218
+ )
219
+ runner.run(
220
+ config=pathlib.Path('/path/to/configuration/file.toml'),
221
+ requirements=pathlib.Path('/path/to/requirements/file.toml'),
222
+ testparam=testparameter_config_path,
223
+ )
224
+
225
+ # or, if you have already downloaded SDCcc
226
+ testparameter_config = runner.get_test_parameter() # load default configuration
227
+ testparameter_config['TestParameter']['Biceps547TimeInterval'] = 10
228
+ # save and run as above
229
+ ```
230
+
231
+ ### Execute SDCcc from command-line interface (cli)
232
+
233
+ There exists a cli wrapper for the SDCcc executable. If `pysdccc[cli]` is installed, `sdccc` can be used to execute arbitrary SDCcc commands, e.g. `sdccc --version`. More information can be found [here](https://github.com/draegerwerk/sdccc?tab=readme-ov-file#running-sdccc).
234
+
235
+ ## Notices
236
+
237
+ `pysdccc` is not intended for use in medical products, clinical trials, clinical studies, or in clinical routine.
238
+
239
+ ### ISO 9001
240
+
241
+ `pysdccc` was not developed according to ISO 9001.
242
+
243
+ ## License
244
+
245
+ [MIT](https://choosealicense.com/licenses/mit/)
@@ -0,0 +1,12 @@
1
+ pysdccc/__init__.py,sha256=qnHztGXtWUV3taE_vaNcCOuktWyFpJNWnJh3tJmYLTs,599
2
+ pysdccc/_cli.py,sha256=pbzr3hhc2stlSrNWybLoE6XxTEZgSN1XyeHyUF2Hq7o,4803
3
+ pysdccc/_common.py,sha256=ujgKH6_oA1h2KZXPyShfa3o-nho_I2snmkvfcs7rLm8,2246
4
+ pysdccc/_download.py,sha256=1PVyGcEC63mmZXdUlfg4HaPb-gz-47iB4IAdnne_GAg,4320
5
+ pysdccc/_result_parser.py,sha256=QsRbpV4obBFrirwMHXWLm6zdoxxV_rsdhqzm5YJ13Uk,4603
6
+ pysdccc/_runner.py,sha256=AaWtU7vj_cgfRgrGpn6SQhrvhyFDQhmPQHKaAbMF1b0,13830
7
+ pysdccc/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ pysdccc-0.1.0.dist-info/METADATA,sha256=yLzQyyXJFzi8pcC3jBR2zb1__r9AuyC5zVL_JAlKKSo,8519
9
+ pysdccc-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
10
+ pysdccc-0.1.0.dist-info/entry_points.txt,sha256=1Y-7xLrgZ-44p0AnJikH9buOqLGo-1mDfORut7ZXCkU,72
11
+ pysdccc-0.1.0.dist-info/licenses/LICENSE,sha256=W8bC5k53mY5GatF3EpFnE4bK6m-htGV_IsiveTOwKMc,1064
12
+ pysdccc-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ pysdccc = pysdccc._cli:cli
3
+ sdccc = pysdccc._cli:sdccc
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Dräger
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.