diff --git a/bazel/py_itf_test.bzl b/bazel/py_itf_test.bzl index 9f94aa6..2437078 100644 --- a/bazel/py_itf_test.bzl +++ b/bazel/py_itf_test.bzl @@ -12,7 +12,6 @@ # ******************************************************************************* """Bazel interface for running pytest""" -load("@itf_pip//:requirements.bzl", "requirement") load("@rules_python//python:defs.bzl", "py_test") load("@score_itf//bazel/rules:run_as_exec.bzl", "test_as_exec") diff --git a/score/itf/core/target/BUILD b/score/itf/core/target/BUILD new file mode 100644 index 0000000..a78b29a --- /dev/null +++ b/score/itf/core/target/BUILD @@ -0,0 +1,27 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("@itf_pip//:requirements.bzl", "requirement") +load("@rules_python//python:defs.bzl", "py_library") + +py_library( + name = "target", + srcs = [ + "__init__.py", + "target.py", + ], + visibility = ["//visibility:public"], + deps = [ + requirement("typing-extensions"), + ], +) diff --git a/score/itf/core/target/__init__.py b/score/itf/core/target/__init__.py new file mode 100644 index 0000000..89e0ed8 --- /dev/null +++ b/score/itf/core/target/__init__.py @@ -0,0 +1,14 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +from .target import Target diff --git a/score/itf/core/target/target.py b/score/itf/core/target/target.py new file mode 100644 index 0000000..6b38777 --- /dev/null +++ b/score/itf/core/target/target.py @@ -0,0 +1,102 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +from typing import Set, Optional, Any, Dict + + +class Target: + """Base target interface for test implementations. + + This class provides a common interface for targets that tests can implement against. + Targets support a capability-based system that allows tests to query and make + decisions based on available capabilities. + + Example: + class MyTarget(Target): + def __init__(self): + super().__init__(capabilities={'ssh', 'file_transfer'}) + + def execute(self, command): + # Implementation specific logic + pass + + # In a test: + if target.has_capability('ssh'): + target.execute('ls -la') + """ + + def __init__(self, capabilities: Optional[Set[str]] = None): + """Initialize the target with a set of capabilities. + + Args: + capabilities: Set of capability identifiers supported by this target. + If None, an empty set is used. + """ + self._capabilities: Set[str] = capabilities if capabilities is not None else set() + + def has_capability(self, capability: str) -> bool: + """Check if the target supports a specific capability. + + Args: + capability: The capability identifier to check. + + Returns: + True if the capability is supported, False otherwise. + """ + return capability in self._capabilities + + def has_all_capabilities(self, capabilities: Set[str]) -> bool: + """Check if the target supports all of the specified capabilities. + + Args: + capabilities: Set of capability identifiers to check. + + Returns: + True if all capabilities are supported, False otherwise. + """ + return capabilities.issubset(self._capabilities) + + def has_any_capability(self, capabilities: Set[str]) -> bool: + """Check if the target supports any of the specified capabilities. + + Args: + capabilities: Set of capability identifiers to check. + + Returns: + True if at least one capability is supported, False otherwise. + """ + return bool(capabilities.intersection(self._capabilities)) + + def get_capabilities(self) -> Set[str]: + """Get all capabilities supported by this target. + + Returns: + Set of all capability identifiers. + """ + return self._capabilities.copy() + + def add_capability(self, capability: str) -> None: + """Add a capability to the target. + + Args: + capability: The capability identifier to add. + """ + self._capabilities.add(capability) + + def remove_capability(self, capability: str) -> None: + """Remove a capability from the target. + + Args: + capability: The capability identifier to remove. + """ + self._capabilities.discard(capability) diff --git a/score/itf/plugins/BUILD b/score/itf/plugins/BUILD index c4f0048..c597277 100644 --- a/score/itf/plugins/BUILD +++ b/score/itf/plugins/BUILD @@ -18,6 +18,7 @@ py_library( srcs = ["core.py"], visibility = ["//visibility:public"], deps = [ + "//score/itf/core/target", requirement("pytest"), ], ) @@ -27,6 +28,8 @@ py_library( srcs = ["docker.py"], visibility = ["//visibility:public"], deps = [ + ":core", + "//score/itf/core/target", requirement("docker"), requirement("pytest"), ], diff --git a/score/itf/plugins/core.py b/score/itf/plugins/core.py index 7020d47..57f4391 100644 --- a/score/itf/plugins/core.py +++ b/score/itf/plugins/core.py @@ -10,7 +10,11 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* + import pytest +import functools + +from score.itf.core.target import Target def pytest_addoption(parser): @@ -32,6 +36,16 @@ def determine_target_scope(fixture_name, config): return "function" +@pytest.fixture(scope=determine_target_scope) +def target_init(): + """Fixture to initialize the target. + + Plugins need to implement this fixture to provide the actual target instance. + The scope of this fixture is determined by the --keep-target command line option. + """ + yield Target() + + @pytest.fixture(scope=determine_target_scope) def target(target_init): """Use automatic fixture resolution @@ -39,3 +53,35 @@ def target(target_init): Plugins need to define a pytest fixture 'target_init' """ yield target_init + + +def requires_capabilities(*capabilities): + """Decorator to skip tests if target doesn't have all required capabilities. + + Args: + *capabilities: Variable number of capability identifiers required for the test. + + Example: + @requires_capabilities("exec", "ssh") + def test_remote_command(target): + target.exec_run("ls -la") + """ + + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + target = kwargs.get("target") + if target is None: + for arg in args: + if hasattr(arg, "has_all_capabilities"): + target = arg + break + + if target and not target.has_all_capabilities(set(capabilities)): + pytest.skip(f"Target missing required capabilities: {capabilities}") + + return func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/score/itf/plugins/dlt/__init__.py b/score/itf/plugins/dlt/__init__.py index c4b52d8..420d19b 100644 --- a/score/itf/plugins/dlt/__init__.py +++ b/score/itf/plugins/dlt/__init__.py @@ -15,6 +15,7 @@ from score.itf.core.utils.bunch import Bunch from score.itf.plugins.core import determine_target_scope +from score.itf.plugins.dlt.dlt_receive import DltReceive, Protocol def pytest_addoption(parser): diff --git a/score/itf/plugins/docker.py b/score/itf/plugins/docker.py index 56b2f89..ebf7bc6 100644 --- a/score/itf/plugins/docker.py +++ b/score/itf/plugins/docker.py @@ -16,6 +16,7 @@ import pytest from score.itf.plugins.core import determine_target_scope +from score.itf.plugins.core import Target logger = logging.getLogger(__name__) @@ -36,6 +37,18 @@ def pytest_addoption(parser): ) +DOCKER_CAPABILITIES = ["exec"] + + +class DockerTarget(Target): + def __init__(self, container, capabilities=DOCKER_CAPABILITIES): + super().__init__(capabilities=capabilities) + self.container = container + + def __getattr__(self, name): + return getattr(self.container, name) + + @pytest.fixture(scope=determine_target_scope) def target_init(request): docker_image_bootstrap = request.config.getoption("docker_image_bootstrap") @@ -46,5 +59,5 @@ def target_init(request): docker_image = request.config.getoption("docker_image") client = pypi_docker.from_env() container = client.containers.run(docker_image, "sleep infinity", detach=True, auto_remove=True, init=True) - yield container + yield DockerTarget(container) container.stop(timeout=1) diff --git a/test/test_docker.py b/test/test_docker.py index b260dbd..e560f8f 100644 --- a/test/test_docker.py +++ b/test/test_docker.py @@ -11,6 +11,8 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* +import score.itf + def check_command_exec(target, message): exit_code, output = target.exec_run(f"echo -n {message}") @@ -23,3 +25,13 @@ def test_docker_runs_1(target): def test_docker_runs_2(target): assert check_command_exec(target, "hello, world 1") + + +@score.itf.plugins.core.requires_capabilities("exec") +def test_docker_runs_for_exec_capability(target): + assert check_command_exec(target, "hello, world 1") + + +@score.itf.plugins.core.requires_capabilities("non-existing-capability") +def test_docker_skipped_for_non_existing_capability(target): + assert False, "This test should have been skipped due to missing capability" diff --git a/test/test_rules_are_working_correctly.py b/test/test_rules_are_working_correctly.py index 45d6706..39cbe6d 100644 --- a/test/test_rules_are_working_correctly.py +++ b/test/test_rules_are_working_correctly.py @@ -12,3 +12,11 @@ # ******************************************************************************* def test_local_fixture_has_correct_value(fixture42): assert 42 == fixture42 + + +def test_target_fixture_is_available(target): + assert target is not None + + +def test_target_fixture_has_no_capabilities_by_default(target): + assert target.get_capabilities() == set()