diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3d29ac8..2c55674 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -63,7 +63,7 @@ jobs: - name: Install DragonHPC run: | git clone https://github.com/DragonHPC/dragon.git - pushd dragon && git checkout $_DRAGON_VERSION && pushd devtools && source VARIABLES && popd && pip install -e src/ + pushd dragon && git checkout $_DRAGON_VERSION && pushd devtools && source VARIABLES && popd && pip install src/ - name: Install Redis backend run: | diff --git a/dev-resources/Makefile b/dev-resources/Makefile index 6a09377..982b1da 100644 --- a/dev-resources/Makefile +++ b/dev-resources/Makefile @@ -35,4 +35,4 @@ docs: docs-serve: @command -v doxygen >/dev/null 2>&1 || \ echo "warning: 'doxygen' not found on PATH; the C++ API reference will fail to build." - cd $(ROOT)/.. && $(PYTHON) -m mkdocs serve -a 0.0.0.0:8001 \ No newline at end of file + cd $(ROOT)/.. && $(PYTHON) -m mkdocs serve -a 0.0.0.0:8001 diff --git a/dev-resources/radex-config.toml b/dev-resources/radex-config.toml index 9239999..d41daa8 100644 --- a/dev-resources/radex-config.toml +++ b/dev-resources/radex-config.toml @@ -6,7 +6,8 @@ strict_config = true strict_markers = true markers = [ "slow: Test may be slow to run", - "compiled: Test has a component compiled at run time" + "compiled: Test has a component compiled at run time", + "example: Test is running one of the examples", ] [tool.black] @@ -15,4 +16,4 @@ markers = [ profile = "black" py_version = 312 src_paths = ["../src/python/src", "../tests", "../example"] -known_first_party = ["radex"] \ No newline at end of file +known_first_party = ["radex"] diff --git a/example/active-learn/cpp-mpi-with-ddict/driver.py b/example/active-learn/cpp-mpi-with-ddict/driver.py index ed9d7ab..c716485 100644 --- a/example/active-learn/cpp-mpi-with-ddict/driver.py +++ b/example/active-learn/cpp-mpi-with-ddict/driver.py @@ -65,7 +65,7 @@ MAX_ITER: int = 10 # hard cap on iterations # ── Consts ───────────────────────────────────────────────────────────────────── -HERE = Path(__file__).parent.absolute() +HERE = Path(__file__).resolve().parent ROOT = HERE.parent.parent.parent EXAMPLES_BIN_DIR = ROOT / "install" / "bin" / "examples" diff --git a/example/cpp-exchange/dragon/driver.py b/example/cpp-exchange/dragon/driver.py index 310e9b7..0934efe 100644 --- a/example/cpp-exchange/dragon/driver.py +++ b/example/cpp-exchange/dragon/driver.py @@ -5,7 +5,7 @@ from dragon.data.ddict import DDict from dragon.native.process import Process, ProcessTemplate -HERE = pathlib.Path(__file__).parent.absolute() +HERE = pathlib.Path(__file__).resolve().parent ROOT = HERE.parent.parent.parent EXAMPLES_BIN_DIR = ROOT / "install" / "bin" / "examples" diff --git a/example/cpp-exchange/dragon/requirements.txt b/example/cpp-exchange/dragon/requirements.txt new file mode 100644 index 0000000..547ce39 --- /dev/null +++ b/example/cpp-exchange/dragon/requirements.txt @@ -0,0 +1 @@ +dragonhpc[telemetry]>=0.14.1 diff --git a/example/cpp-exchange/dragon/result.txt b/example/cpp-exchange/dragon/result.txt new file mode 100644 index 0000000..77127c4 --- /dev/null +++ b/example/cpp-exchange/dragon/result.txt @@ -0,0 +1,23 @@ +==> Running Producer... +=========================== +Hello World from Producer!! +--------------------------- +--------------------------- +Goodbye from Producer +=========================== +==> Producer Joined +==> Running Consumer... +=========================== +Hello World from Consumer!! +--------------------------- +Some Int: 123 +Some Float: 1.23 +Some Int Tensor: [ 1, 2, 3, 4, 5, 6, 7, 8, ] + \ -> Dims: [ 2, 4, ] +Some Float Tensor: [ 0.120000, 3.450000, 6.780000, 9.123000, ] + \ -> Dims: [ 4, ] +--------------------------- +Goodbye from Consumer!! +=========================== +==> Consumer Joined ++++ head proc exited, code 0 diff --git a/example/cpp-exchange/in-mem/driver.py b/example/cpp-exchange/in-mem/driver.py index 87db0e2..b75df34 100644 --- a/example/cpp-exchange/in-mem/driver.py +++ b/example/cpp-exchange/in-mem/driver.py @@ -1,7 +1,7 @@ import pathlib import subprocess -HERE = pathlib.Path(__file__).parent.absolute() +HERE = pathlib.Path(__file__).resolve().parent ROOT = HERE.parent.parent.parent EXAMPLES_BIN_DIR = ROOT / "install" / "bin" / "examples" diff --git a/example/cpp-exchange/in-mem/result.txt b/example/cpp-exchange/in-mem/result.txt new file mode 100644 index 0000000..35e2012 --- /dev/null +++ b/example/cpp-exchange/in-mem/result.txt @@ -0,0 +1,11 @@ +============= +Hello World!! +============= +Some Int: 123 +Some Double: 1.23 + +Some Int Tensor: [ 0, 1, 2, 3, ] + \- Dims: [ 2, 2, ] +Some Double Tensor: [ 0.100000, 2.300000, 4.500000, 6.700000, ] + \- Dims: [ 4, ] +============= diff --git a/example/py-cpp-exchange/dragon/app.cpp b/example/py-cpp-exchange/dragon/app.cpp index 1de3e8c..dec1c3c 100644 --- a/example/py-cpp-exchange/dragon/app.cpp +++ b/example/py-cpp-exchange/dragon/app.cpp @@ -46,6 +46,7 @@ void print_vector_key(radex::IClient &client, const std::string &key) { } int main() { + const auto DELAY_SET_TIME = 1'000ms; char *serialized_dd = getenv("SERIALIZED_DDICT"); if (serialized_dd == nullptr) { throw std::runtime_error("DDict descriptor not found!"); @@ -62,22 +63,22 @@ int main() { print_vector_key(client, "py-int-tensor"); print_vector_key(client, "py-float-tensor"); - std::this_thread::sleep_for(3'000ms); + std::this_thread::sleep_for(DELAY_SET_TIME); std::cout << IDENT << "App: Setting Double" << std::endl; client.put_scalar(radex::data::OutgoingHandle{"cpp-double"}, 1.23); - std::this_thread::sleep_for(3'000ms); + std::this_thread::sleep_for(DELAY_SET_TIME); std::cout << IDENT << "App: Setting Int" << std::endl; client.put_scalar(radex::data::OutgoingHandle{"cpp-int"}, 987); - std::this_thread::sleep_for(3'000ms); + std::this_thread::sleep_for(DELAY_SET_TIME); std::cout << IDENT << "App: Setting Double Tensor" << std::endl; std::vector v(12); std::iota(v.begin(), v.end(), 0); client.put_tensor(radex::data::OutgoingHandle{"cpp-double-tensor"}, {4, 3}, v); - std::this_thread::sleep_for(3'000ms); + std::this_thread::sleep_for(DELAY_SET_TIME); std::cout << IDENT << "App: Setting Long Tensor" << std::endl; client.put_tensor(radex::data::OutgoingHandle{"cpp-long-tensor"}, {2, 2, 2}, {1, 2, 3, 4, 5, 6, 7, 8}); diff --git a/example/py-cpp-exchange/dragon/driver.py b/example/py-cpp-exchange/dragon/driver.py index 3a425f4..229d663 100644 --- a/example/py-cpp-exchange/dragon/driver.py +++ b/example/py-cpp-exchange/dragon/driver.py @@ -12,13 +12,20 @@ from radex.clients.core import DragonClient as Client from radex.handles.handles import IncomingHandle, OutgoingHandle -HERE = pathlib.Path(__file__).parent.absolute() +HERE = pathlib.Path(__file__).resolve().parent ROOT = HERE.parent.parent.parent EXAMPLES_BIN_DIR = ROOT / "install" / "bin" / "examples" +DELAY_SET_TIME = 1 def main() -> int: - dd = DDict(managers_per_node=1, n_nodes=1, trace=False) + dd = DDict( + managers_per_node=1, + n_nodes=1, + trace=False, + wait_for_keys=True, + working_set_size=3, + ) serial_dd = dd.serialize() app_tmpl = ProcessTemplate( target=os.fspath(EXAMPLES_BIN_DIR / "dragon-cpp-with-py"), @@ -26,36 +33,36 @@ def main() -> int: ) app = Process.from_template(app_tmpl) - print(f"Driver: Making client") + print(f"Driver: Making client", flush=True) client = Client(serial_dd, 5) - print(f"Driver: Starting app") + print(f"Driver: Starting app", flush=True) app.start() try: - time.sleep(3) - print("Driver: Setting Int") + time.sleep(DELAY_SET_TIME) + print("Driver: Setting Int", flush=True) client.put_scalar(OutgoingHandle("py-int"), 123) - time.sleep(3) - print("Driver: Setting Double") + time.sleep(DELAY_SET_TIME) + print("Driver: Setting Double", flush=True) client.put_scalar(OutgoingHandle("py-double"), 9.87) - time.sleep(3) - print("Driver: Setting Numpy Int") + time.sleep(DELAY_SET_TIME) + print("Driver: Setting Numpy Int", flush=True) client.put_scalar(OutgoingHandle("py-np-float"), np.float32(45.6)) - time.sleep(3) - print("Driver: Setting Int Tensor") + time.sleep(DELAY_SET_TIME) + print("Driver: Setting Int Tensor", flush=True) client.put_tensor(OutgoingHandle("py-int-tensor"), np.arange(4, dtype=np.int32)) - time.sleep(3) - print("Driver: Setting Float Tensor") + time.sleep(DELAY_SET_TIME) + print("Driver: Setting Float Tensor", flush=True) client.put_tensor( OutgoingHandle("py-float-tensor"), np.arange(12, dtype=np.float64).reshape((6, 2)), ) - print(f"Driver: Looking for keys") + print(f"Driver: Looking for keys", flush=True) print_scalar(client, "cpp-double") print_scalar(client, "cpp-int") print_tensor(client, "cpp-double-tensor") @@ -65,9 +72,9 @@ def main() -> int: py_obj_key = "my-py-obj" obj = C("spam-and-eggs") client.put_picklable(py_obj_key, obj) - print("Driver: Getting a py object") + print("Driver: Getting a py object", flush=True) recv = client.get_picklable(py_obj_key) - print(f"Driver: Got object `{recv}`") + print(f"Driver: Got object `{recv}`", flush=True) finally: app.join() @@ -75,24 +82,30 @@ def main() -> int: def print_scalar(client, key): - print(f"Driver: Waiting for scalar key `{key}`") + print(f"Driver: Waiting for scalar key `{key}`", flush=True) scalar = client.wait_for_scalar(IncomingHandle(key), 10) - print(textwrap.dedent(f"""\ + print( + textwrap.dedent(f"""\ Driver: Got scalar: |- Type: {scalar.dtype} \\- Value: {scalar} - """)) + """), + flush=True, + ) def print_tensor(client, key): - print(f"Driver: Waiting for tensor key `{key}`") + print(f"Driver: Waiting for tensor key `{key}`", flush=True) tensor = client.wait_for_tensor(IncomingHandle(key), 10) - print(textwrap.dedent(f"""\ + print( + textwrap.dedent(f"""\ Driver: Got tensor: |- Type: {tensor.dtype} |- Dims: {tensor.shape} \\- Data: {tensor.ravel()} - """)) + """), + flush=True, + ) @dataclasses.dataclass(frozen=True) diff --git a/example/py-cpp-exchange/dragon/requirements.txt b/example/py-cpp-exchange/dragon/requirements.txt new file mode 100644 index 0000000..547ce39 --- /dev/null +++ b/example/py-cpp-exchange/dragon/requirements.txt @@ -0,0 +1 @@ +dragonhpc[telemetry]>=0.14.1 diff --git a/example/py-cpp-exchange/dragon/result.txt b/example/py-cpp-exchange/dragon/result.txt new file mode 100644 index 0000000..fbddb24 --- /dev/null +++ b/example/py-cpp-exchange/dragon/result.txt @@ -0,0 +1,59 @@ +Driver: Making client +Driver: Starting app + App: Creating client + App: Client created + App: Waiting for scalar key `py-int` +Driver: Setting Int + App: Got key `py-int` has value 123 + + App: Waiting for scalar key `py-double` +Driver: Setting Double + App: Got key `py-double` has value 9.87 + + App: Waiting for scalar key `py-np-float` +Driver: Setting Numpy Int + App: Got key `py-np-float` has value 45.6 + + App: Waiting for tensor key `py-int-tensor` +Driver: Setting Int Tensor + App: Got key `py-int-tensor` + |- Data: [ 0, 1, 2, 3, ] + \- Dims: [ 4, ] + + App: Waiting for tensor key `py-float-tensor` +Driver: Setting Float Tensor +Driver: Looking for keys +Driver: Waiting for scalar key `cpp-double` + App: Got key `py-float-tensor` + |- Data: [ 0.000000, 1.000000, 2.000000, 3.000000, 4.000000, 5.000000, 6.000000, 7.000000, 8.000000, 9.000000, 10.000000, 11.000000, ] + \- Dims: [ 6, 2, ] + + App: Setting Double +Driver: Got scalar: + |- Type: float64 + \- Value: 1.23 + +Driver: Waiting for scalar key `cpp-int` + App: Setting Int +Driver: Got scalar: + |- Type: int32 + \- Value: 987 + +Driver: Waiting for tensor key `cpp-double-tensor` + App: Setting Double Tensor +Driver: Got tensor: + |- Type: float64 + |- Dims: (4, 3) + \- Data: [ 0. 1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11.] + +Driver: Waiting for tensor key `cpp-long-tensor` + App: Setting Long Tensor +Driver: Got tensor: + |- Type: int32 + |- Dims: (2, 2, 2) + \- Data: [1 2 3 4 5 6 7 8] + +Driver: Setting a py object +Driver: Getting a py object +Driver: Got object `C(msg='spam-and-eggs')` ++++ head proc exited, code 0 diff --git a/tests/examples/conftest.py b/tests/examples/conftest.py new file mode 100644 index 0000000..263b9b3 --- /dev/null +++ b/tests/examples/conftest.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import abc +import dataclasses +import importlib.metadata +import os +import pathlib +import re +import subprocess as sp +import sys + +import pytest + +HERE = pathlib.Path(__file__).absolute().parent +ROOT = HERE.parent.parent +EXAMPLES_DIR = ROOT / "example" + + +@dataclasses.dataclass(frozen=True) +class Example(abc.ABC): + directory: pathlib.Path + marks: list[pytest.MarkDecorator] = dataclasses.field( + default_factory=list, kw_only=True + ) + + def __post_init__(self): + self.marks.append(pytest.mark.example) + + @abc.abstractmethod + def _run(self, cwd, out, err) -> int: ... + + @property + def test_id(self) -> str: + return os.fspath(self.directory.relative_to(EXAMPLES_DIR)) + + @property + def driver(self) -> pathlib.Path: + return self.directory / "driver.py" + + @property + def requirements_file(self) -> pathlib.Path: + return self.directory / "requirements.txt" + + @property + def expected_stdout(self) -> pathlib.Path: + return self.directory / "result.txt" + + def check_for_requirements(self) -> None: + if not self.requirements_file.exists(): + return + pattern = re.compile(r"^\s*([\w\.\-]+)") + with self.requirements_file.open("r", encoding="utf-8") as reqs: + for req in reqs: + match = pattern.match(req) + if match is None: + continue + pkg_name = match.group(1) + try: + importlib.metadata.version(pkg_name) + except importlib.metadata.PackageNotFoundError: + pytest.skip( + f"Failed to find requirement `{pkg_name}`. " + "Try running " + f"`pip install -r {os.fspath(self.requirements_file)}`" + ) + + def run(self, *, where) -> tuple[int, pathlib.Path, pathlib.Path]: + self.check_for_requirements() + out_file = where / "example.out" + err_file = where / "example.err" + with ( + open(out_file, "w", encoding="utf-8") as out, + open(err_file, "w", encoding="utf-8") as err, + ): + return self._run(self.directory, out, err), out_file, err_file + + +class LocalExample(Example): + def _run(self, cwd, out, err) -> int: + return sp.run( + [sys.executable, os.fspath(self.driver)], cwd=cwd, stdout=out, stderr=err + ).returncode + + +class DragonExample(Example): + def __init__( + self, + directory: pathlib.Path, + num_nodes: int | None, + *, + marks: list[pytest.MarkDecorator] | None = None, + ) -> None: + marks = marks or [] + dragon_args = [] + if num_nodes is not None and num_nodes <= 0: + raise ValueError( + "Dragon examples must either be runnable without an allocation " + "or on a posative number of nodes" + ) + + try: + import dragon + from dragon.globalservices.api_setup import get_gs_ret_cuid + from dragon.native.machine import System as DrgSystem + except ImportError: + libs_path = None + marks.append(pytest.mark.skip(reason="This example requires dragon")) + else: + NOT_ENOUGH_NODES = pytest.mark.skip( + reason=f"Example requires an allocation of {num_nodes} node(s)" + ) + try: + get_gs_ret_cuid() + except Exception: + # Test suite was not run through dragon + dragon_args.append("-s") + if num_nodes is not None: + marks.append(NOT_ENOUGH_NODES) + else: + # Test suite was run through dragon (including `dragon -s ...`) + drg_system = DrgSystem() + if num_nodes is not None and num_nodes < drg_system.nnodes: + marks.append(NOT_ENOUGH_NODES) + + # FIXME: We should allow for running multinode examples. This + # is a good enough starting point to wire in a few + # single node examples for now. We should probably move + # this over to launching a proper + # `dragon.native.process.Process` or equivalent. + if num_nodes is None: + dragon_args.append("-s") + else: + dragon_args.extend(["-N", str(num_nodes)]) + marks.append( + pytest.mark.xfail( + strict=False, + reason=( + "Examples that cannot be run with `dragon -s ...` " + "are not fully supported in the test suite. See " + "https://github.com/radical-cybertools/radex/issues/25 " + "for more info" + ), + ) + ) + + super().__init__(directory, marks=marks) + self._dragon_args = dragon_args + + def _run(self, cwd, out, err) -> int: + return sp.run( + [ + sys.executable, + "-m", + "dragon", + "--", + *self._dragon_args, + os.fspath(self.driver), + ], + cwd=cwd, + stdout=out, + stderr=err, + ).returncode + + +@pytest.fixture( + scope="function", + params=[ + pytest.param(example, id=example.test_id, marks=example.marks) + for example in [ + LocalExample(EXAMPLES_DIR / "cpp-exchange/in-mem"), + DragonExample(EXAMPLES_DIR / "cpp-exchange/dragon", num_nodes=None), + DragonExample( + EXAMPLES_DIR / "py-cpp-exchange/dragon", + num_nodes=None, + marks=[pytest.mark.slow], + ), + ] + ], +) +def example(request): + yield request.param diff --git a/tests/examples/test_examples.py b/tests/examples/test_examples.py new file mode 100644 index 0000000..ab88313 --- /dev/null +++ b/tests/examples/test_examples.py @@ -0,0 +1,47 @@ +import os +import pathlib +import sys + + +def test_run_example(example, tmp_path, monkeypatch): + # >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + # FIXME: Ideally we can remove this env futzing when we figure out how to + # properly set the rpath on the examples such that `libdragon.so` + # does not need to be present on + # `LD_LIBRARY_PATH`/`DYLD_LIBRARY_PATH`. For now we can just add it + # based on the install site of the dragon package. There is likely a + # similar error with `libsmartredis.so` as well, but our CI appends + # the path to the library look up env var already, so we do not need + # to do that here. + # ================================================================================= + ld_lib_path = "DYLD_LIBRARY_PATH" if sys.platform == "darwin" else "LD_LIBRARY_PATH" + try: + import dragon + except ImportError: + pytest.xfail( + reason=( + f"Dragon library `libdragon.so` must be on {ld_lib_path} " + "to run the examples due to unresolved linking errors" + ) + ) + else: + dragon_path, *_ = dragon.__path__ + drg_libs_paths = ":".join( + os.fspath(p) for p in pathlib.Path(dragon_path).absolute().glob("lib*") + ) + + prev_ld_lib_path = os.environ.get(ld_lib_path, "") + new_ld_lib_path = ( + f"{drg_libs_paths}:{prev_ld_lib_path}" if prev_ld_lib_path else dragon_libs + ) + monkeypatch.setenv(ld_lib_path, new_ld_lib_path) + # <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + returncode, out, err = example.run(where=tmp_path) + assert returncode == 0 + with ( + open(out, "r", encoding="utf-8") as fh, + open(example.expected_stdout, "r", encoding="utf-8") as xfh, + ): + for line, xline in zip(fh, xfh): + assert line.strip() == xline.strip()