From 7eef40d0ab9794783cda6ef0f2b2b635002591db Mon Sep 17 00:00:00 2001 From: Bryan Van de Ven Date: Tue, 27 Sep 2022 13:57:43 -0700 Subject: [PATCH 01/16] initial import of test driver code --- legate/tester/__init__.py | 74 +++++ legate/tester/args.py | 286 ++++++++++++++++++ legate/tester/config.py | 161 ++++++++++ legate/tester/logger.py | 67 ++++ legate/tester/stages/__init__.py | 41 +++ legate/tester/stages/_linux/__init__.py | 24 ++ legate/tester/stages/_linux/cpu.py | 80 +++++ legate/tester/stages/_linux/eager.py | 71 +++++ legate/tester/stages/_linux/gpu.py | 82 +++++ legate/tester/stages/_linux/omp.py | 84 +++++ legate/tester/stages/_osx/__init__.py | 24 ++ legate/tester/stages/_osx/cpu.py | 64 ++++ legate/tester/stages/_osx/eager.py | 64 ++++ legate/tester/stages/_osx/gpu.py | 51 ++++ legate/tester/stages/_osx/omp.py | 70 +++++ legate/tester/stages/test_stage.py | 265 ++++++++++++++++ legate/tester/stages/util.py | 115 +++++++ legate/tester/system.py | 170 +++++++++++ legate/tester/test_plan.py | 131 ++++++++ legate/tester/types.py | 50 +++ legate/tester/ui.py | 229 ++++++++++++++ tests/unit/legate/tester/__init__.py | 15 + tests/unit/legate/tester/stages/__init__.py | 38 +++ .../legate/tester/stages/_linux/__init__.py | 22 ++ .../legate/tester/stages/_linux/test_cpu.py | 132 ++++++++ .../legate/tester/stages/_linux/test_eager.py | 82 +++++ .../legate/tester/stages/_linux/test_gpu.py | 101 +++++++ .../legate/tester/stages/_linux/test_omp.py | 164 ++++++++++ .../legate/tester/stages/test_test_stage.py | 88 ++++++ tests/unit/legate/tester/stages/test_util.py | 48 +++ tests/unit/legate/tester/test___init__.py | 73 +++++ tests/unit/legate/tester/test_args.py | 132 ++++++++ tests/unit/legate/tester/test_config.py | 182 +++++++++++ tests/unit/legate/tester/test_logger.py | 74 +++++ tests/unit/legate/tester/test_system.py | 78 +++++ tests/unit/legate/tester/test_types.py | 30 ++ tests/unit/legate/tester/test_ui.py | 103 +++++++ 37 files changed, 3565 insertions(+) create mode 100644 legate/tester/__init__.py create mode 100644 legate/tester/args.py create mode 100644 legate/tester/config.py create mode 100644 legate/tester/logger.py create mode 100644 legate/tester/stages/__init__.py create mode 100644 legate/tester/stages/_linux/__init__.py create mode 100644 legate/tester/stages/_linux/cpu.py create mode 100644 legate/tester/stages/_linux/eager.py create mode 100644 legate/tester/stages/_linux/gpu.py create mode 100644 legate/tester/stages/_linux/omp.py create mode 100644 legate/tester/stages/_osx/__init__.py create mode 100644 legate/tester/stages/_osx/cpu.py create mode 100644 legate/tester/stages/_osx/eager.py create mode 100644 legate/tester/stages/_osx/gpu.py create mode 100644 legate/tester/stages/_osx/omp.py create mode 100644 legate/tester/stages/test_stage.py create mode 100644 legate/tester/stages/util.py create mode 100644 legate/tester/system.py create mode 100644 legate/tester/test_plan.py create mode 100644 legate/tester/types.py create mode 100644 legate/tester/ui.py create mode 100644 tests/unit/legate/tester/__init__.py create mode 100644 tests/unit/legate/tester/stages/__init__.py create mode 100644 tests/unit/legate/tester/stages/_linux/__init__.py create mode 100644 tests/unit/legate/tester/stages/_linux/test_cpu.py create mode 100644 tests/unit/legate/tester/stages/_linux/test_eager.py create mode 100644 tests/unit/legate/tester/stages/_linux/test_gpu.py create mode 100644 tests/unit/legate/tester/stages/_linux/test_omp.py create mode 100644 tests/unit/legate/tester/stages/test_test_stage.py create mode 100644 tests/unit/legate/tester/stages/test_util.py create mode 100644 tests/unit/legate/tester/test___init__.py create mode 100644 tests/unit/legate/tester/test_args.py create mode 100644 tests/unit/legate/tester/test_config.py create mode 100644 tests/unit/legate/tester/test_logger.py create mode 100644 tests/unit/legate/tester/test_system.py create mode 100644 tests/unit/legate/tester/test_types.py create mode 100644 tests/unit/legate/tester/test_ui.py diff --git a/legate/tester/__init__.py b/legate/tester/__init__.py new file mode 100644 index 0000000000..11b8f1d700 --- /dev/null +++ b/legate/tester/__init__.py @@ -0,0 +1,74 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Utilities and helpers for implementing the Cunumeric custom test runner. + +""" +from __future__ import annotations + +from typing import Union +from typing_extensions import Literal, TypeAlias + +#: Define the available feature types for tests +FeatureType: TypeAlias = Union[ + Literal["cpus"], Literal["cuda"], Literal["eager"], Literal["openmp"] +] + +#: Value to use if --cpus is not specified. +DEFAULT_CPUS_PER_NODE = 4 + +#: Value to use if --gpus is not specified. +DEFAULT_GPUS_PER_NODE = 1 + +# Delay to introduce between GPU test invocations (ms) +DEFAULT_GPU_DELAY = 2000 + +# Value to use if --fbmem is not specified (MB) +DEFAULT_GPU_MEMORY_BUDGET = 4096 + +#: Value to use if --omps is not specified. +DEFAULT_OMPS_PER_NODE = 1 + +#: Value to use if --ompthreads is not specified. +DEFAULT_OMPTHREADS = 4 + +#: Default values to apply to normalize the testing environment. +DEFAULT_PROCESS_ENV = { + "LEGATE_TEST": "1", +} + +#: Width for terminal ouput headers and footers. +UI_WIDTH = 65 + +#: Feature values that are accepted for --use, in the relative order +#: that the corresponding test stages should always execute in +FEATURES: tuple[FeatureType, ...] = ( + "cpus", + "cuda", + "eager", + "openmp", +) + +#: Paths to example files that should be skipped. +SKIPPED_EXAMPLES = { + "examples/ingest.py", + "examples/kmeans_sort.py", + "examples/lstm_full.py", + "examples/wgrad.py", +} + +#: Extra arguments to supply when specific examples are executed. +PER_FILE_ARGS = { + "examples/lstm_full.py": ["--file", "resources/lstm_input.txt"], +} diff --git a/legate/tester/args.py b/legate/tester/args.py new file mode 100644 index 0000000000..d97ebf603d --- /dev/null +++ b/legate/tester/args.py @@ -0,0 +1,286 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Provide an argparse ArgumentParser for the test runner. + +""" +from __future__ import annotations + +from argparse import Action, ArgumentParser, Namespace +from typing import ( + Any, + Generic, + Iterable, + Iterator, + Literal, + Sequence, + TypeVar, + Union, +) + +from typing_extensions import TypeAlias + +from . import ( + DEFAULT_CPUS_PER_NODE, + DEFAULT_GPU_DELAY, + DEFAULT_GPU_MEMORY_BUDGET, + DEFAULT_GPUS_PER_NODE, + DEFAULT_OMPS_PER_NODE, + DEFAULT_OMPTHREADS, + FEATURES, +) + +T = TypeVar("T") + +PinOptionsType: TypeAlias = Union[ + Literal["partial"], + Literal["none"], + Literal["strict"], +] + +PIN_OPTIONS: tuple[PinOptionsType, ...] = ( + "partial", + "none", + "strict", +) + + +class MultipleChoices(Generic[T]): + """A container that reports True for any item or subset inclusion. + + Parameters + ---------- + choices: Iterable[T] + The values to populate the containter. + + Examples + -------- + + >>> choices = MultipleChoices(["a", "b", "c"]) + + >>> "a" in choices + True + + >>> ("b", "c") in choices + True + + """ + + def __init__(self, choices: Iterable[T]) -> None: + self.choices = set(choices) + + def __contains__(self, x: Union[T, Iterable[T]]) -> bool: + if isinstance(x, (list, tuple)): + return set(x).issubset(self.choices) + return x in self.choices + + def __iter__(self) -> Iterator[T]: + return self.choices.__iter__() + + +class ExtendAction(Action): + """A custom argparse action to collect multiple values into a list.""" + + def __call__( + self, + parser: ArgumentParser, + namespace: Namespace, + values: Union[str, Sequence[Any], None], + option_string: Union[str, None] = None, + ) -> None: + items = getattr(namespace, self.dest, None) or [] + if isinstance(values, list): + items.extend(values) + else: + items.append(values) + setattr(namespace, self.dest, items) + + +#: The argument parser for test.py +parser = ArgumentParser( + description="Run the Cunumeric test suite", + epilog="Any extra arguments will be forwarded to the Legate script", +) + + +stages = parser.add_argument_group("Feature stage selection") + + +stages.add_argument( + "--use", + dest="features", + action=ExtendAction, + choices=MultipleChoices(sorted(FEATURES)), + # argpase evidently only expects string returns from the type converter + # here, but returning a list of strings seems to work in practice + type=lambda s: s.split(","), # type: ignore[return-value, arg-type] + help="Test Legate with features (also via USE_*)", +) + + +selection = parser.add_argument_group("Test file selection") + + +selection.add_argument( + "--files", + nargs="+", + default=None, + help="Explicit list of test files to run", +) + + +selection.add_argument( + "--unit", + dest="unit", + action="store_true", + default=False, + help="Include unit tests", +) + + +feature_opts = parser.add_argument_group("Feature stage configuration options") + + +feature_opts.add_argument( + "--cpus", + dest="cpus", + type=int, + default=DEFAULT_CPUS_PER_NODE, + help="Number of CPUs per node to use", +) + + +feature_opts.add_argument( + "--gpus", + dest="gpus", + type=int, + default=DEFAULT_GPUS_PER_NODE, + help="Number of GPUs per node to use", +) + + +feature_opts.add_argument( + "--omps", + dest="omps", + type=int, + default=DEFAULT_OMPS_PER_NODE, + help="Number OpenMP processors per node to use", +) + + +feature_opts.add_argument( + "--utility", + dest="utility", + type=int, + default=1, + help="Number of of utility CPUs to reserve for runtime services", +) + + +feature_opts.add_argument( + "--cpu-pin", + dest="cpu_pin", + choices=PIN_OPTIONS, + default="partial", + help="CPU pinning behavior on platforms that support CPU pinning", +) + +feature_opts.add_argument( + "--gpu-delay", + dest="gpu_delay", + type=int, + default=DEFAULT_GPU_DELAY, + help="Delay to introduce between GPU tests (ms)", +) + + +feature_opts.add_argument( + "--fbmem", + dest="fbmem", + type=int, + default=DEFAULT_GPU_MEMORY_BUDGET, + help="GPU framebuffer memory (MB)", +) + + +feature_opts.add_argument( + "--ompthreads", + dest="ompthreads", + metavar="THREADS", + type=int, + default=DEFAULT_OMPTHREADS, + help="Number of threads per OpenMP processor", +) + + +test_opts = parser.add_argument_group("Test run configuration options") + + +test_opts.add_argument( + "--legate", + dest="legate_dir", + metavar="LEGATE_DIR", + action="store", + default=None, + required=False, + help="Path to Legate installation directory", +) + + +test_opts.add_argument( + "-C", + "--directory", + dest="test_root", + metavar="DIR", + action="store", + default=None, + required=False, + help="Root directory containing the tests subdirectory", +) + + +test_opts.add_argument( + "-j", + "--workers", + dest="workers", + type=int, + default=None, + help="Number of parallel workers for testing", +) + + +test_opts.add_argument( + "-v", + "--verbose", + dest="verbose", + action="count", + default=0, + help="Display verbose output. Use -vv for even more output (test stdout)", +) + + +test_opts.add_argument( + "--dry-run", + dest="dry_run", + action="store_true", + help="Print the test plan but don't run anything", +) + + +test_opts.add_argument( + "--debug", + dest="debug", + action="store_true", + help="Print out the commands that are to be executed", +) diff --git a/legate/tester/config.py b/legate/tester/config.py new file mode 100644 index 0000000000..a758da913a --- /dev/null +++ b/legate/tester/config.py @@ -0,0 +1,161 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Consolidate test configuration from command-line and environment. + +""" +from __future__ import annotations + +import os +from argparse import Namespace +from pathlib import Path + +from . import DEFAULT_PROCESS_ENV, FEATURES, SKIPPED_EXAMPLES, FeatureType +from .args import parser +from .types import ArgList, EnvDict + + +class Config: + """A centralized configuration object that provides the information + needed by test stages in order to run. + + Parameters + ---------- + argv : ArgList + command-line arguments to use when building the configuration + + """ + + def __init__(self, argv: ArgList) -> None: + args, self._extra_args = parser.parse_known_args(argv[1:]) + + # which tests to run + self.examples = True + self.integration = True + self.unit = args.unit + self.files = args.files + + # feature configuration + self.features = self._compute_features(args) + + # feature options for integration tests + self.cpus = args.cpus + self.gpus = args.gpus + self.omps = args.omps + self.utility = args.utility + self.cpu_pin = args.cpu_pin + self.fbmem = args.fbmem + self.gpu_delay = args.gpu_delay + self.ompthreads = args.ompthreads + + # test run configuration + self.debug = args.debug + self.dry_run = args.dry_run + self.verbose = args.verbose + self.test_root = args.test_root + self.requested_workers = args.workers + self.legate_dir = self._compute_legate_dir(args) + + @property + def env(self) -> EnvDict: + """Custom environment settings used for process exectution.""" + return dict(DEFAULT_PROCESS_ENV) + + @property + def extra_args(self) -> ArgList: + """Extra command-line arguments to pass on to individual test files.""" + return self._extra_args + + @property + def root_dir(self) -> Path: + """Path to the directory containing the tests.""" + if self.test_root: + return Path(self.test_root) + return Path(__file__).parents[2] + + @property + def test_files(self) -> tuple[Path, ...]: + """List of all test files to use for each stage. + + An explicit list of files from the command line will take precedence. + + Otherwise, the files are computed based on command-line options, etc. + + """ + if self.files: + return self.files + + files = [] + + if self.examples: + examples = ( + path.relative_to(self.root_dir) + for path in self.root_dir.joinpath("examples").glob("*.py") + if str(path.relative_to(self.root_dir)) not in SKIPPED_EXAMPLES + ) + files.extend(sorted(examples)) + + if self.integration: + integration_tests = ( + path.relative_to(self.root_dir) + for path in self.root_dir.joinpath("tests/integration").glob( + "*.py" + ) + ) + files.extend(sorted(integration_tests)) + + if self.unit: + unit_tests = ( + path.relative_to(self.root_dir) + for path in self.root_dir.joinpath("tests/unit").glob( + "**/*.py" + ) + ) + files.extend(sorted(unit_tests)) + + return tuple(files) + + @property + def legate_path(self) -> str: + """Computed path to the legate driver script""" + if self.legate_dir is None: + return "legate" + return str(self.legate_dir / "bin" / "legate") + + def _compute_features(self, args: Namespace) -> tuple[FeatureType, ...]: + if args.features is not None: + computed = args.features + else: + computed = [ + feature + for feature in FEATURES + if os.environ.get(f"USE_{feature.upper()}", None) == "1" + ] + + # if nothing is specified any other way, at least run CPU stage + if len(computed) == 0: + computed.append("cpus") + + return tuple(computed) + + def _compute_legate_dir(self, args: Namespace) -> Path | None: + # self._legate_source below is purely for testing + if args.legate_dir: + self._legate_source = "cmd" + return Path(args.legate_dir) + elif "LEGATE_DIR" in os.environ: + self._legate_source = "env" + return Path(os.environ["LEGATE_DIR"]) + self._legate_source = "install" + return None diff --git a/legate/tester/logger.py b/legate/tester/logger.py new file mode 100644 index 0000000000..f409042192 --- /dev/null +++ b/legate/tester/logger.py @@ -0,0 +1,67 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Provide a basic logger that can scrub ANSI color codes. + +""" +from __future__ import annotations + +import re + +# ref: https://stackoverflow.com/a/14693789 +_ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + + +class Log: + def __init__(self) -> None: + self._record: list[str] = [] + + def __call__(self, *lines: str) -> tuple[int, int]: + return self.record(*lines) + + def record(self, *lines: str) -> tuple[int, int]: + if len(lines) == 1 and "\n" in lines[0]: + lines = tuple(lines[0].split("\n")) + + start = len(self._record) + for line in lines: + self._record.append(line) + print(line, flush=True) + return (start, len(self._record)) + + def clear(self) -> None: + self._record = [] + + def dump( + self, + *, + start: int = 0, + end: int | None = None, + filter_ansi: bool = True, + ) -> str: + lines = self._record[start:end] + + if filter_ansi: + full_text = _ANSI_ESCAPE.sub("", "\n".join(lines)) + else: + full_text = "\n".join(lines) + + return full_text + + @property + def lines(self) -> tuple[str, ...]: + return tuple(self._record) + + +LOG = Log() diff --git a/legate/tester/stages/__init__.py b/legate/tester/stages/__init__.py new file mode 100644 index 0000000000..fa8f916d58 --- /dev/null +++ b/legate/tester/stages/__init__.py @@ -0,0 +1,41 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Provide TestStage subclasses for running configured test files using +specific features. + +""" +from __future__ import annotations + +import sys +from typing import Dict, Type + +from .. import FeatureType +from .test_stage import TestStage +from .util import log_proc + +if sys.platform == "darwin": + from ._osx import CPU, Eager, GPU, OMP +elif sys.platform.startswith("linux"): + from ._linux import CPU, Eager, GPU, OMP +else: + raise RuntimeError(f"unsupported platform: {sys.platform}") + +#: All the available test stages that can be selected +STAGES: Dict[FeatureType, Type[TestStage]] = { + "cpus": CPU, + "cuda": GPU, + "openmp": OMP, + "eager": Eager, +} diff --git a/legate/tester/stages/_linux/__init__.py b/legate/tester/stages/_linux/__init__.py new file mode 100644 index 0000000000..032305f9ca --- /dev/null +++ b/legate/tester/stages/_linux/__init__.py @@ -0,0 +1,24 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Provide TestStage subclasses for running configured test files using +specific features on linux platforms. + +""" +from __future__ import annotations + +from .cpu import CPU +from .gpu import GPU +from .eager import Eager +from .omp import OMP diff --git a/legate/tester/stages/_linux/cpu.py b/legate/tester/stages/_linux/cpu.py new file mode 100644 index 0000000000..6657930816 --- /dev/null +++ b/legate/tester/stages/_linux/cpu.py @@ -0,0 +1,80 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations + +from itertools import chain + +from ... import FeatureType +from ...config import Config +from ...system import System +from ...types import ArgList, EnvDict +from ..test_stage import TestStage +from ..util import ( + CUNUMERIC_TEST_ARG, + UNPIN_ENV, + Shard, + StageSpec, + adjust_workers, +) + + +class CPU(TestStage): + """A test stage for exercising CPU features. + + Parameters + ---------- + config: Config + Test runner configuration + + system: System + Process execution wrapper + + """ + + kind: FeatureType = "cpus" + + args = [CUNUMERIC_TEST_ARG] + + def __init__(self, config: Config, system: System) -> None: + self._init(config, system) + + def env(self, config: Config, system: System) -> EnvDict: + return {} if config.cpu_pin == "strict" else dict(UNPIN_ENV) + + def shard_args(self, shard: Shard, config: Config) -> ArgList: + args = [ + "--cpus", + str(config.cpus), + ] + if config.cpu_pin != "none": + args += [ + "--cpu-bind", + ",".join(str(x) for x in shard), + ] + return args + + def compute_spec(self, config: Config, system: System) -> StageSpec: + cpus = system.cpus + + procs = config.cpus + config.utility + int(config.cpu_pin == "strict") + workers = adjust_workers(len(cpus) // procs, config.requested_workers) + + shards: list[tuple[int, ...]] = [] + for i in range(workers): + shard_cpus = range(i * procs, (i + 1) * procs) + shard = chain.from_iterable(cpus[j].ids for j in shard_cpus) + shards.append(tuple(sorted(shard))) + + return StageSpec(workers, shards) diff --git a/legate/tester/stages/_linux/eager.py b/legate/tester/stages/_linux/eager.py new file mode 100644 index 0000000000..8e63fc49b7 --- /dev/null +++ b/legate/tester/stages/_linux/eager.py @@ -0,0 +1,71 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations + +from ... import FeatureType +from ...config import Config +from ...system import System +from ...types import ArgList, EnvDict +from ..test_stage import TestStage +from ..util import Shard, StageSpec, adjust_workers + + +class Eager(TestStage): + """A test stage for exercising Eager Numpy execution features. + + Parameters + ---------- + config: Config + Test runner configuration + + system: System + Process execution wrapper + + """ + + kind: FeatureType = "eager" + + args: ArgList = [] + + def __init__(self, config: Config, system: System) -> None: + self._init(config, system) + + def env(self, config: Config, system: System) -> EnvDict: + # Raise min chunk sizes for deferred codepaths to force eager execution + env = { + "CUNUMERIC_MIN_CPU_CHUNK": "2000000000", + "CUNUMERIC_MIN_OMP_CHUNK": "2000000000", + "CUNUMERIC_MIN_GPU_CHUNK": "2000000000", + } + return env + + def shard_args(self, shard: Shard, config: Config) -> ArgList: + return [ + "--cpus", + "1", + "--cpu-bind", + ",".join(str(x) for x in shard), + ] + + def compute_spec(self, config: Config, system: System) -> StageSpec: + N = len(system.cpus) + + degree = min(N, 60) # ~LEGION_MAX_NUM_PROCS just in case + workers = adjust_workers(degree, config.requested_workers) + + # Just put each worker on its own full CPU for eager tests + shards = [cpu.ids for cpu in system.cpus] + + return StageSpec(workers, shards) diff --git a/legate/tester/stages/_linux/gpu.py b/legate/tester/stages/_linux/gpu.py new file mode 100644 index 0000000000..12012a4816 --- /dev/null +++ b/legate/tester/stages/_linux/gpu.py @@ -0,0 +1,82 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations + +import time + +from ... import FeatureType +from ...config import Config +from ...system import System +from ...types import ArgList, EnvDict +from ..test_stage import TestStage +from ..util import CUNUMERIC_TEST_ARG, Shard, StageSpec, adjust_workers + +BLOAT_FACTOR = 1.5 # hard coded for now + + +class GPU(TestStage): + """A test stage for exercising GPU features. + + Parameters + ---------- + config: Config + Test runner configuration + + system: System + Process execution wrapper + + """ + + kind: FeatureType = "cuda" + + args = [CUNUMERIC_TEST_ARG] + + def __init__(self, config: Config, system: System) -> None: + self._init(config, system) + + def env(self, config: Config, system: System) -> EnvDict: + return {} + + def delay(self, shard: Shard, config: Config, system: System) -> None: + time.sleep(config.gpu_delay / 1000) + + def shard_args(self, shard: Shard, config: Config) -> ArgList: + return [ + "--fbmem", + str(config.fbmem), + "--gpus", + str(len(shard)), + "--gpu-bind", + ",".join(str(x) for x in shard), + ] + + def compute_spec(self, config: Config, system: System) -> StageSpec: + N = len(system.gpus) + degree = N // config.gpus + + fbsize = min(gpu.total for gpu in system.gpus) / (2 << 20) # MB + oversub_factor = int(fbsize // (config.fbmem * BLOAT_FACTOR)) + workers = adjust_workers( + degree * oversub_factor, config.requested_workers + ) + + # https://docs.python.org/3/library/itertools.html#itertools-recipes + # grouper('ABCDEF', 3) --> ABC DEF + args = [iter(range(degree * config.gpus))] * config.gpus + per_worker_shards = list(zip(*args)) + + shards = per_worker_shards * workers + + return StageSpec(workers, shards) diff --git a/legate/tester/stages/_linux/omp.py b/legate/tester/stages/_linux/omp.py new file mode 100644 index 0000000000..84a9544126 --- /dev/null +++ b/legate/tester/stages/_linux/omp.py @@ -0,0 +1,84 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations + +from itertools import chain + +from ... import FeatureType +from ...config import Config +from ...system import System +from ...types import ArgList, EnvDict +from ..test_stage import TestStage +from ..util import ( + CUNUMERIC_TEST_ARG, + UNPIN_ENV, + Shard, + StageSpec, + adjust_workers, +) + + +class OMP(TestStage): + """A test stage for exercising OpenMP features. + + Parameters + ---------- + config: Config + Test runner configuration + + system: System + Process execution wrapper + + """ + + kind: FeatureType = "openmp" + + args = [CUNUMERIC_TEST_ARG] + + def __init__(self, config: Config, system: System) -> None: + self._init(config, system) + + def env(self, config: Config, system: System) -> EnvDict: + return {} if config.cpu_pin == "strict" else dict(UNPIN_ENV) + + def shard_args(self, shard: Shard, config: Config) -> ArgList: + args = [ + "--omps", + str(config.omps), + "--ompthreads", + str(config.ompthreads), + ] + if config.cpu_pin != "none": + args += [ + "--cpu-bind", + ",".join(str(x) for x in shard), + ] + return args + + def compute_spec(self, config: Config, system: System) -> StageSpec: + cpus = system.cpus + omps, threads = config.omps, config.ompthreads + procs = ( + omps * threads + config.utility + int(config.cpu_pin == "strict") + ) + workers = adjust_workers(len(cpus) // procs, config.requested_workers) + + shards: list[tuple[int, ...]] = [] + for i in range(workers): + shard_cpus = range(i * procs, (i + 1) * procs) + shard = chain.from_iterable(cpus[j].ids for j in shard_cpus) + shards.append(tuple(sorted(shard))) + + return StageSpec(workers, shards) diff --git a/legate/tester/stages/_osx/__init__.py b/legate/tester/stages/_osx/__init__.py new file mode 100644 index 0000000000..80a7c368de --- /dev/null +++ b/legate/tester/stages/_osx/__init__.py @@ -0,0 +1,24 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Provide TestStage subclasses for running configured test files using +specific features on OSX. + +""" +from __future__ import annotations + +from .cpu import CPU +from .gpu import GPU +from .eager import Eager +from .omp import OMP diff --git a/legate/tester/stages/_osx/cpu.py b/legate/tester/stages/_osx/cpu.py new file mode 100644 index 0000000000..ec6d23f207 --- /dev/null +++ b/legate/tester/stages/_osx/cpu.py @@ -0,0 +1,64 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations + +from ... import FeatureType +from ...config import Config +from ...system import System +from ...types import ArgList, EnvDict +from ..test_stage import TestStage +from ..util import ( + CUNUMERIC_TEST_ARG, + UNPIN_ENV, + Shard, + StageSpec, + adjust_workers, +) + + +class CPU(TestStage): + """A test stage for exercising CPU features. + + Parameters + ---------- + config: Config + Test runner configuration + + system: System + Process execution wrapper + + """ + + kind: FeatureType = "cpus" + + args = [CUNUMERIC_TEST_ARG] + + def __init__(self, config: Config, system: System) -> None: + self._init(config, system) + + def env(self, config: Config, system: System) -> EnvDict: + return UNPIN_ENV + + def shard_args(self, shard: Shard, config: Config) -> ArgList: + return ["--cpus", str(config.cpus)] + + def compute_spec(self, config: Config, system: System) -> StageSpec: + procs = config.cpus + config.utility + workers = adjust_workers( + len(system.cpus) // procs, config.requested_workers + ) + + # return a dummy set of shards just for the runner to iterate over + return StageSpec(workers, [(i,) for i in range(workers)]) diff --git a/legate/tester/stages/_osx/eager.py b/legate/tester/stages/_osx/eager.py new file mode 100644 index 0000000000..5cc5d557da --- /dev/null +++ b/legate/tester/stages/_osx/eager.py @@ -0,0 +1,64 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations + +from ... import FeatureType +from ...config import Config +from ...system import System +from ...types import ArgList, EnvDict +from ..test_stage import TestStage +from ..util import UNPIN_ENV, Shard, StageSpec, adjust_workers + + +class Eager(TestStage): + """A test stage for exercising Eager Numpy execution features. + + Parameters + ---------- + config: Config + Test runner configuration + + system: System + Process execution wrapper + + """ + + kind: FeatureType = "eager" + + args: ArgList = [] + + def __init__(self, config: Config, system: System) -> None: + self._init(config, system) + + def env(self, config: Config, system: System) -> EnvDict: + # Raise min chunk sizes for deferred codepaths to force eager execution + env = { + "CUNUMERIC_MIN_CPU_CHUNK": "2000000000", + "CUNUMERIC_MIN_OMP_CHUNK": "2000000000", + "CUNUMERIC_MIN_GPU_CHUNK": "2000000000", + } + env.update(UNPIN_ENV) + return env + + def shard_args(self, shard: Shard, config: Config) -> ArgList: + return ["--cpus", "1"] + + def compute_spec(self, config: Config, system: System) -> StageSpec: + N = len(system.cpus) + degree = min(N, 60) # ~LEGION_MAX_NUM_PROCS just in case + workers = adjust_workers(degree, config.requested_workers) + + # return a dummy set of shards just for the runner to iterate over + return StageSpec(workers, [(i,) for i in range(workers)]) diff --git a/legate/tester/stages/_osx/gpu.py b/legate/tester/stages/_osx/gpu.py new file mode 100644 index 0000000000..f89fe7377d --- /dev/null +++ b/legate/tester/stages/_osx/gpu.py @@ -0,0 +1,51 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations + +import time + +from ... import FeatureType +from ...config import Config +from ...system import System +from ...types import ArgList, EnvDict +from ..test_stage import TestStage +from ..util import CUNUMERIC_TEST_ARG, UNPIN_ENV, Shard + + +class GPU(TestStage): + """A test stage for exercising GPU features. + + Parameters + ---------- + config: Config + Test runner configuration + + system: System + Process execution wrapper + + """ + + kind: FeatureType = "cuda" + + args: ArgList = [CUNUMERIC_TEST_ARG] + + def __init__(self, config: Config, system: System) -> None: + raise RuntimeError("GPU test are not supported on OSX") + + def env(self, config: Config, system: System) -> EnvDict: + return UNPIN_ENV + + def delay(self, shard: Shard, config: Config, system: System) -> None: + time.sleep(config.gpu_delay / 1000) diff --git a/legate/tester/stages/_osx/omp.py b/legate/tester/stages/_osx/omp.py new file mode 100644 index 0000000000..f5f19194dc --- /dev/null +++ b/legate/tester/stages/_osx/omp.py @@ -0,0 +1,70 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations + +from ... import FeatureType +from ...config import Config +from ...system import System +from ...types import ArgList, EnvDict +from ..test_stage import TestStage +from ..util import ( + CUNUMERIC_TEST_ARG, + UNPIN_ENV, + Shard, + StageSpec, + adjust_workers, +) + + +class OMP(TestStage): + """A test stage for exercising OpenMP features. + + Parameters + ---------- + config: Config + Test runner configuration + + system: System + Process execution wrapper + + """ + + kind: FeatureType = "openmp" + + args = [CUNUMERIC_TEST_ARG] + + def __init__(self, config: Config, system: System) -> None: + self._init(config, system) + + def env(self, config: Config, system: System) -> EnvDict: + return UNPIN_ENV + + def shard_args(self, shard: Shard, config: Config) -> ArgList: + return [ + "--omps", + str(config.omps), + "--ompthreads", + str(config.ompthreads), + ] + + def compute_spec(self, config: Config, system: System) -> StageSpec: + omps, threads = config.omps, config.ompthreads + procs = omps * threads + config.utility + workers = adjust_workers( + len(system.cpus) // procs, config.requested_workers + ) + + # return a dummy set of shards just for the runner to iterate over + return StageSpec(workers, [(i,) for i in range(workers)]) diff --git a/legate/tester/stages/test_stage.py b/legate/tester/stages/test_stage.py new file mode 100644 index 0000000000..0bfbe4f065 --- /dev/null +++ b/legate/tester/stages/test_stage.py @@ -0,0 +1,265 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations + +import multiprocessing +from datetime import datetime +from pathlib import Path + +from typing_extensions import Protocol + +from .. import PER_FILE_ARGS, FeatureType +from ..config import Config +from ..system import ProcessResult, System +from ..types import ArgList, EnvDict +from ..ui import banner, summary, yellow +from .util import Shard, StageResult, StageSpec, log_proc + + +class TestStage(Protocol): + """Encapsulate running configured test files using specific features. + + Parameters + ---------- + config: Config + Test runner configuration + + system: System + Process execution wrapper + + """ + + kind: FeatureType + + #: The computed specification for processes to launch to run the + #: configured test files. + spec: StageSpec + + #: The computed sharding id sets to use for job runs + shards: multiprocessing.Queue[Shard] + + #: After the stage completes, results will be stored here + result: StageResult + + #: Any fixed stage-specific command-line args to pass + args: ArgList + + # --- Protocol methods + + def __init__(self, config: Config, system: System) -> None: + ... + + def env(self, config: Config, system: System) -> EnvDict: + """Generate stage-specific customizations to the process env + + Parameters + ---------- + config: Config + Test runner configuration + + system: System + Process execution wrapper + + """ + ... + + def delay(self, shard: Shard, config: Config, system: System) -> None: + """Wait any delay that should be applied before running the next + test. + + Parameters + ---------- + shard: Shard + The shard to be used for the next test that is run + + config: Config + Test runner configuration + + system: System + Process execution wrapper + + """ + ... + + def shard_args(self, shard: Shard, config: Config) -> ArgList: + """Generate the command line arguments necessary to launch + the next test process on the given shard. + + Parameters + ---------- + shard: Shard + The shard to be used for the next test that is run + + config: Config + Test runner configuration + + """ + ... + + def compute_spec(self, config: Config, system: System) -> StageSpec: + """Compute the number of worker processes to launch and stage shards + to use for running the configured test files. + + Parameters + ---------- + config: Config + Test runner configuration + + system: System + Process execution wrapper + + """ + ... + + # --- Shared implementation methods + + def __call__(self, config: Config, system: System) -> None: + """Execute this test stage. + + Parameters + ---------- + config: Config + Test runner configuration + + system: System + Process execution wrapper + + """ + t0 = datetime.now() + procs = self._launch(config, system) + t1 = datetime.now() + + self.result = StageResult(procs, t1 - t0) + + @property + def name(self) -> str: + """A stage name to display for tests in this stage.""" + return self.__class__.__name__ + + @property + def intro(self) -> str: + """An informative banner to display at stage end.""" + workers = self.spec.workers + workers_text = f"{workers} worker{'s' if workers > 1 else ''}" + return ( + banner(f"Entering stage: {self.name} (with {workers_text})") + "\n" + ) + + @property + def outro(self) -> str: + """An informative banner to display at stage end.""" + total, passed = self.result.total, self.result.passed + + result = summary(self.name, total, passed, self.result.time) + + footer = banner( + f"Exiting stage: {self.name}", + details=( + "* Results : " + + yellow( + f"{passed} / {total} files passed " # noqa E500 + f"({passed/total*100:0.1f}%)" + if total > 0 + else "0 tests are running, Please check " + ), + "* Elapsed time : " + yellow(f"{self.result.time}"), + ), + ) + + return f"{result}\n{footer}" + + def file_args(self, test_file: Path, config: Config) -> ArgList: + """Extra command line arguments based on the test file. + + Parameters + ---------- + test_file : Path + Path to a test file + + config: Config + Test runner configuration + + """ + test_file_string = str(test_file) + args = PER_FILE_ARGS.get(test_file_string, []) + + # These are a bit ugly but necessary in order to make pytest generate + # more verbose output for integration tests when -v, -vv is specified + if "integration" in test_file_string and config.verbose > 0: + args += ["-v"] + if "integration" in test_file_string and config.verbose > 1: + args += ["-s"] + + return args + + def run( + self, test_file: Path, config: Config, system: System + ) -> ProcessResult: + """Execute a single test files with appropriate environment and + command-line options for a feature test stage. + + Parameters + ---------- + test_file : Path + Test file to execute + + config: Config + Test runner configuration + + system: System + Process execution wrapper + + """ + test_path = config.root_dir / test_file + + shard = self.shards.get() + + stage_args = self.args + self.shard_args(shard, config) + file_args = self.file_args(test_file, config) + + cmd = [str(config.legate_path), str(test_path)] + cmd += stage_args + file_args + config.extra_args + + self.delay(shard, config, system) + + result = system.run(cmd, test_file, env=self._env(config, system)) + log_proc(self.name, result, config, verbose=config.verbose) + + self.shards.put(shard) + + return result + + def _env(self, config: Config, system: System) -> EnvDict: + env = dict(config.env) + env.update(self.env(config, system)) + return env + + def _init(self, config: Config, system: System) -> None: + self.spec = self.compute_spec(config, system) + self.shards = system.manager.Queue(len(self.spec.shards)) + for shard in self.spec.shards: + self.shards.put(shard) + + def _launch(self, config: Config, system: System) -> list[ProcessResult]: + + pool = multiprocessing.pool.ThreadPool(self.spec.workers) + + jobs = [ + pool.apply_async(self.run, (path, config, system)) + for path in config.test_files + ] + pool.close() + + return [job.get() for job in jobs] diff --git a/legate/tester/stages/util.py b/legate/tester/stages/util.py new file mode 100644 index 0000000000..357474c908 --- /dev/null +++ b/legate/tester/stages/util.py @@ -0,0 +1,115 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +from typing import Tuple, Union + +from typing_extensions import TypeAlias + +from ..config import Config +from ..logger import LOG +from ..system import ProcessResult +from ..ui import failed, passed, shell, skipped + +CUNUMERIC_TEST_ARG = "-cunumeric:test" + +UNPIN_ENV = {"REALM_SYNTHETIC_CORE_MAP": ""} + +Shard: TypeAlias = Tuple[int, ...] + + +@dataclass(frozen=True) +class StageSpec: + """Specify the operation of a test run""" + + #: The number of worker processes to start for running tests + workers: int + + # A list of (cpu or gpu) shards to draw on for each test + shards: list[Shard] + + +@dataclass(frozen=True) +class StageResult: + """Collect results from all tests in a TestStage.""" + + #: Individual test process results including return code and stdout. + procs: list[ProcessResult] + + #: Cumulative execution time for all tests in a stage. + time: timedelta + + @property + def total(self) -> int: + """The total number of tests run in this stage.""" + return len(self.procs) + + @property + def passed(self) -> int: + """The number of tests in this stage that passed.""" + return sum(p.returncode == 0 for p in self.procs) + + +def adjust_workers(workers: int, requested_workers: Union[int, None]) -> int: + """Adjust computed workers according to command line requested workers. + + The final number of workers will only be adjusted down by this function. + + Parameters + ---------- + workers: int + The computed number of workers to use + + requested_workers: int | None, optional + Requested number of workers from the user, if supplied (default: None) + + Returns + ------- + int + The number of workers to actually use + + """ + if requested_workers is not None and requested_workers < 0: + raise ValueError("requested workers must be non-negative") + + if requested_workers is not None: + if requested_workers > workers: + raise RuntimeError( + "Requested workers greater than assignable workers" + ) + workers = requested_workers + + if workers == 0: + raise RuntimeError("Current configuration results in zero workers") + + return workers + + +def log_proc( + name: str, proc: ProcessResult, config: Config, *, verbose: bool +) -> None: + """Log a process result according to the current configuration""" + if config.debug or config.dry_run: + LOG(shell(proc.invocation)) + msg = f"({name}) {proc.test_file}" + details = proc.output.split("\n") if verbose else None + if proc.skipped: + LOG(skipped(msg)) + elif proc.returncode == 0: + LOG(passed(msg, details=details)) + else: + LOG(failed(msg, details=details)) diff --git a/legate/tester/system.py b/legate/tester/system.py new file mode 100644 index 0000000000..71411b45b8 --- /dev/null +++ b/legate/tester/system.py @@ -0,0 +1,170 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Provide a System class to encapsulate process execution and reporting +system information (number of CPUs present, etc). + +""" +from __future__ import annotations + +import multiprocessing +import os +import sys +from dataclasses import dataclass +from functools import cached_property +from pathlib import Path +from subprocess import PIPE, STDOUT, run as stdlib_run +from typing import Sequence + +from .types import CPUInfo, EnvDict, GPUInfo + + +@dataclass +class ProcessResult: + + #: The command invovation, including relevant environment vars + invocation: str + + # User-friendly test file path to use in reported output + test_file: Path + + #: Whether this process was actually invoked + skipped: bool = False + + #: The returncode from the process + returncode: int = 0 + + #: The collected stdout and stderr output from the process + output: str = "" + + +class System: + """A facade class for system-related functions. + + Parameters + ---------- + dry_run : bool, optional + If True, no commands will be executed, but a log of any commands + submitted to ``run`` will be made. (default: False) + + """ + + def __init__( + self, + *, + dry_run: bool = False, + ) -> None: + self.manager = multiprocessing.Manager() + self.dry_run: bool = dry_run + + def run( + self, + cmd: Sequence[str], + test_file: Path, + *, + env: EnvDict | None = None, + cwd: str | None = None, + ) -> ProcessResult: + """Wrapper for subprocess.run that encapsulates logging. + + Parameters + ---------- + cmd : sequence of str + The command to run, split on whitespace into a sequence + of strings + + test_file : Path + User-friendly test file path to use in reported output + + env : dict[str, str] or None, optional, default: None + Environment variables to apply when running the command + + cwd: str or None, optional, default: None + A current working directory to pass to stdlib ``run``. + + """ + + env = env or {} + + envstr = ( + " ".join(f"{k}={v}" for k, v in env.items()) + + min(len(env), 1) * " " + ) + + invocation = envstr + " ".join(cmd) + + if self.dry_run: + return ProcessResult(invocation, test_file, skipped=True) + + full_env = dict(os.environ) + full_env.update(env) + + proc = stdlib_run( + cmd, cwd=cwd, env=full_env, stdout=PIPE, stderr=STDOUT, text=True + ) + + return ProcessResult( + invocation, + test_file, + returncode=proc.returncode, + output=proc.stdout, + ) + + @cached_property + def cpus(self) -> tuple[CPUInfo, ...]: + """A list of CPUs on the system.""" + + N = multiprocessing.cpu_count() + + if sys.platform == "darwin": + return tuple(CPUInfo((i,)) for i in range(N)) + + sibling_sets: set[tuple[int, ...]] = set() + for i in range(N): + line = open( + f"/sys/devices/system/cpu/cpu{i}/topology/thread_siblings_list" + ).read() + sibling_sets.add( + tuple(sorted(int(x) for x in line.strip().split(","))) + ) + return tuple(CPUInfo(siblings) for siblings in sorted(sibling_sets)) + + @cached_property + def gpus(self) -> tuple[GPUInfo, ...]: + """A list of GPUs on the system, including total memory information.""" + + try: + # This pynvml import is protected inside this method so that in + # case pynvml is not installed, tests stages that don't need gpu + # info (e.g. cpus, eager) will proceed unaffected. Test stages + # that do require gpu info will fail here with an ImportError. + import pynvml # type: ignore[import] + + # Also a pynvml package is available on some platforms that won't + # have GPUs for some reason. In which case this init call will + # fail. + pynvml.nvmlInit() + except Exception: + return () + + num_gpus = pynvml.nvmlDeviceGetCount() + + results = [] + for i in range(num_gpus): + info = pynvml.nvmlDeviceGetMemoryInfo( + pynvml.nvmlDeviceGetHandleByIndex(i) + ) + results.append(GPUInfo(i, info.total)) + + return tuple(results) diff --git a/legate/tester/test_plan.py b/legate/tester/test_plan.py new file mode 100644 index 0000000000..9e2a925321 --- /dev/null +++ b/legate/tester/test_plan.py @@ -0,0 +1,131 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Provide a TestPlan class to coordinate multiple feature test stages. + +""" +from __future__ import annotations + +from datetime import timedelta +from itertools import chain + +from .config import Config +from .logger import LOG +from .stages import STAGES, log_proc +from .system import System +from .ui import banner, rule, summary, yellow + + +class TestPlan: + """Encapsulate an entire test run with multiple feature test stages. + + Parameters + ---------- + config: Config + Test runner configuration + + system: System + Process execution wrapper + + """ + + def __init__(self, config: Config, system: System) -> None: + self._config = config + self._system = system + self._stages = [ + STAGES[feature](config, system) for feature in config.features + ] + + def execute(self) -> int: + """Execute the entire test run with all configured feature stages.""" + LOG.clear() + + LOG(self.intro) + + for stage in self._stages: + LOG(stage.intro) + stage(self._config, self._system) + LOG(stage.outro) + + all_procs = tuple( + chain.from_iterable(s.result.procs for s in self._stages) + ) + total = len(all_procs) + passed = sum(proc.returncode == 0 for proc in all_procs) + + LOG(f"\n{rule()}") + + self._log_failures(total, passed) + + LOG(self.outro(total, passed)) + + return int((total - passed) > 0) + + @property + def intro(self) -> str: + """An informative banner to display at test run start.""" + + cpus = len(self._system.cpus) + try: + gpus = len(self._system.gpus) + except ImportError: + gpus = 0 + + details = ( + f"* Feature stages : {', '.join(yellow(x) for x in self._config.features)}", # noqa E501 + f"* Test files per stage : {yellow(str(len(self._config.test_files)))}", # noqa E501 + f"* System description : {yellow(str(cpus) + ' cpus')} / {yellow(str(gpus) + ' gpus')}", # noqa E501 + ) + return banner("Test Suite Configuration", details=details) + + def outro(self, total: int, passed: int) -> str: + """An informative banner to display at test run end. + + Parameters + ---------- + total: int + Number of total tests that ran in all stages + + passed: int + Number of tests that passed in all stages + + """ + details = [ + f"* {s.name: <6}: " + + yellow( + f"{s.result.passed} / {s.result.total} passed in {s.result.time.total_seconds():0.2f}s" # noqa E501 + ) + for s in self._stages + ] + + time = sum((s.result.time for s in self._stages), timedelta(0, 0)) + details.append("") + details.append( + summary("All tests", total, passed, time, justify=False) + ) + + overall = banner("Overall summary", details=details) + + return f"{overall}\n" + + def _log_failures(self, total: int, passed: int) -> None: + if total == passed: + return + + LOG(f"{banner('FAILURES')}\n") + + for stage in self._stages: + procs = (proc for proc in stage.result.procs if proc.returncode) + for proc in procs: + log_proc(stage.name, proc, self._config, verbose=True) diff --git a/legate/tester/types.py b/legate/tester/types.py new file mode 100644 index 0000000000..1641bd597a --- /dev/null +++ b/legate/tester/types.py @@ -0,0 +1,50 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Provide types that are useful throughout the test driver code. + +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, List + +from typing_extensions import TypeAlias + + +@dataclass(frozen=True) +class CPUInfo: + """Encapsulate information about a single CPU""" + + #: IDs of hypterthreading sibling cores for a given physscal core + ids: tuple[int, ...] + + +@dataclass(frozen=True) +class GPUInfo: + """Encapsulate information about a single CPU""" + + #: ID of the GPU to specify in test shards + id: int + + #: The total framebuffer memory of this GPU + total: int + + +#: Represent command line arguments +ArgList = List[str] + + +#: Represent str->str environment variable mappings +EnvDict: TypeAlias = Dict[str, str] diff --git a/legate/tester/ui.py b/legate/tester/ui.py new file mode 100644 index 0000000000..eaa97d7c01 --- /dev/null +++ b/legate/tester/ui.py @@ -0,0 +1,229 @@ +# Copyright AS2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Helpler functions for simple text UI output. + +The color functions in this module require ``colorama`` to be installed in +order to generate color output. If ``colorama`` is not available, plain +text output (i.e. without ANSI color codes) will generated. + +""" +from __future__ import annotations + +import sys +from datetime import timedelta +from typing import Iterable + +from typing_extensions import TypeAlias + +from . import UI_WIDTH + +Details: TypeAlias = Iterable[str] + + +def _text(text: str) -> str: + return text + + +try: + import colorama # type: ignore[import] + + def bright(text: str) -> str: + return f"{colorama.Style.BRIGHT}{text}{colorama.Style.RESET_ALL}" + + def dim(text: str) -> str: + return f"{colorama.Style.DIM}{text}{colorama.Style.RESET_ALL}" + + def white(text: str) -> str: + return f"{colorama.Fore.WHITE}{text}{colorama.Style.RESET_ALL}" + + def cyan(text: str) -> str: + return f"{colorama.Fore.CYAN}{text}{colorama.Style.RESET_ALL}" + + def red(text: str) -> str: + return f"{colorama.Fore.RED}{text}{colorama.Style.RESET_ALL}" + + def green(text: str) -> str: + return f"{colorama.Fore.GREEN}{text}{colorama.Style.RESET_ALL}" + + def yellow(text: str) -> str: + return f"{colorama.Fore.YELLOW}{text}{colorama.Style.RESET_ALL}" + + if sys.platform == "win32": + colorama.init() + +except ImportError: + + bright = dim = white = cyan = red = green = yellow = _text + + +def _format_details( + details: Iterable[str] | None = None, pre: str = " " +) -> str: + if details: + return f"{pre}" + f"\n{pre}".join(f"{line}" for line in details) + return "" + + +def banner( + heading: str, + *, + char: str = "#", + width: int = UI_WIDTH, + details: Iterable[str] | None = None, +) -> str: + """Generate a title banner, with optional details included. + + Parameters + ---------- + heading : str + Text to use for the title + + char : str, optional + A character to use to frame the banner. (default: "#") + + width : int, optional + How wide to draw the banner. (Note: user-supplied heading or + details willnot be truncated if they exceed this width) + + details : Iterable[str], optional + A list of lines to diplay inside the banner area below the heading + + """ + pre = f"{char*3} " + divider = char * width + if not details: + return f"\n{divider}\n{pre}{heading}\n{divider}" + return f""" +{divider} +{pre} +{pre}{heading} +{pre} +{_format_details(details, pre)} +{pre} +{divider}""" + + +def failed(msg: str, *, details: Details | None = None) -> str: + """Report a failed test result with a bright red [FAIL]. + + Parameters + ---------- + msg : str + Text to display after [FAIL] + + details : Iterable[str], optional + A sequenece of text lines to diplay below the ``msg`` line + + """ + if details: + return f"{bright(red('[FAIL]'))} {msg}\n{_format_details(details)}" + return f"{bright(red('[FAIL]'))} {msg}" + + +def passed(msg: str, *, details: Details | None = None) -> str: + """Report a passed test result with a bright green [PASS]. + + Parameters + ---------- + msg : str + Text to display after [PASS] + + details : Iterable[str], optional + A sequenece of text lines to diplay below the ``msg`` line + + """ + if details: + return f"{bright(green('[PASS]'))} {msg}\n{_format_details(details)}" + return f"{bright(green('[PASS]'))} {msg}" + + +def rule(pad: int = 4, char: str = "~") -> str: + """Generate a horizontal rule. + + Parameters + ---------- + pad : int, optional + How much whitespace to precede the rule. (default: 4) + + char : str, optional + A character to use to "draw" the rule. (default: "~") + + """ + w = UI_WIDTH - pad + return f"{char*w: >{UI_WIDTH}}" + + +def shell(cmd: str, *, char: str = "+") -> str: + """Report a shell command in a dim white color. + + Parameters + ---------- + cmd : str + The shell command string to display + + char : str, optional + A character to prefix the ``cmd`` with. (default: "+") + + """ + return dim(white(f"{char}{cmd}")) + + +def skipped(msg: str) -> str: + """Report a skipped test with a cyan [SKIP] + + Parameters + ---------- + msg : str + Text to display after [SKIP] + + """ + return f"{cyan('[SKIP]')} {msg}" + + +def summary( + name: str, + total: int, + passed: int, + time: timedelta, + *, + justify: bool = True, +) -> str: + """Generate a test result summary line. + + The output is bright green if all tests passed, otherwise bright red. + + Parameters + ---------- + name : str + A name to display in this summary line. + + total : int + The total number of tests to report. + + passed : int + The number of passed tests to report. + + time : timedelta + The time taken to run the tests + + """ + summary = ( + f"{name}: Passed {passed} of {total} tests ({passed/total*100:0.1f}%) " + f"in {time.total_seconds():0.2f}s" + if total > 0 + else f"{name}: 0 tests are running, Please check" + ) + color = green if passed == total and total > 0 else red + return bright(color(f"{summary: >{UI_WIDTH}}" if justify else summary)) diff --git a/tests/unit/legate/tester/__init__.py b/tests/unit/legate/tester/__init__.py new file mode 100644 index 0000000000..f0b271624d --- /dev/null +++ b/tests/unit/legate/tester/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations diff --git a/tests/unit/legate/tester/stages/__init__.py b/tests/unit/legate/tester/stages/__init__.py new file mode 100644 index 0000000000..69970d335a --- /dev/null +++ b/tests/unit/legate/tester/stages/__init__.py @@ -0,0 +1,38 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations + +from typing import Any + +from legate.tester.system import System +from legate.tester.types import CPUInfo, GPUInfo + + +class FakeSystem(System): + def __init__( + self, cpus: int = 6, gpus: int = 6, fbmem: int = 6 << 32, **kwargs: Any + ) -> None: + self._cpus = cpus + self._gpus = gpus + self._fbmem = fbmem + super().__init__(**kwargs) + + @property + def cpus(self) -> tuple[CPUInfo, ...]: + return tuple(CPUInfo((i,)) for i in range(self._cpus)) + + @property + def gpus(self) -> tuple[GPUInfo, ...]: + return tuple(GPUInfo(i, self._fbmem) for i in range(self._gpus)) diff --git a/tests/unit/legate/tester/stages/_linux/__init__.py b/tests/unit/legate/tester/stages/_linux/__init__.py new file mode 100644 index 0000000000..3459839199 --- /dev/null +++ b/tests/unit/legate/tester/stages/_linux/__init__.py @@ -0,0 +1,22 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations + +import sys + +import pytest + +if sys.platform != "linux": + pytestmark = pytest.mark.skip() diff --git a/tests/unit/legate/tester/stages/_linux/test_cpu.py b/tests/unit/legate/tester/stages/_linux/test_cpu.py new file mode 100644 index 0000000000..24a4eef3d7 --- /dev/null +++ b/tests/unit/legate/tester/stages/_linux/test_cpu.py @@ -0,0 +1,132 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Consolidate test configuration from command-line and environment. + +""" +from __future__ import annotations + +import pytest + +from legate.tester.config import Config +from legate.tester.stages._linux import cpu as m +from legate.tester.stages.util import UNPIN_ENV + +from .. import FakeSystem + + +def test_default() -> None: + c = Config([]) + s = FakeSystem(cpus=12) + stage = m.CPU(c, s) + assert stage.kind == "cpus" + assert stage.args == ["-cunumeric:test"] + assert stage.env(c, s) == UNPIN_ENV + assert stage.spec.workers > 0 + + shard = (1, 2, 3) + assert "--cpu-bind" in stage.shard_args(shard, c) + + +def test_cpu_pin_strict() -> None: + c = Config(["test.py", "--cpu-pin", "strict"]) + s = FakeSystem(cpus=12) + stage = m.CPU(c, s) + assert stage.kind == "cpus" + assert stage.args == ["-cunumeric:test"] + assert stage.env(c, s) == {} + assert stage.spec.workers > 0 + + shard = (1, 2, 3) + assert "--cpu-bind" in stage.shard_args(shard, c) + + +def test_cpu_pin_none() -> None: + c = Config(["test.py", "--cpu-pin", "none"]) + s = FakeSystem(cpus=12) + stage = m.CPU(c, s) + assert stage.kind == "cpus" + assert stage.args == ["-cunumeric:test"] + assert stage.env(c, s) == UNPIN_ENV + assert stage.spec.workers > 0 + + shard = (1, 2, 3) + assert "--cpu-bind" not in stage.shard_args(shard, c) + + +@pytest.mark.parametrize("shard,expected", [[(2,), "2"], [(1, 2, 3), "1,2,3"]]) +def test_shard_args(shard: tuple[int, ...], expected: str) -> None: + c = Config([]) + s = FakeSystem() + stage = m.CPU(c, s) + result = stage.shard_args(shard, c) + assert result == ["--cpus", f"{c.cpus}", "--cpu-bind", expected] + + +def test_spec_with_cpus_1() -> None: + c = Config(["test.py", "--cpus", "1"]) + s = FakeSystem() + stage = m.CPU(c, s) + assert stage.spec.workers == 3 + assert stage.spec.shards == [(0, 1), (2, 3), (4, 5)] + + +def test_spec_with_cpus_2() -> None: + c = Config(["test.py", "--cpus", "2"]) + s = FakeSystem() + stage = m.CPU(c, s) + assert stage.spec.workers == 2 + assert stage.spec.shards == [(0, 1, 2), (3, 4, 5)] + + +def test_spec_with_utility() -> None: + c = Config(["test.py", "--cpus", "1", "--utility", "2"]) + s = FakeSystem() + stage = m.CPU(c, s) + assert stage.spec.workers == 2 + assert stage.spec.shards == [(0, 1, 2), (3, 4, 5)] + + +def test_spec_with_requested_workers() -> None: + c = Config(["test.py", "--cpus", "1", "-j", "2"]) + s = FakeSystem() + stage = m.CPU(c, s) + assert stage.spec.workers == 2 + assert stage.spec.shards == [(0, 1), (2, 3)] + + +def test_spec_with_requested_workers_zero() -> None: + s = FakeSystem() + c = Config(["test.py", "-j", "0"]) + assert c.requested_workers == 0 + with pytest.raises(RuntimeError): + m.CPU(c, s) + + +def test_spec_with_requested_workers_bad() -> None: + s = FakeSystem() + c = Config(["test.py", "-j", f"{len(s.cpus)+1}"]) + assert c.requested_workers > len(s.cpus) + with pytest.raises(RuntimeError): + m.CPU(c, s) + + +def test_spec_with_verbose() -> None: + args = ["test.py", "--cpus", "2"] + c = Config(args) + cv = Config(args + ["--verbose"]) + s = FakeSystem() + + spec, vspec = m.CPU(c, s).spec, m.CPU(cv, s).spec + assert vspec == spec diff --git a/tests/unit/legate/tester/stages/_linux/test_eager.py b/tests/unit/legate/tester/stages/_linux/test_eager.py new file mode 100644 index 0000000000..eb8c486290 --- /dev/null +++ b/tests/unit/legate/tester/stages/_linux/test_eager.py @@ -0,0 +1,82 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Consolidate test configuration from command-line and environment. + +""" +from __future__ import annotations + +import pytest + +from legate.tester.config import Config +from legate.tester.stages._linux import eager as m + +from .. import FakeSystem + + +def test_default() -> None: + c = Config([]) + s = FakeSystem() + stage = m.Eager(c, s) + assert stage.kind == "eager" + assert stage.args == [] + assert stage.env(c, s) == { + "CUNUMERIC_MIN_CPU_CHUNK": "2000000000", + "CUNUMERIC_MIN_OMP_CHUNK": "2000000000", + "CUNUMERIC_MIN_GPU_CHUNK": "2000000000", + } + assert stage.spec.workers > 0 + + +@pytest.mark.parametrize("shard,expected", [[(2,), "2"], [(1, 2, 3), "1,2,3"]]) +def test_shard_args(shard: tuple[int, ...], expected: str) -> None: + c = Config([]) + s = FakeSystem() + stage = m.Eager(c, s) + result = stage.shard_args(shard, c) + assert result == ["--cpus", "1", "--cpu-bind", expected] + + +def test_spec() -> None: + c = Config([]) + s = FakeSystem() + stage = m.Eager(c, s) + assert stage.spec.workers == len(s.cpus) + # [cpu.ids for cpu in system.cpus] + assert stage.spec.shards == [(i,) for i in range(stage.spec.workers)] + + +def test_spec_with_requested_workers_zero() -> None: + s = FakeSystem() + c = Config(["test.py", "-j", "0"]) + assert c.requested_workers == 0 + with pytest.raises(RuntimeError): + m.Eager(c, s) + + +def test_spec_with_requested_workers_bad() -> None: + s = FakeSystem() + c = Config(["test.py", "-j", f"{len(s.cpus)+1}"]) + assert c.requested_workers > len(s.cpus) + with pytest.raises(RuntimeError): + m.Eager(c, s) + + +def test_spec_with_verbose() -> None: + c = Config(["test.py"]) + cv = Config(["test.py", "--verbose"]) + s = FakeSystem() + + spec, vspec = m.Eager(c, s).spec, m.Eager(cv, s).spec + assert vspec == spec diff --git a/tests/unit/legate/tester/stages/_linux/test_gpu.py b/tests/unit/legate/tester/stages/_linux/test_gpu.py new file mode 100644 index 0000000000..df1441c65d --- /dev/null +++ b/tests/unit/legate/tester/stages/_linux/test_gpu.py @@ -0,0 +1,101 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Consolidate test configuration from command-line and environment. + +""" +from __future__ import annotations + +import pytest + +from legate.tester.config import Config +from legate.tester.stages._linux import gpu as m + +from .. import FakeSystem + + +def test_default() -> None: + c = Config([]) + s = FakeSystem() + stage = m.GPU(c, s) + assert stage.kind == "cuda" + assert stage.args == ["-cunumeric:test"] + assert stage.env(c, s) == {} + assert stage.spec.workers > 0 + + +@pytest.mark.parametrize("shard,expected", [[(2,), "2"], [(1, 2, 3), "1,2,3"]]) +def test_shard_args(shard: tuple[int, ...], expected: str) -> None: + c = Config([]) + s = FakeSystem() + stage = m.GPU(c, s) + result = stage.shard_args(shard, c) + assert result == [ + "--fbmem", + "4096", + "--gpus", + f"{len(shard)}", + "--gpu-bind", + expected, + ] + + +def test_spec_with_gpus_1() -> None: + c = Config(["test.py", "--gpus", "1"]) + s = FakeSystem() + stage = m.GPU(c, s) + assert stage.spec.workers == 12 + assert stage.spec.shards == [(0,), (1,), (2,), (3,), (4,), (5,)] * 12 + + +def test_spec_with_gpus_2() -> None: + c = Config(["test.py", "--gpus", "2"]) + s = FakeSystem() + stage = m.GPU(c, s) + assert stage.spec.workers == 6 + assert stage.spec.shards == [(0, 1), (2, 3), (4, 5)] * 6 + + +def test_spec_with_requested_workers() -> None: + c = Config(["test.py", "--gpus", "1", "-j", "2"]) + s = FakeSystem() + stage = m.GPU(c, s) + assert stage.spec.workers == 2 + assert stage.spec.shards == [(0,), (1,), (2,), (3,), (4,), (5,)] * 2 + + +def test_spec_with_requested_workers_zero() -> None: + s = FakeSystem() + c = Config(["test.py", "-j", "0"]) + assert c.requested_workers == 0 + with pytest.raises(RuntimeError): + m.GPU(c, s) + + +def test_spec_with_requested_workers_bad() -> None: + s = FakeSystem() + c = Config(["test.py", "-j", f"{len(s.gpus)+100}"]) + assert c.requested_workers > len(s.gpus) + with pytest.raises(RuntimeError): + m.GPU(c, s) + + +def test_spec_with_verbose() -> None: + args = ["test.py", "--gpus", "2"] + c = Config(args) + cv = Config(args + ["--verbose"]) + s = FakeSystem() + + spec, vspec = m.GPU(c, s).spec, m.GPU(cv, s).spec + assert vspec == spec diff --git a/tests/unit/legate/tester/stages/_linux/test_omp.py b/tests/unit/legate/tester/stages/_linux/test_omp.py new file mode 100644 index 0000000000..a4d319fc0c --- /dev/null +++ b/tests/unit/legate/tester/stages/_linux/test_omp.py @@ -0,0 +1,164 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Consolidate test configuration from command-line and environment. + +""" +from __future__ import annotations + +import pytest + +from legate.tester.config import Config +from legate.tester.stages._linux import omp as m +from legate.tester.stages.util import UNPIN_ENV + +from .. import FakeSystem + + +def test_default() -> None: + c = Config([]) + s = FakeSystem(cpus=12) + stage = m.OMP(c, s) + assert stage.kind == "openmp" + assert stage.args == ["-cunumeric:test"] + assert stage.env(c, s) == UNPIN_ENV + assert stage.spec.workers > 0 + + shard = (1, 2, 3) + assert "--cpu-bind" in stage.shard_args(shard, c) + + +def test_cpu_pin_strict() -> None: + c = Config(["test.py", "--cpu-pin", "strict"]) + s = FakeSystem(cpus=12) + stage = m.OMP(c, s) + assert stage.kind == "openmp" + assert stage.args == ["-cunumeric:test"] + assert stage.env(c, s) == {} + assert stage.spec.workers > 0 + + shard = (1, 2, 3) + assert "--cpu-bind" in stage.shard_args(shard, c) + + +def test_cpu_pin_none() -> None: + c = Config(["test.py", "--cpu-pin", "none"]) + s = FakeSystem(cpus=12) + stage = m.OMP(c, s) + assert stage.kind == "openmp" + assert stage.args == ["-cunumeric:test"] + assert stage.env(c, s) == UNPIN_ENV + assert stage.spec.workers > 0 + + shard = (1, 2, 3) + assert "--cpu-bind" not in stage.shard_args(shard, c) + + +@pytest.mark.parametrize("shard,expected", [[(2,), "2"], [(1, 2, 3), "1,2,3"]]) +def test_shard_args(shard: tuple[int, ...], expected: str) -> None: + c = Config([]) + s = FakeSystem(cpus=12) + stage = m.OMP(c, s) + result = stage.shard_args(shard, c) + assert result == [ + "--omps", + f"{c.omps}", + "--ompthreads", + f"{c.ompthreads}", + "--cpu-bind", + expected, + ] + + +def test_spec_with_omps_1_threads_1() -> None: + c = Config(["test.py", "--omps", "1", "--ompthreads", "1"]) + s = FakeSystem(cpus=12) + stage = m.OMP(c, s) + assert stage.spec.workers == 6 + assert stage.spec.shards == [ + (0, 1), + (2, 3), + (4, 5), + (6, 7), + (8, 9), + (10, 11), + ] + + +def test_spec_with_omps_1_threads_2() -> None: + c = Config(["test.py", "--omps", "1", "--ompthreads", "2"]) + s = FakeSystem(cpus=12) + stage = m.OMP(c, s) + assert stage.spec.workers == 4 + assert stage.spec.shards == [(0, 1, 2), (3, 4, 5), (6, 7, 8), (9, 10, 11)] + + +def test_spec_with_omps_2_threads_1() -> None: + c = Config(["test.py", "--omps", "2", "--ompthreads", "1"]) + s = FakeSystem(cpus=12) + stage = m.OMP(c, s) + assert stage.spec.workers == 4 + assert stage.spec.shards == [(0, 1, 2), (3, 4, 5), (6, 7, 8), (9, 10, 11)] + + +def test_spec_with_omps_2_threads_2() -> None: + c = Config(["test.py", "--omps", "2", "--ompthreads", "2"]) + s = FakeSystem(cpus=12) + stage = m.OMP(c, s) + assert stage.spec.workers == 2 + assert stage.spec.shards == [(0, 1, 2, 3, 4), (5, 6, 7, 8, 9)] + + +def test_spec_with_utility() -> None: + c = Config( + ["test.py", "--omps", "2", "--ompthreads", "2", "--utility", "3"] + ) + s = FakeSystem(cpus=12) + stage = m.OMP(c, s) + assert stage.spec.workers == 1 + assert stage.spec.shards == [(0, 1, 2, 3, 4, 5, 6)] + + +def test_spec_with_requested_workers() -> None: + c = Config(["test.py", "--omps", "1", "--ompthreads", "1", "-j", "2"]) + s = FakeSystem(cpus=12) + stage = m.OMP(c, s) + assert stage.spec.workers == 2 + assert stage.spec.shards == [(0, 1), (2, 3)] + + +def test_spec_with_requested_workers_zero() -> None: + s = FakeSystem(cpus=12) + c = Config(["test.py", "-j", "0"]) + assert c.requested_workers == 0 + with pytest.raises(RuntimeError): + m.OMP(c, s) + + +def test_spec_with_requested_workers_bad() -> None: + s = FakeSystem(cpus=12) + c = Config(["test.py", "-j", f"{len(s.cpus)+1}"]) + assert c.requested_workers > len(s.cpus) + with pytest.raises(RuntimeError): + m.OMP(c, s) + + +def test_spec_with_verbose() -> None: + args = ["test.py", "--cpus", "2"] + c = Config(args) + cv = Config(args + ["--verbose"]) + s = FakeSystem(cpus=12) + + spec, vspec = m.OMP(c, s).spec, m.OMP(cv, s).spec + assert vspec == spec diff --git a/tests/unit/legate/tester/stages/test_test_stage.py b/tests/unit/legate/tester/stages/test_test_stage.py new file mode 100644 index 0000000000..dec596452c --- /dev/null +++ b/tests/unit/legate/tester/stages/test_test_stage.py @@ -0,0 +1,88 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Consolidate test configuration from command-line and environment. + +""" +from __future__ import annotations + +from datetime import timedelta +from pathlib import Path + +from legate.tester import FeatureType +from legate.tester.config import Config +from legate.tester.stages import test_stage as m +from legate.tester.stages.util import StageResult, StageSpec +from legate.tester.system import ProcessResult, System + +from . import FakeSystem + +s = FakeSystem() + + +class MockTestStage(m.TestStage): + + kind: FeatureType = "eager" + + name = "mock" + + args = ["-foo", "-bar"] + + def __init__(self, config: Config, system: System) -> None: + self._init(config, system) + + def compute_spec(self, config: Config, system: System) -> StageSpec: + return StageSpec(2, [(0,), (1,), (2,)]) + + +class TestTestStage: + def test_name(self) -> None: + c = Config([]) + stage = MockTestStage(c, s) + assert stage.name == "mock" + + def test_intro(self) -> None: + c = Config([]) + stage = MockTestStage(c, s) + assert "Entering stage: mock" in stage.intro + + def test_outro(self) -> None: + c = Config([]) + stage = MockTestStage(c, s) + stage.result = StageResult( + [ProcessResult("invoke", Path("test/file"))], + timedelta(seconds=2.123), + ) + outro = stage.outro + assert "Exiting stage: mock" in outro + assert "Passed 1 of 1 tests (100.0%)" in outro + assert "2.123" in outro + + def test_file_args_default(self) -> None: + c = Config([]) + stage = MockTestStage(c, s) + assert stage.file_args(Path("integration/foo"), c) == [] + assert stage.file_args(Path("unit/foo"), c) == [] + + def test_file_args_v(self) -> None: + c = Config(["test.py", "-v"]) + stage = MockTestStage(c, s) + assert stage.file_args(Path("integration/foo"), c) == ["-v"] + assert stage.file_args(Path("unit/foo"), c) == [] + + def test_file_args_vv(self) -> None: + c = Config(["test.py", "-vv"]) + stage = MockTestStage(c, s) + assert stage.file_args(Path("integration/foo"), c) == ["-v", "-s"] + assert stage.file_args(Path("unit/foo"), c) == [] diff --git a/tests/unit/legate/tester/stages/test_util.py b/tests/unit/legate/tester/stages/test_util.py new file mode 100644 index 0000000000..b4c528d060 --- /dev/null +++ b/tests/unit/legate/tester/stages/test_util.py @@ -0,0 +1,48 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Consolidate test configuration from command-line and environment. + +""" +from __future__ import annotations + +import pytest + +from legate.tester.stages import util as m + + +class Test_adjust_workers: + @pytest.mark.parametrize("n", (1, 5, 100)) + def test_None_requested(self, n: int) -> None: + assert m.adjust_workers(n, None) == n + + @pytest.mark.parametrize("n", (1, 2, 9)) + def test_requested(self, n: int) -> None: + assert m.adjust_workers(10, n) == n + + def test_negative_requested(self) -> None: + with pytest.raises(ValueError): + assert m.adjust_workers(10, -1) + + def test_zero_requested(self) -> None: + with pytest.raises(RuntimeError): + assert m.adjust_workers(10, 0) + + def test_zero_computed(self) -> None: + with pytest.raises(RuntimeError): + assert m.adjust_workers(0, None) + + def test_requested_too_large(self) -> None: + with pytest.raises(RuntimeError): + assert m.adjust_workers(10, 11) diff --git a/tests/unit/legate/tester/test___init__.py b/tests/unit/legate/tester/test___init__.py new file mode 100644 index 0000000000..9e5676129f --- /dev/null +++ b/tests/unit/legate/tester/test___init__.py @@ -0,0 +1,73 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Consolidate test configuration from command-line and environment. + +""" +from __future__ import annotations + +from legate.tester import ( + DEFAULT_CPUS_PER_NODE, + DEFAULT_GPU_DELAY, + DEFAULT_GPU_MEMORY_BUDGET, + DEFAULT_GPUS_PER_NODE, + DEFAULT_OMPS_PER_NODE, + DEFAULT_OMPTHREADS, + DEFAULT_PROCESS_ENV, + FEATURES, + PER_FILE_ARGS, + SKIPPED_EXAMPLES, + UI_WIDTH, +) + + +class TestConsts: + def test_DEFAULT_CPUS_PER_NODE(self) -> None: + assert DEFAULT_CPUS_PER_NODE == 4 + + def test_DEFAULT_GPUS_PER_NODE(self) -> None: + assert DEFAULT_GPUS_PER_NODE == 1 + + def test_DEFAULT_GPU_DELAY(self) -> None: + assert DEFAULT_GPU_DELAY == 2000 + + def test_DEFAULT_GPU_MEMORY_BUDGET(self) -> None: + assert DEFAULT_GPU_MEMORY_BUDGET == 4096 + + def test_DEFAULT_OMPS_PER_NODE(self) -> None: + assert DEFAULT_OMPS_PER_NODE == 1 + + def test_DEFAULT_OMPTHREADS(self) -> None: + assert DEFAULT_OMPTHREADS == 4 + + def test_DEFAULT_PROCESS_ENV(self) -> None: + assert DEFAULT_PROCESS_ENV == { + "LEGATE_TEST": "1", + } + + def test_UI_WIDTH(self) -> None: + assert UI_WIDTH == 65 + + def test_FEATURES(self) -> None: + assert FEATURES == ("cpus", "cuda", "eager", "openmp") + + def test_SKIPPED_EXAMPLES(self) -> None: + assert isinstance(SKIPPED_EXAMPLES, set) + assert all(isinstance(x, str) for x in SKIPPED_EXAMPLES) + assert all(x.startswith("examples") for x in SKIPPED_EXAMPLES) + + def test_PER_FILE_ARGS(self) -> None: + assert isinstance(PER_FILE_ARGS, dict) + assert all(isinstance(x, str) for x in PER_FILE_ARGS.keys()) + assert all(isinstance(x, list) for x in PER_FILE_ARGS.values()) diff --git a/tests/unit/legate/tester/test_args.py b/tests/unit/legate/tester/test_args.py new file mode 100644 index 0000000000..5ae20dbcae --- /dev/null +++ b/tests/unit/legate/tester/test_args.py @@ -0,0 +1,132 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Consolidate test configuration from command-line and environment. + +""" +from __future__ import annotations + +from itertools import chain, combinations +from typing import Iterable, TypeVar + +import pytest + +from legate.tester import ( + DEFAULT_CPUS_PER_NODE, + DEFAULT_GPU_DELAY, + DEFAULT_GPU_MEMORY_BUDGET, + DEFAULT_GPUS_PER_NODE, + DEFAULT_OMPS_PER_NODE, + DEFAULT_OMPTHREADS, + args as m, +) + +T = TypeVar("T") + + +# https://docs.python.org/3/library/itertools.html#itertools-recipes +def powerset(iterable: Iterable[T]) -> Iterable[Iterable[T]]: + xs = list(iterable) + return chain.from_iterable(combinations(xs, n) for n in range(len(xs) + 1)) + + +class TestParserDefaults: + def test_featurs(self) -> None: + assert m.parser.get_default("features") is None + + def test_files(self) -> None: + assert m.parser.get_default("files") is None + + def test_unit(self) -> None: + assert m.parser.get_default("unit") is False + + def test_cpus(self) -> None: + assert m.parser.get_default("cpus") == DEFAULT_CPUS_PER_NODE + + def test_gpus(self) -> None: + assert m.parser.get_default("gpus") == DEFAULT_GPUS_PER_NODE + + def test_cpu_pin(self) -> None: + assert m.parser.get_default("cpu_pin") == "partial" + + def test_gpu_delay(self) -> None: + assert m.parser.get_default("gpu_delay") == DEFAULT_GPU_DELAY + + def test_fbmem(self) -> None: + assert m.parser.get_default("fbmem") == DEFAULT_GPU_MEMORY_BUDGET + + def test_omps(self) -> None: + assert m.parser.get_default("omps") == DEFAULT_OMPS_PER_NODE + + def test_ompthreads(self) -> None: + assert m.parser.get_default("ompthreads") == DEFAULT_OMPTHREADS + + def test_legate_dir(self) -> None: + assert m.parser.get_default("legate_dir") is None + + def test_test_root(self) -> None: + assert m.parser.get_default("test_root") is None + + def test_workers(self) -> None: + assert m.parser.get_default("workers") is None + + def test_verbose(self) -> None: + assert m.parser.get_default("verbose") == 0 + + def test_dry_run(self) -> None: + assert m.parser.get_default("dry_run") is False + + def test_debug(self) -> None: + assert m.parser.get_default("debug") is False + + +class TestParserConfig: + def test_parser_epilog(self) -> None: + assert ( + m.parser.epilog + == "Any extra arguments will be forwarded to the Legate script" + ) + + def test_parser_description(self) -> None: + assert m.parser.description == "Run the Cunumeric test suite" + + +class TestMultipleChoices: + @pytest.mark.parametrize("choices", ([1, 2, 3], range(4), ("a", "b"))) + def test_init(self, choices: Iterable[T]) -> None: + mc = m.MultipleChoices(choices) + assert mc.choices == set(choices) + + def test_contains_item(self) -> None: + choices = [1, 2, 3] + mc = m.MultipleChoices(choices) + for item in choices: + assert item in mc + + def test_contains_subset(self) -> None: + choices = [1, 2, 3] + mc = m.MultipleChoices(choices) + for subset in powerset(choices): + assert subset in mc + + def test_iter(self) -> None: + choices = [1, 2, 3] + mc = m.MultipleChoices(choices) + assert list(mc) == choices + + +# Testing this directly would require getting into argparse +# internals. See test_config.py for indirect tests with --use +class TestExtendAction: + pass diff --git a/tests/unit/legate/tester/test_config.py b/tests/unit/legate/tester/test_config.py new file mode 100644 index 0000000000..d551049802 --- /dev/null +++ b/tests/unit/legate/tester/test_config.py @@ -0,0 +1,182 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Consolidate test configuration from command-line and environment. + +""" +from __future__ import annotations + +from pathlib import Path, PurePath + +import pytest + +from legate.tester import ( + DEFAULT_CPUS_PER_NODE, + DEFAULT_GPU_DELAY, + DEFAULT_GPU_MEMORY_BUDGET, + DEFAULT_GPUS_PER_NODE, + DEFAULT_OMPS_PER_NODE, + DEFAULT_OMPTHREADS, + FEATURES, + config as m, +) +from legate.tester.args import PIN_OPTIONS, PinOptionsType + + +class TestConfig: + def test_default_init(self) -> None: + c = m.Config([]) + + assert c.examples is True + assert c.integration is True + assert c.unit is False + assert c.files is None + + assert c.features == ("cpus",) + + assert c.cpus == DEFAULT_CPUS_PER_NODE + assert c.gpus == DEFAULT_GPUS_PER_NODE + assert c.cpu_pin == "partial" + assert c.gpu_delay == DEFAULT_GPU_DELAY + assert c.fbmem == DEFAULT_GPU_MEMORY_BUDGET + assert c.omps == DEFAULT_OMPS_PER_NODE + assert c.ompthreads == DEFAULT_OMPTHREADS + + assert c.debug is False + assert c.dry_run is False + assert c.verbose == 0 + assert c.test_root is None + assert c.requested_workers is None + assert c.legate_dir is None + + assert c.extra_args == [] + assert c.root_dir == PurePath(m.__file__).parents[2] + + # TODO (bv) restore when generalized + # assert len(c.test_files) > 0 + # assert any("examples" in str(x) for x in c.test_files) + # assert any("integration" in str(x) for x in c.test_files) + # assert all("unit" not in str(x) for x in c.test_files) + + assert c.legate_path == "legate" + + @pytest.mark.parametrize("feature", FEATURES) + def test_env_features( + self, monkeypatch: pytest.MonkeyPatch, feature: str + ) -> None: + monkeypatch.setenv(f"USE_{feature.upper()}", "1") + + # test default config + c = m.Config([]) + assert set(c.features) == {feature} + + # also test with a --use value provided + c = m.Config(["test.py", "--use", "cuda"]) + assert set(c.features) == {"cuda"} + + @pytest.mark.parametrize("feature", FEATURES) + def test_cmd_features(self, feature: str) -> None: + + # test a single value + c = m.Config(["test.py", "--use", feature]) + assert set(c.features) == {feature} + + # also test with multiple / duplication + c = m.Config(["test.py", "--use", f"cpus,{feature}"]) + assert set(c.features) == {"cpus", feature} + + # TODO (bv) restore when generalized + @pytest.mark.skip + def test_unit(self) -> None: + c = m.Config(["test.py", "--unit"]) + assert len(c.test_files) > 0 + assert any("examples" in str(x) for x in c.test_files) + assert any("integration" in str(x) for x in c.test_files) + assert any("unit" in str(x) for x in c.test_files) + + def test_files(self) -> None: + c = m.Config(["test.py", "--files", "a", "b", "c"]) + assert c.files == ["a", "b", "c"] + + @pytest.mark.parametrize( + "opt", ("cpus", "gpus", "gpu-delay", "fbmem", "omps", "ompthreads") + ) + def test_feature_options(self, opt: str) -> None: + c = m.Config(["test.py", f"--{opt}", "1234"]) + assert getattr(c, opt.replace("-", "_")) == 1234 + + @pytest.mark.parametrize("value", PIN_OPTIONS) + def test_cpu_pin(self, value: PinOptionsType) -> None: + c = m.Config(["test.py", "--cpu-pin", value]) + assert c.cpu_pin == value + + def test_workers(self) -> None: + c = m.Config(["test.py", "-j", "1234"]) + assert c.requested_workers == 1234 + + def test_debug(self) -> None: + c = m.Config(["test.py", "--debug"]) + assert c.debug is True + + def test_dry_run(self) -> None: + c = m.Config(["test.py", "--dry-run"]) + assert c.dry_run is True + + @pytest.mark.parametrize("arg", ("-v", "--verbose")) + def test_verbose1(self, arg: str) -> None: + c = m.Config(["test.py", arg]) + assert c.verbose == 1 + + def test_verbose2(self) -> None: + c = m.Config(["test.py", "-vv"]) + assert c.verbose == 2 + + @pytest.mark.parametrize("arg", ("-C", "--directory")) + def test_test_root(self, arg: str) -> None: + c = m.Config(["test.py", arg, "some/path"]) + assert c.test_root == "some/path" + + def test_legate_dir(self) -> None: + c = m.Config([]) + assert c.legate_dir is None + assert c.legate_path == "legate" + assert c._legate_source == "install" + + def test_cmd_legate_dir_good(self) -> None: + legate_dir = Path("/usr/local") + c = m.Config(["test.py", "--legate", str(legate_dir)]) + assert c.legate_dir == legate_dir + assert c.legate_path == str(legate_dir / "bin" / "legate") + assert c._legate_source == "cmd" + + def test_env_legate_dir_good( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + legate_dir = Path("/usr/local") + monkeypatch.setenv("LEGATE_DIR", str(legate_dir)) + c = m.Config([]) + assert c.legate_dir == legate_dir + assert c.legate_path == str(legate_dir / "bin" / "legate") + assert c._legate_source == "env" + + def test_extra_args(self) -> None: + extra = ["-foo", "--bar", "--baz", "10"] + c = m.Config(["test.py"] + extra) + assert c.extra_args == extra + + # also test with --files since that option collects arguments + c = m.Config(["test.py", "--files", "a", "b"] + extra) + assert c.extra_args == extra + c = m.Config(["test.py"] + extra + ["--files", "a", "b"]) + assert c.extra_args == extra diff --git a/tests/unit/legate/tester/test_logger.py b/tests/unit/legate/tester/test_logger.py new file mode 100644 index 0000000000..40228c2f43 --- /dev/null +++ b/tests/unit/legate/tester/test_logger.py @@ -0,0 +1,74 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Consolidate test configuration from command-line and environment. + +""" +from __future__ import annotations + +from legate.tester import logger as m + +TEST_LINES = ( + "line 1", + "\x1b[31mfoo\x1b[0m", # ui.red("foo") + "bar", + "last line", +) + + +class TestLogger: + def test_init(self) -> None: + log = m.Log() + assert log.lines == () + assert log.dump() == "" + + def test_record_lines(self) -> None: + log = m.Log() + log.record(*TEST_LINES) + assert log.lines == TEST_LINES + assert log.dump(filter_ansi=False) == "\n".join(TEST_LINES) + + def test_record_line_with_newlines(self) -> None: + log = m.Log() + log.record("\n".join(TEST_LINES)) + assert log.lines == TEST_LINES + assert log.dump(filter_ansi=False) == "\n".join(TEST_LINES) + + def test_call(self) -> None: + log = m.Log() + log(*TEST_LINES) + assert log.lines == TEST_LINES + assert log.dump() == "line 1\nfoo\nbar\nlast line" + + def test_dump_filter(self) -> None: + log = m.Log() + log.record(*TEST_LINES) + assert log.lines == TEST_LINES + assert log.dump() == "line 1\nfoo\nbar\nlast line" + + def test_dump_index(self) -> None: + log = m.Log() + log.record(*TEST_LINES) + assert log.dump(start=1, end=3) == "foo\nbar" + + def test_clear(self) -> None: + log = m.Log() + log.record(*TEST_LINES) + assert len(log.lines) > 0 + log.clear() + assert len(log.lines) == 0 + + +def test_LOG() -> None: + assert isinstance(m.LOG, m.Log) diff --git a/tests/unit/legate/tester/test_system.py b/tests/unit/legate/tester/test_system.py new file mode 100644 index 0000000000..99fd2afe3c --- /dev/null +++ b/tests/unit/legate/tester/test_system.py @@ -0,0 +1,78 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Consolidate test configuration from command-line and environment. + +""" +from __future__ import annotations + +import sys +from pathlib import Path +from subprocess import CompletedProcess +from unittest.mock import MagicMock + +import pytest +from pytest_mock import MockerFixture + +from legate.tester import system as m + + +@pytest.fixture +def mock_subprocess_run(mocker: MockerFixture) -> MagicMock: + return mocker.patch.object(m, "stdlib_run") + + +CMD = "legate script.py --cpus 4" + + +class TestSystem: + def test_init(self) -> None: + s = m.System() + assert s.dry_run is False + + def test_run(self, mock_subprocess_run: MagicMock) -> None: + s = m.System() + + expected = m.ProcessResult( + CMD, Path("test/file"), returncode=10, output="" + ) + mock_subprocess_run.return_value = CompletedProcess( + CMD, 10, stdout="" + ) + + result = s.run(CMD.split(), Path("test/file")) + mock_subprocess_run.assert_called() + + assert result == expected + + def test_dry_run(self, mock_subprocess_run: MagicMock) -> None: + s = m.System(dry_run=True) + + result = s.run(CMD.split(), Path("test/file")) + mock_subprocess_run.assert_not_called() + + assert result.output == "" + assert result.skipped + + def test_cpus(self) -> None: + s = m.System() + cpus = s.cpus + assert len(cpus) > 0 + assert all(len(cpu.ids) > 0 for cpu in cpus) + + @pytest.mark.skipif(sys.platform != "linux", reason="pynvml required") + def test_gpus(self) -> None: + s = m.System() + # can't really assume / test much here + s.gpus diff --git a/tests/unit/legate/tester/test_types.py b/tests/unit/legate/tester/test_types.py new file mode 100644 index 0000000000..8d4e69f5c8 --- /dev/null +++ b/tests/unit/legate/tester/test_types.py @@ -0,0 +1,30 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Consolidate test configuration from command-line and environment. + +""" +from __future__ import annotations + +from legate.tester import types as m + + +class TestCPUInfo: + def test_fields(self) -> None: + assert set(m.CPUInfo.__dataclass_fields__) == {"ids"} + + +class TestGPUInfo: + def test_fields(self) -> None: + assert set(m.GPUInfo.__dataclass_fields__) == {"id", "total"} diff --git a/tests/unit/legate/tester/test_ui.py b/tests/unit/legate/tester/test_ui.py new file mode 100644 index 0000000000..64277c528e --- /dev/null +++ b/tests/unit/legate/tester/test_ui.py @@ -0,0 +1,103 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Consolidate test configuration from command-line and environment. + +""" +from __future__ import annotations + +from datetime import timedelta + +import pytest +from pytest_mock import MockerFixture + +from legate.tester import UI_WIDTH, ui as m + + +@pytest.fixture(autouse=True) +def use_plain_text(mocker: MockerFixture) -> None: + mocker.patch.object(m, "bright", m._text) + mocker.patch.object(m, "dim", m._text) + mocker.patch.object(m, "white", m._text) + mocker.patch.object(m, "cyan", m._text) + mocker.patch.object(m, "red", m._text) + mocker.patch.object(m, "green", m._text) + mocker.patch.object(m, "yellow", m._text) + + +def test_banner_simple() -> None: + assert ( + m.banner("some text") + == "\n" + "#" * UI_WIDTH + "\n### some text\n" + "#" * UI_WIDTH + ) + + +def test_banner_full() -> None: + assert ( + m.banner("some text", char="*", width=100, details=["a", "b"]) + == "\n" + + "*" * 100 + + "\n*** \n*** some text\n*** \n*** a\n*** b\n*** \n" + + "*" * 100 + ) + + +def test_rule_default() -> None: + assert m.rule() == " " + "~" * (UI_WIDTH - 4) + + +def test_rule_with_args() -> None: + assert m.rule(10, "-") == " " * 10 + "-" * (UI_WIDTH - 10) + + +def test_shell() -> None: + assert m.shell("cmd --foo") == "+cmd --foo" + + +def test_shell_with_char() -> None: + assert m.shell("cmd --foo", char="") == "cmd --foo" + + +def test_passed() -> None: + assert m.passed("msg") == "[PASS] msg" + + +def test_passed_with_details() -> None: + assert m.passed("msg", details=["a", "b"]) == "[PASS] msg\n a\n b" + + +def test_failed() -> None: + assert m.failed("msg") == "[FAIL] msg" + + +def test_failed_with_details() -> None: + assert m.failed("msg", details=["a", "b"]) == "[FAIL] msg\n a\n b" + + +def test_skipped() -> None: + assert m.skipped("msg") == "[SKIP] msg" + + +def test_summary() -> None: + assert ( + m.summary("foo", 12, 11, timedelta(seconds=2.123)) + == f"{'foo: Passed 11 of 12 tests (91.7%) in 2.12s': >{UI_WIDTH}}" + ) + + +def test_summary_no_justify() -> None: + assert ( + m.summary("foo", 12, 11, timedelta(seconds=2.123), justify=False) + == "foo: Passed 11 of 12 tests (91.7%) in 2.12s" + ) From 424167e907ef465cab9e72a87b9f3a9ae60a1517 Mon Sep 17 00:00:00 2001 From: Bryan Van de Ven Date: Tue, 27 Sep 2022 14:54:04 -0700 Subject: [PATCH 02/16] consolidate some utils and types --- legate/core/__init__.py | 3 +- legate/core/runtime.py | 3 +- legate/rc.py | 101 ---------------- legate/utils/__init__.py | 15 +++ legate/utils/args.py | 116 ++++++++++++++++++ tests/unit/__init__.py | 15 +++ tests/unit/legate/__init__.py | 15 +++ tests/unit/legate/driver/__init__.py | 15 +++ tests/unit/legate/driver/conftest.py | 3 +- tests/unit/legate/driver/test_command.py | 4 +- tests/unit/legate/driver/test_config.py | 3 +- tests/unit/legate/driver/test_driver.py | 4 +- tests/unit/legate/driver/test_launcher.py | 4 +- tests/unit/legate/driver/test_logs.py | 4 +- tests/unit/legate/driver/test_util.py | 3 +- tests/unit/legate/driver/util.py | 16 +-- tests/unit/legate/test_rc.py | 122 ++----------------- tests/unit/legate/utils/__init__.py | 15 +++ tests/unit/legate/utils/test_args.py | 140 ++++++++++++++++++++++ tests/unit/util.py | 33 +++++ 20 files changed, 393 insertions(+), 241 deletions(-) create mode 100644 legate/utils/__init__.py create mode 100644 legate/utils/args.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/legate/__init__.py create mode 100644 tests/unit/legate/driver/__init__.py create mode 100644 tests/unit/legate/utils/__init__.py create mode 100644 tests/unit/legate/utils/test_args.py create mode 100644 tests/unit/util.py diff --git a/legate/core/__init__.py b/legate/core/__init__.py index 4ad4c308b3..ab9a3cdfcd 100644 --- a/legate/core/__init__.py +++ b/legate/core/__init__.py @@ -14,7 +14,8 @@ # from __future__ import annotations -from ..rc import check_legion, parse_command_args +from ..rc import check_legion +from ..utils.args import parse_command_args check_legion() diff --git a/legate/core/runtime.py b/legate/core/runtime.py index fa4fdaad99..c0bda55682 100644 --- a/legate/core/runtime.py +++ b/legate/core/runtime.py @@ -24,8 +24,7 @@ from legion_top import add_cleanup_item, top_level -from legate.rc import ArgSpec, Argument, parse_command_args - +from ..utils.args import ArgSpec, Argument, parse_command_args from . import ffi # Make sure we only have one ffi instance from . import ( Fence, diff --git a/legate/rc.py b/legate/rc.py index 6a54cc530f..437eac1462 100644 --- a/legate/rc.py +++ b/legate/rc.py @@ -14,14 +14,6 @@ # from __future__ import annotations -import sys -import warnings -from argparse import ArgumentParser, Namespace -from dataclasses import dataclass, fields -from typing import Any, Iterable, Literal, Sequence, Type, TypeVar, Union - -from typing_extensions import TypeAlias - LEGION_WARNING = """ All Legate programs must be run with a legion_python interperter. We @@ -55,96 +47,3 @@ def check_legion(msg: str = LEGION_WARNING) -> None: """Raise an error if we are not running in legion_python.""" if not has_legion_context(): raise RuntimeError(msg) - - -class _UnsetType: - pass - - -Unset = _UnsetType() - -_T = TypeVar("_T") -NotRequired = Union[_UnsetType, _T] - - -def entries(obj: Any) -> Iterable[tuple[str, Any]]: - for f in fields(obj): - value = getattr(obj, f.name) - if value is not Unset: - yield (f.name, value) - - -# https://docs.python.org/3/library/argparse.html#action -ActionType: TypeAlias = Literal[ - "store", - "store_const", - "store_true", - "append", - "append_const", - "count", - "help", - "version", - "extend", -] - -# https://docs.python.org/3/library/argparse.html#nargs -NargsType: TypeAlias = Literal["?", "*", "+", "..."] - - -@dataclass(frozen=True) -class ArgSpec: - dest: str - action: NotRequired[ActionType] = "store_true" - nargs: NotRequired[Union[int, NargsType]] = Unset - const: NotRequired[Any] = Unset - default: NotRequired[Any] = Unset - type: NotRequired[Type[Any]] = Unset - choices: NotRequired[Sequence[Any]] = Unset - help: NotRequired[str] = Unset - metavar: NotRequired[str] = Unset - - -@dataclass(frozen=True) -class Argument: - name: str - spec: ArgSpec - - -def parse_command_args(libname: str, args: Iterable[Argument]) -> Namespace: - """ """ - if not libname.isidentifier(): - raise ValueError( - f"Invalid library {libname!r} for command line arguments" - ) - - parser = ArgumentParser( - prog=f"<{libname} program>", add_help=False, allow_abbrev=False - ) - - lib_prefix = f"-{libname}:" - - argnames = [arg.name for arg in args] - - for arg in args: - argname = f"{lib_prefix}{arg.name}" - kwargs = dict(entries(arg.spec)) - parser.add_argument(argname, **kwargs) - - has_custom_help = "help" in argnames - - if f"{lib_prefix}help" in sys.argv and not has_custom_help: - parser.print_help() - sys.exit() - - args, extra = parser.parse_known_args() - - for item in extra: - if item.startswith(lib_prefix): - warnings.warn( - f"Unrecognized argument {item!r} for {libname} (passed on as-is)" # noqa: E501 - ) - break - - sys.argv = sys.argv[:1] + extra - - return args diff --git a/legate/utils/__init__.py b/legate/utils/__init__.py new file mode 100644 index 0000000000..98636f9f74 --- /dev/null +++ b/legate/utils/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2021-2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations diff --git a/legate/utils/args.py b/legate/utils/args.py new file mode 100644 index 0000000000..e590ada576 --- /dev/null +++ b/legate/utils/args.py @@ -0,0 +1,116 @@ +# Copyright 2021-2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations + +import sys +import warnings +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass, fields +from typing import Any, Iterable, Literal, Sequence, Type, TypeVar, Union + +from typing_extensions import TypeAlias + + +class _UnsetType: + pass + + +Unset = _UnsetType() + +_T = TypeVar("_T") +NotRequired = Union[_UnsetType, _T] + + +# https://docs.python.org/3/library/argparse.html#action +ActionType: TypeAlias = Literal[ + "store", + "store_const", + "store_true", + "append", + "append_const", + "count", + "help", + "version", + "extend", +] + +# https://docs.python.org/3/library/argparse.html#nargs +NargsType: TypeAlias = Literal["?", "*", "+", "..."] + + +@dataclass(frozen=True) +class ArgSpec: + dest: str + action: NotRequired[ActionType] = "store_true" + nargs: NotRequired[Union[int, NargsType]] = Unset + const: NotRequired[Any] = Unset + default: NotRequired[Any] = Unset + type: NotRequired[Type[Any]] = Unset + choices: NotRequired[Sequence[Any]] = Unset + help: NotRequired[str] = Unset + metavar: NotRequired[str] = Unset + + +@dataclass(frozen=True) +class Argument: + name: str + spec: ArgSpec + + +def entries(obj: Any) -> Iterable[tuple[str, Any]]: + for f in fields(obj): + value = getattr(obj, f.name) + if value is not Unset: + yield (f.name, value) + + +def parse_command_args(libname: str, args: Iterable[Argument]) -> Namespace: + """ """ + if not libname.isidentifier(): + raise ValueError( + f"Invalid library {libname!r} for command line arguments" + ) + + parser = ArgumentParser( + prog=f"<{libname} program>", add_help=False, allow_abbrev=False + ) + + lib_prefix = f"-{libname}:" + + argnames = [arg.name for arg in args] + + for arg in args: + argname = f"{lib_prefix}{arg.name}" + kwargs = dict(entries(arg.spec)) + parser.add_argument(argname, **kwargs) + + has_custom_help = "help" in argnames + + if f"{lib_prefix}help" in sys.argv and not has_custom_help: + parser.print_help() + sys.exit() + + args, extra = parser.parse_known_args() + + for item in extra: + if item.startswith(lib_prefix): + warnings.warn( + f"Unrecognized argument {item!r} for {libname} (passed on as-is)" # noqa: E501 + ) + break + + sys.argv = sys.argv[:1] + extra + + return args diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000000..f0b271624d --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations diff --git a/tests/unit/legate/__init__.py b/tests/unit/legate/__init__.py new file mode 100644 index 0000000000..f0b271624d --- /dev/null +++ b/tests/unit/legate/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations diff --git a/tests/unit/legate/driver/__init__.py b/tests/unit/legate/driver/__init__.py new file mode 100644 index 0000000000..f0b271624d --- /dev/null +++ b/tests/unit/legate/driver/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations diff --git a/tests/unit/legate/driver/conftest.py b/tests/unit/legate/driver/conftest.py index 1b1f31e48c..b60488c351 100644 --- a/tests/unit/legate/driver/conftest.py +++ b/tests/unit/legate/driver/conftest.py @@ -19,11 +19,12 @@ from typing import Any, Callable, Iterable import pytest -from util import GenConfig, GenSystem from legate.driver import Config, Launcher, System from legate.driver.config import MultiNode +from .util import GenConfig, GenSystem + @pytest.fixture def clear_and_reload( diff --git a/tests/unit/legate/driver/test_command.py b/tests/unit/legate/driver/test_command.py index f7188990f9..35c52ac637 100644 --- a/tests/unit/legate/driver/test_command.py +++ b/tests/unit/legate/driver/test_command.py @@ -18,13 +18,15 @@ from pathlib import Path import pytest -from util import Capsys, GenObjs, powerset_nonempty import legate.driver.command as m from legate.driver.launcher import RANK_ENV_VARS from legate.driver.types import LauncherType from legate.driver.ui import scrub +from ...util import Capsys, powerset_nonempty +from .util import GenObjs + def test___all__() -> None: assert m.__all__ == ("CMD_PARTS",) diff --git a/tests/unit/legate/driver/test_config.py b/tests/unit/legate/driver/test_config.py index 0523b1db91..d5fd729adc 100644 --- a/tests/unit/legate/driver/test_config.py +++ b/tests/unit/legate/driver/test_config.py @@ -20,13 +20,14 @@ import pytest from pytest_mock import MockerFixture -from util import Capsys, powerset, powerset_nonempty import legate.driver.config as m import legate.driver.defaults as defaults from legate.driver.types import DataclassMixin from legate.driver.ui import scrub +from ...util import Capsys, powerset, powerset_nonempty + DEFAULTS_ENV_VARS = ( "LEGATE_EAGER_ALLOC_PERCENTAGE", "LEGATE_FBMEM", diff --git a/tests/unit/legate/driver/test_driver.py b/tests/unit/legate/driver/test_driver.py index f6aea4a0b4..9f011d7024 100644 --- a/tests/unit/legate/driver/test_driver.py +++ b/tests/unit/legate/driver/test_driver.py @@ -18,7 +18,6 @@ import pytest from pytest_mock import MockerFixture -from util import Capsys, GenConfig import legate.driver.driver as m from legate.driver.args import LAUNCHERS @@ -29,6 +28,9 @@ from legate.driver.ui import scrub from legate.driver.util import print_verbose +from ...util import Capsys +from .util import GenConfig + SYSTEM = System() DARWIN_GDB_WARN_EXPECTED_PAT = """\ diff --git a/tests/unit/legate/driver/test_launcher.py b/tests/unit/legate/driver/test_launcher.py index 1c5b451af4..bb173fcb43 100644 --- a/tests/unit/legate/driver/test_launcher.py +++ b/tests/unit/legate/driver/test_launcher.py @@ -17,13 +17,15 @@ import os import pytest -from util import GenConfig, GenObjs, powerset_nonempty import legate.driver.launcher as m from legate.driver.args import LAUNCHERS from legate.driver.system import System from legate.driver.types import LauncherType +from ...util import powerset_nonempty +from .util import GenConfig, GenObjs + SYSTEM = System() diff --git a/tests/unit/legate/driver/test_logs.py b/tests/unit/legate/driver/test_logs.py index 918dfc283b..9133939b33 100644 --- a/tests/unit/legate/driver/test_logs.py +++ b/tests/unit/legate/driver/test_logs.py @@ -16,13 +16,15 @@ import pytest from pytest_mock import MockerFixture -from util import Capsys, GenObjs, powerset_nonempty import legate.driver.logs as m from legate.driver.config import Config from legate.driver.launcher import RANK_ENV_VARS from legate.driver.ui import scrub +from ...util import Capsys, powerset_nonempty +from .util import GenObjs + class MockHandler(m.LogHandler): _process_called = False diff --git a/tests/unit/legate/driver/test_util.py b/tests/unit/legate/driver/test_util.py index a864ddc8c9..ba735e494b 100644 --- a/tests/unit/legate/driver/test_util.py +++ b/tests/unit/legate/driver/test_util.py @@ -19,7 +19,6 @@ from shlex import quote import pytest -from util import Capsys import legate.driver.util as m from legate.driver.config import Config @@ -27,6 +26,8 @@ from legate.driver.system import System from legate.driver.ui import scrub +from ...util import Capsys + class Source: foo = 10 diff --git a/tests/unit/legate/driver/util.py b/tests/unit/legate/driver/util.py index d918969772..fad7a9f76e 100644 --- a/tests/unit/legate/driver/util.py +++ b/tests/unit/legate/driver/util.py @@ -14,26 +14,12 @@ # from __future__ import annotations -from itertools import chain, combinations -from typing import Any, Iterable, Iterator +from typing import Any -import pytest from typing_extensions import TypeAlias -Capsys: TypeAlias = pytest.CaptureFixture[str] - GenConfig: TypeAlias = Any GenSystem: TypeAlias = Any GenObjs: TypeAlias = Any - - -# ref: https://docs.python.org/3/library/itertools.html -def powerset(iterable: Iterable[Any]) -> Iterator[Any]: - s = list(iterable) - return chain.from_iterable(combinations(s, r) for r in range(len(s) + 1)) - - -def powerset_nonempty(iterable: Iterable[Any]) -> Iterator[Any]: - return (x for x in powerset(iterable) if len(x)) diff --git a/tests/unit/legate/test_rc.py b/tests/unit/legate/test_rc.py index d497163ef1..74cea3092d 100644 --- a/tests/unit/legate/test_rc.py +++ b/tests/unit/legate/test_rc.py @@ -14,7 +14,6 @@ # import sys -from dataclasses import dataclass from unittest.mock import MagicMock import pytest @@ -30,27 +29,28 @@ def mock_has_legion_context(monkeypatch: pytest.MonkeyPatch) -> MagicMock: class Test_check_legion: - def test_True(self, mock_has_legion_context) -> None: + def test_True(self, mock_has_legion_context: MagicMock) -> None: mock_has_legion_context.return_value = True - assert m.check_legion() is None + assert m.check_legion() is None # type: ignore[func-returns-value] - def test_True_with_msg(self, mock_has_legion_context) -> None: + def test_True_with_msg(self, mock_has_legion_context: MagicMock) -> None: mock_has_legion_context.return_value = True - assert m.check_legion(msg="custom") is None + assert m.check_legion(msg="custom") is None # type: ignore[func-returns-value] # noqa - def test_False(self, mock_has_legion_context) -> None: + def test_False(self, mock_has_legion_context: MagicMock) -> None: mock_has_legion_context.return_value = False with pytest.raises(RuntimeError) as e: m.check_legion() assert str(e) == m.LEGION_WARNING - def test_False_with_msg(self, mock_has_legion_context) -> None: + def test_False_with_msg(self, mock_has_legion_context: MagicMock) -> None: mock_has_legion_context.return_value = False with pytest.raises(RuntimeError) as e: m.check_legion(msg="custom") assert str(e) == "custom" +@pytest.mark.skip class Test_has_legion_context: def test_True(self) -> None: assert m.has_legion_context() is True @@ -62,113 +62,5 @@ def test_False(self) -> None: pass -@dataclass(frozen=True) -class _TestObj: - a: int = 10 - b: m.NotRequired[int] = m.Unset - c: m.NotRequired[str] = "foo" - d: m.NotRequired[str] = m.Unset - - -def test_entries() -> None: - assert set(m.entries(_TestObj())) == {("a", 10), ("c", "foo")} - - -class TestArgSpec: - def test_dest_required(self): - with pytest.raises(TypeError) as e: - m.ArgSpec() - assert ( - str(e.value) - == "__init__() missing 1 required positional argument: 'dest'" - ) - - def test_default(self): - spec = m.ArgSpec("dest") - assert spec.dest == "dest" - assert spec.action == "store_true" - - # all others are unset - assert set(m.entries(spec)) == { - ("dest", "dest"), - ("action", "store_true"), - } - - -class Test_parse_command_args: - @pytest.mark.parametrize("name", ("1foo", "a.b", "a/b", "a[", "a(")) - def test_bad_libname(self, name): - with pytest.raises(ValueError): - m.parse_command_args(name, []) - - def test_default_help(self, monkeypatch, capsys): - monkeypatch.setattr("sys.argv", ["app", "-foo:help"]) - with pytest.raises(SystemExit) as e: - m.parse_command_args("foo", []) - assert e.value.code is None - out, err = capsys.readouterr() - assert out.startswith("usage: ") - - def test_default_help_precedence(self, monkeypatch, capsys): - monkeypatch.setattr("sys.argv", ["app", "-foo:help", "-foo:bar"]) - args = [m.Argument("bar", m.ArgSpec(dest="help"))] - with pytest.raises(SystemExit) as e: - m.parse_command_args("foo", args) - assert e.value.code is None - out, err = capsys.readouterr() - assert out.startswith("usage: ") - - def test_help_override(self, monkeypatch, capsys): - monkeypatch.setattr("sys.argv", ["app", "-foo:help"]) - args = [m.Argument("help", m.ArgSpec(dest="help"))] - ns = m.parse_command_args("foo", args) - out, err = capsys.readouterr() - assert out == "" - assert vars(ns) == {"help": True} - assert sys.argv == ["app"] - - def test_basic(self, monkeypatch, capsys): - monkeypatch.setattr("sys.argv", ["app", "-foo:bar", "-foo:quux", "1"]) - args = [ - m.Argument("bar", m.ArgSpec(dest="bar")), - m.Argument( - "quux", m.ArgSpec(dest="quux", action="store", type=int) - ), - ] - ns = m.parse_command_args("foo", args) - out, err = capsys.readouterr() - assert out == "" - assert vars(ns) == {"bar": True, "quux": 1} - assert sys.argv == ["app"] - - def test_extra_args_passed_on(self, monkeypatch, capsys): - monkeypatch.setattr("sys.argv", ["app", "-foo:bar", "--extra", "1"]) - args = [m.Argument("bar", m.ArgSpec(dest="bar"))] - ns = m.parse_command_args("foo", args) - out, err = capsys.readouterr() - assert out == "" - assert vars(ns) == {"bar": True} - assert sys.argv == ["app", "--extra", "1"] - - def test_unrecognized_libname_arg(self, monkeypatch, capsys): - monkeypatch.setattr("sys.argv", ["app", "-foo:bar", "-foo:baz"]) - with pytest.warns(UserWarning) as record: - ns = m.parse_command_args("foo", []) - out, err = capsys.readouterr() - assert out == "" - assert vars(ns) == {} - assert sys.argv == ["app", "-foo:bar", "-foo:baz"] - - # issues one warning for the first encountered - assert len(record) == 1 - assert ( - record[0].message.args[0] - == "Unrecognized argument '-foo:bar' for foo (passed on as-is)" - ) - assert out == "" - assert vars(ns) == {} - assert sys.argv == ["app", "-foo:bar", "-foo:baz"] - - if __name__ == "__main__": sys.exit(pytest.main(sys.argv)) diff --git a/tests/unit/legate/utils/__init__.py b/tests/unit/legate/utils/__init__.py new file mode 100644 index 0000000000..f0b271624d --- /dev/null +++ b/tests/unit/legate/utils/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations diff --git a/tests/unit/legate/utils/test_args.py b/tests/unit/legate/utils/test_args.py new file mode 100644 index 0000000000..92759806ad --- /dev/null +++ b/tests/unit/legate/utils/test_args.py @@ -0,0 +1,140 @@ +# Copyright 2021-2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import sys +from dataclasses import dataclass + +import pytest + +import legate.utils.args as m + +from ...util import Capsys + + +@dataclass(frozen=True) +class _TestObj: + a: int = 10 + b: m.NotRequired[int] = m.Unset + c: m.NotRequired[str] = "foo" + d: m.NotRequired[str] = m.Unset + + +class TestArgSpec: + def test_default(self) -> None: + spec = m.ArgSpec("dest") + assert spec.dest == "dest" + assert spec.action == "store_true" + + # all others are unset + assert set(m.entries(spec)) == { + ("dest", "dest"), + ("action", "store_true"), + } + + +def test_entries() -> None: + assert set(m.entries(_TestObj())) == {("a", 10), ("c", "foo")} + + +class Test_parse_command_args: + @pytest.mark.parametrize("name", ("1foo", "a.b", "a/b", "a[", "a(")) + def test_bad_libname(self, name: str) -> None: + with pytest.raises(ValueError): + m.parse_command_args(name, []) + + def test_default_help( + self, monkeypatch: pytest.MonkeyPatch, capsys: Capsys + ) -> None: + monkeypatch.setattr("sys.argv", ["app", "-foo:help"]) + with pytest.raises(SystemExit) as e: + m.parse_command_args("foo", []) + assert e.value.code is None + out, err = capsys.readouterr() # type: ignore[unreachable] + assert out.startswith("usage: ") + + def test_default_help_precedence( + self, monkeypatch: pytest.MonkeyPatch, capsys: Capsys + ) -> None: + monkeypatch.setattr("sys.argv", ["app", "-foo:help", "-foo:bar"]) + args = [m.Argument("bar", m.ArgSpec(dest="help"))] + with pytest.raises(SystemExit) as e: + m.parse_command_args("foo", args) + assert e.value.code is None + out, err = capsys.readouterr() # type: ignore[unreachable] + assert out.startswith("usage: ") + + def test_help_override( + self, monkeypatch: pytest.MonkeyPatch, capsys: Capsys + ) -> None: + monkeypatch.setattr("sys.argv", ["app", "-foo:help"]) + args = [m.Argument("help", m.ArgSpec(dest="help"))] + ns = m.parse_command_args("foo", args) + out, err = capsys.readouterr() + assert out == "" + assert vars(ns) == {"help": True} + assert sys.argv == ["app"] + + def test_basic( + self, monkeypatch: pytest.MonkeyPatch, capsys: Capsys + ) -> None: + monkeypatch.setattr("sys.argv", ["app", "-foo:bar", "-foo:quux", "1"]) + args = [ + m.Argument("bar", m.ArgSpec(dest="bar")), + m.Argument( + "quux", m.ArgSpec(dest="quux", action="store", type=int) + ), + ] + ns = m.parse_command_args("foo", args) + out, err = capsys.readouterr() + assert out == "" + assert vars(ns) == {"bar": True, "quux": 1} + assert sys.argv == ["app"] + + def test_extra_args_passed_on( + self, monkeypatch: pytest.MonkeyPatch, capsys: Capsys + ) -> None: + monkeypatch.setattr("sys.argv", ["app", "-foo:bar", "--extra", "1"]) + args = [m.Argument("bar", m.ArgSpec(dest="bar"))] + ns = m.parse_command_args("foo", args) + out, err = capsys.readouterr() + assert out == "" + assert vars(ns) == {"bar": True} + assert sys.argv == ["app", "--extra", "1"] + + def test_unrecognized_libname_arg( + self, monkeypatch: pytest.MonkeyPatch, capsys: Capsys + ) -> None: + monkeypatch.setattr("sys.argv", ["app", "-foo:bar", "-foo:baz"]) + with pytest.warns(UserWarning) as record: + ns = m.parse_command_args("foo", []) + out, err = capsys.readouterr() + assert out == "" + assert vars(ns) == {} + assert sys.argv == ["app", "-foo:bar", "-foo:baz"] + + # issues one warning for the first encountered + assert len(record) == 1 + assert isinstance(record[0].message, Warning) + assert ( + record[0].message.args[0] + == "Unrecognized argument '-foo:bar' for foo (passed on as-is)" + ) + assert out == "" + assert vars(ns) == {} + assert sys.argv == ["app", "-foo:bar", "-foo:baz"] + + +if __name__ == "__main__": + sys.exit(pytest.main(sys.argv)) diff --git a/tests/unit/util.py b/tests/unit/util.py new file mode 100644 index 0000000000..b6ce793c03 --- /dev/null +++ b/tests/unit/util.py @@ -0,0 +1,33 @@ +# Copyright 2021-2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations + +from itertools import chain, combinations +from typing import Any, Iterable, Iterator + +import pytest +from typing_extensions import TypeAlias + +Capsys: TypeAlias = pytest.CaptureFixture[str] + + +# ref: https://docs.python.org/3/library/itertools.html +def powerset(iterable: Iterable[Any]) -> Iterator[Any]: + s = list(iterable) + return chain.from_iterable(combinations(s, r) for r in range(len(s) + 1)) + + +def powerset_nonempty(iterable: Iterable[Any]) -> Iterator[Any]: + return (x for x in powerset(iterable) if len(x)) From bac311e5f25075cd1ca930c65571c36f5f4c19d2 Mon Sep 17 00:00:00 2001 From: Bryan Van de Ven Date: Tue, 27 Sep 2022 14:58:26 -0700 Subject: [PATCH 03/16] ignore vscode workspace for now at least --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 42f7cc262b..f7cd920b29 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ config.mk .vscode _cmake_test_compile !cmake/versions.json +legate.core.code-workspace + From 4cfaf82836a7ca2ea1745d273dec5b0ad44aea70 Mon Sep 17 00:00:00 2001 From: Bryan Van de Ven Date: Tue, 27 Sep 2022 15:01:42 -0700 Subject: [PATCH 04/16] parse_command_args -> parse_library_command_args --- legate/core/__init__.py | 2 +- legate/core/runtime.py | 4 ++-- legate/utils/args.py | 4 +++- tests/unit/legate/utils/test_args.py | 16 ++++++++-------- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/legate/core/__init__.py b/legate/core/__init__.py index ab9a3cdfcd..19200d71e9 100644 --- a/legate/core/__init__.py +++ b/legate/core/__init__.py @@ -15,7 +15,7 @@ from __future__ import annotations from ..rc import check_legion -from ..utils.args import parse_command_args +from ..utils.args import parse_library_command_args check_legion() diff --git a/legate/core/runtime.py b/legate/core/runtime.py index c0bda55682..08a9c2be4e 100644 --- a/legate/core/runtime.py +++ b/legate/core/runtime.py @@ -24,7 +24,7 @@ from legion_top import add_cleanup_item, top_level -from ..utils.args import ArgSpec, Argument, parse_command_args +from ..utils.args import ArgSpec, Argument, parse_library_command_args from . import ffi # Make sure we only have one ffi instance from . import ( Fence, @@ -834,7 +834,7 @@ def __init__(self, core_library: CoreLib) -> None: focus on implementing their domain logic. """ - self._args = parse_command_args("legate", ARGS) + self._args = parse_library_command_args("legate", ARGS) try: self._legion_context = top_level.context[0] diff --git a/legate/utils/args.py b/legate/utils/args.py index e590ada576..993150cb65 100644 --- a/legate/utils/args.py +++ b/legate/utils/args.py @@ -76,7 +76,9 @@ def entries(obj: Any) -> Iterable[tuple[str, Any]]: yield (f.name, value) -def parse_command_args(libname: str, args: Iterable[Argument]) -> Namespace: +def parse_library_command_args( + libname: str, args: Iterable[Argument] +) -> Namespace: """ """ if not libname.isidentifier(): raise ValueError( diff --git a/tests/unit/legate/utils/test_args.py b/tests/unit/legate/utils/test_args.py index 92759806ad..fc54f53745 100644 --- a/tests/unit/legate/utils/test_args.py +++ b/tests/unit/legate/utils/test_args.py @@ -48,18 +48,18 @@ def test_entries() -> None: assert set(m.entries(_TestObj())) == {("a", 10), ("c", "foo")} -class Test_parse_command_args: +class Test_parse_library_command_args: @pytest.mark.parametrize("name", ("1foo", "a.b", "a/b", "a[", "a(")) def test_bad_libname(self, name: str) -> None: with pytest.raises(ValueError): - m.parse_command_args(name, []) + m.parse_library_command_args(name, []) def test_default_help( self, monkeypatch: pytest.MonkeyPatch, capsys: Capsys ) -> None: monkeypatch.setattr("sys.argv", ["app", "-foo:help"]) with pytest.raises(SystemExit) as e: - m.parse_command_args("foo", []) + m.parse_library_command_args("foo", []) assert e.value.code is None out, err = capsys.readouterr() # type: ignore[unreachable] assert out.startswith("usage: ") @@ -70,7 +70,7 @@ def test_default_help_precedence( monkeypatch.setattr("sys.argv", ["app", "-foo:help", "-foo:bar"]) args = [m.Argument("bar", m.ArgSpec(dest="help"))] with pytest.raises(SystemExit) as e: - m.parse_command_args("foo", args) + m.parse_library_command_args("foo", args) assert e.value.code is None out, err = capsys.readouterr() # type: ignore[unreachable] assert out.startswith("usage: ") @@ -80,7 +80,7 @@ def test_help_override( ) -> None: monkeypatch.setattr("sys.argv", ["app", "-foo:help"]) args = [m.Argument("help", m.ArgSpec(dest="help"))] - ns = m.parse_command_args("foo", args) + ns = m.parse_library_command_args("foo", args) out, err = capsys.readouterr() assert out == "" assert vars(ns) == {"help": True} @@ -96,7 +96,7 @@ def test_basic( "quux", m.ArgSpec(dest="quux", action="store", type=int) ), ] - ns = m.parse_command_args("foo", args) + ns = m.parse_library_command_args("foo", args) out, err = capsys.readouterr() assert out == "" assert vars(ns) == {"bar": True, "quux": 1} @@ -107,7 +107,7 @@ def test_extra_args_passed_on( ) -> None: monkeypatch.setattr("sys.argv", ["app", "-foo:bar", "--extra", "1"]) args = [m.Argument("bar", m.ArgSpec(dest="bar"))] - ns = m.parse_command_args("foo", args) + ns = m.parse_library_command_args("foo", args) out, err = capsys.readouterr() assert out == "" assert vars(ns) == {"bar": True} @@ -118,7 +118,7 @@ def test_unrecognized_libname_arg( ) -> None: monkeypatch.setattr("sys.argv", ["app", "-foo:bar", "-foo:baz"]) with pytest.warns(UserWarning) as record: - ns = m.parse_command_args("foo", []) + ns = m.parse_library_command_args("foo", []) out, err = capsys.readouterr() assert out == "" assert vars(ns) == {} From 6ab8a76bbd47002136870a13a6125cd0f1a959be Mon Sep 17 00:00:00 2001 From: Bryan Van de Ven Date: Tue, 27 Sep 2022 16:00:52 -0700 Subject: [PATCH 05/16] factor out colorama --- legate/driver/ui.py | 80 +++------------ legate/tester/stages/test_stage.py | 3 +- legate/tester/test_plan.py | 3 +- legate/tester/ui.py | 38 +------- legate/utils/colors.py | 95 ++++++++++++++++++ tests/unit/legate/driver/test_command.py | 2 +- tests/unit/legate/driver/test_config.py | 2 +- tests/unit/legate/driver/test_driver.py | 2 +- tests/unit/legate/driver/test_logs.py | 2 +- tests/unit/legate/driver/test_ui.py | 102 +++++-------------- tests/unit/legate/driver/test_util.py | 2 +- tests/unit/legate/tester/test_ui.py | 119 +++++++++++++++++++++-- tests/unit/legate/utils/test_colors.py | 103 ++++++++++++++++++++ 13 files changed, 352 insertions(+), 201 deletions(-) create mode 100644 legate/utils/colors.py create mode 100644 tests/unit/legate/utils/test_colors.py diff --git a/legate/driver/ui.py b/legate/driver/ui.py index e6f5ee37d7..30cef73463 100644 --- a/legate/driver/ui.py +++ b/legate/driver/ui.py @@ -21,72 +21,30 @@ """ from __future__ import annotations -import re -import sys from typing import Any, Iterable +from ..utils.colors import ( + bright, + cyan, + dim, + green, + magenta, + red, + white, + yellow, +) + __all__ = ( - "bright", - "cyan", - "dim", "error", - "green", "key", "kvtable", - "magenta", - "red", "rule", - "scrub", "section", "value", "warn", - "white", - "yellow", ) -def _text(text: str) -> str: - return text - - -try: - import colorama # type: ignore[import] - - def bright(text: str) -> str: - return f"{colorama.Style.BRIGHT}{text}{colorama.Style.RESET_ALL}" - - def dim(text: str) -> str: - return f"{colorama.Style.DIM}{text}{colorama.Style.RESET_ALL}" - - def white(text: str) -> str: - return f"{colorama.Fore.WHITE}{text}{colorama.Style.RESET_ALL}" - - def cyan(text: str) -> str: - return f"{colorama.Fore.CYAN}{text}{colorama.Style.RESET_ALL}" - - def red(text: str) -> str: - return f"{colorama.Fore.RED}{text}{colorama.Style.RESET_ALL}" - - def magenta(text: str) -> str: - return f"{colorama.Fore.MAGENTA}{text}{colorama.Style.RESET_ALL}" - - def green(text: str) -> str: - return f"{colorama.Fore.GREEN}{text}{colorama.Style.RESET_ALL}" - - def yellow(text: str) -> str: - return f"{colorama.Fore.YELLOW}{text}{colorama.Style.RESET_ALL}" - - if sys.platform == "win32": - colorama.init() - -except ImportError: - - bright = dim = white = cyan = red = magenta = green = yellow = _text - -# ref: https://stackoverflow.com/a/14693789 -_ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") - - def error(text: str) -> str: """Format text as an error. @@ -214,22 +172,6 @@ def section(text: str) -> str: return bright(white(text)) -def scrub(text: str) -> str: - """Remove ANSI color codes from a text string. - - Parameters - ---------- - text : str - The text to scrub - - Returns - ------- - str - - """ - return _ANSI_ESCAPE.sub("", text) - - def warn(text: str) -> str: """Format text as a warning. diff --git a/legate/tester/stages/test_stage.py b/legate/tester/stages/test_stage.py index 0bfbe4f065..19f3c2d09f 100644 --- a/legate/tester/stages/test_stage.py +++ b/legate/tester/stages/test_stage.py @@ -20,11 +20,12 @@ from typing_extensions import Protocol +from ...utils.colors import yellow from .. import PER_FILE_ARGS, FeatureType from ..config import Config from ..system import ProcessResult, System from ..types import ArgList, EnvDict -from ..ui import banner, summary, yellow +from ..ui import banner, summary from .util import Shard, StageResult, StageSpec, log_proc diff --git a/legate/tester/test_plan.py b/legate/tester/test_plan.py index 9e2a925321..9208fa9bb0 100644 --- a/legate/tester/test_plan.py +++ b/legate/tester/test_plan.py @@ -20,11 +20,12 @@ from datetime import timedelta from itertools import chain +from ..utils.colors import yellow from .config import Config from .logger import LOG from .stages import STAGES, log_proc from .system import System -from .ui import banner, rule, summary, yellow +from .ui import banner, rule, summary class TestPlan: diff --git a/legate/tester/ui.py b/legate/tester/ui.py index eaa97d7c01..272a1ed3af 100644 --- a/legate/tester/ui.py +++ b/legate/tester/ui.py @@ -21,53 +21,17 @@ """ from __future__ import annotations -import sys from datetime import timedelta from typing import Iterable from typing_extensions import TypeAlias +from ..utils.colors import bright, cyan, dim, green, red, white from . import UI_WIDTH Details: TypeAlias = Iterable[str] -def _text(text: str) -> str: - return text - - -try: - import colorama # type: ignore[import] - - def bright(text: str) -> str: - return f"{colorama.Style.BRIGHT}{text}{colorama.Style.RESET_ALL}" - - def dim(text: str) -> str: - return f"{colorama.Style.DIM}{text}{colorama.Style.RESET_ALL}" - - def white(text: str) -> str: - return f"{colorama.Fore.WHITE}{text}{colorama.Style.RESET_ALL}" - - def cyan(text: str) -> str: - return f"{colorama.Fore.CYAN}{text}{colorama.Style.RESET_ALL}" - - def red(text: str) -> str: - return f"{colorama.Fore.RED}{text}{colorama.Style.RESET_ALL}" - - def green(text: str) -> str: - return f"{colorama.Fore.GREEN}{text}{colorama.Style.RESET_ALL}" - - def yellow(text: str) -> str: - return f"{colorama.Fore.YELLOW}{text}{colorama.Style.RESET_ALL}" - - if sys.platform == "win32": - colorama.init() - -except ImportError: - - bright = dim = white = cyan = red = green = yellow = _text - - def _format_details( details: Iterable[str] | None = None, pre: str = " " ) -> str: diff --git a/legate/utils/colors.py b/legate/utils/colors.py new file mode 100644 index 0000000000..5bb0b14b36 --- /dev/null +++ b/legate/utils/colors.py @@ -0,0 +1,95 @@ +# Copyright 2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Helper functions for adding colors to simple text UI output. + +The color functions in this module require ``colorama`` to be installed in +order to generate color output. If ``colorama`` is not available, plain +text output (i.e. without ANSI color codes) will be generated. + +""" +from __future__ import annotations + +import re +import sys + +__all__ = ( + "bright", + "cyan", + "dim", + "green", + "magenta", + "red", + "scrub", + "white", + "yellow", +) + + +def _text(text: str) -> str: + return text + + +try: + import colorama # type: ignore[import] + + def bright(text: str) -> str: + return f"{colorama.Style.BRIGHT}{text}{colorama.Style.RESET_ALL}" + + def dim(text: str) -> str: + return f"{colorama.Style.DIM}{text}{colorama.Style.RESET_ALL}" + + def white(text: str) -> str: + return f"{colorama.Fore.WHITE}{text}{colorama.Style.RESET_ALL}" + + def cyan(text: str) -> str: + return f"{colorama.Fore.CYAN}{text}{colorama.Style.RESET_ALL}" + + def red(text: str) -> str: + return f"{colorama.Fore.RED}{text}{colorama.Style.RESET_ALL}" + + def magenta(text: str) -> str: + return f"{colorama.Fore.MAGENTA}{text}{colorama.Style.RESET_ALL}" + + def green(text: str) -> str: + return f"{colorama.Fore.GREEN}{text}{colorama.Style.RESET_ALL}" + + def yellow(text: str) -> str: + return f"{colorama.Fore.YELLOW}{text}{colorama.Style.RESET_ALL}" + + if sys.platform == "win32": + colorama.init() + +except ImportError: + + bright = dim = white = cyan = red = magenta = green = yellow = _text + +# ref: https://stackoverflow.com/a/14693789 +_ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + + +def scrub(text: str) -> str: + """Remove ANSI color codes from a text string. + + Parameters + ---------- + text : str + The text to scrub + + Returns + ------- + str + + """ + return _ANSI_ESCAPE.sub("", text) diff --git a/tests/unit/legate/driver/test_command.py b/tests/unit/legate/driver/test_command.py index 35c52ac637..7a8b78ef48 100644 --- a/tests/unit/legate/driver/test_command.py +++ b/tests/unit/legate/driver/test_command.py @@ -22,7 +22,7 @@ import legate.driver.command as m from legate.driver.launcher import RANK_ENV_VARS from legate.driver.types import LauncherType -from legate.driver.ui import scrub +from legate.utils.colors import scrub from ...util import Capsys, powerset_nonempty from .util import GenObjs diff --git a/tests/unit/legate/driver/test_config.py b/tests/unit/legate/driver/test_config.py index d5fd729adc..1a924807e6 100644 --- a/tests/unit/legate/driver/test_config.py +++ b/tests/unit/legate/driver/test_config.py @@ -24,7 +24,7 @@ import legate.driver.config as m import legate.driver.defaults as defaults from legate.driver.types import DataclassMixin -from legate.driver.ui import scrub +from legate.utils.colors import scrub from ...util import Capsys, powerset, powerset_nonempty diff --git a/tests/unit/legate/driver/test_driver.py b/tests/unit/legate/driver/test_driver.py index 9f011d7024..28a87a13e0 100644 --- a/tests/unit/legate/driver/test_driver.py +++ b/tests/unit/legate/driver/test_driver.py @@ -25,8 +25,8 @@ from legate.driver.launcher import Launcher from legate.driver.system import System from legate.driver.types import LauncherType -from legate.driver.ui import scrub from legate.driver.util import print_verbose +from legate.utils.colors import scrub from ...util import Capsys from .util import GenConfig diff --git a/tests/unit/legate/driver/test_logs.py b/tests/unit/legate/driver/test_logs.py index 9133939b33..fb78febb0e 100644 --- a/tests/unit/legate/driver/test_logs.py +++ b/tests/unit/legate/driver/test_logs.py @@ -20,7 +20,7 @@ import legate.driver.logs as m from legate.driver.config import Config from legate.driver.launcher import RANK_ENV_VARS -from legate.driver.ui import scrub +from legate.utils.colors import scrub from ...util import Capsys, powerset_nonempty from .util import GenObjs diff --git a/tests/unit/legate/driver/test_ui.py b/tests/unit/legate/driver/test_ui.py index 33b8b03eb3..d6d3fcf542 100644 --- a/tests/unit/legate/driver/test_ui.py +++ b/tests/unit/legate/driver/test_ui.py @@ -21,6 +21,7 @@ from typing_extensions import TypeAlias import legate.driver.ui as m +import legate.utils.colors as colors try: import colorama # type: ignore @@ -32,56 +33,19 @@ @pytest.fixture def use_plain_text(mocker: MockerFixture) -> None: - mocker.patch.object(m, "bright", m._text) - mocker.patch.object(m, "dim", m._text) - mocker.patch.object(m, "white", m._text) - mocker.patch.object(m, "cyan", m._text) - mocker.patch.object(m, "red", m._text) - mocker.patch.object(m, "green", m._text) - mocker.patch.object(m, "yellow", m._text) - mocker.patch.object(m, "magenta", m._text) - - -COLOR_FUNCS = ( - "cyan", - "green", - "magenta", - "red", - "white", - "yellow", -) - -STYLE_FUNCS = ( - "bright", - "dim", -) + mocker.patch.object(m, "bright", colors._text) + mocker.patch.object(m, "dim", colors._text) + mocker.patch.object(m, "white", colors._text) + mocker.patch.object(m, "cyan", colors._text) + mocker.patch.object(m, "red", colors._text) + mocker.patch.object(m, "green", colors._text) + mocker.patch.object(m, "yellow", colors._text) + mocker.patch.object(m, "magenta", colors._text) @pytest.mark.skipif(colorama is None, reason="colorama required") -@pytest.mark.parametrize("color", COLOR_FUNCS) -def test_color_functions(color: str) -> None: - cfunc = getattr(m, color) - cprop = getattr(colorama.Fore, color.upper()) - - out = cfunc("some text") - - assert out == f"{cprop}some text{colorama.Style.RESET_ALL}" - - -@pytest.mark.skipif(colorama is None, reason="colorama required") -@pytest.mark.parametrize("style", STYLE_FUNCS) -def test_style_functions(style: str) -> None: - sfunc = getattr(m, style) - sprop = getattr(colorama.Style, style.upper()) - - out = sfunc("some text") - - assert out == f"{sprop}some text{colorama.Style.RESET_ALL}" - - -@pytest.mark.skipif(colorama is None, reason="colorama required") -def test_error(use_plain_text: UsePlainTextFixture) -> None: - assert m.error("some message") == m.red("ERROR: some message") +def test_error() -> None: + assert m.error("some message") == colors.red("ERROR: some message") def test_error_plain(use_plain_text: UsePlainTextFixture) -> None: @@ -89,8 +53,8 @@ def test_error_plain(use_plain_text: UsePlainTextFixture) -> None: @pytest.mark.skipif(colorama is None, reason="colorama required") -def test_key(use_plain_text: UsePlainTextFixture) -> None: - assert m.key("some key") == m.dim(m.green("some key")) +def test_key() -> None: + assert m.key("some key") == colors.dim(colors.green("some key")) def test_key_plain(use_plain_text: UsePlainTextFixture) -> None: @@ -98,7 +62,7 @@ def test_key_plain(use_plain_text: UsePlainTextFixture) -> None: @pytest.mark.skipif(colorama is None, reason="colorama required") -def test_value(use_plain_text: UsePlainTextFixture) -> None: +def test_value() -> None: assert m.value("some value") == m.yellow("some value") @@ -183,19 +147,21 @@ def test_keys_plain(self, use_plain_text: UsePlainTextFixture) -> None: class Test_rule: @pytest.mark.skipif(colorama is None, reason="colorama required") def test_text(self) -> None: - assert m.rule("foo bar") == m.cyan("--- foo bar " + "-" * 68) + assert m.rule("foo bar") == colors.cyan("--- foo bar " + "-" * 68) @pytest.mark.skipif(colorama is None, reason="colorama required") def test_char(self) -> None: - assert m.rule(char="a") == m.cyan("a" * 80) + assert m.rule(char="a") == colors.cyan("a" * 80) @pytest.mark.skipif(colorama is None, reason="colorama required") def test_N(self) -> None: - assert m.rule(N=60) == m.cyan("-" * 60) + assert m.rule(N=60) == colors.cyan("-" * 60) @pytest.mark.skipif(colorama is None, reason="colorama required") def test_N_with_text(self) -> None: - assert m.rule("foo bar", N=65) == m.cyan("--- foo bar " + "-" * 53) + assert m.rule("foo bar", N=65) == colors.cyan( + "--- foo bar " + "-" * 53 + ) def test_text_plain(self, use_plain_text: UsePlainTextFixture) -> None: assert m.rule("foo bar") == "--- foo bar " + "-" * 68 @@ -213,31 +179,7 @@ def test_N_with_text_plain( @pytest.mark.skipif(colorama is None, reason="colorama required") -@pytest.mark.parametrize("color", COLOR_FUNCS) -@pytest.mark.parametrize("style", STYLE_FUNCS) -def test_scrub(style: str, color: str) -> None: - cfunc = getattr(m, color) - sfunc = getattr(m, style) - - assert m.scrub(cfunc(sfunc("some text"))) == "some text" - assert m.scrub(sfunc(cfunc("some text"))) == "some text" - - -@pytest.mark.skipif(colorama is None, reason="colorama required") -@pytest.mark.parametrize("color", COLOR_FUNCS) -@pytest.mark.parametrize("style", STYLE_FUNCS) -def test_scrub_plain( - use_plain_text: UsePlainTextFixture, style: str, color: str -) -> None: - cfunc = getattr(m, color) - sfunc = getattr(m, style) - - assert m.scrub(cfunc(sfunc("some text"))) == "some text" - assert m.scrub(sfunc(cfunc("some text"))) == "some text" - - -@pytest.mark.skipif(colorama is None, reason="colorama required") -def test_section(use_plain_text: UsePlainTextFixture) -> None: +def test_section() -> None: assert m.section("some section") == m.bright(m.white("some section")) @@ -246,7 +188,7 @@ def test_section_plain(use_plain_text: UsePlainTextFixture) -> None: @pytest.mark.skipif(colorama is None, reason="colorama required") -def test_warn(use_plain_text: UsePlainTextFixture) -> None: +def test_warn() -> None: assert m.warn("some message") == m.magenta("WARNING: some message") diff --git a/tests/unit/legate/driver/test_util.py b/tests/unit/legate/driver/test_util.py index ba735e494b..6d17800308 100644 --- a/tests/unit/legate/driver/test_util.py +++ b/tests/unit/legate/driver/test_util.py @@ -24,7 +24,7 @@ from legate.driver.config import Config from legate.driver.driver import Driver from legate.driver.system import System -from legate.driver.ui import scrub +from legate.utils.colors import scrub from ...util import Capsys diff --git a/tests/unit/legate/tester/test_ui.py b/tests/unit/legate/tester/test_ui.py index 64277c528e..6ce986aa5b 100644 --- a/tests/unit/legate/tester/test_ui.py +++ b/tests/unit/legate/tester/test_ui.py @@ -18,24 +18,34 @@ from __future__ import annotations from datetime import timedelta +from typing import Any import pytest from pytest_mock import MockerFixture +from typing_extensions import TypeAlias +import legate.utils.colors as colors from legate.tester import UI_WIDTH, ui as m +try: + import colorama # type: ignore +except ImportError: + colorama = None -@pytest.fixture(autouse=True) +UsePlainTextFixture: TypeAlias = Any + + +@pytest.fixture def use_plain_text(mocker: MockerFixture) -> None: - mocker.patch.object(m, "bright", m._text) - mocker.patch.object(m, "dim", m._text) - mocker.patch.object(m, "white", m._text) - mocker.patch.object(m, "cyan", m._text) - mocker.patch.object(m, "red", m._text) - mocker.patch.object(m, "green", m._text) - mocker.patch.object(m, "yellow", m._text) + mocker.patch.object(m, "bright", colors._text) + mocker.patch.object(m, "dim", colors._text) + mocker.patch.object(m, "white", colors._text) + mocker.patch.object(m, "cyan", colors._text) + mocker.patch.object(m, "red", colors._text) + mocker.patch.object(m, "green", colors._text) +@pytest.mark.skipif(colorama is None, reason="colorama required") def test_banner_simple() -> None: assert ( m.banner("some text") @@ -43,6 +53,14 @@ def test_banner_simple() -> None: ) +def test_banner_simple_plain(use_plain_text: UsePlainTextFixture) -> None: + assert ( + m.banner("some text") + == "\n" + "#" * UI_WIDTH + "\n### some text\n" + "#" * UI_WIDTH + ) + + +@pytest.mark.skipif(colorama is None, reason="colorama required") def test_banner_full() -> None: assert ( m.banner("some text", char="*", width=100, details=["a", "b"]) @@ -53,50 +71,135 @@ def test_banner_full() -> None: ) +def test_banner_full_plain(use_plain_text: UsePlainTextFixture) -> None: + assert ( + m.banner("some text", char="*", width=100, details=["a", "b"]) + == "\n" + + "*" * 100 + + "\n*** \n*** some text\n*** \n*** a\n*** b\n*** \n" + + "*" * 100 + ) + + +@pytest.mark.skipif(colorama is None, reason="colorama required") def test_rule_default() -> None: assert m.rule() == " " + "~" * (UI_WIDTH - 4) +def test_rule_default_plain(use_plain_text: UsePlainTextFixture) -> None: + assert m.rule() == " " + "~" * (UI_WIDTH - 4) + + +@pytest.mark.skipif(colorama is None, reason="colorama required") def test_rule_with_args() -> None: assert m.rule(10, "-") == " " * 10 + "-" * (UI_WIDTH - 10) +def test_rule_with_args_plain(use_plain_text: UsePlainTextFixture) -> None: + assert m.rule(10, "-") == " " * 10 + "-" * (UI_WIDTH - 10) + + +@pytest.mark.skipif(colorama is None, reason="colorama required") def test_shell() -> None: + assert m.shell("cmd --foo") == colors.dim(colors.white("+cmd --foo")) + + +def test_shell_plain(use_plain_text: UsePlainTextFixture) -> None: assert m.shell("cmd --foo") == "+cmd --foo" +@pytest.mark.skipif(colorama is None, reason="colorama required") def test_shell_with_char() -> None: + assert m.shell("cmd --foo", char="") == colors.dim( + colors.white("cmd --foo") + ) + + +def test_shell_with_char_plain(use_plain_text: UsePlainTextFixture) -> None: assert m.shell("cmd --foo", char="") == "cmd --foo" +@pytest.mark.skipif(colorama is None, reason="colorama required") def test_passed() -> None: + assert m.passed("msg") == f"{colors.bright(colors.green('[PASS]'))} msg" + + +def test_passed_plain(use_plain_text: UsePlainTextFixture) -> None: assert m.passed("msg") == "[PASS] msg" +@pytest.mark.skipif(colorama is None, reason="colorama required") def test_passed_with_details() -> None: + assert ( + m.passed("msg", details=["a", "b"]) + == f"{colors.bright(colors.green('[PASS]'))} msg\n a\n b" + ) + + +def test_passed_with_details_plain( + use_plain_text: UsePlainTextFixture, +) -> None: assert m.passed("msg", details=["a", "b"]) == "[PASS] msg\n a\n b" +@pytest.mark.skipif(colorama is None, reason="colorama required") def test_failed() -> None: + assert m.failed("msg") == f"{colors.bright(colors.red('[FAIL]'))} msg" + + +def test_failed_plain(use_plain_text: UsePlainTextFixture) -> None: assert m.failed("msg") == "[FAIL] msg" +@pytest.mark.skipif(colorama is None, reason="colorama required") def test_failed_with_details() -> None: + assert ( + m.failed("msg", details=["a", "b"]) + == f"{colors.bright(colors.red('[FAIL]'))} msg\n a\n b" + ) + + +def test_failed_with_details_plain( + use_plain_text: UsePlainTextFixture, +) -> None: assert m.failed("msg", details=["a", "b"]) == "[FAIL] msg\n a\n b" +@pytest.mark.skipif(colorama is None, reason="colorama required") def test_skipped() -> None: + assert m.skipped("msg") == f"{colors.cyan('[SKIP]')} msg" + + +def test_skipped_plain(use_plain_text: UsePlainTextFixture) -> None: assert m.skipped("msg") == "[SKIP] msg" +@pytest.mark.skipif(colorama is None, reason="colorama required") def test_summary() -> None: + assert m.summary("foo", 12, 11, timedelta(seconds=2.123)) == colors.bright( + colors.red( + f"{'foo: Passed 11 of 12 tests (91.7%) in 2.12s': >{UI_WIDTH}}" + ) + ) + + +def test_summary_plain(use_plain_text: UsePlainTextFixture) -> None: assert ( m.summary("foo", 12, 11, timedelta(seconds=2.123)) == f"{'foo: Passed 11 of 12 tests (91.7%) in 2.12s': >{UI_WIDTH}}" ) +@pytest.mark.skipif(colorama is None, reason="colorama required") def test_summary_no_justify() -> None: + assert m.summary( + "foo", 12, 11, timedelta(seconds=2.123), justify=False + ) == colors.bright( + colors.red("foo: Passed 11 of 12 tests (91.7%) in 2.12s") + ) + + +def test_summary_no_justify_plain(use_plain_text: UsePlainTextFixture) -> None: assert ( m.summary("foo", 12, 11, timedelta(seconds=2.123), justify=False) == "foo: Passed 11 of 12 tests (91.7%) in 2.12s" diff --git a/tests/unit/legate/utils/test_colors.py b/tests/unit/legate/utils/test_colors.py new file mode 100644 index 0000000000..84a4b33bc3 --- /dev/null +++ b/tests/unit/legate/utils/test_colors.py @@ -0,0 +1,103 @@ +# Copyright 2021-2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations + +from typing import Any + +import pytest +from pytest_mock import MockerFixture +from typing_extensions import TypeAlias + +import legate.utils.colors as m + +try: + import colorama # type: ignore +except ImportError: + colorama = None + +UsePlainTextFixture: TypeAlias = Any + + +@pytest.fixture +def use_plain_text(mocker: MockerFixture) -> None: + mocker.patch.object(m, "bright", m._text) + mocker.patch.object(m, "dim", m._text) + mocker.patch.object(m, "white", m._text) + mocker.patch.object(m, "cyan", m._text) + mocker.patch.object(m, "red", m._text) + mocker.patch.object(m, "green", m._text) + mocker.patch.object(m, "yellow", m._text) + mocker.patch.object(m, "magenta", m._text) + + +COLOR_FUNCS = ( + "cyan", + "green", + "magenta", + "red", + "white", + "yellow", +) + +STYLE_FUNCS = ( + "bright", + "dim", +) + + +@pytest.mark.skipif(colorama is None, reason="colorama required") +@pytest.mark.parametrize("color", COLOR_FUNCS) +def test_color_functions(color: str) -> None: + cfunc = getattr(m, color) + cprop = getattr(colorama.Fore, color.upper()) + + out = cfunc("some text") + + assert out == f"{cprop}some text{colorama.Style.RESET_ALL}" + + +@pytest.mark.skipif(colorama is None, reason="colorama required") +@pytest.mark.parametrize("style", STYLE_FUNCS) +def test_style_functions(style: str) -> None: + sfunc = getattr(m, style) + sprop = getattr(colorama.Style, style.upper()) + + out = sfunc("some text") + + assert out == f"{sprop}some text{colorama.Style.RESET_ALL}" + + +@pytest.mark.skipif(colorama is None, reason="colorama required") +@pytest.mark.parametrize("color", COLOR_FUNCS) +@pytest.mark.parametrize("style", STYLE_FUNCS) +def test_scrub(style: str, color: str) -> None: + cfunc = getattr(m, color) + sfunc = getattr(m, style) + + assert m.scrub(cfunc(sfunc("some text"))) == "some text" + assert m.scrub(sfunc(cfunc("some text"))) == "some text" + + +@pytest.mark.skipif(colorama is None, reason="colorama required") +@pytest.mark.parametrize("color", COLOR_FUNCS) +@pytest.mark.parametrize("style", STYLE_FUNCS) +def test_scrub_plain( + use_plain_text: UsePlainTextFixture, style: str, color: str +) -> None: + cfunc = getattr(m, color) + sfunc = getattr(m, style) + + assert m.scrub(cfunc(sfunc("some text"))) == "some text" + assert m.scrub(sfunc(cfunc("some text"))) == "some text" From f955a5ea0c2a448e796dc8e60b67c1eca10448b1 Mon Sep 17 00:00:00 2001 From: Bryan Van de Ven Date: Tue, 27 Sep 2022 16:32:49 -0700 Subject: [PATCH 06/16] Consolidate types --- legate/driver/args.py | 2 +- legate/driver/command.py | 2 +- legate/driver/config.py | 2 +- legate/driver/driver.py | 2 +- legate/driver/launcher.py | 9 ++-- legate/driver/logs.py | 12 +++-- legate/driver/util.py | 2 +- legate/tester/config.py | 2 +- legate/tester/stages/_linux/cpu.py | 11 ++-- legate/tester/stages/_linux/eager.py | 12 +++-- legate/tester/stages/_linux/gpu.py | 11 ++-- legate/tester/stages/_linux/omp.py | 11 ++-- legate/tester/stages/_osx/cpu.py | 12 +++-- legate/tester/stages/_osx/eager.py | 12 +++-- legate/tester/stages/_osx/gpu.py | 11 ++-- legate/tester/stages/_osx/omp.py | 12 +++-- legate/tester/stages/test_stage.py | 2 +- legate/tester/system.py | 2 +- legate/tester/types.py | 50 ------------------- legate/{driver => utils}/types.py | 26 +++++++++- tests/unit/legate/driver/test_command.py | 2 +- tests/unit/legate/driver/test_config.py | 2 +- tests/unit/legate/driver/test_driver.py | 2 +- tests/unit/legate/driver/test_launcher.py | 2 +- tests/unit/legate/driver/test_types.py | 15 ------ tests/unit/legate/tester/stages/__init__.py | 2 +- .../legate/{tester => utils}/test_types.py | 2 +- 27 files changed, 111 insertions(+), 121 deletions(-) delete mode 100644 legate/tester/types.py rename legate/{driver => utils}/types.py (81%) delete mode 100644 tests/unit/legate/driver/test_types.py rename tests/unit/legate/{tester => utils}/test_types.py (96%) diff --git a/legate/driver/args.py b/legate/driver/args.py index 2e919a2bc3..9680fe45ef 100755 --- a/legate/driver/args.py +++ b/legate/driver/args.py @@ -18,8 +18,8 @@ from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser +from ..utils.types import LauncherType from . import defaults -from .types import LauncherType __all__ = ("parser",) diff --git a/legate/driver/command.py b/legate/driver/command.py index 2c582e4b74..26ffa06cd1 100644 --- a/legate/driver/command.py +++ b/legate/driver/command.py @@ -19,10 +19,10 @@ from .ui import warn if TYPE_CHECKING: + from ..utils.types import CommandPart from .config import Config from .launcher import Launcher from .system import System - from .types import CommandPart __all__ = ("CMD_PARTS",) diff --git a/legate/driver/config.py b/legate/driver/config.py index e90a3454cf..041d4f2d68 100644 --- a/legate/driver/config.py +++ b/legate/driver/config.py @@ -23,8 +23,8 @@ from pathlib import Path from typing import Any +from ..utils.types import ArgList, DataclassMixin, LauncherType from .args import parser -from .types import ArgList, DataclassMixin, LauncherType from .ui import warn from .util import object_to_dataclass diff --git a/legate/driver/driver.py b/legate/driver/driver.py index 9548f197b2..2958143ca0 100644 --- a/legate/driver/driver.py +++ b/legate/driver/driver.py @@ -16,12 +16,12 @@ from subprocess import run +from ..utils.types import Command, EnvDict from .command import CMD_PARTS from .config import Config from .launcher import Launcher from .logs import process_logs from .system import System -from .types import Command, EnvDict from .ui import warn from .util import print_verbose diff --git a/legate/driver/launcher.py b/legate/driver/launcher.py index 922eb4f6f9..c5e541944d 100644 --- a/legate/driver/launcher.py +++ b/legate/driver/launcher.py @@ -17,13 +17,16 @@ import os import sys from pathlib import Path +from typing import TYPE_CHECKING -from .config import Config -from .system import System -from .types import Command, EnvDict, LauncherType from .ui import warn from .util import read_c_define +if TYPE_CHECKING: + from ..utils.types import Command, EnvDict, LauncherType + from .config import Config + from .system import System + __all__ = ("Launcher",) RANK_ENV_VARS = ( diff --git a/legate/driver/logs.py b/legate/driver/logs.py index 1173e84864..c6d81ba4e9 100644 --- a/legate/driver/logs.py +++ b/legate/driver/logs.py @@ -22,14 +22,16 @@ from contextlib import contextmanager from shlex import quote from subprocess import run -from typing import Iterator +from typing import TYPE_CHECKING, Iterator -from .config import Config -from .launcher import Launcher -from .system import System -from .types import Command from .ui import warn +if TYPE_CHECKING: + from ..utils.types import Command + from .config import Config + from .launcher import Launcher + from .system import System + __all__ = ( "DebuggingHandler", "LogHandler", diff --git a/legate/driver/util.py b/legate/driver/util.py index 499b250e37..26c7dd42fa 100644 --- a/legate/driver/util.py +++ b/legate/driver/util.py @@ -21,7 +21,7 @@ from textwrap import indent from typing import TYPE_CHECKING, Type, TypeVar -from .types import DataclassProtocol, LegatePaths, LegionPaths +from ..utils.types import DataclassProtocol, LegatePaths, LegionPaths from .ui import kvtable, rule, section, value if TYPE_CHECKING: diff --git a/legate/tester/config.py b/legate/tester/config.py index a758da913a..4d143fa37a 100644 --- a/legate/tester/config.py +++ b/legate/tester/config.py @@ -21,9 +21,9 @@ from argparse import Namespace from pathlib import Path +from ..utils.types import ArgList, EnvDict from . import DEFAULT_PROCESS_ENV, FEATURES, SKIPPED_EXAMPLES, FeatureType from .args import parser -from .types import ArgList, EnvDict class Config: diff --git a/legate/tester/stages/_linux/cpu.py b/legate/tester/stages/_linux/cpu.py index 6657930816..b776d42f3e 100644 --- a/legate/tester/stages/_linux/cpu.py +++ b/legate/tester/stages/_linux/cpu.py @@ -15,11 +15,8 @@ from __future__ import annotations from itertools import chain +from typing import TYPE_CHECKING -from ... import FeatureType -from ...config import Config -from ...system import System -from ...types import ArgList, EnvDict from ..test_stage import TestStage from ..util import ( CUNUMERIC_TEST_ARG, @@ -29,6 +26,12 @@ adjust_workers, ) +if TYPE_CHECKING: + from ....utils.types import ArgList, EnvDict + from ... import FeatureType + from ...config import Config + from ...system import System + class CPU(TestStage): """A test stage for exercising CPU features. diff --git a/legate/tester/stages/_linux/eager.py b/legate/tester/stages/_linux/eager.py index 8e63fc49b7..174c231630 100644 --- a/legate/tester/stages/_linux/eager.py +++ b/legate/tester/stages/_linux/eager.py @@ -14,13 +14,17 @@ # from __future__ import annotations -from ... import FeatureType -from ...config import Config -from ...system import System -from ...types import ArgList, EnvDict +from typing import TYPE_CHECKING + from ..test_stage import TestStage from ..util import Shard, StageSpec, adjust_workers +if TYPE_CHECKING: + from ....utils.types import ArgList, EnvDict + from ... import FeatureType + from ...config import Config + from ...system import System + class Eager(TestStage): """A test stage for exercising Eager Numpy execution features. diff --git a/legate/tester/stages/_linux/gpu.py b/legate/tester/stages/_linux/gpu.py index 12012a4816..dfed7a99c1 100644 --- a/legate/tester/stages/_linux/gpu.py +++ b/legate/tester/stages/_linux/gpu.py @@ -15,14 +15,17 @@ from __future__ import annotations import time +from typing import TYPE_CHECKING -from ... import FeatureType -from ...config import Config -from ...system import System -from ...types import ArgList, EnvDict from ..test_stage import TestStage from ..util import CUNUMERIC_TEST_ARG, Shard, StageSpec, adjust_workers +if TYPE_CHECKING: + from ....utils.types import ArgList, EnvDict + from ... import FeatureType + from ...config import Config + from ...system import System + BLOAT_FACTOR = 1.5 # hard coded for now diff --git a/legate/tester/stages/_linux/omp.py b/legate/tester/stages/_linux/omp.py index 84a9544126..54da668c39 100644 --- a/legate/tester/stages/_linux/omp.py +++ b/legate/tester/stages/_linux/omp.py @@ -15,11 +15,8 @@ from __future__ import annotations from itertools import chain +from typing import TYPE_CHECKING -from ... import FeatureType -from ...config import Config -from ...system import System -from ...types import ArgList, EnvDict from ..test_stage import TestStage from ..util import ( CUNUMERIC_TEST_ARG, @@ -29,6 +26,12 @@ adjust_workers, ) +if TYPE_CHECKING: + from ....utils.types import ArgList, EnvDict + from ... import FeatureType + from ...config import Config + from ...system import System + class OMP(TestStage): """A test stage for exercising OpenMP features. diff --git a/legate/tester/stages/_osx/cpu.py b/legate/tester/stages/_osx/cpu.py index ec6d23f207..84730dfe1a 100644 --- a/legate/tester/stages/_osx/cpu.py +++ b/legate/tester/stages/_osx/cpu.py @@ -14,10 +14,8 @@ # from __future__ import annotations -from ... import FeatureType -from ...config import Config -from ...system import System -from ...types import ArgList, EnvDict +from typing import TYPE_CHECKING + from ..test_stage import TestStage from ..util import ( CUNUMERIC_TEST_ARG, @@ -27,6 +25,12 @@ adjust_workers, ) +if TYPE_CHECKING: + from ....utils.types import ArgList, EnvDict + from ... import FeatureType + from ...config import Config + from ...system import System + class CPU(TestStage): """A test stage for exercising CPU features. diff --git a/legate/tester/stages/_osx/eager.py b/legate/tester/stages/_osx/eager.py index 5cc5d557da..b86cf28625 100644 --- a/legate/tester/stages/_osx/eager.py +++ b/legate/tester/stages/_osx/eager.py @@ -14,13 +14,17 @@ # from __future__ import annotations -from ... import FeatureType -from ...config import Config -from ...system import System -from ...types import ArgList, EnvDict +from typing import TYPE_CHECKING + from ..test_stage import TestStage from ..util import UNPIN_ENV, Shard, StageSpec, adjust_workers +if TYPE_CHECKING: + from ....utils.types import ArgList, EnvDict + from ... import FeatureType + from ...config import Config + from ...system import System + class Eager(TestStage): """A test stage for exercising Eager Numpy execution features. diff --git a/legate/tester/stages/_osx/gpu.py b/legate/tester/stages/_osx/gpu.py index f89fe7377d..c846f81924 100644 --- a/legate/tester/stages/_osx/gpu.py +++ b/legate/tester/stages/_osx/gpu.py @@ -15,14 +15,17 @@ from __future__ import annotations import time +from typing import TYPE_CHECKING -from ... import FeatureType -from ...config import Config -from ...system import System -from ...types import ArgList, EnvDict from ..test_stage import TestStage from ..util import CUNUMERIC_TEST_ARG, UNPIN_ENV, Shard +if TYPE_CHECKING: + from ....utils.types import ArgList, EnvDict + from ... import FeatureType + from ...config import Config + from ...system import System + class GPU(TestStage): """A test stage for exercising GPU features. diff --git a/legate/tester/stages/_osx/omp.py b/legate/tester/stages/_osx/omp.py index f5f19194dc..e0a79ab106 100644 --- a/legate/tester/stages/_osx/omp.py +++ b/legate/tester/stages/_osx/omp.py @@ -14,10 +14,8 @@ # from __future__ import annotations -from ... import FeatureType -from ...config import Config -from ...system import System -from ...types import ArgList, EnvDict +from typing import TYPE_CHECKING + from ..test_stage import TestStage from ..util import ( CUNUMERIC_TEST_ARG, @@ -27,6 +25,12 @@ adjust_workers, ) +if TYPE_CHECKING: + from ....utils.types import ArgList, EnvDict + from ... import FeatureType + from ...config import Config + from ...system import System + class OMP(TestStage): """A test stage for exercising OpenMP features. diff --git a/legate/tester/stages/test_stage.py b/legate/tester/stages/test_stage.py index 19f3c2d09f..e906eb52be 100644 --- a/legate/tester/stages/test_stage.py +++ b/legate/tester/stages/test_stage.py @@ -21,10 +21,10 @@ from typing_extensions import Protocol from ...utils.colors import yellow +from ...utils.types import ArgList, EnvDict from .. import PER_FILE_ARGS, FeatureType from ..config import Config from ..system import ProcessResult, System -from ..types import ArgList, EnvDict from ..ui import banner, summary from .util import Shard, StageResult, StageSpec, log_proc diff --git a/legate/tester/system.py b/legate/tester/system.py index 71411b45b8..414f00179b 100644 --- a/legate/tester/system.py +++ b/legate/tester/system.py @@ -27,7 +27,7 @@ from subprocess import PIPE, STDOUT, run as stdlib_run from typing import Sequence -from .types import CPUInfo, EnvDict, GPUInfo +from ..utils.types import CPUInfo, EnvDict, GPUInfo @dataclass diff --git a/legate/tester/types.py b/legate/tester/types.py deleted file mode 100644 index 1641bd597a..0000000000 --- a/legate/tester/types.py +++ /dev/null @@ -1,50 +0,0 @@ -# Copyright 2022 NVIDIA Corporation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -"""Provide types that are useful throughout the test driver code. - -""" -from __future__ import annotations - -from dataclasses import dataclass -from typing import Dict, List - -from typing_extensions import TypeAlias - - -@dataclass(frozen=True) -class CPUInfo: - """Encapsulate information about a single CPU""" - - #: IDs of hypterthreading sibling cores for a given physscal core - ids: tuple[int, ...] - - -@dataclass(frozen=True) -class GPUInfo: - """Encapsulate information about a single CPU""" - - #: ID of the GPU to specify in test shards - id: int - - #: The total framebuffer memory of this GPU - total: int - - -#: Represent command line arguments -ArgList = List[str] - - -#: Represent str->str environment variable mappings -EnvDict: TypeAlias = Dict[str, str] diff --git a/legate/driver/types.py b/legate/utils/types.py similarity index 81% rename from legate/driver/types.py rename to legate/utils/types.py index 0bde4643b8..a77c3cc6a8 100644 --- a/legate/driver/types.py +++ b/legate/utils/types.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -"""Provide types that are useful throughout the driver code. +"""Provide types that are useful throughout the test driver code. """ from __future__ import annotations @@ -23,20 +23,42 @@ from typing_extensions import Literal, TypeAlias -from .ui import kvtable +from ..driver.ui import kvtable # TODO (bv) consolidate ui __all__ = ( "ArgList", "Command", "CommandPart", + "CPUInfo", "DataclassMixin", "DataclassProtocol", "EnvDict", + "GPUInfo", "LauncherType", "LegatePaths", "LegionPaths", ) + +@dataclass(frozen=True) +class CPUInfo: + """Encapsulate information about a single CPU""" + + #: IDs of hypterthreading sibling cores for a given physscal core + ids: tuple[int, ...] + + +@dataclass(frozen=True) +class GPUInfo: + """Encapsulate information about a single CPU""" + + #: ID of the GPU to specify in test shards + id: int + + #: The total framebuffer memory of this GPU + total: int + + #: Define the available launcher for the driver to use LauncherType: TypeAlias = Union[ Literal["mpirun"], Literal["jsrun"], Literal["srun"], Literal["none"] diff --git a/tests/unit/legate/driver/test_command.py b/tests/unit/legate/driver/test_command.py index 7a8b78ef48..3be61bc02f 100644 --- a/tests/unit/legate/driver/test_command.py +++ b/tests/unit/legate/driver/test_command.py @@ -21,8 +21,8 @@ import legate.driver.command as m from legate.driver.launcher import RANK_ENV_VARS -from legate.driver.types import LauncherType from legate.utils.colors import scrub +from legate.utils.types import LauncherType from ...util import Capsys, powerset_nonempty from .util import GenObjs diff --git a/tests/unit/legate/driver/test_config.py b/tests/unit/legate/driver/test_config.py index 1a924807e6..f5e7dee011 100644 --- a/tests/unit/legate/driver/test_config.py +++ b/tests/unit/legate/driver/test_config.py @@ -23,8 +23,8 @@ import legate.driver.config as m import legate.driver.defaults as defaults -from legate.driver.types import DataclassMixin from legate.utils.colors import scrub +from legate.utils.types import DataclassMixin from ...util import Capsys, powerset, powerset_nonempty diff --git a/tests/unit/legate/driver/test_driver.py b/tests/unit/legate/driver/test_driver.py index 28a87a13e0..69cf159c53 100644 --- a/tests/unit/legate/driver/test_driver.py +++ b/tests/unit/legate/driver/test_driver.py @@ -24,9 +24,9 @@ from legate.driver.command import CMD_PARTS from legate.driver.launcher import Launcher from legate.driver.system import System -from legate.driver.types import LauncherType from legate.driver.util import print_verbose from legate.utils.colors import scrub +from legate.utils.types import LauncherType from ...util import Capsys from .util import GenConfig diff --git a/tests/unit/legate/driver/test_launcher.py b/tests/unit/legate/driver/test_launcher.py index bb173fcb43..22bc9fb275 100644 --- a/tests/unit/legate/driver/test_launcher.py +++ b/tests/unit/legate/driver/test_launcher.py @@ -21,7 +21,7 @@ import legate.driver.launcher as m from legate.driver.args import LAUNCHERS from legate.driver.system import System -from legate.driver.types import LauncherType +from legate.utils.types import LauncherType from ...util import powerset_nonempty from .util import GenConfig, GenObjs diff --git a/tests/unit/legate/driver/test_types.py b/tests/unit/legate/driver/test_types.py deleted file mode 100644 index 98636f9f74..0000000000 --- a/tests/unit/legate/driver/test_types.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright 2021-2022 NVIDIA Corporation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -from __future__ import annotations diff --git a/tests/unit/legate/tester/stages/__init__.py b/tests/unit/legate/tester/stages/__init__.py index 69970d335a..d7eec02903 100644 --- a/tests/unit/legate/tester/stages/__init__.py +++ b/tests/unit/legate/tester/stages/__init__.py @@ -17,7 +17,7 @@ from typing import Any from legate.tester.system import System -from legate.tester.types import CPUInfo, GPUInfo +from legate.utils.types import CPUInfo, GPUInfo class FakeSystem(System): diff --git a/tests/unit/legate/tester/test_types.py b/tests/unit/legate/utils/test_types.py similarity index 96% rename from tests/unit/legate/tester/test_types.py rename to tests/unit/legate/utils/test_types.py index 8d4e69f5c8..77ff5a3c00 100644 --- a/tests/unit/legate/tester/test_types.py +++ b/tests/unit/legate/utils/test_types.py @@ -17,7 +17,7 @@ """ from __future__ import annotations -from legate.tester import types as m +import legate.utils.types as m class TestCPUInfo: From d52ac0d06f4280ba5526414a07e0ea9f940ec07a Mon Sep 17 00:00:00 2001 From: Bryan Van de Ven Date: Wed, 28 Sep 2022 09:33:38 -0700 Subject: [PATCH 07/16] consolidate ui modules --- legate/driver/command.py | 2 +- legate/driver/config.py | 2 +- legate/driver/driver.py | 2 +- legate/driver/launcher.py | 2 +- legate/driver/logs.py | 2 +- legate/driver/main.py | 2 +- legate/driver/ui.py | 188 ---------------- legate/driver/util.py | 2 +- legate/tester/__init__.py | 3 - legate/tester/stages/test_stage.py | 2 +- legate/tester/stages/util.py | 2 +- legate/tester/test_plan.py | 4 +- legate/utils/types.py | 2 +- legate/{tester => utils}/ui.py | 178 +++++++++++++-- tests/unit/legate/tester/test___init__.py | 4 - tests/unit/legate/tester/test_ui.py | 206 ------------------ .../unit/legate/{driver => utils}/test_ui.py | 195 ++++++++++++++++- 17 files changed, 364 insertions(+), 434 deletions(-) delete mode 100644 legate/driver/ui.py rename legate/{tester => utils}/ui.py (57%) delete mode 100644 tests/unit/legate/tester/test_ui.py rename tests/unit/legate/{driver => utils}/test_ui.py (52%) diff --git a/legate/driver/command.py b/legate/driver/command.py index 26ffa06cd1..dc564e9059 100644 --- a/legate/driver/command.py +++ b/legate/driver/command.py @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING -from .ui import warn +from ..utils.ui import warn if TYPE_CHECKING: from ..utils.types import CommandPart diff --git a/legate/driver/config.py b/legate/driver/config.py index 041d4f2d68..c12aab2797 100644 --- a/legate/driver/config.py +++ b/legate/driver/config.py @@ -24,8 +24,8 @@ from typing import Any from ..utils.types import ArgList, DataclassMixin, LauncherType +from ..utils.ui import warn from .args import parser -from .ui import warn from .util import object_to_dataclass __all__ = ("Config",) diff --git a/legate/driver/driver.py b/legate/driver/driver.py index 2958143ca0..6c0457bddb 100644 --- a/legate/driver/driver.py +++ b/legate/driver/driver.py @@ -17,12 +17,12 @@ from subprocess import run from ..utils.types import Command, EnvDict +from ..utils.ui import warn from .command import CMD_PARTS from .config import Config from .launcher import Launcher from .logs import process_logs from .system import System -from .ui import warn from .util import print_verbose __all__ = ("Driver",) diff --git a/legate/driver/launcher.py b/legate/driver/launcher.py index c5e541944d..fb6a0b5a7a 100644 --- a/legate/driver/launcher.py +++ b/legate/driver/launcher.py @@ -19,7 +19,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from .ui import warn +from ..utils.ui import warn from .util import read_c_define if TYPE_CHECKING: diff --git a/legate/driver/logs.py b/legate/driver/logs.py index c6d81ba4e9..22063871c3 100644 --- a/legate/driver/logs.py +++ b/legate/driver/logs.py @@ -24,7 +24,7 @@ from subprocess import run from typing import TYPE_CHECKING, Iterator -from .ui import warn +from ..utils.ui import warn if TYPE_CHECKING: from ..utils.types import Command diff --git a/legate/driver/main.py b/legate/driver/main.py index c2e0ac5770..dcd52f7f52 100644 --- a/legate/driver/main.py +++ b/legate/driver/main.py @@ -34,8 +34,8 @@ def main(argv: list[str]) -> int: int, a process return code """ + from ..utils.ui import error from . import Config, Driver, System - from .ui import error from .util import print_verbose try: diff --git a/legate/driver/ui.py b/legate/driver/ui.py deleted file mode 100644 index 30cef73463..0000000000 --- a/legate/driver/ui.py +++ /dev/null @@ -1,188 +0,0 @@ -# Copyright 2022 NVIDIA Corporation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -"""Helper functions for simple text UI output. - -The color functions in this module require ``colorama`` to be installed in -order to generate color output. If ``colorama`` is not available, plain -text output (i.e. without ANSI color codes) will be generated. - -""" -from __future__ import annotations - -from typing import Any, Iterable - -from ..utils.colors import ( - bright, - cyan, - dim, - green, - magenta, - red, - white, - yellow, -) - -__all__ = ( - "error", - "key", - "kvtable", - "rule", - "section", - "value", - "warn", -) - - -def error(text: str) -> str: - """Format text as an error. - - Parameters - ---------- - text : str - The text to format - - Returns - ------- - str - - """ - return red(f"ERROR: {text}") - - -def key(text: str) -> str: - """Format a 'key' from a key-value pair. - - Parameters - ---------- - text : str - The key to format - - Returns - ------- - str - - """ - return dim(green(text)) - - -def value(text: str) -> str: - """Format a 'value' from of a key-value pair. - - Parameters - ---------- - text : str - The key to format - - Returns - ------- - str - - """ - return yellow(text) - - -def kvtable( - items: dict[str, Any], - *, - delim: str = " : ", - align: bool = True, - keys: Iterable[str] | None = None, -) -> str: - """Format a dictionay as a table of key-value pairs. - - Parameters - ---------- - items : dict[str, Any] - The dictionary of items to format - - delim : str, optional - A delimiter to display between keys and values (default: " : ") - - align : bool, optional - Whether to align delimiters to the longest key length (default: True) - - keys : Iterable[str] or None, optional - If not None, only the specified subset of keys is included in the - table output (default: None) - - Returns - ------- - str - - """ - # annoying but necessary to take len on color-formatted version - N = max(len(key(k)) for k in items) if align else 0 - - keys = items.keys() if keys is None else keys - - return "\n".join( - f"{key(k): <{N}}{delim}{value(str(items[k]))}" for k in keys - ) - - -def rule(text: str | None = None, *, char: str = "-", N: int = 80) -> str: - """Format a horizontal rule, optionally with text - - Parameters - ---------- - text : str or None, optional - If not None, display this text inline in the rule (default: None) - - char: str, optional - A character to use for the rule (default: "-") - - N : int, optional - Character width for the rule (default: 80) - - Returns - ------- - str - - """ - if text is None: - return cyan(char * N) - return cyan(char * 3 + f"{f' {text} ' :{char}<{N-3}}") - - -def section(text: str) -> str: - """Format text as a section header - - Parameters - ---------- - text : str - The text to format - - Returns - ------- - str - - """ - return bright(white(text)) - - -def warn(text: str) -> str: - """Format text as a warning. - - Parameters - ---------- - text : str - The text to format - - Returns - ------- - str - - """ - return magenta(f"WARNING: {text}") diff --git a/legate/driver/util.py b/legate/driver/util.py index 26c7dd42fa..5fbb88bca7 100644 --- a/legate/driver/util.py +++ b/legate/driver/util.py @@ -22,7 +22,7 @@ from typing import TYPE_CHECKING, Type, TypeVar from ..utils.types import DataclassProtocol, LegatePaths, LegionPaths -from .ui import kvtable, rule, section, value +from ..utils.ui import kvtable, rule, section, value if TYPE_CHECKING: from .driver import Driver diff --git a/legate/tester/__init__.py b/legate/tester/__init__.py index 11b8f1d700..270abcf8d3 100644 --- a/legate/tester/__init__.py +++ b/legate/tester/__init__.py @@ -48,9 +48,6 @@ "LEGATE_TEST": "1", } -#: Width for terminal ouput headers and footers. -UI_WIDTH = 65 - #: Feature values that are accepted for --use, in the relative order #: that the corresponding test stages should always execute in FEATURES: tuple[FeatureType, ...] = ( diff --git a/legate/tester/stages/test_stage.py b/legate/tester/stages/test_stage.py index e906eb52be..f29f1ac0ca 100644 --- a/legate/tester/stages/test_stage.py +++ b/legate/tester/stages/test_stage.py @@ -22,10 +22,10 @@ from ...utils.colors import yellow from ...utils.types import ArgList, EnvDict +from ...utils.ui import banner, summary from .. import PER_FILE_ARGS, FeatureType from ..config import Config from ..system import ProcessResult, System -from ..ui import banner, summary from .util import Shard, StageResult, StageSpec, log_proc diff --git a/legate/tester/stages/util.py b/legate/tester/stages/util.py index 357474c908..ff8a28d904 100644 --- a/legate/tester/stages/util.py +++ b/legate/tester/stages/util.py @@ -20,10 +20,10 @@ from typing_extensions import TypeAlias +from ...utils.ui import failed, passed, shell, skipped from ..config import Config from ..logger import LOG from ..system import ProcessResult -from ..ui import failed, passed, shell, skipped CUNUMERIC_TEST_ARG = "-cunumeric:test" diff --git a/legate/tester/test_plan.py b/legate/tester/test_plan.py index 9208fa9bb0..fd1ede4e40 100644 --- a/legate/tester/test_plan.py +++ b/legate/tester/test_plan.py @@ -21,11 +21,11 @@ from itertools import chain from ..utils.colors import yellow +from ..utils.ui import banner, rule, summary from .config import Config from .logger import LOG from .stages import STAGES, log_proc from .system import System -from .ui import banner, rule, summary class TestPlan: @@ -65,7 +65,7 @@ def execute(self) -> int: total = len(all_procs) passed = sum(proc.returncode == 0 for proc in all_procs) - LOG(f"\n{rule()}") + LOG(f"\n{rule(pad=4)}") self._log_failures(total, passed) diff --git a/legate/utils/types.py b/legate/utils/types.py index a77c3cc6a8..fdda7aa2a8 100644 --- a/legate/utils/types.py +++ b/legate/utils/types.py @@ -23,7 +23,7 @@ from typing_extensions import Literal, TypeAlias -from ..driver.ui import kvtable # TODO (bv) consolidate ui +from .ui import kvtable __all__ = ( "ArgList", diff --git a/legate/tester/ui.py b/legate/utils/ui.py similarity index 57% rename from legate/tester/ui.py rename to legate/utils/ui.py index 272a1ed3af..9cf74b0940 100644 --- a/legate/tester/ui.py +++ b/legate/utils/ui.py @@ -1,4 +1,4 @@ -# Copyright AS2022 NVIDIA Corporation +# Copyright 2022 NVIDIA Corporation # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,25 +12,40 @@ # See the License for the specific language governing permissions and # limitations under the License. # -"""Helpler functions for simple text UI output. +"""Helper functions for simple text UI output. The color functions in this module require ``colorama`` to be installed in order to generate color output. If ``colorama`` is not available, plain -text output (i.e. without ANSI color codes) will generated. +text output (i.e. without ANSI color codes) will be generated. """ from __future__ import annotations from datetime import timedelta -from typing import Iterable +from typing import Any, Iterable from typing_extensions import TypeAlias -from ..utils.colors import bright, cyan, dim, green, red, white -from . import UI_WIDTH +from .colors import bright, cyan, dim, green, magenta, red, white, yellow Details: TypeAlias = Iterable[str] +__all__ = ( + "UI_WIDTH", + "banner", + "error", + "key", + "kvtable", + "rule", + "section", + "value", + "warn", +) + + +#: Width for terminal ouput headers and footers. +UI_WIDTH = 80 + def _format_details( details: Iterable[str] | None = None, pre: str = " " @@ -79,6 +94,22 @@ def banner( {divider}""" +def error(text: str) -> str: + """Format text as an error. + + Parameters + ---------- + text : str + The text to format + + Returns + ------- + str + + """ + return red(f"ERROR: {text}") + + def failed(msg: str, *, details: Details | None = None) -> str: """Report a failed test result with a bright red [FAIL]. @@ -113,20 +144,125 @@ def passed(msg: str, *, details: Details | None = None) -> str: return f"{bright(green('[PASS]'))} {msg}" -def rule(pad: int = 4, char: str = "~") -> str: - """Generate a horizontal rule. +def key(text: str) -> str: + """Format a 'key' from a key-value pair. Parameters ---------- + text : str + The key to format + + Returns + ------- + str + + """ + return dim(green(text)) + + +def value(text: str) -> str: + """Format a 'value' from of a key-value pair. + + Parameters + ---------- + text : str + The key to format + + Returns + ------- + str + + """ + return yellow(text) + + +def kvtable( + items: dict[str, Any], + *, + delim: str = " : ", + align: bool = True, + keys: Iterable[str] | None = None, +) -> str: + """Format a dictionay as a table of key-value pairs. + + Parameters + ---------- + items : dict[str, Any] + The dictionary of items to format + + delim : str, optional + A delimiter to display between keys and values (default: " : ") + + align : bool, optional + Whether to align delimiters to the longest key length (default: True) + + keys : Iterable[str] or None, optional + If not None, only the specified subset of keys is included in the + table output (default: None) + + Returns + ------- + str + + """ + # annoying but necessary to take len on color-formatted version + N = max(len(key(k)) for k in items) if align else 0 + + keys = items.keys() if keys is None else keys + + return "\n".join( + f"{key(k): <{N}}{delim}{value(str(items[k]))}" for k in keys + ) + + +def rule( + text: str | None = None, + *, + pad: int = 0, + char: str = "-", + N: int = UI_WIDTH, +) -> str: + """Format a horizontal rule, optionally with text + + Parameters + ---------- + text : str or None, optional + If not None, display this text inline in the rule (default: None) + pad : int, optional - How much whitespace to precede the rule. (default: 4) + An amount of padding to put in front of the rule - char : str, optional - A character to use to "draw" the rule. (default: "~") + char: str, optional + A character to use for the rule (default: "-") + + N : int, optional + Character width for the rule (default: 80) + + Returns + ------- + str """ - w = UI_WIDTH - pad - return f"{char*w: >{UI_WIDTH}}" + width = N - pad + if text is None: + return cyan(f"{char*width: >{N}}") + return cyan(" " * pad + char * 3 + f"{f' {text} ' :{char}<{width-3}}") + + +def section(text: str) -> str: + """Format text as a section header + + Parameters + ---------- + text : str + The text to format + + Returns + ------- + str + + """ + return bright(white(text)) def shell(cmd: str, *, char: str = "+") -> str: @@ -191,3 +327,19 @@ def summary( ) color = green if passed == total and total > 0 else red return bright(color(f"{summary: >{UI_WIDTH}}" if justify else summary)) + + +def warn(text: str) -> str: + """Format text as a warning. + + Parameters + ---------- + text : str + The text to format + + Returns + ------- + str + + """ + return magenta(f"WARNING: {text}") diff --git a/tests/unit/legate/tester/test___init__.py b/tests/unit/legate/tester/test___init__.py index 9e5676129f..6431469ff4 100644 --- a/tests/unit/legate/tester/test___init__.py +++ b/tests/unit/legate/tester/test___init__.py @@ -28,7 +28,6 @@ FEATURES, PER_FILE_ARGS, SKIPPED_EXAMPLES, - UI_WIDTH, ) @@ -56,9 +55,6 @@ def test_DEFAULT_PROCESS_ENV(self) -> None: "LEGATE_TEST": "1", } - def test_UI_WIDTH(self) -> None: - assert UI_WIDTH == 65 - def test_FEATURES(self) -> None: assert FEATURES == ("cpus", "cuda", "eager", "openmp") diff --git a/tests/unit/legate/tester/test_ui.py b/tests/unit/legate/tester/test_ui.py deleted file mode 100644 index 6ce986aa5b..0000000000 --- a/tests/unit/legate/tester/test_ui.py +++ /dev/null @@ -1,206 +0,0 @@ -# Copyright 2022 NVIDIA Corporation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -"""Consolidate test configuration from command-line and environment. - -""" -from __future__ import annotations - -from datetime import timedelta -from typing import Any - -import pytest -from pytest_mock import MockerFixture -from typing_extensions import TypeAlias - -import legate.utils.colors as colors -from legate.tester import UI_WIDTH, ui as m - -try: - import colorama # type: ignore -except ImportError: - colorama = None - -UsePlainTextFixture: TypeAlias = Any - - -@pytest.fixture -def use_plain_text(mocker: MockerFixture) -> None: - mocker.patch.object(m, "bright", colors._text) - mocker.patch.object(m, "dim", colors._text) - mocker.patch.object(m, "white", colors._text) - mocker.patch.object(m, "cyan", colors._text) - mocker.patch.object(m, "red", colors._text) - mocker.patch.object(m, "green", colors._text) - - -@pytest.mark.skipif(colorama is None, reason="colorama required") -def test_banner_simple() -> None: - assert ( - m.banner("some text") - == "\n" + "#" * UI_WIDTH + "\n### some text\n" + "#" * UI_WIDTH - ) - - -def test_banner_simple_plain(use_plain_text: UsePlainTextFixture) -> None: - assert ( - m.banner("some text") - == "\n" + "#" * UI_WIDTH + "\n### some text\n" + "#" * UI_WIDTH - ) - - -@pytest.mark.skipif(colorama is None, reason="colorama required") -def test_banner_full() -> None: - assert ( - m.banner("some text", char="*", width=100, details=["a", "b"]) - == "\n" - + "*" * 100 - + "\n*** \n*** some text\n*** \n*** a\n*** b\n*** \n" - + "*" * 100 - ) - - -def test_banner_full_plain(use_plain_text: UsePlainTextFixture) -> None: - assert ( - m.banner("some text", char="*", width=100, details=["a", "b"]) - == "\n" - + "*" * 100 - + "\n*** \n*** some text\n*** \n*** a\n*** b\n*** \n" - + "*" * 100 - ) - - -@pytest.mark.skipif(colorama is None, reason="colorama required") -def test_rule_default() -> None: - assert m.rule() == " " + "~" * (UI_WIDTH - 4) - - -def test_rule_default_plain(use_plain_text: UsePlainTextFixture) -> None: - assert m.rule() == " " + "~" * (UI_WIDTH - 4) - - -@pytest.mark.skipif(colorama is None, reason="colorama required") -def test_rule_with_args() -> None: - assert m.rule(10, "-") == " " * 10 + "-" * (UI_WIDTH - 10) - - -def test_rule_with_args_plain(use_plain_text: UsePlainTextFixture) -> None: - assert m.rule(10, "-") == " " * 10 + "-" * (UI_WIDTH - 10) - - -@pytest.mark.skipif(colorama is None, reason="colorama required") -def test_shell() -> None: - assert m.shell("cmd --foo") == colors.dim(colors.white("+cmd --foo")) - - -def test_shell_plain(use_plain_text: UsePlainTextFixture) -> None: - assert m.shell("cmd --foo") == "+cmd --foo" - - -@pytest.mark.skipif(colorama is None, reason="colorama required") -def test_shell_with_char() -> None: - assert m.shell("cmd --foo", char="") == colors.dim( - colors.white("cmd --foo") - ) - - -def test_shell_with_char_plain(use_plain_text: UsePlainTextFixture) -> None: - assert m.shell("cmd --foo", char="") == "cmd --foo" - - -@pytest.mark.skipif(colorama is None, reason="colorama required") -def test_passed() -> None: - assert m.passed("msg") == f"{colors.bright(colors.green('[PASS]'))} msg" - - -def test_passed_plain(use_plain_text: UsePlainTextFixture) -> None: - assert m.passed("msg") == "[PASS] msg" - - -@pytest.mark.skipif(colorama is None, reason="colorama required") -def test_passed_with_details() -> None: - assert ( - m.passed("msg", details=["a", "b"]) - == f"{colors.bright(colors.green('[PASS]'))} msg\n a\n b" - ) - - -def test_passed_with_details_plain( - use_plain_text: UsePlainTextFixture, -) -> None: - assert m.passed("msg", details=["a", "b"]) == "[PASS] msg\n a\n b" - - -@pytest.mark.skipif(colorama is None, reason="colorama required") -def test_failed() -> None: - assert m.failed("msg") == f"{colors.bright(colors.red('[FAIL]'))} msg" - - -def test_failed_plain(use_plain_text: UsePlainTextFixture) -> None: - assert m.failed("msg") == "[FAIL] msg" - - -@pytest.mark.skipif(colorama is None, reason="colorama required") -def test_failed_with_details() -> None: - assert ( - m.failed("msg", details=["a", "b"]) - == f"{colors.bright(colors.red('[FAIL]'))} msg\n a\n b" - ) - - -def test_failed_with_details_plain( - use_plain_text: UsePlainTextFixture, -) -> None: - assert m.failed("msg", details=["a", "b"]) == "[FAIL] msg\n a\n b" - - -@pytest.mark.skipif(colorama is None, reason="colorama required") -def test_skipped() -> None: - assert m.skipped("msg") == f"{colors.cyan('[SKIP]')} msg" - - -def test_skipped_plain(use_plain_text: UsePlainTextFixture) -> None: - assert m.skipped("msg") == "[SKIP] msg" - - -@pytest.mark.skipif(colorama is None, reason="colorama required") -def test_summary() -> None: - assert m.summary("foo", 12, 11, timedelta(seconds=2.123)) == colors.bright( - colors.red( - f"{'foo: Passed 11 of 12 tests (91.7%) in 2.12s': >{UI_WIDTH}}" - ) - ) - - -def test_summary_plain(use_plain_text: UsePlainTextFixture) -> None: - assert ( - m.summary("foo", 12, 11, timedelta(seconds=2.123)) - == f"{'foo: Passed 11 of 12 tests (91.7%) in 2.12s': >{UI_WIDTH}}" - ) - - -@pytest.mark.skipif(colorama is None, reason="colorama required") -def test_summary_no_justify() -> None: - assert m.summary( - "foo", 12, 11, timedelta(seconds=2.123), justify=False - ) == colors.bright( - colors.red("foo: Passed 11 of 12 tests (91.7%) in 2.12s") - ) - - -def test_summary_no_justify_plain(use_plain_text: UsePlainTextFixture) -> None: - assert ( - m.summary("foo", 12, 11, timedelta(seconds=2.123), justify=False) - == "foo: Passed 11 of 12 tests (91.7%) in 2.12s" - ) diff --git a/tests/unit/legate/driver/test_ui.py b/tests/unit/legate/utils/test_ui.py similarity index 52% rename from tests/unit/legate/driver/test_ui.py rename to tests/unit/legate/utils/test_ui.py index d6d3fcf542..d33d776297 100644 --- a/tests/unit/legate/driver/test_ui.py +++ b/tests/unit/legate/utils/test_ui.py @@ -14,14 +14,14 @@ # from __future__ import annotations +from datetime import timedelta from typing import Any import pytest from pytest_mock import MockerFixture from typing_extensions import TypeAlias -import legate.driver.ui as m -import legate.utils.colors as colors +from legate.utils import colors, ui as m try: import colorama # type: ignore @@ -43,6 +43,46 @@ def use_plain_text(mocker: MockerFixture) -> None: mocker.patch.object(m, "magenta", colors._text) +def test_UI_WIDTH() -> None: + assert m.UI_WIDTH == 80 + + +@pytest.mark.skipif(colorama is None, reason="colorama required") +def test_banner_simple() -> None: + assert ( + m.banner("some text") + == "\n" + "#" * m.UI_WIDTH + "\n### some text\n" + "#" * m.UI_WIDTH + ) + + +def test_banner_simple_plain(use_plain_text: UsePlainTextFixture) -> None: + assert ( + m.banner("some text") + == "\n" + "#" * m.UI_WIDTH + "\n### some text\n" + "#" * m.UI_WIDTH + ) + + +@pytest.mark.skipif(colorama is None, reason="colorama required") +def test_banner_full() -> None: + assert ( + m.banner("some text", char="*", width=100, details=["a", "b"]) + == "\n" + + "*" * 100 + + "\n*** \n*** some text\n*** \n*** a\n*** b\n*** \n" + + "*" * 100 + ) + + +def test_banner_full_plain(use_plain_text: UsePlainTextFixture) -> None: + assert ( + m.banner("some text", char="*", width=100, details=["a", "b"]) + == "\n" + + "*" * 100 + + "\n*** \n*** some text\n*** \n*** a\n*** b\n*** \n" + + "*" * 100 + ) + + @pytest.mark.skipif(colorama is None, reason="colorama required") def test_error() -> None: assert m.error("some message") == colors.red("ERROR: some message") @@ -145,13 +185,28 @@ def test_keys_plain(self, use_plain_text: UsePlainTextFixture) -> None: class Test_rule: + @pytest.mark.skipif(colorama is None, reason="colorama required") + def test_pad(self) -> None: + assert m.rule(pad=4) == colors.cyan(" " + "-" * (m.UI_WIDTH - 4)) + + def test_pad_with_text( + self, + ) -> None: + front = " --- foo bar " + assert m.rule("foo bar", pad=4) == colors.cyan( + front + "-" * (m.UI_WIDTH - len(front)) + ) + @pytest.mark.skipif(colorama is None, reason="colorama required") def test_text(self) -> None: - assert m.rule("foo bar") == colors.cyan("--- foo bar " + "-" * 68) + front = "--- foo bar " + assert m.rule("foo bar") == colors.cyan( + front + "-" * (m.UI_WIDTH - len(front)) + ) @pytest.mark.skipif(colorama is None, reason="colorama required") def test_char(self) -> None: - assert m.rule(char="a") == colors.cyan("a" * 80) + assert m.rule(char="a") == colors.cyan("a" * m.UI_WIDTH) @pytest.mark.skipif(colorama is None, reason="colorama required") def test_N(self) -> None: @@ -159,15 +214,31 @@ def test_N(self) -> None: @pytest.mark.skipif(colorama is None, reason="colorama required") def test_N_with_text(self) -> None: + front = "--- foo bar " assert m.rule("foo bar", N=65) == colors.cyan( - "--- foo bar " + "-" * 53 + front + "-" * (65 - len(front)) + ) + + @pytest.mark.skipif(colorama is None, reason="colorama required") + def test_pad_plain(self, use_plain_text: UsePlainTextFixture) -> None: + assert m.rule(pad=4) == " " + "-" * (m.UI_WIDTH - 4) + + def test_pad_with_text_plain( + self, use_plain_text: UsePlainTextFixture + ) -> None: + front = " --- foo bar " + assert m.rule("foo bar", pad=4) == front + "-" * ( + m.UI_WIDTH - len(front) ) def test_text_plain(self, use_plain_text: UsePlainTextFixture) -> None: - assert m.rule("foo bar") == "--- foo bar " + "-" * 68 + front = "--- foo bar " + assert m.rule("foo bar") == "--- foo bar " + "-" * ( + m.UI_WIDTH - len(front) + ) def test_char_plain(self, use_plain_text: UsePlainTextFixture) -> None: - assert m.rule(char="a") == "a" * 80 + assert m.rule(char="a") == "a" * m.UI_WIDTH def test_N_plain(self, use_plain_text: UsePlainTextFixture) -> None: assert m.rule(N=60) == "-" * 60 @@ -175,7 +246,8 @@ def test_N_plain(self, use_plain_text: UsePlainTextFixture) -> None: def test_N_with_text_plain( self, use_plain_text: UsePlainTextFixture ) -> None: - assert m.rule("foo bar", N=65) == "--- foo bar " + "-" * 53 + front = "--- foo bar " + assert m.rule("foo bar", N=65) == front + "-" * (65 - len(front)) @pytest.mark.skipif(colorama is None, reason="colorama required") @@ -194,3 +266,110 @@ def test_warn() -> None: def test_warn_plain(use_plain_text: UsePlainTextFixture) -> None: assert m.warn("some message") == "WARNING: some message" + + +@pytest.mark.skipif(colorama is None, reason="colorama required") +def test_shell() -> None: + assert m.shell("cmd --foo") == colors.dim(colors.white("+cmd --foo")) + + +def test_shell_plain(use_plain_text: UsePlainTextFixture) -> None: + assert m.shell("cmd --foo") == "+cmd --foo" + + +@pytest.mark.skipif(colorama is None, reason="colorama required") +def test_shell_with_char() -> None: + assert m.shell("cmd --foo", char="") == colors.dim( + colors.white("cmd --foo") + ) + + +def test_shell_with_char_plain(use_plain_text: UsePlainTextFixture) -> None: + assert m.shell("cmd --foo", char="") == "cmd --foo" + + +@pytest.mark.skipif(colorama is None, reason="colorama required") +def test_passed() -> None: + assert m.passed("msg") == f"{colors.bright(colors.green('[PASS]'))} msg" + + +def test_passed_plain(use_plain_text: UsePlainTextFixture) -> None: + assert m.passed("msg") == "[PASS] msg" + + +@pytest.mark.skipif(colorama is None, reason="colorama required") +def test_passed_with_details() -> None: + assert ( + m.passed("msg", details=["a", "b"]) + == f"{colors.bright(colors.green('[PASS]'))} msg\n a\n b" + ) + + +def test_passed_with_details_plain( + use_plain_text: UsePlainTextFixture, +) -> None: + assert m.passed("msg", details=["a", "b"]) == "[PASS] msg\n a\n b" + + +@pytest.mark.skipif(colorama is None, reason="colorama required") +def test_failed() -> None: + assert m.failed("msg") == f"{colors.bright(colors.red('[FAIL]'))} msg" + + +def test_failed_plain(use_plain_text: UsePlainTextFixture) -> None: + assert m.failed("msg") == "[FAIL] msg" + + +@pytest.mark.skipif(colorama is None, reason="colorama required") +def test_failed_with_details() -> None: + assert ( + m.failed("msg", details=["a", "b"]) + == f"{colors.bright(colors.red('[FAIL]'))} msg\n a\n b" + ) + + +def test_failed_with_details_plain( + use_plain_text: UsePlainTextFixture, +) -> None: + assert m.failed("msg", details=["a", "b"]) == "[FAIL] msg\n a\n b" + + +@pytest.mark.skipif(colorama is None, reason="colorama required") +def test_skipped() -> None: + assert m.skipped("msg") == f"{colors.cyan('[SKIP]')} msg" + + +def test_skipped_plain(use_plain_text: UsePlainTextFixture) -> None: + assert m.skipped("msg") == "[SKIP] msg" + + +@pytest.mark.skipif(colorama is None, reason="colorama required") +def test_summary() -> None: + assert m.summary("foo", 12, 11, timedelta(seconds=2.123)) == colors.bright( + colors.red( + f"{'foo: Passed 11 of 12 tests (91.7%) in 2.12s': >{m.UI_WIDTH}}" + ) + ) + + +def test_summary_plain(use_plain_text: UsePlainTextFixture) -> None: + assert ( + m.summary("foo", 12, 11, timedelta(seconds=2.123)) + == f"{'foo: Passed 11 of 12 tests (91.7%) in 2.12s': >{m.UI_WIDTH}}" + ) + + +@pytest.mark.skipif(colorama is None, reason="colorama required") +def test_summary_no_justify() -> None: + assert m.summary( + "foo", 12, 11, timedelta(seconds=2.123), justify=False + ) == colors.bright( + colors.red("foo: Passed 11 of 12 tests (91.7%) in 2.12s") + ) + + +def test_summary_no_justify_plain(use_plain_text: UsePlainTextFixture) -> None: + assert ( + m.summary("foo", 12, 11, timedelta(seconds=2.123), justify=False) + == "foo: Passed 11 of 12 tests (91.7%) in 2.12s" + ) From 89eb9bd01e61edc5028f892a5bb137bd09fa763e Mon Sep 17 00:00:00 2001 From: Bryan Van de Ven Date: Wed, 28 Sep 2022 10:31:31 -0700 Subject: [PATCH 08/16] consolidate system classes --- legate/driver/__init__.py | 1 - legate/driver/command.py | 2 +- legate/driver/driver.py | 2 +- legate/driver/launcher.py | 4 +- legate/driver/logs.py | 2 +- legate/driver/main.py | 3 +- legate/driver/util.py | 312 +---------------- legate/tester/stages/_linux/cpu.py | 10 +- legate/tester/stages/_linux/eager.py | 10 +- legate/tester/stages/_linux/gpu.py | 12 +- legate/tester/stages/_linux/omp.py | 10 +- legate/tester/stages/_osx/cpu.py | 10 +- legate/tester/stages/_osx/eager.py | 10 +- legate/tester/stages/_osx/gpu.py | 10 +- legate/tester/stages/_osx/omp.py | 10 +- legate/tester/stages/test_stage.py | 34 +- legate/tester/stages/util.py | 2 +- legate/tester/test_plan.py | 8 +- legate/tester/{system.py => test_system.py} | 57 +-- legate/utils/fs.py | 331 ++++++++++++++++++ legate/{driver => utils}/system.py | 53 ++- tests/unit/legate/driver/conftest.py | 3 +- tests/unit/legate/driver/test_driver.py | 2 +- tests/unit/legate/driver/test_launcher.py | 2 +- tests/unit/legate/driver/test_main.py | 6 +- tests/unit/legate/driver/test_util.py | 38 +- tests/unit/legate/tester/stages/__init__.py | 4 +- .../legate/tester/stages/test_test_stage.py | 6 +- .../{test_system.py => test_test_system.py} | 21 +- .../{driver => utils}/sample_cmake_cache.txt | 0 .../legate/{driver => utils}/sample_header.h | 0 tests/unit/legate/utils/test_fs.py | 53 +++ .../legate/{driver => utils}/test_system.py | 19 +- 33 files changed, 547 insertions(+), 500 deletions(-) rename legate/tester/{system.py => test_system.py} (63%) create mode 100644 legate/utils/fs.py rename legate/{driver => utils}/system.py (51%) rename tests/unit/legate/tester/{test_system.py => test_test_system.py} (78%) rename tests/unit/legate/{driver => utils}/sample_cmake_cache.txt (100%) rename tests/unit/legate/{driver => utils}/sample_header.h (100%) create mode 100644 tests/unit/legate/utils/test_fs.py rename tests/unit/legate/{driver => utils}/test_system.py (83%) diff --git a/legate/driver/__init__.py b/legate/driver/__init__.py index f5803f8a8b..b8496597d6 100644 --- a/legate/driver/__init__.py +++ b/legate/driver/__init__.py @@ -18,4 +18,3 @@ from .driver import Driver from .launcher import Launcher from .main import main -from .system import System diff --git a/legate/driver/command.py b/legate/driver/command.py index dc564e9059..fef37a44d3 100644 --- a/legate/driver/command.py +++ b/legate/driver/command.py @@ -19,10 +19,10 @@ from ..utils.ui import warn if TYPE_CHECKING: + from ..utils.system import System from ..utils.types import CommandPart from .config import Config from .launcher import Launcher - from .system import System __all__ = ("CMD_PARTS",) diff --git a/legate/driver/driver.py b/legate/driver/driver.py index 6c0457bddb..ccb1ce0cc4 100644 --- a/legate/driver/driver.py +++ b/legate/driver/driver.py @@ -16,13 +16,13 @@ from subprocess import run +from ..utils.system import System from ..utils.types import Command, EnvDict from ..utils.ui import warn from .command import CMD_PARTS from .config import Config from .launcher import Launcher from .logs import process_logs -from .system import System from .util import print_verbose __all__ = ("Driver",) diff --git a/legate/driver/launcher.py b/legate/driver/launcher.py index fb6a0b5a7a..4e6e82ee22 100644 --- a/legate/driver/launcher.py +++ b/legate/driver/launcher.py @@ -19,13 +19,13 @@ from pathlib import Path from typing import TYPE_CHECKING +from ..utils.fs import read_c_define from ..utils.ui import warn -from .util import read_c_define if TYPE_CHECKING: + from ..utils.system import System from ..utils.types import Command, EnvDict, LauncherType from .config import Config - from .system import System __all__ = ("Launcher",) diff --git a/legate/driver/logs.py b/legate/driver/logs.py index 22063871c3..65cf4989b9 100644 --- a/legate/driver/logs.py +++ b/legate/driver/logs.py @@ -27,10 +27,10 @@ from ..utils.ui import warn if TYPE_CHECKING: + from ..utils.system import System from ..utils.types import Command from .config import Config from .launcher import Launcher - from .system import System __all__ = ( "DebuggingHandler", diff --git a/legate/driver/main.py b/legate/driver/main.py index dcd52f7f52..2dd55eb588 100644 --- a/legate/driver/main.py +++ b/legate/driver/main.py @@ -34,8 +34,9 @@ def main(argv: list[str]) -> int: int, a process return code """ + from ..utils.system import System from ..utils.ui import error - from . import Config, Driver, System + from . import Config, Driver from .util import print_verbose try: diff --git a/legate/driver/util.py b/legate/driver/util.py index 5fbb88bca7..2dd052808e 100644 --- a/legate/driver/util.py +++ b/legate/driver/util.py @@ -14,28 +14,20 @@ # from __future__ import annotations -import re -import sys -from pathlib import Path from shlex import quote from textwrap import indent from typing import TYPE_CHECKING, Type, TypeVar -from ..utils.types import DataclassProtocol, LegatePaths, LegionPaths +from ..utils.types import DataclassProtocol from ..utils.ui import kvtable, rule, section, value if TYPE_CHECKING: + from ..utils.system import System from .driver import Driver - from .system import System __all__ = ( - "get_legate_build_dir", - "get_legate_paths", - "get_legion_paths", "object_to_dataclass", "print_verbose", - "read_c_define", - "read_cmake_cache_value", ) @@ -109,303 +101,3 @@ def print_verbose( print(f"\n{rule()}") print(flush=True) - - -def read_c_define(header_path: Path, name: str) -> str | None: - """Open a C header file and read the value of a #define - - Parameters - ---------- - header_path : Path - Location of the C header file to scan - - name : str - The name to search the header for - - Returns - ------- - str : value from the header or None, if it does not exist - - """ - try: - with open(header_path, "r") as f: - lines = (line for line in f if line.startswith("#define")) - for line in lines: - tokens = line.split(" ") - if tokens[1].strip() == name: - return tokens[2].strip() - except IOError: - pass - - return None - - -def read_cmake_cache_value(file_path: Path, pattern: str) -> str: - """Search a cmake cache file for a given pattern and return the associated - value. - - Parameters - ---------- - file_path: Path - Location of the cmake cache file to scan - - pattern : str - A pattern to seach for in the file - - Returns - ------- - str - - Raises - ------ - RuntimeError, if the value is not found - - """ - with open(file_path, encoding="utf-8") as f: - for line in f: - if re.match(pattern, line): - return line.strip().split("=")[1] - - raise RuntimeError(f"Could not find value for {pattern} in {file_path}") - - -def get_legate_build_dir(legate_dir: Path) -> Path | None: - """Determine the location of the Legate build directory. - - If the build directory cannot be found, None is returned. - - Parameters - ---------- - legate_dir : Path - Directory containing a Legate executable - - - Returns - Path or None - - """ - # If using a local non-scikit-build CMake build dir, read - # Legion_BINARY_DIR and Legion_SOURCE_DIR from CMakeCache.txt - legate_build_dir = legate_dir / "build" - cmake_cache_txt = legate_build_dir.joinpath("CMakeCache.txt") - if legate_build_dir.exists() and cmake_cache_txt.exists(): - return legate_build_dir - - skbuild_dir = legate_dir / "_skbuild" - if not skbuild_dir.exists(): - return None - - for f in skbuild_dir.iterdir(): - - # If using a local scikit-build dir at _skbuild//cmake-build, - # read Legion_BINARY_DIR and Legion_SOURCE_DIR from CMakeCache.txt - - legate_build_dir = skbuild_dir / f / "cmake-build" - cmake_cache_txt = legate_build_dir / "CMakeCache.txt" - - if legate_build_dir.exists() and cmake_cache_txt.exists(): - try: - # Test whether FIND_LEGATE_CORE_CPP is set to ON. If it - # isn't, then we built legate_core C++ as a side-effect of - # building legate_core_python. - read_cmake_cache_value( - cmake_cache_txt, "FIND_LEGATE_CORE_CPP:BOOL=OFF" - ) - except Exception: - # If FIND_LEGATE_CORE_CPP is set to ON, check to see if - # legate_core_DIR is a valid path. If it is, check whether - # legate_core_DIR is a path to a legate_core build dir i.e. - # `-D legate_core_ROOT=/legate.core/build` - legate_core_dir = Path( - read_cmake_cache_value( - cmake_cache_txt, "legate_core_DIR:PATH=" - ) - ) - - # If legate_core_dir doesn't have a CMakeCache.txt, CMake's - # find_package found a system legate_core installation. - # Return the installation paths. - cmake_cache_txt = legate_core_dir / "CMakeCache.txt" - if cmake_cache_txt.exists(): - return Path( - read_cmake_cache_value( - cmake_cache_txt, "legate_core_BINARY_DIR:STATIC=" - ) - ) - return None - - return legate_build_dir - - return None - - -def get_legate_paths() -> LegatePaths: - """Determine all the important runtime paths for Legate - - Returns - ------- - LegatePaths - - """ - import legate - - legate_dir = Path(legate.__path__[0]).parent - legate_build_dir = get_legate_build_dir(legate_dir) - - if legate_build_dir is None: - return LegatePaths( - legate_dir=legate_dir, - legate_build_dir=legate_build_dir, - bind_sh_path=Path(sys.argv[0]).parent / "bind.sh", - legate_lib_path=Path(sys.argv[0]).parents[1] / "lib", - ) - - cmake_cache_txt = legate_build_dir.joinpath("CMakeCache.txt") - - legate_source_dir = Path( - read_cmake_cache_value( - cmake_cache_txt, "legate_core_SOURCE_DIR:STATIC=" - ) - ) - - legate_binary_dir = Path( - read_cmake_cache_value( - cmake_cache_txt, "legate_core_BINARY_DIR:STATIC=" - ) - ) - - return LegatePaths( - legate_dir=legate_dir, - legate_build_dir=legate_build_dir, - bind_sh_path=legate_source_dir / "bind.sh", - legate_lib_path=legate_binary_dir / "lib", - ) - - -def get_legion_paths(legate_paths: LegatePaths) -> LegionPaths: - """Determine all the important runtime paths for Legion - - Parameters - ---------- - legate_paths : LegatePaths - Locations of Legate runtime paths - - Returns - ------- - LegionPaths - - """ - - # Construct and return paths needed to launch `legion_python`,accounting - # for multiple ways Legion and legate_core may be configured or installed. - # - # 1. Legion was found in a standard system location (/usr, $CONDA_PREFIX) - # 2. Legion was built as a side-effect of building legate_core: - # ``` - # SKBUILD_CONFIGURE_OPTIONS="" python -m pip install . - # ``` - # 3. Legion was built in a separate directory independent of legate_core - # and the path to its build directory was given when configuring - # legate_core: - # ``` - # SKBUILD_CONFIGURE_OPTIONS="-D Legion_ROOT=/legion/build" \ - # python -m pip install . - # ``` - # - # Additionally, legate_core has multiple run modes: - # - # 1. As an installed Python module (`python -m pip install .`) - # 2. As an "editable" install (`python -m pip install --editable .`) - # - # When determining locations of Legion and legate_core paths, prioritize - # local builds over global installations. This allows devs to work in the - # source tree and re-run without overwriting existing installations. - - def installed_legion_paths( - legion_dir: Path, legion_module: Path | None = None - ) -> LegionPaths: - if legion_module is None: - legion_lib_dir = legion_dir / "lib" - for f in legion_lib_dir.iterdir(): - if f.joinpath("site-packages").exists(): - legion_module = f / "site-packages" - break - - legion_bin_path = legion_dir / "bin" - legion_include_path = legion_dir / "include" - - return LegionPaths( - legion_bin_path=legion_bin_path, - legion_lib_path=legion_lib_dir, - realm_defines_h=legion_include_path / "realm_defines.h", - legion_defines_h=legion_include_path / "legion_defines.h", - legion_spy_py=legion_bin_path / "legion_spy.py", - legion_prof_py=legion_bin_path / "legion_prof.py", - legion_python=legion_bin_path / "legion_python", - legion_module=legion_module, - legion_jupyter_module=legion_module, - ) - - if (legate_build_dir := legate_paths.legate_build_dir) is None: - legate_build_dir = get_legate_build_dir(legate_paths.legate_dir) - - # If no local build dir found, assume legate installed into the python env - if legate_build_dir is None: - return installed_legion_paths(Path(sys.argv[0]).parents[1]) - - # If a legate build dir was found, read `Legion_SOURCE_DIR` and - # `Legion_BINARY_DIR` from in CMakeCache.txt, return paths into the source - # and build dirs. This allows devs to quickly rebuild inplace and use the - # most up-to-date versions without needing to install Legion and - # legate_core globally. - - cmake_cache_txt = legate_build_dir / "CMakeCache.txt" - - try: - # Test whether Legion_DIR is set. If it isn't, then we built Legion as - # a side-effect of building legate_core - read_cmake_cache_value( - cmake_cache_txt, "Legion_DIR:PATH=Legion_DIR-NOTFOUND" - ) - except Exception: - # If Legion_DIR is a valid path, check whether it's a - # Legion build dir, i.e. `-D Legion_ROOT=/legion/build` - legion_dir = Path( - read_cmake_cache_value(cmake_cache_txt, "Legion_DIR:PATH=") - ) - if legion_dir.joinpath("CMakeCache.txt").exists(): - cmake_cache_txt = legion_dir / "CMakeCache.txt" - - try: - # If Legion_SOURCE_DIR and Legion_BINARY_DIR are in CMakeCache.txt, - # return the paths to Legion in the legate_core build dir. - legion_source_dir = Path( - read_cmake_cache_value( - cmake_cache_txt, "Legion_SOURCE_DIR:STATIC=" - ) - ) - legion_binary_dir = Path( - read_cmake_cache_value( - cmake_cache_txt, "Legion_BINARY_DIR:STATIC=" - ) - ) - - legion_runtime_dir = legion_binary_dir / "runtime" - legion_bindings_dir = legion_source_dir / "bindings" - - return LegionPaths( - legion_bin_path=legion_binary_dir / "bin", - legion_lib_path=legion_binary_dir / "lib", - realm_defines_h=legion_runtime_dir / "realm_defines.h", - legion_defines_h=legion_runtime_dir / "legion_defines.h", - legion_spy_py=legion_source_dir / "tools" / "legion_spy.py", - legion_prof_py=legion_source_dir / "tools" / "legion_prof.py", - legion_python=legion_binary_dir / "bin" / "legion_python", - legion_module=legion_bindings_dir / "python" / "build" / "lib", - legion_jupyter_module=legion_source_dir / "jupyter_notebook", - ) - except Exception: - pass - - # Otherwise return the installation paths. - return installed_legion_paths(Path(sys.argv[0]).parents[1]) diff --git a/legate/tester/stages/_linux/cpu.py b/legate/tester/stages/_linux/cpu.py index b776d42f3e..f866df90de 100644 --- a/legate/tester/stages/_linux/cpu.py +++ b/legate/tester/stages/_linux/cpu.py @@ -30,7 +30,7 @@ from ....utils.types import ArgList, EnvDict from ... import FeatureType from ...config import Config - from ...system import System + from ...test_system import TestSystem class CPU(TestStage): @@ -41,7 +41,7 @@ class CPU(TestStage): config: Config Test runner configuration - system: System + system: TestSystem Process execution wrapper """ @@ -50,10 +50,10 @@ class CPU(TestStage): args = [CUNUMERIC_TEST_ARG] - def __init__(self, config: Config, system: System) -> None: + def __init__(self, config: Config, system: TestSystem) -> None: self._init(config, system) - def env(self, config: Config, system: System) -> EnvDict: + def env(self, config: Config, system: TestSystem) -> EnvDict: return {} if config.cpu_pin == "strict" else dict(UNPIN_ENV) def shard_args(self, shard: Shard, config: Config) -> ArgList: @@ -68,7 +68,7 @@ def shard_args(self, shard: Shard, config: Config) -> ArgList: ] return args - def compute_spec(self, config: Config, system: System) -> StageSpec: + def compute_spec(self, config: Config, system: TestSystem) -> StageSpec: cpus = system.cpus procs = config.cpus + config.utility + int(config.cpu_pin == "strict") diff --git a/legate/tester/stages/_linux/eager.py b/legate/tester/stages/_linux/eager.py index 174c231630..f7734c91a9 100644 --- a/legate/tester/stages/_linux/eager.py +++ b/legate/tester/stages/_linux/eager.py @@ -23,7 +23,7 @@ from ....utils.types import ArgList, EnvDict from ... import FeatureType from ...config import Config - from ...system import System + from ...test_system import TestSystem class Eager(TestStage): @@ -34,7 +34,7 @@ class Eager(TestStage): config: Config Test runner configuration - system: System + system: TestSystem Process execution wrapper """ @@ -43,10 +43,10 @@ class Eager(TestStage): args: ArgList = [] - def __init__(self, config: Config, system: System) -> None: + def __init__(self, config: Config, system: TestSystem) -> None: self._init(config, system) - def env(self, config: Config, system: System) -> EnvDict: + def env(self, config: Config, system: TestSystem) -> EnvDict: # Raise min chunk sizes for deferred codepaths to force eager execution env = { "CUNUMERIC_MIN_CPU_CHUNK": "2000000000", @@ -63,7 +63,7 @@ def shard_args(self, shard: Shard, config: Config) -> ArgList: ",".join(str(x) for x in shard), ] - def compute_spec(self, config: Config, system: System) -> StageSpec: + def compute_spec(self, config: Config, system: TestSystem) -> StageSpec: N = len(system.cpus) degree = min(N, 60) # ~LEGION_MAX_NUM_PROCS just in case diff --git a/legate/tester/stages/_linux/gpu.py b/legate/tester/stages/_linux/gpu.py index dfed7a99c1..6dab34274a 100644 --- a/legate/tester/stages/_linux/gpu.py +++ b/legate/tester/stages/_linux/gpu.py @@ -24,7 +24,7 @@ from ....utils.types import ArgList, EnvDict from ... import FeatureType from ...config import Config - from ...system import System + from ...test_system import TestSystem BLOAT_FACTOR = 1.5 # hard coded for now @@ -37,7 +37,7 @@ class GPU(TestStage): config: Config Test runner configuration - system: System + system: TestSystem Process execution wrapper """ @@ -46,13 +46,13 @@ class GPU(TestStage): args = [CUNUMERIC_TEST_ARG] - def __init__(self, config: Config, system: System) -> None: + def __init__(self, config: Config, system: TestSystem) -> None: self._init(config, system) - def env(self, config: Config, system: System) -> EnvDict: + def env(self, config: Config, system: TestSystem) -> EnvDict: return {} - def delay(self, shard: Shard, config: Config, system: System) -> None: + def delay(self, shard: Shard, config: Config, system: TestSystem) -> None: time.sleep(config.gpu_delay / 1000) def shard_args(self, shard: Shard, config: Config) -> ArgList: @@ -65,7 +65,7 @@ def shard_args(self, shard: Shard, config: Config) -> ArgList: ",".join(str(x) for x in shard), ] - def compute_spec(self, config: Config, system: System) -> StageSpec: + def compute_spec(self, config: Config, system: TestSystem) -> StageSpec: N = len(system.gpus) degree = N // config.gpus diff --git a/legate/tester/stages/_linux/omp.py b/legate/tester/stages/_linux/omp.py index 54da668c39..f172a87ae4 100644 --- a/legate/tester/stages/_linux/omp.py +++ b/legate/tester/stages/_linux/omp.py @@ -30,7 +30,7 @@ from ....utils.types import ArgList, EnvDict from ... import FeatureType from ...config import Config - from ...system import System + from ...test_system import TestSystem class OMP(TestStage): @@ -41,7 +41,7 @@ class OMP(TestStage): config: Config Test runner configuration - system: System + system: TestSystem Process execution wrapper """ @@ -50,10 +50,10 @@ class OMP(TestStage): args = [CUNUMERIC_TEST_ARG] - def __init__(self, config: Config, system: System) -> None: + def __init__(self, config: Config, system: TestSystem) -> None: self._init(config, system) - def env(self, config: Config, system: System) -> EnvDict: + def env(self, config: Config, system: TestSystem) -> EnvDict: return {} if config.cpu_pin == "strict" else dict(UNPIN_ENV) def shard_args(self, shard: Shard, config: Config) -> ArgList: @@ -70,7 +70,7 @@ def shard_args(self, shard: Shard, config: Config) -> ArgList: ] return args - def compute_spec(self, config: Config, system: System) -> StageSpec: + def compute_spec(self, config: Config, system: TestSystem) -> StageSpec: cpus = system.cpus omps, threads = config.omps, config.ompthreads procs = ( diff --git a/legate/tester/stages/_osx/cpu.py b/legate/tester/stages/_osx/cpu.py index 84730dfe1a..e976ac3835 100644 --- a/legate/tester/stages/_osx/cpu.py +++ b/legate/tester/stages/_osx/cpu.py @@ -29,7 +29,7 @@ from ....utils.types import ArgList, EnvDict from ... import FeatureType from ...config import Config - from ...system import System + from ...test_system import TestSystem class CPU(TestStage): @@ -40,7 +40,7 @@ class CPU(TestStage): config: Config Test runner configuration - system: System + system: TestSystem Process execution wrapper """ @@ -49,16 +49,16 @@ class CPU(TestStage): args = [CUNUMERIC_TEST_ARG] - def __init__(self, config: Config, system: System) -> None: + def __init__(self, config: Config, system: TestSystem) -> None: self._init(config, system) - def env(self, config: Config, system: System) -> EnvDict: + def env(self, config: Config, system: TestSystem) -> EnvDict: return UNPIN_ENV def shard_args(self, shard: Shard, config: Config) -> ArgList: return ["--cpus", str(config.cpus)] - def compute_spec(self, config: Config, system: System) -> StageSpec: + def compute_spec(self, config: Config, system: TestSystem) -> StageSpec: procs = config.cpus + config.utility workers = adjust_workers( len(system.cpus) // procs, config.requested_workers diff --git a/legate/tester/stages/_osx/eager.py b/legate/tester/stages/_osx/eager.py index b86cf28625..6db752f20d 100644 --- a/legate/tester/stages/_osx/eager.py +++ b/legate/tester/stages/_osx/eager.py @@ -23,7 +23,7 @@ from ....utils.types import ArgList, EnvDict from ... import FeatureType from ...config import Config - from ...system import System + from ...test_system import TestSystem class Eager(TestStage): @@ -34,7 +34,7 @@ class Eager(TestStage): config: Config Test runner configuration - system: System + system: TestSystem Process execution wrapper """ @@ -43,10 +43,10 @@ class Eager(TestStage): args: ArgList = [] - def __init__(self, config: Config, system: System) -> None: + def __init__(self, config: Config, system: TestSystem) -> None: self._init(config, system) - def env(self, config: Config, system: System) -> EnvDict: + def env(self, config: Config, system: TestSystem) -> EnvDict: # Raise min chunk sizes for deferred codepaths to force eager execution env = { "CUNUMERIC_MIN_CPU_CHUNK": "2000000000", @@ -59,7 +59,7 @@ def env(self, config: Config, system: System) -> EnvDict: def shard_args(self, shard: Shard, config: Config) -> ArgList: return ["--cpus", "1"] - def compute_spec(self, config: Config, system: System) -> StageSpec: + def compute_spec(self, config: Config, system: TestSystem) -> StageSpec: N = len(system.cpus) degree = min(N, 60) # ~LEGION_MAX_NUM_PROCS just in case workers = adjust_workers(degree, config.requested_workers) diff --git a/legate/tester/stages/_osx/gpu.py b/legate/tester/stages/_osx/gpu.py index c846f81924..6779bc254c 100644 --- a/legate/tester/stages/_osx/gpu.py +++ b/legate/tester/stages/_osx/gpu.py @@ -24,7 +24,7 @@ from ....utils.types import ArgList, EnvDict from ... import FeatureType from ...config import Config - from ...system import System + from ...test_system import TestSystem class GPU(TestStage): @@ -35,7 +35,7 @@ class GPU(TestStage): config: Config Test runner configuration - system: System + system: TestSystem Process execution wrapper """ @@ -44,11 +44,11 @@ class GPU(TestStage): args: ArgList = [CUNUMERIC_TEST_ARG] - def __init__(self, config: Config, system: System) -> None: + def __init__(self, config: Config, system: TestSystem) -> None: raise RuntimeError("GPU test are not supported on OSX") - def env(self, config: Config, system: System) -> EnvDict: + def env(self, config: Config, system: TestSystem) -> EnvDict: return UNPIN_ENV - def delay(self, shard: Shard, config: Config, system: System) -> None: + def delay(self, shard: Shard, config: Config, system: TestSystem) -> None: time.sleep(config.gpu_delay / 1000) diff --git a/legate/tester/stages/_osx/omp.py b/legate/tester/stages/_osx/omp.py index e0a79ab106..21f3fafadb 100644 --- a/legate/tester/stages/_osx/omp.py +++ b/legate/tester/stages/_osx/omp.py @@ -29,7 +29,7 @@ from ....utils.types import ArgList, EnvDict from ... import FeatureType from ...config import Config - from ...system import System + from ...test_system import TestSystem class OMP(TestStage): @@ -40,7 +40,7 @@ class OMP(TestStage): config: Config Test runner configuration - system: System + system: TestSystem Process execution wrapper """ @@ -49,10 +49,10 @@ class OMP(TestStage): args = [CUNUMERIC_TEST_ARG] - def __init__(self, config: Config, system: System) -> None: + def __init__(self, config: Config, system: TestSystem) -> None: self._init(config, system) - def env(self, config: Config, system: System) -> EnvDict: + def env(self, config: Config, system: TestSystem) -> EnvDict: return UNPIN_ENV def shard_args(self, shard: Shard, config: Config) -> ArgList: @@ -63,7 +63,7 @@ def shard_args(self, shard: Shard, config: Config) -> ArgList: str(config.ompthreads), ] - def compute_spec(self, config: Config, system: System) -> StageSpec: + def compute_spec(self, config: Config, system: TestSystem) -> StageSpec: omps, threads = config.omps, config.ompthreads procs = omps * threads + config.utility workers = adjust_workers( diff --git a/legate/tester/stages/test_stage.py b/legate/tester/stages/test_stage.py index f29f1ac0ca..b98f6a13b9 100644 --- a/legate/tester/stages/test_stage.py +++ b/legate/tester/stages/test_stage.py @@ -25,7 +25,7 @@ from ...utils.ui import banner, summary from .. import PER_FILE_ARGS, FeatureType from ..config import Config -from ..system import ProcessResult, System +from ..test_system import ProcessResult, TestSystem from .util import Shard, StageResult, StageSpec, log_proc @@ -37,7 +37,7 @@ class TestStage(Protocol): config: Config Test runner configuration - system: System + system: TestSystem Process execution wrapper """ @@ -59,10 +59,10 @@ class TestStage(Protocol): # --- Protocol methods - def __init__(self, config: Config, system: System) -> None: + def __init__(self, config: Config, system: TestSystem) -> None: ... - def env(self, config: Config, system: System) -> EnvDict: + def env(self, config: Config, system: TestSystem) -> EnvDict: """Generate stage-specific customizations to the process env Parameters @@ -70,13 +70,13 @@ def env(self, config: Config, system: System) -> EnvDict: config: Config Test runner configuration - system: System + system: TestSystem Process execution wrapper """ ... - def delay(self, shard: Shard, config: Config, system: System) -> None: + def delay(self, shard: Shard, config: Config, system: TestSystem) -> None: """Wait any delay that should be applied before running the next test. @@ -88,7 +88,7 @@ def delay(self, shard: Shard, config: Config, system: System) -> None: config: Config Test runner configuration - system: System + system: TestSystem Process execution wrapper """ @@ -109,7 +109,7 @@ def shard_args(self, shard: Shard, config: Config) -> ArgList: """ ... - def compute_spec(self, config: Config, system: System) -> StageSpec: + def compute_spec(self, config: Config, system: TestSystem) -> StageSpec: """Compute the number of worker processes to launch and stage shards to use for running the configured test files. @@ -118,7 +118,7 @@ def compute_spec(self, config: Config, system: System) -> StageSpec: config: Config Test runner configuration - system: System + system: TestSystem Process execution wrapper """ @@ -126,7 +126,7 @@ def compute_spec(self, config: Config, system: System) -> StageSpec: # --- Shared implementation methods - def __call__(self, config: Config, system: System) -> None: + def __call__(self, config: Config, system: TestSystem) -> None: """Execute this test stage. Parameters @@ -134,7 +134,7 @@ def __call__(self, config: Config, system: System) -> None: config: Config Test runner configuration - system: System + system: TestSystem Process execution wrapper """ @@ -206,7 +206,7 @@ def file_args(self, test_file: Path, config: Config) -> ArgList: return args def run( - self, test_file: Path, config: Config, system: System + self, test_file: Path, config: Config, system: TestSystem ) -> ProcessResult: """Execute a single test files with appropriate environment and command-line options for a feature test stage. @@ -219,7 +219,7 @@ def run( config: Config Test runner configuration - system: System + system: TestSystem Process execution wrapper """ @@ -242,18 +242,20 @@ def run( return result - def _env(self, config: Config, system: System) -> EnvDict: + def _env(self, config: Config, system: TestSystem) -> EnvDict: env = dict(config.env) env.update(self.env(config, system)) return env - def _init(self, config: Config, system: System) -> None: + def _init(self, config: Config, system: TestSystem) -> None: self.spec = self.compute_spec(config, system) self.shards = system.manager.Queue(len(self.spec.shards)) for shard in self.spec.shards: self.shards.put(shard) - def _launch(self, config: Config, system: System) -> list[ProcessResult]: + def _launch( + self, config: Config, system: TestSystem + ) -> list[ProcessResult]: pool = multiprocessing.pool.ThreadPool(self.spec.workers) diff --git a/legate/tester/stages/util.py b/legate/tester/stages/util.py index ff8a28d904..8633a72645 100644 --- a/legate/tester/stages/util.py +++ b/legate/tester/stages/util.py @@ -23,7 +23,7 @@ from ...utils.ui import failed, passed, shell, skipped from ..config import Config from ..logger import LOG -from ..system import ProcessResult +from ..test_system import ProcessResult CUNUMERIC_TEST_ARG = "-cunumeric:test" diff --git a/legate/tester/test_plan.py b/legate/tester/test_plan.py index fd1ede4e40..aef117bf57 100644 --- a/legate/tester/test_plan.py +++ b/legate/tester/test_plan.py @@ -25,7 +25,7 @@ from .config import Config from .logger import LOG from .stages import STAGES, log_proc -from .system import System +from .test_system import TestSystem class TestPlan: @@ -36,12 +36,12 @@ class TestPlan: config: Config Test runner configuration - system: System + system: TestSystem Process execution wrapper """ - def __init__(self, config: Config, system: System) -> None: + def __init__(self, config: Config, system: TestSystem) -> None: self._config = config self._system = system self._stages = [ @@ -86,7 +86,7 @@ def intro(self) -> str: details = ( f"* Feature stages : {', '.join(yellow(x) for x in self._config.features)}", # noqa E501 f"* Test files per stage : {yellow(str(len(self._config.test_files)))}", # noqa E501 - f"* System description : {yellow(str(cpus) + ' cpus')} / {yellow(str(gpus) + ' gpus')}", # noqa E501 + f"* TestSystem description : {yellow(str(cpus) + ' cpus')} / {yellow(str(gpus) + ' gpus')}", # noqa E501 ) return banner("Test Suite Configuration", details=details) diff --git a/legate/tester/system.py b/legate/tester/test_system.py similarity index 63% rename from legate/tester/system.py rename to legate/tester/test_system.py index 414f00179b..2978a8ad8c 100644 --- a/legate/tester/system.py +++ b/legate/tester/test_system.py @@ -20,14 +20,15 @@ import multiprocessing import os -import sys from dataclasses import dataclass -from functools import cached_property from pathlib import Path from subprocess import PIPE, STDOUT, run as stdlib_run from typing import Sequence -from ..utils.types import CPUInfo, EnvDict, GPUInfo +from ..utils.system import System +from ..utils.types import EnvDict + +__all__ = ("TestSystem",) @dataclass @@ -49,7 +50,7 @@ class ProcessResult: output: str = "" -class System: +class TestSystem(System): """A facade class for system-related functions. Parameters @@ -120,51 +121,3 @@ def run( returncode=proc.returncode, output=proc.stdout, ) - - @cached_property - def cpus(self) -> tuple[CPUInfo, ...]: - """A list of CPUs on the system.""" - - N = multiprocessing.cpu_count() - - if sys.platform == "darwin": - return tuple(CPUInfo((i,)) for i in range(N)) - - sibling_sets: set[tuple[int, ...]] = set() - for i in range(N): - line = open( - f"/sys/devices/system/cpu/cpu{i}/topology/thread_siblings_list" - ).read() - sibling_sets.add( - tuple(sorted(int(x) for x in line.strip().split(","))) - ) - return tuple(CPUInfo(siblings) for siblings in sorted(sibling_sets)) - - @cached_property - def gpus(self) -> tuple[GPUInfo, ...]: - """A list of GPUs on the system, including total memory information.""" - - try: - # This pynvml import is protected inside this method so that in - # case pynvml is not installed, tests stages that don't need gpu - # info (e.g. cpus, eager) will proceed unaffected. Test stages - # that do require gpu info will fail here with an ImportError. - import pynvml # type: ignore[import] - - # Also a pynvml package is available on some platforms that won't - # have GPUs for some reason. In which case this init call will - # fail. - pynvml.nvmlInit() - except Exception: - return () - - num_gpus = pynvml.nvmlDeviceGetCount() - - results = [] - for i in range(num_gpus): - info = pynvml.nvmlDeviceGetMemoryInfo( - pynvml.nvmlDeviceGetHandleByIndex(i) - ) - results.append(GPUInfo(i, info.total)) - - return tuple(results) diff --git a/legate/utils/fs.py b/legate/utils/fs.py new file mode 100644 index 0000000000..ed13db278e --- /dev/null +++ b/legate/utils/fs.py @@ -0,0 +1,331 @@ +# Copyright 2021-2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from .types import LegatePaths, LegionPaths + +__all__ = ( + "get_legate_build_dir", + "get_legate_paths", + "get_legion_paths", + "read_c_define", + "read_cmake_cache_value", +) + + +def read_c_define(header_path: Path, name: str) -> str | None: + """Open a C header file and read the value of a #define + + Parameters + ---------- + header_path : Path + Location of the C header file to scan + + name : str + The name to search the header for + + Returns + ------- + str : value from the header or None, if it does not exist + + """ + try: + with open(header_path, "r") as f: + lines = (line for line in f if line.startswith("#define")) + for line in lines: + tokens = line.split(" ") + if tokens[1].strip() == name: + return tokens[2].strip() + except IOError: + pass + + return None + + +def read_cmake_cache_value(file_path: Path, pattern: str) -> str: + """Search a cmake cache file for a given pattern and return the associated + value. + + Parameters + ---------- + file_path: Path + Location of the cmake cache file to scan + + pattern : str + A pattern to seach for in the file + + Returns + ------- + str + + Raises + ------ + RuntimeError, if the value is not found + + """ + with open(file_path, encoding="utf-8") as f: + for line in f: + if re.match(pattern, line): + return line.strip().split("=")[1] + + raise RuntimeError(f"Could not find value for {pattern} in {file_path}") + + +def get_legate_build_dir(legate_dir: Path) -> Path | None: + """Determine the location of the Legate build directory. + + If the build directory cannot be found, None is returned. + + Parameters + ---------- + legate_dir : Path + Directory containing a Legate executable + + + Returns + Path or None + + """ + # If using a local non-scikit-build CMake build dir, read + # Legion_BINARY_DIR and Legion_SOURCE_DIR from CMakeCache.txt + legate_build_dir = legate_dir / "build" + cmake_cache_txt = legate_build_dir.joinpath("CMakeCache.txt") + if legate_build_dir.exists() and cmake_cache_txt.exists(): + return legate_build_dir + + skbuild_dir = legate_dir / "_skbuild" + if not skbuild_dir.exists(): + return None + + for f in skbuild_dir.iterdir(): + + # If using a local scikit-build dir at _skbuild//cmake-build, + # read Legion_BINARY_DIR and Legion_SOURCE_DIR from CMakeCache.txt + + legate_build_dir = skbuild_dir / f / "cmake-build" + cmake_cache_txt = legate_build_dir / "CMakeCache.txt" + + if legate_build_dir.exists() and cmake_cache_txt.exists(): + try: + # Test whether FIND_LEGATE_CORE_CPP is set to ON. If it + # isn't, then we built legate_core C++ as a side-effect of + # building legate_core_python. + read_cmake_cache_value( + cmake_cache_txt, "FIND_LEGATE_CORE_CPP:BOOL=OFF" + ) + except Exception: + # If FIND_LEGATE_CORE_CPP is set to ON, check to see if + # legate_core_DIR is a valid path. If it is, check whether + # legate_core_DIR is a path to a legate_core build dir i.e. + # `-D legate_core_ROOT=/legate.core/build` + legate_core_dir = Path( + read_cmake_cache_value( + cmake_cache_txt, "legate_core_DIR:PATH=" + ) + ) + + # If legate_core_dir doesn't have a CMakeCache.txt, CMake's + # find_package found a system legate_core installation. + # Return the installation paths. + cmake_cache_txt = legate_core_dir / "CMakeCache.txt" + if cmake_cache_txt.exists(): + return Path( + read_cmake_cache_value( + cmake_cache_txt, "legate_core_BINARY_DIR:STATIC=" + ) + ) + return None + + return legate_build_dir + + return None + + +def get_legate_paths() -> LegatePaths: + """Determine all the important runtime paths for Legate + + Returns + ------- + LegatePaths + + """ + import legate + + legate_dir = Path(legate.__path__[0]).parent + legate_build_dir = get_legate_build_dir(legate_dir) + + if legate_build_dir is None: + return LegatePaths( + legate_dir=legate_dir, + legate_build_dir=legate_build_dir, + bind_sh_path=Path(sys.argv[0]).parent / "bind.sh", + legate_lib_path=Path(sys.argv[0]).parents[1] / "lib", + ) + + cmake_cache_txt = legate_build_dir.joinpath("CMakeCache.txt") + + legate_source_dir = Path( + read_cmake_cache_value( + cmake_cache_txt, "legate_core_SOURCE_DIR:STATIC=" + ) + ) + + legate_binary_dir = Path( + read_cmake_cache_value( + cmake_cache_txt, "legate_core_BINARY_DIR:STATIC=" + ) + ) + + return LegatePaths( + legate_dir=legate_dir, + legate_build_dir=legate_build_dir, + bind_sh_path=legate_source_dir / "bind.sh", + legate_lib_path=legate_binary_dir / "lib", + ) + + +def get_legion_paths(legate_paths: LegatePaths) -> LegionPaths: + """Determine all the important runtime paths for Legion + + Parameters + ---------- + legate_paths : LegatePaths + Locations of Legate runtime paths + + Returns + ------- + LegionPaths + + """ + + # Construct and return paths needed to launch `legion_python`,accounting + # for multiple ways Legion and legate_core may be configured or installed. + # + # 1. Legion was found in a standard system location (/usr, $CONDA_PREFIX) + # 2. Legion was built as a side-effect of building legate_core: + # ``` + # SKBUILD_CONFIGURE_OPTIONS="" python -m pip install . + # ``` + # 3. Legion was built in a separate directory independent of legate_core + # and the path to its build directory was given when configuring + # legate_core: + # ``` + # SKBUILD_CONFIGURE_OPTIONS="-D Legion_ROOT=/legion/build" \ + # python -m pip install . + # ``` + # + # Additionally, legate_core has multiple run modes: + # + # 1. As an installed Python module (`python -m pip install .`) + # 2. As an "editable" install (`python -m pip install --editable .`) + # + # When determining locations of Legion and legate_core paths, prioritize + # local builds over global installations. This allows devs to work in the + # source tree and re-run without overwriting existing installations. + + def installed_legion_paths( + legion_dir: Path, legion_module: Path | None = None + ) -> LegionPaths: + if legion_module is None: + legion_lib_dir = legion_dir / "lib" + for f in legion_lib_dir.iterdir(): + if legion_lib_dir.joinpath(f / "site-packages").exists(): + legion_module = legion_lib_dir / f / "site-packages" + break + + legion_bin_path = legion_dir / "bin" + legion_include_path = legion_dir / "include" + + return LegionPaths( + legion_bin_path=legion_bin_path, + legion_lib_path=legion_lib_dir, + realm_defines_h=legion_include_path / "realm_defines.h", + legion_defines_h=legion_include_path / "legion_defines.h", + legion_spy_py=legion_bin_path / "legion_spy.py", + legion_prof_py=legion_bin_path / "legion_prof.py", + legion_python=legion_bin_path / "legion_python", + legion_module=legion_module, + legion_jupyter_module=legion_module, + ) + + raise RuntimeError("Could not determine legion paths") + + if (legate_build_dir := legate_paths.legate_build_dir) is None: + legate_build_dir = get_legate_build_dir(legate_paths.legate_dir) + + # If no local build dir found, assume legate installed into the python env + if legate_build_dir is None: + return installed_legion_paths(Path(sys.argv[0]).parents[1]) + + # If a legate build dir was found, read `Legion_SOURCE_DIR` and + # `Legion_BINARY_DIR` from in CMakeCache.txt, return paths into the source + # and build dirs. This allows devs to quickly rebuild inplace and use the + # most up-to-date versions without needing to install Legion and + # legate_core globally. + + cmake_cache_txt = legate_build_dir / "CMakeCache.txt" + + try: + # Test whether Legion_DIR is set. If it isn't, then we built Legion as + # a side-effect of building legate_core + read_cmake_cache_value( + cmake_cache_txt, "Legion_DIR:PATH=Legion_DIR-NOTFOUND" + ) + except Exception: + # If Legion_DIR is a valid path, check whether it's a + # Legion build dir, i.e. `-D Legion_ROOT=/legion/build` + legion_dir = Path( + read_cmake_cache_value(cmake_cache_txt, "Legion_DIR:PATH=") + ) + if legion_dir.joinpath("CMakeCache.txt").exists(): + cmake_cache_txt = legion_dir / "CMakeCache.txt" + + try: + # If Legion_SOURCE_DIR and Legion_BINARY_DIR are in CMakeCache.txt, + # return the paths to Legion in the legate_core build dir. + legion_source_dir = Path( + read_cmake_cache_value( + cmake_cache_txt, "Legion_SOURCE_DIR:STATIC=" + ) + ) + legion_binary_dir = Path( + read_cmake_cache_value( + cmake_cache_txt, "Legion_BINARY_DIR:STATIC=" + ) + ) + + legion_runtime_dir = legion_binary_dir / "runtime" + legion_bindings_dir = legion_source_dir / "bindings" + + return LegionPaths( + legion_bin_path=legion_binary_dir / "bin", + legion_lib_path=legion_binary_dir / "lib", + realm_defines_h=legion_runtime_dir / "realm_defines.h", + legion_defines_h=legion_runtime_dir / "legion_defines.h", + legion_spy_py=legion_source_dir / "tools" / "legion_spy.py", + legion_prof_py=legion_source_dir / "tools" / "legion_prof.py", + legion_python=legion_binary_dir / "bin" / "legion_python", + legion_module=legion_bindings_dir / "python" / "build" / "lib", + legion_jupyter_module=legion_source_dir / "jupyter_notebook", + ) + except Exception: + pass + + # Otherwise return the installation paths. + return installed_legion_paths(Path(sys.argv[0]).parents[1]) diff --git a/legate/driver/system.py b/legate/utils/system.py similarity index 51% rename from legate/driver/system.py rename to legate/utils/system.py index 57f9ec2268..702514cc27 100644 --- a/legate/driver/system.py +++ b/legate/utils/system.py @@ -14,11 +14,14 @@ # from __future__ import annotations +import multiprocessing import os import platform +import sys from functools import cached_property -from .util import LegatePaths, LegionPaths, get_legate_paths, get_legion_paths +from .fs import get_legate_paths, get_legion_paths +from .types import CPUInfo, GPUInfo, LegatePaths, LegionPaths __all__ = ("System",) @@ -78,3 +81,51 @@ def LIB_PATH(self) -> str: """ return "LD_LIBRARY_PATH" if self.os == "Linux" else "DYLD_LIBRARY_PATH" + + @cached_property + def cpus(self) -> tuple[CPUInfo, ...]: + """A list of CPUs on the system.""" + + N = multiprocessing.cpu_count() + + if sys.platform == "darwin": + return tuple(CPUInfo((i,)) for i in range(N)) + + sibling_sets: set[tuple[int, ...]] = set() + for i in range(N): + line = open( + f"/sys/devices/system/cpu/cpu{i}/topology/thread_siblings_list" + ).read() + sibling_sets.add( + tuple(sorted(int(x) for x in line.strip().split(","))) + ) + return tuple(CPUInfo(siblings) for siblings in sorted(sibling_sets)) + + @cached_property + def gpus(self) -> tuple[GPUInfo, ...]: + """A list of GPUs on the system, including total memory information.""" + + try: + # This pynvml import is protected inside this method so that in + # case pynvml is not installed, tests stages that don't need gpu + # info (e.g. cpus, eager) will proceed unaffected. Test stages + # that do require gpu info will fail here with an ImportError. + import pynvml # type: ignore[import] + + # Also a pynvml package is available on some platforms that won't + # have GPUs for some reason. In which case this init call will + # fail. + pynvml.nvmlInit() + except Exception: + return () + + num_gpus = pynvml.nvmlDeviceGetCount() + + results = [] + for i in range(num_gpus): + info = pynvml.nvmlDeviceGetMemoryInfo( + pynvml.nvmlDeviceGetHandleByIndex(i) + ) + results.append(GPUInfo(i, info.total)) + + return tuple(results) diff --git a/tests/unit/legate/driver/conftest.py b/tests/unit/legate/driver/conftest.py index b60488c351..8f7fe816a6 100644 --- a/tests/unit/legate/driver/conftest.py +++ b/tests/unit/legate/driver/conftest.py @@ -20,8 +20,9 @@ import pytest -from legate.driver import Config, Launcher, System +from legate.driver import Config, Launcher from legate.driver.config import MultiNode +from legate.utils.system import System from .util import GenConfig, GenSystem diff --git a/tests/unit/legate/driver/test_driver.py b/tests/unit/legate/driver/test_driver.py index 69cf159c53..a27efdb32d 100644 --- a/tests/unit/legate/driver/test_driver.py +++ b/tests/unit/legate/driver/test_driver.py @@ -23,9 +23,9 @@ from legate.driver.args import LAUNCHERS from legate.driver.command import CMD_PARTS from legate.driver.launcher import Launcher -from legate.driver.system import System from legate.driver.util import print_verbose from legate.utils.colors import scrub +from legate.utils.system import System from legate.utils.types import LauncherType from ...util import Capsys diff --git a/tests/unit/legate/driver/test_launcher.py b/tests/unit/legate/driver/test_launcher.py index 22bc9fb275..cd16c645bf 100644 --- a/tests/unit/legate/driver/test_launcher.py +++ b/tests/unit/legate/driver/test_launcher.py @@ -20,7 +20,7 @@ import legate.driver.launcher as m from legate.driver.args import LAUNCHERS -from legate.driver.system import System +from legate.utils.system import System from legate.utils.types import LauncherType from ...util import powerset_nonempty diff --git a/tests/unit/legate/driver/test_main.py b/tests/unit/legate/driver/test_main.py index 0784246a34..74a3589c3e 100644 --- a/tests/unit/legate/driver/test_main.py +++ b/tests/unit/legate/driver/test_main.py @@ -28,10 +28,10 @@ def test_main(mocker: MockerFixture) -> None: import legate.driver.config import legate.driver.driver - import legate.driver.system + import legate.utils.system config_spy = mocker.spy(legate.driver.config.Config, "__init__") - system_spy = mocker.spy(legate.driver.system.System, "__init__") + system_spy = mocker.spy(legate.utils.system.System, "__init__") driver_spy = mocker.spy(legate.driver.driver.Driver, "__init__") mocker.patch("legate.driver.driver.Driver.run", return_value=123) @@ -48,7 +48,7 @@ def test_main(mocker: MockerFixture) -> None: assert driver_spy.call_count == 1 assert len(driver_spy.call_args[0]) == 3 assert isinstance(driver_spy.call_args[0][1], legate.driver.config.Config) - assert isinstance(driver_spy.call_args[0][2], legate.driver.system.System) + assert isinstance(driver_spy.call_args[0][2], legate.utils.system.System) assert driver_spy.call_args[1] == {} assert result == 123 diff --git a/tests/unit/legate/driver/test_util.py b/tests/unit/legate/driver/test_util.py index 6d17800308..ce787e91a4 100644 --- a/tests/unit/legate/driver/test_util.py +++ b/tests/unit/legate/driver/test_util.py @@ -15,16 +15,13 @@ from __future__ import annotations from dataclasses import dataclass -from pathlib import Path from shlex import quote -import pytest - import legate.driver.util as m from legate.driver.config import Config from legate.driver.driver import Driver -from legate.driver.system import System from legate.utils.colors import scrub +from legate.utils.system import System from ...util import Capsys @@ -97,36 +94,3 @@ def test_system_and_driver(self, capsys: Capsys) -> None: assert f"{k}={driver.env[k]}" in out assert out.endswith(f"\n{'-':-<80}") - - -HEADER_PATH = Path(__file__).parent / "sample_header.h" - - -def test_read_c_define_hit() -> None: - assert m.read_c_define(HEADER_PATH, "FOO") == "10" - assert m.read_c_define(HEADER_PATH, "BAR") == '"bar"' - - -def test_read_c_define_miss() -> None: - assert m.read_c_define(HEADER_PATH, "JUNK") is None - - -CMAKE_CACHE_PATH = Path(__file__).parent / "sample_cmake_cache.txt" - - -def test_read_cmake_cache_value_hit() -> None: - assert ( - m.read_cmake_cache_value(CMAKE_CACHE_PATH, "Legion_SOURCE_DIR:STATIC=") - == '"foo/bar"' - ) - assert ( - m.read_cmake_cache_value( - CMAKE_CACHE_PATH, "FIND_LEGATE_CORE_CPP:BOOL=OFF" - ) - == "OFF" - ) - - -def test_read_cmake_cache_value_miss() -> None: - with pytest.raises(RuntimeError): - assert m.read_cmake_cache_value(CMAKE_CACHE_PATH, "JUNK") is None diff --git a/tests/unit/legate/tester/stages/__init__.py b/tests/unit/legate/tester/stages/__init__.py index d7eec02903..9028537bac 100644 --- a/tests/unit/legate/tester/stages/__init__.py +++ b/tests/unit/legate/tester/stages/__init__.py @@ -16,11 +16,11 @@ from typing import Any -from legate.tester.system import System +from legate.tester.test_system import TestSystem from legate.utils.types import CPUInfo, GPUInfo -class FakeSystem(System): +class FakeSystem(TestSystem): def __init__( self, cpus: int = 6, gpus: int = 6, fbmem: int = 6 << 32, **kwargs: Any ) -> None: diff --git a/tests/unit/legate/tester/stages/test_test_stage.py b/tests/unit/legate/tester/stages/test_test_stage.py index dec596452c..590f9d237e 100644 --- a/tests/unit/legate/tester/stages/test_test_stage.py +++ b/tests/unit/legate/tester/stages/test_test_stage.py @@ -24,7 +24,7 @@ from legate.tester.config import Config from legate.tester.stages import test_stage as m from legate.tester.stages.util import StageResult, StageSpec -from legate.tester.system import ProcessResult, System +from legate.tester.test_system import ProcessResult, TestSystem from . import FakeSystem @@ -39,10 +39,10 @@ class MockTestStage(m.TestStage): args = ["-foo", "-bar"] - def __init__(self, config: Config, system: System) -> None: + def __init__(self, config: Config, system: TestSystem) -> None: self._init(config, system) - def compute_spec(self, config: Config, system: System) -> StageSpec: + def compute_spec(self, config: Config, system: TestSystem) -> StageSpec: return StageSpec(2, [(0,), (1,), (2,)]) diff --git a/tests/unit/legate/tester/test_system.py b/tests/unit/legate/tester/test_test_system.py similarity index 78% rename from tests/unit/legate/tester/test_system.py rename to tests/unit/legate/tester/test_test_system.py index 99fd2afe3c..268a6a32fc 100644 --- a/tests/unit/legate/tester/test_system.py +++ b/tests/unit/legate/tester/test_test_system.py @@ -17,7 +17,6 @@ """ from __future__ import annotations -import sys from pathlib import Path from subprocess import CompletedProcess from unittest.mock import MagicMock @@ -25,7 +24,7 @@ import pytest from pytest_mock import MockerFixture -from legate.tester import system as m +from legate.tester import test_system as m @pytest.fixture @@ -38,11 +37,11 @@ def mock_subprocess_run(mocker: MockerFixture) -> MagicMock: class TestSystem: def test_init(self) -> None: - s = m.System() + s = m.TestSystem() assert s.dry_run is False def test_run(self, mock_subprocess_run: MagicMock) -> None: - s = m.System() + s = m.TestSystem() expected = m.ProcessResult( CMD, Path("test/file"), returncode=10, output="" @@ -57,22 +56,10 @@ def test_run(self, mock_subprocess_run: MagicMock) -> None: assert result == expected def test_dry_run(self, mock_subprocess_run: MagicMock) -> None: - s = m.System(dry_run=True) + s = m.TestSystem(dry_run=True) result = s.run(CMD.split(), Path("test/file")) mock_subprocess_run.assert_not_called() assert result.output == "" assert result.skipped - - def test_cpus(self) -> None: - s = m.System() - cpus = s.cpus - assert len(cpus) > 0 - assert all(len(cpu.ids) > 0 for cpu in cpus) - - @pytest.mark.skipif(sys.platform != "linux", reason="pynvml required") - def test_gpus(self) -> None: - s = m.System() - # can't really assume / test much here - s.gpus diff --git a/tests/unit/legate/driver/sample_cmake_cache.txt b/tests/unit/legate/utils/sample_cmake_cache.txt similarity index 100% rename from tests/unit/legate/driver/sample_cmake_cache.txt rename to tests/unit/legate/utils/sample_cmake_cache.txt diff --git a/tests/unit/legate/driver/sample_header.h b/tests/unit/legate/utils/sample_header.h similarity index 100% rename from tests/unit/legate/driver/sample_header.h rename to tests/unit/legate/utils/sample_header.h diff --git a/tests/unit/legate/utils/test_fs.py b/tests/unit/legate/utils/test_fs.py new file mode 100644 index 0000000000..93720b43a9 --- /dev/null +++ b/tests/unit/legate/utils/test_fs.py @@ -0,0 +1,53 @@ +# Copyright 2021-2022 NVIDIA Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations + +from pathlib import Path + +import pytest + +import legate.utils.fs as m + +HEADER_PATH = Path(__file__).parent / "sample_header.h" + + +def test_read_c_define_hit() -> None: + assert m.read_c_define(HEADER_PATH, "FOO") == "10" + assert m.read_c_define(HEADER_PATH, "BAR") == '"bar"' + + +def test_read_c_define_miss() -> None: + assert m.read_c_define(HEADER_PATH, "JUNK") is None + + +CMAKE_CACHE_PATH = Path(__file__).parent / "sample_cmake_cache.txt" + + +def test_read_cmake_cache_value_hit() -> None: + assert ( + m.read_cmake_cache_value(CMAKE_CACHE_PATH, "Legion_SOURCE_DIR:STATIC=") + == '"foo/bar"' + ) + assert ( + m.read_cmake_cache_value( + CMAKE_CACHE_PATH, "FIND_LEGATE_CORE_CPP:BOOL=OFF" + ) + == "OFF" + ) + + +def test_read_cmake_cache_value_miss() -> None: + with pytest.raises(RuntimeError): + assert m.read_cmake_cache_value(CMAKE_CACHE_PATH, "JUNK") is None diff --git a/tests/unit/legate/driver/test_system.py b/tests/unit/legate/utils/test_system.py similarity index 83% rename from tests/unit/legate/driver/test_system.py rename to tests/unit/legate/utils/test_system.py index a1b9054969..51aed4e587 100644 --- a/tests/unit/legate/driver/test_system.py +++ b/tests/unit/legate/utils/test_system.py @@ -15,11 +15,12 @@ from __future__ import annotations import os +import sys import pytest from pytest_mock import MockerFixture -import legate.driver.system as m +import legate.utils.system as m def test___all__() -> None: @@ -73,7 +74,7 @@ def test_LIBPATH_Darwin(self, mocker: MockerFixture) -> None: def test_legate_paths(self, mocker: MockerFixture) -> None: mocker.patch( - "legate.driver.system.get_legate_paths", + "legate.utils.system.get_legate_paths", return_value="legate paths", ) @@ -83,10 +84,22 @@ def test_legate_paths(self, mocker: MockerFixture) -> None: def test_legion_paths(self, mocker: MockerFixture) -> None: mocker.patch( - "legate.driver.system.get_legion_paths", + "legate.utils.system.get_legion_paths", return_value="legion paths", ) s = m.System() assert s.legion_paths == "legion paths" # type: ignore + + def test_cpus(self) -> None: + s = m.System() + cpus = s.cpus + assert len(cpus) > 0 + assert all(len(cpu.ids) > 0 for cpu in cpus) + + @pytest.mark.skipif(sys.platform != "linux", reason="pynvml required") + def test_gpus(self) -> None: + s = m.System() + # can't really assume / test much here + s.gpus From 7f08965f89cbd8169e4ee67e60e5a65b9e8023bc Mon Sep 17 00:00:00 2001 From: Bryan Van de Ven Date: Wed, 28 Sep 2022 11:36:59 -0700 Subject: [PATCH 09/16] get rid of driver.util --- legate/driver/config.py | 8 +- legate/driver/driver.py | 60 +++++++++++++- legate/driver/main.py | 2 +- legate/driver/util.py | 103 ------------------------ legate/utils/types.py | 27 ++++++- tests/unit/legate/driver/test_driver.py | 50 +++++++++++- tests/unit/legate/driver/test_util.py | 96 ---------------------- tests/unit/legate/utils/test_types.py | 27 +++++++ 8 files changed, 164 insertions(+), 209 deletions(-) delete mode 100644 legate/driver/util.py delete mode 100644 tests/unit/legate/driver/test_util.py diff --git a/legate/driver/config.py b/legate/driver/config.py index c12aab2797..30afc95a83 100644 --- a/legate/driver/config.py +++ b/legate/driver/config.py @@ -23,10 +23,14 @@ from pathlib import Path from typing import Any -from ..utils.types import ArgList, DataclassMixin, LauncherType +from ..utils.types import ( + ArgList, + DataclassMixin, + LauncherType, + object_to_dataclass, +) from ..utils.ui import warn from .args import parser -from .util import object_to_dataclass __all__ = ("Config",) diff --git a/legate/driver/driver.py b/legate/driver/driver.py index ccb1ce0cc4..82355fc954 100644 --- a/legate/driver/driver.py +++ b/legate/driver/driver.py @@ -14,18 +14,22 @@ # from __future__ import annotations +from shlex import quote from subprocess import run +from textwrap import indent +from typing import TYPE_CHECKING from ..utils.system import System -from ..utils.types import Command, EnvDict -from ..utils.ui import warn +from ..utils.ui import kvtable, rule, section, value, warn from .command import CMD_PARTS from .config import Config from .launcher import Launcher from .logs import process_logs -from .util import print_verbose -__all__ = ("Driver",) +if TYPE_CHECKING: + from ..utils.types import Command, EnvDict + +__all__ = ("Driver", "print_verbose") _DARWIN_GDB_WARN = """\ You must start the debugging session with the following command, @@ -111,3 +115,51 @@ def _darwin_gdb_warn(self) -> None: ) ) ) + + +def print_verbose( + system: System, + driver: Driver | None = None, +) -> None: + """Print system and driver configuration values. + + Parameters + ---------- + system : System + A System instance to obtain Legate and Legion paths from + + driver : Driver or None, optional + If not None, a Driver instance to obtain command invocation and + environment from (default: None) + + Returns + ------- + None + + """ + + print(f"\n{rule('Legion Python Configuration')}") + + print(section("\nLegate paths:")) + print(indent(str(system.legate_paths), prefix=" ")) + + print(section("\nLegion paths:")) + print(indent(str(system.legion_paths), prefix=" ")) + + if driver: + print(section("\nCommand:")) + cmd = " ".join(quote(t) for t in driver.cmd) + print(f" {value(cmd)}") + + if keys := sorted(driver.custom_env_vars): + print(section("\nCustomized Environment:")) + print( + indent( + kvtable(driver.env, delim="=", align=False, keys=keys), + prefix=" ", + ) + ) + + print(f"\n{rule()}") + + print(flush=True) diff --git a/legate/driver/main.py b/legate/driver/main.py index 2dd55eb588..7aedaad8c5 100644 --- a/legate/driver/main.py +++ b/legate/driver/main.py @@ -37,7 +37,7 @@ def main(argv: list[str]) -> int: from ..utils.system import System from ..utils.ui import error from . import Config, Driver - from .util import print_verbose + from .driver import print_verbose try: config = Config(argv) diff --git a/legate/driver/util.py b/legate/driver/util.py deleted file mode 100644 index 2dd052808e..0000000000 --- a/legate/driver/util.py +++ /dev/null @@ -1,103 +0,0 @@ -# Copyright 2021-2022 NVIDIA Corporation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -from __future__ import annotations - -from shlex import quote -from textwrap import indent -from typing import TYPE_CHECKING, Type, TypeVar - -from ..utils.types import DataclassProtocol -from ..utils.ui import kvtable, rule, section, value - -if TYPE_CHECKING: - from ..utils.system import System - from .driver import Driver - -__all__ = ( - "object_to_dataclass", - "print_verbose", -) - - -T = TypeVar("T", bound=DataclassProtocol) - - -def object_to_dataclass(obj: object, typ: Type[T]) -> T: - """Automatically generate a dataclass from an object with appropriate - attributes. - - Parameters - ---------- - obj: object - An object to pull values from (e.g. an argparse Namespace) - - typ: - A dataclass type to generate from ``obj`` - - Returns - ------- - The generated dataclass instance - - """ - kws = {name: getattr(obj, name) for name in typ.__dataclass_fields__} - return typ(**kws) - - -def print_verbose( - system: System, - driver: Driver | None = None, -) -> None: - """Print system and driver configuration values. - - Parameters - ---------- - system : System - A System instance to obtain Legate and Legion paths from - - driver : Driver or None, optional - If not None, a Driver instance to obtain command invocation and - environment from (default: None) - - Returns - ------- - None - - """ - - print(f"\n{rule('Legion Python Configuration')}") - - print(section("\nLegate paths:")) - print(indent(str(system.legate_paths), prefix=" ")) - - print(section("\nLegion paths:")) - print(indent(str(system.legion_paths), prefix=" ")) - - if driver: - print(section("\nCommand:")) - cmd = " ".join(quote(t) for t in driver.cmd) - print(f" {value(cmd)}") - - if keys := sorted(driver.custom_env_vars): - print(section("\nCustomized Environment:")) - print( - indent( - kvtable(driver.env, delim="=", align=False, keys=keys), - prefix=" ", - ) - ) - - print(f"\n{rule()}") - - print(flush=True) diff --git a/legate/utils/types.py b/legate/utils/types.py index fdda7aa2a8..2a81663739 100644 --- a/legate/utils/types.py +++ b/legate/utils/types.py @@ -19,7 +19,7 @@ from dataclasses import Field, dataclass from pathlib import Path -from typing import Any, Dict, List, Protocol, Tuple, Union +from typing import Any, Dict, List, Protocol, Tuple, Type, TypeVar, Union from typing_extensions import Literal, TypeAlias @@ -37,6 +37,7 @@ "LauncherType", "LegatePaths", "LegionPaths", + "object_to_dataclass", ) @@ -95,6 +96,30 @@ def __str__(self) -> str: return kvtable(self.__dict__) +T = TypeVar("T", bound=DataclassProtocol) + + +def object_to_dataclass(obj: object, typ: Type[T]) -> T: + """Automatically generate a dataclass from an object with appropriate + attributes. + + Parameters + ---------- + obj: object + An object to pull values from (e.g. an argparse Namespace) + + typ: + A dataclass type to generate from ``obj`` + + Returns + ------- + The generated dataclass instance + + """ + kws = {name: getattr(obj, name) for name in typ.__dataclass_fields__} + return typ(**kws) + + @dataclass(frozen=True) class LegatePaths(DataclassMixin): """Collect all the filesystem paths relevant for Legate.""" diff --git a/tests/unit/legate/driver/test_driver.py b/tests/unit/legate/driver/test_driver.py index a27efdb32d..277f14b4bf 100644 --- a/tests/unit/legate/driver/test_driver.py +++ b/tests/unit/legate/driver/test_driver.py @@ -15,6 +15,7 @@ from __future__ import annotations import re +from shlex import quote import pytest from pytest_mock import MockerFixture @@ -22,8 +23,8 @@ import legate.driver.driver as m from legate.driver.args import LAUNCHERS from legate.driver.command import CMD_PARTS +from legate.driver.config import Config from legate.driver.launcher import Launcher -from legate.driver.util import print_verbose from legate.utils.colors import scrub from legate.utils.system import System from legate.utils.types import LauncherType @@ -125,7 +126,7 @@ def test_verbose( run_out = scrub(capsys.readouterr()[0]).strip() - print_verbose(driver.system, driver) + m.print_verbose(driver.system, driver) pv_out = scrub(capsys.readouterr()[0]).strip() @@ -153,3 +154,48 @@ def test_darwin_gdb_warning( out, _ = capsys.readouterr() assert re.search(DARWIN_GDB_WARN_EXPECTED_PAT, scrub(out)) + + +class Test_print_verbose: + def test_system_only(self, capsys: Capsys) -> None: + system = System() + + m.print_verbose(system) + + out = scrub(capsys.readouterr()[0]).strip() + + assert out.startswith(f"{'--- Legion Python Configuration ':-<80}") + assert "Legate paths:" in out + for line in scrub(str(system.legate_paths)).split(): + assert line in out + + assert "Legion paths:" in out + for line in scrub(str(system.legion_paths)).split(): + assert line in out + + def test_system_and_driver(self, capsys: Capsys) -> None: + config = Config(["legate", "--no-replicate"]) + system = System() + driver = m.Driver(config, system) + + m.print_verbose(system, driver) + + out = scrub(capsys.readouterr()[0]).strip() + + assert out.startswith(f"{'--- Legion Python Configuration ':-<80}") + assert "Legate paths:" in out + for line in scrub(str(system.legate_paths)).split(): + assert line in out + + assert "Legion paths:" in out + for line in scrub(str(system.legion_paths)).split(): + assert line in out + + assert "Command:" in out + assert f" {' '.join(quote(t) for t in driver.cmd)}" in out + + assert "Customized Environment:" in out + for k in driver.custom_env_vars: + assert f"{k}={driver.env[k]}" in out + + assert out.endswith(f"\n{'-':-<80}") diff --git a/tests/unit/legate/driver/test_util.py b/tests/unit/legate/driver/test_util.py deleted file mode 100644 index ce787e91a4..0000000000 --- a/tests/unit/legate/driver/test_util.py +++ /dev/null @@ -1,96 +0,0 @@ -# Copyright 2021-2022 NVIDIA Corporation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -from __future__ import annotations - -from dataclasses import dataclass -from shlex import quote - -import legate.driver.util as m -from legate.driver.config import Config -from legate.driver.driver import Driver -from legate.utils.colors import scrub -from legate.utils.system import System - -from ...util import Capsys - - -class Source: - foo = 10 - bar = 10.2 - baz = "test" - quux = ["a", "b", "c"] - extra = (1, 2, 3) - - -@dataclass(frozen=True) -class Target: - foo: int - bar: float - baz: str - quux: list[str] - - -def test_object_to_dataclass() -> None: - source = Source() - target = m.object_to_dataclass(source, Target) - - assert set(target.__dict__) == set(Target.__dataclass_fields__) - for k, v in target.__dict__.items(): - assert getattr(source, k) == v - - -class Test_print_verbose: - def test_system_only(self, capsys: Capsys) -> None: - system = System() - - m.print_verbose(system) - - out = scrub(capsys.readouterr()[0]).strip() - - assert out.startswith(f"{'--- Legion Python Configuration ':-<80}") - assert "Legate paths:" in out - for line in scrub(str(system.legate_paths)).split(): - assert line in out - - assert "Legion paths:" in out - for line in scrub(str(system.legion_paths)).split(): - assert line in out - - def test_system_and_driver(self, capsys: Capsys) -> None: - config = Config(["legate", "--no-replicate"]) - system = System() - driver = Driver(config, system) - - m.print_verbose(system, driver) - - out = scrub(capsys.readouterr()[0]).strip() - - assert out.startswith(f"{'--- Legion Python Configuration ':-<80}") - assert "Legate paths:" in out - for line in scrub(str(system.legate_paths)).split(): - assert line in out - - assert "Legion paths:" in out - for line in scrub(str(system.legion_paths)).split(): - assert line in out - - assert "Command:" in out - assert f" {' '.join(quote(t) for t in driver.cmd)}" in out - - assert "Customized Environment:" in out - for k in driver.custom_env_vars: - assert f"{k}={driver.env[k]}" in out - - assert out.endswith(f"\n{'-':-<80}") diff --git a/tests/unit/legate/utils/test_types.py b/tests/unit/legate/utils/test_types.py index 77ff5a3c00..fbe65ff04e 100644 --- a/tests/unit/legate/utils/test_types.py +++ b/tests/unit/legate/utils/test_types.py @@ -17,6 +17,8 @@ """ from __future__ import annotations +from dataclasses import dataclass + import legate.utils.types as m @@ -28,3 +30,28 @@ def test_fields(self) -> None: class TestGPUInfo: def test_fields(self) -> None: assert set(m.GPUInfo.__dataclass_fields__) == {"id", "total"} + + +class Source: + foo = 10 + bar = 10.2 + baz = "test" + quux = ["a", "b", "c"] + extra = (1, 2, 3) + + +@dataclass(frozen=True) +class Target: + foo: int + bar: float + baz: str + quux: list[str] + + +def test_object_to_dataclass() -> None: + source = Source() + target = m.object_to_dataclass(source, Target) + + assert set(target.__dict__) == set(Target.__dataclass_fields__) + for k, v in target.__dict__.items(): + assert getattr(source, k) == v From cd36a5d6b79fe73c4faf15fee802012b77c7d32b Mon Sep 17 00:00:00 2001 From: Bryan Van de Ven Date: Wed, 28 Sep 2022 13:20:24 -0700 Subject: [PATCH 10/16] temp compat imports --- legate/rc.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/legate/rc.py b/legate/rc.py index 437eac1462..5488cee05e 100644 --- a/legate/rc.py +++ b/legate/rc.py @@ -27,6 +27,13 @@ legion_python directly. """ +# TODO (bv) temp transitive imports until cunumeric is updated +from .utils.args import ( # noqa + ArgSpec, + Argument, + parse_library_command_args as parse_command_args, +) + def has_legion_context() -> bool: """Determine whether we are running in legion_python. From 7945efc0926776569c7d5a46dfb4973abab3936a Mon Sep 17 00:00:00 2001 From: Bryan Van de Ven Date: Wed, 28 Sep 2022 14:39:13 -0700 Subject: [PATCH 11/16] probable fix for https://github.com/nv-legate/legate.core/issues/393 --- legate/utils/fs.py | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/legate/utils/fs.py b/legate/utils/fs.py index ed13db278e..0653398bf2 100644 --- a/legate/utils/fs.py +++ b/legate/utils/fs.py @@ -245,26 +245,24 @@ def installed_legion_paths( if legion_module is None: legion_lib_dir = legion_dir / "lib" for f in legion_lib_dir.iterdir(): - if legion_lib_dir.joinpath(f / "site-packages").exists(): - legion_module = legion_lib_dir / f / "site-packages" + if f.joinpath("site-packages").exists(): + legion_module = f / "site-packages" break - legion_bin_path = legion_dir / "bin" - legion_include_path = legion_dir / "include" - - return LegionPaths( - legion_bin_path=legion_bin_path, - legion_lib_path=legion_lib_dir, - realm_defines_h=legion_include_path / "realm_defines.h", - legion_defines_h=legion_include_path / "legion_defines.h", - legion_spy_py=legion_bin_path / "legion_spy.py", - legion_prof_py=legion_bin_path / "legion_prof.py", - legion_python=legion_bin_path / "legion_python", - legion_module=legion_module, - legion_jupyter_module=legion_module, - ) + legion_bin_path = legion_dir / "bin" + legion_include_path = legion_dir / "include" - raise RuntimeError("Could not determine legion paths") + return LegionPaths( + legion_bin_path=legion_bin_path, + legion_lib_path=legion_lib_dir, + realm_defines_h=legion_include_path / "realm_defines.h", + legion_defines_h=legion_include_path / "legion_defines.h", + legion_spy_py=legion_bin_path / "legion_spy.py", + legion_prof_py=legion_bin_path / "legion_prof.py", + legion_python=legion_bin_path / "legion_python", + legion_module=legion_module, + legion_jupyter_module=legion_module, + ) if (legate_build_dir := legate_paths.legate_build_dir) is None: legate_build_dir = get_legate_build_dir(legate_paths.legate_dir) From d8d19155a56df2101fab5f276036eb27317e7dcd Mon Sep 17 00:00:00 2001 From: Bryan Van de Ven Date: Wed, 28 Sep 2022 14:55:35 -0700 Subject: [PATCH 12/16] bail if legate_module cannot be determined --- legate/utils/fs.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/legate/utils/fs.py b/legate/utils/fs.py index 0653398bf2..e05e15279c 100644 --- a/legate/utils/fs.py +++ b/legate/utils/fs.py @@ -239,15 +239,16 @@ def get_legion_paths(legate_paths: LegatePaths) -> LegionPaths: # local builds over global installations. This allows devs to work in the # source tree and re-run without overwriting existing installations. - def installed_legion_paths( - legion_dir: Path, legion_module: Path | None = None - ) -> LegionPaths: - if legion_module is None: - legion_lib_dir = legion_dir / "lib" - for f in legion_lib_dir.iterdir(): - if f.joinpath("site-packages").exists(): - legion_module = f / "site-packages" - break + def installed_legion_paths(legion_dir: Path) -> LegionPaths: + legion_lib_dir = legion_dir / "lib" + for f in legion_lib_dir.iterdir(): + legion_module = f / "site-packages" + if legion_module.exists(): + break + + # NB: for-else clause! (executes if NO loop break) + else: + raise RuntimeError("could not determine legion module location") legion_bin_path = legion_dir / "bin" legion_include_path = legion_dir / "include" From 18b9138cd94862e1ab86cc0f376b8af278e6909f Mon Sep 17 00:00:00 2001 From: Bryan Van de Ven Date: Wed, 28 Sep 2022 15:14:21 -0700 Subject: [PATCH 13/16] use singular util --- legate/core/__init__.py | 2 +- legate/core/runtime.py | 2 +- legate/driver/args.py | 2 +- legate/driver/command.py | 6 +++--- legate/driver/config.py | 4 ++-- legate/driver/driver.py | 6 +++--- legate/driver/launcher.py | 8 ++++---- legate/driver/logs.py | 6 +++--- legate/driver/main.py | 4 ++-- legate/rc.py | 2 +- legate/tester/config.py | 2 +- legate/tester/stages/_linux/cpu.py | 2 +- legate/tester/stages/_linux/eager.py | 2 +- legate/tester/stages/_linux/gpu.py | 2 +- legate/tester/stages/_linux/omp.py | 2 +- legate/tester/stages/_osx/cpu.py | 2 +- legate/tester/stages/_osx/eager.py | 2 +- legate/tester/stages/_osx/gpu.py | 2 +- legate/tester/stages/_osx/omp.py | 2 +- legate/tester/stages/test_stage.py | 6 +++--- legate/tester/stages/util.py | 2 +- legate/tester/test_plan.py | 4 ++-- legate/tester/test_system.py | 4 ++-- legate/{utils => util}/__init__.py | 0 legate/{utils => util}/args.py | 0 legate/{utils => util}/colors.py | 0 legate/{utils => util}/fs.py | 0 legate/{utils => util}/system.py | 0 legate/{utils => util}/types.py | 0 legate/{utils => util}/ui.py | 0 tests/unit/legate/driver/conftest.py | 2 +- tests/unit/legate/driver/test_command.py | 4 ++-- tests/unit/legate/driver/test_config.py | 4 ++-- tests/unit/legate/driver/test_driver.py | 6 +++--- tests/unit/legate/driver/test_launcher.py | 4 ++-- tests/unit/legate/driver/test_logs.py | 2 +- tests/unit/legate/driver/test_main.py | 6 +++--- tests/unit/legate/tester/stages/__init__.py | 2 +- tests/unit/legate/{utils => util}/__init__.py | 0 tests/unit/legate/{utils => util}/sample_cmake_cache.txt | 0 tests/unit/legate/{utils => util}/sample_header.h | 0 tests/unit/legate/{utils => util}/test_args.py | 2 +- tests/unit/legate/{utils => util}/test_colors.py | 2 +- tests/unit/legate/{utils => util}/test_fs.py | 2 +- tests/unit/legate/{utils => util}/test_system.py | 6 +++--- tests/unit/legate/{utils => util}/test_types.py | 2 +- tests/unit/legate/{utils => util}/test_ui.py | 2 +- 47 files changed, 61 insertions(+), 61 deletions(-) rename legate/{utils => util}/__init__.py (100%) rename legate/{utils => util}/args.py (100%) rename legate/{utils => util}/colors.py (100%) rename legate/{utils => util}/fs.py (100%) rename legate/{utils => util}/system.py (100%) rename legate/{utils => util}/types.py (100%) rename legate/{utils => util}/ui.py (100%) rename tests/unit/legate/{utils => util}/__init__.py (100%) rename tests/unit/legate/{utils => util}/sample_cmake_cache.txt (100%) rename tests/unit/legate/{utils => util}/sample_header.h (100%) rename tests/unit/legate/{utils => util}/test_args.py (99%) rename tests/unit/legate/{utils => util}/test_colors.py (98%) rename tests/unit/legate/{utils => util}/test_fs.py (98%) rename tests/unit/legate/{utils => util}/test_system.py (95%) rename tests/unit/legate/{utils => util}/test_types.py (97%) rename tests/unit/legate/{utils => util}/test_ui.py (99%) diff --git a/legate/core/__init__.py b/legate/core/__init__.py index 19200d71e9..8a6beee0ae 100644 --- a/legate/core/__init__.py +++ b/legate/core/__init__.py @@ -15,7 +15,7 @@ from __future__ import annotations from ..rc import check_legion -from ..utils.args import parse_library_command_args +from ..util.args import parse_library_command_args check_legion() diff --git a/legate/core/runtime.py b/legate/core/runtime.py index 08a9c2be4e..3c6562921a 100644 --- a/legate/core/runtime.py +++ b/legate/core/runtime.py @@ -24,7 +24,7 @@ from legion_top import add_cleanup_item, top_level -from ..utils.args import ArgSpec, Argument, parse_library_command_args +from ..util.args import ArgSpec, Argument, parse_library_command_args from . import ffi # Make sure we only have one ffi instance from . import ( Fence, diff --git a/legate/driver/args.py b/legate/driver/args.py index 9680fe45ef..7397221709 100755 --- a/legate/driver/args.py +++ b/legate/driver/args.py @@ -18,7 +18,7 @@ from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser -from ..utils.types import LauncherType +from ..util.types import LauncherType from . import defaults __all__ = ("parser",) diff --git a/legate/driver/command.py b/legate/driver/command.py index fef37a44d3..7d11c2c9b9 100644 --- a/legate/driver/command.py +++ b/legate/driver/command.py @@ -16,11 +16,11 @@ from typing import TYPE_CHECKING -from ..utils.ui import warn +from ..util.ui import warn if TYPE_CHECKING: - from ..utils.system import System - from ..utils.types import CommandPart + from ..util.system import System + from ..util.types import CommandPart from .config import Config from .launcher import Launcher diff --git a/legate/driver/config.py b/legate/driver/config.py index 30afc95a83..c4acb3c412 100644 --- a/legate/driver/config.py +++ b/legate/driver/config.py @@ -23,13 +23,13 @@ from pathlib import Path from typing import Any -from ..utils.types import ( +from ..util.types import ( ArgList, DataclassMixin, LauncherType, object_to_dataclass, ) -from ..utils.ui import warn +from ..util.ui import warn from .args import parser __all__ = ("Config",) diff --git a/legate/driver/driver.py b/legate/driver/driver.py index 82355fc954..e2ad0463e9 100644 --- a/legate/driver/driver.py +++ b/legate/driver/driver.py @@ -19,15 +19,15 @@ from textwrap import indent from typing import TYPE_CHECKING -from ..utils.system import System -from ..utils.ui import kvtable, rule, section, value, warn +from ..util.system import System +from ..util.ui import kvtable, rule, section, value, warn from .command import CMD_PARTS from .config import Config from .launcher import Launcher from .logs import process_logs if TYPE_CHECKING: - from ..utils.types import Command, EnvDict + from ..util.types import Command, EnvDict __all__ = ("Driver", "print_verbose") diff --git a/legate/driver/launcher.py b/legate/driver/launcher.py index 4e6e82ee22..009b0cf6bb 100644 --- a/legate/driver/launcher.py +++ b/legate/driver/launcher.py @@ -19,12 +19,12 @@ from pathlib import Path from typing import TYPE_CHECKING -from ..utils.fs import read_c_define -from ..utils.ui import warn +from ..util.fs import read_c_define +from ..util.ui import warn if TYPE_CHECKING: - from ..utils.system import System - from ..utils.types import Command, EnvDict, LauncherType + from ..util.system import System + from ..util.types import Command, EnvDict, LauncherType from .config import Config __all__ = ("Launcher",) diff --git a/legate/driver/logs.py b/legate/driver/logs.py index 65cf4989b9..261ab6dd5e 100644 --- a/legate/driver/logs.py +++ b/legate/driver/logs.py @@ -24,11 +24,11 @@ from subprocess import run from typing import TYPE_CHECKING, Iterator -from ..utils.ui import warn +from ..util.ui import warn if TYPE_CHECKING: - from ..utils.system import System - from ..utils.types import Command + from ..util.system import System + from ..util.types import Command from .config import Config from .launcher import Launcher diff --git a/legate/driver/main.py b/legate/driver/main.py index 7aedaad8c5..2ca3f04bed 100644 --- a/legate/driver/main.py +++ b/legate/driver/main.py @@ -34,8 +34,8 @@ def main(argv: list[str]) -> int: int, a process return code """ - from ..utils.system import System - from ..utils.ui import error + from ..util.system import System + from ..util.ui import error from . import Config, Driver from .driver import print_verbose diff --git a/legate/rc.py b/legate/rc.py index 5488cee05e..bd4abca515 100644 --- a/legate/rc.py +++ b/legate/rc.py @@ -28,7 +28,7 @@ """ # TODO (bv) temp transitive imports until cunumeric is updated -from .utils.args import ( # noqa +from .util.args import ( # noqa ArgSpec, Argument, parse_library_command_args as parse_command_args, diff --git a/legate/tester/config.py b/legate/tester/config.py index 4d143fa37a..6f3581d2eb 100644 --- a/legate/tester/config.py +++ b/legate/tester/config.py @@ -21,7 +21,7 @@ from argparse import Namespace from pathlib import Path -from ..utils.types import ArgList, EnvDict +from ..util.types import ArgList, EnvDict from . import DEFAULT_PROCESS_ENV, FEATURES, SKIPPED_EXAMPLES, FeatureType from .args import parser diff --git a/legate/tester/stages/_linux/cpu.py b/legate/tester/stages/_linux/cpu.py index f866df90de..deb5610a6b 100644 --- a/legate/tester/stages/_linux/cpu.py +++ b/legate/tester/stages/_linux/cpu.py @@ -27,7 +27,7 @@ ) if TYPE_CHECKING: - from ....utils.types import ArgList, EnvDict + from ....util.types import ArgList, EnvDict from ... import FeatureType from ...config import Config from ...test_system import TestSystem diff --git a/legate/tester/stages/_linux/eager.py b/legate/tester/stages/_linux/eager.py index f7734c91a9..cc9a08d5a4 100644 --- a/legate/tester/stages/_linux/eager.py +++ b/legate/tester/stages/_linux/eager.py @@ -20,7 +20,7 @@ from ..util import Shard, StageSpec, adjust_workers if TYPE_CHECKING: - from ....utils.types import ArgList, EnvDict + from ....util.types import ArgList, EnvDict from ... import FeatureType from ...config import Config from ...test_system import TestSystem diff --git a/legate/tester/stages/_linux/gpu.py b/legate/tester/stages/_linux/gpu.py index 6dab34274a..f1a222fc0f 100644 --- a/legate/tester/stages/_linux/gpu.py +++ b/legate/tester/stages/_linux/gpu.py @@ -21,7 +21,7 @@ from ..util import CUNUMERIC_TEST_ARG, Shard, StageSpec, adjust_workers if TYPE_CHECKING: - from ....utils.types import ArgList, EnvDict + from ....util.types import ArgList, EnvDict from ... import FeatureType from ...config import Config from ...test_system import TestSystem diff --git a/legate/tester/stages/_linux/omp.py b/legate/tester/stages/_linux/omp.py index f172a87ae4..f7af3e9d0f 100644 --- a/legate/tester/stages/_linux/omp.py +++ b/legate/tester/stages/_linux/omp.py @@ -27,7 +27,7 @@ ) if TYPE_CHECKING: - from ....utils.types import ArgList, EnvDict + from ....util.types import ArgList, EnvDict from ... import FeatureType from ...config import Config from ...test_system import TestSystem diff --git a/legate/tester/stages/_osx/cpu.py b/legate/tester/stages/_osx/cpu.py index e976ac3835..182a6d76b0 100644 --- a/legate/tester/stages/_osx/cpu.py +++ b/legate/tester/stages/_osx/cpu.py @@ -26,7 +26,7 @@ ) if TYPE_CHECKING: - from ....utils.types import ArgList, EnvDict + from ....util.types import ArgList, EnvDict from ... import FeatureType from ...config import Config from ...test_system import TestSystem diff --git a/legate/tester/stages/_osx/eager.py b/legate/tester/stages/_osx/eager.py index 6db752f20d..b32feb17db 100644 --- a/legate/tester/stages/_osx/eager.py +++ b/legate/tester/stages/_osx/eager.py @@ -20,7 +20,7 @@ from ..util import UNPIN_ENV, Shard, StageSpec, adjust_workers if TYPE_CHECKING: - from ....utils.types import ArgList, EnvDict + from ....util.types import ArgList, EnvDict from ... import FeatureType from ...config import Config from ...test_system import TestSystem diff --git a/legate/tester/stages/_osx/gpu.py b/legate/tester/stages/_osx/gpu.py index 6779bc254c..2a15974942 100644 --- a/legate/tester/stages/_osx/gpu.py +++ b/legate/tester/stages/_osx/gpu.py @@ -21,7 +21,7 @@ from ..util import CUNUMERIC_TEST_ARG, UNPIN_ENV, Shard if TYPE_CHECKING: - from ....utils.types import ArgList, EnvDict + from ....util.types import ArgList, EnvDict from ... import FeatureType from ...config import Config from ...test_system import TestSystem diff --git a/legate/tester/stages/_osx/omp.py b/legate/tester/stages/_osx/omp.py index 21f3fafadb..eb279791ac 100644 --- a/legate/tester/stages/_osx/omp.py +++ b/legate/tester/stages/_osx/omp.py @@ -26,7 +26,7 @@ ) if TYPE_CHECKING: - from ....utils.types import ArgList, EnvDict + from ....util.types import ArgList, EnvDict from ... import FeatureType from ...config import Config from ...test_system import TestSystem diff --git a/legate/tester/stages/test_stage.py b/legate/tester/stages/test_stage.py index b98f6a13b9..c21fdd6307 100644 --- a/legate/tester/stages/test_stage.py +++ b/legate/tester/stages/test_stage.py @@ -20,9 +20,9 @@ from typing_extensions import Protocol -from ...utils.colors import yellow -from ...utils.types import ArgList, EnvDict -from ...utils.ui import banner, summary +from ...util.colors import yellow +from ...util.types import ArgList, EnvDict +from ...util.ui import banner, summary from .. import PER_FILE_ARGS, FeatureType from ..config import Config from ..test_system import ProcessResult, TestSystem diff --git a/legate/tester/stages/util.py b/legate/tester/stages/util.py index 8633a72645..2d65148775 100644 --- a/legate/tester/stages/util.py +++ b/legate/tester/stages/util.py @@ -20,7 +20,7 @@ from typing_extensions import TypeAlias -from ...utils.ui import failed, passed, shell, skipped +from ...util.ui import failed, passed, shell, skipped from ..config import Config from ..logger import LOG from ..test_system import ProcessResult diff --git a/legate/tester/test_plan.py b/legate/tester/test_plan.py index aef117bf57..cc877f7a47 100644 --- a/legate/tester/test_plan.py +++ b/legate/tester/test_plan.py @@ -20,8 +20,8 @@ from datetime import timedelta from itertools import chain -from ..utils.colors import yellow -from ..utils.ui import banner, rule, summary +from ..util.colors import yellow +from ..util.ui import banner, rule, summary from .config import Config from .logger import LOG from .stages import STAGES, log_proc diff --git a/legate/tester/test_system.py b/legate/tester/test_system.py index 2978a8ad8c..2c4e9949ff 100644 --- a/legate/tester/test_system.py +++ b/legate/tester/test_system.py @@ -25,8 +25,8 @@ from subprocess import PIPE, STDOUT, run as stdlib_run from typing import Sequence -from ..utils.system import System -from ..utils.types import EnvDict +from ..util.system import System +from ..util.types import EnvDict __all__ = ("TestSystem",) diff --git a/legate/utils/__init__.py b/legate/util/__init__.py similarity index 100% rename from legate/utils/__init__.py rename to legate/util/__init__.py diff --git a/legate/utils/args.py b/legate/util/args.py similarity index 100% rename from legate/utils/args.py rename to legate/util/args.py diff --git a/legate/utils/colors.py b/legate/util/colors.py similarity index 100% rename from legate/utils/colors.py rename to legate/util/colors.py diff --git a/legate/utils/fs.py b/legate/util/fs.py similarity index 100% rename from legate/utils/fs.py rename to legate/util/fs.py diff --git a/legate/utils/system.py b/legate/util/system.py similarity index 100% rename from legate/utils/system.py rename to legate/util/system.py diff --git a/legate/utils/types.py b/legate/util/types.py similarity index 100% rename from legate/utils/types.py rename to legate/util/types.py diff --git a/legate/utils/ui.py b/legate/util/ui.py similarity index 100% rename from legate/utils/ui.py rename to legate/util/ui.py diff --git a/tests/unit/legate/driver/conftest.py b/tests/unit/legate/driver/conftest.py index 8f7fe816a6..09c8c7d180 100644 --- a/tests/unit/legate/driver/conftest.py +++ b/tests/unit/legate/driver/conftest.py @@ -22,7 +22,7 @@ from legate.driver import Config, Launcher from legate.driver.config import MultiNode -from legate.utils.system import System +from legate.util.system import System from .util import GenConfig, GenSystem diff --git a/tests/unit/legate/driver/test_command.py b/tests/unit/legate/driver/test_command.py index 3be61bc02f..29d4a86323 100644 --- a/tests/unit/legate/driver/test_command.py +++ b/tests/unit/legate/driver/test_command.py @@ -21,8 +21,8 @@ import legate.driver.command as m from legate.driver.launcher import RANK_ENV_VARS -from legate.utils.colors import scrub -from legate.utils.types import LauncherType +from legate.util.colors import scrub +from legate.util.types import LauncherType from ...util import Capsys, powerset_nonempty from .util import GenObjs diff --git a/tests/unit/legate/driver/test_config.py b/tests/unit/legate/driver/test_config.py index f5e7dee011..5362892217 100644 --- a/tests/unit/legate/driver/test_config.py +++ b/tests/unit/legate/driver/test_config.py @@ -23,8 +23,8 @@ import legate.driver.config as m import legate.driver.defaults as defaults -from legate.utils.colors import scrub -from legate.utils.types import DataclassMixin +from legate.util.colors import scrub +from legate.util.types import DataclassMixin from ...util import Capsys, powerset, powerset_nonempty diff --git a/tests/unit/legate/driver/test_driver.py b/tests/unit/legate/driver/test_driver.py index 277f14b4bf..178c79150e 100644 --- a/tests/unit/legate/driver/test_driver.py +++ b/tests/unit/legate/driver/test_driver.py @@ -25,9 +25,9 @@ from legate.driver.command import CMD_PARTS from legate.driver.config import Config from legate.driver.launcher import Launcher -from legate.utils.colors import scrub -from legate.utils.system import System -from legate.utils.types import LauncherType +from legate.util.colors import scrub +from legate.util.system import System +from legate.util.types import LauncherType from ...util import Capsys from .util import GenConfig diff --git a/tests/unit/legate/driver/test_launcher.py b/tests/unit/legate/driver/test_launcher.py index cd16c645bf..ecf980d873 100644 --- a/tests/unit/legate/driver/test_launcher.py +++ b/tests/unit/legate/driver/test_launcher.py @@ -20,8 +20,8 @@ import legate.driver.launcher as m from legate.driver.args import LAUNCHERS -from legate.utils.system import System -from legate.utils.types import LauncherType +from legate.util.system import System +from legate.util.types import LauncherType from ...util import powerset_nonempty from .util import GenConfig, GenObjs diff --git a/tests/unit/legate/driver/test_logs.py b/tests/unit/legate/driver/test_logs.py index fb78febb0e..44e89a3644 100644 --- a/tests/unit/legate/driver/test_logs.py +++ b/tests/unit/legate/driver/test_logs.py @@ -20,7 +20,7 @@ import legate.driver.logs as m from legate.driver.config import Config from legate.driver.launcher import RANK_ENV_VARS -from legate.utils.colors import scrub +from legate.util.colors import scrub from ...util import Capsys, powerset_nonempty from .util import GenObjs diff --git a/tests/unit/legate/driver/test_main.py b/tests/unit/legate/driver/test_main.py index 74a3589c3e..4c0260abba 100644 --- a/tests/unit/legate/driver/test_main.py +++ b/tests/unit/legate/driver/test_main.py @@ -28,10 +28,10 @@ def test_main(mocker: MockerFixture) -> None: import legate.driver.config import legate.driver.driver - import legate.utils.system + import legate.util.system config_spy = mocker.spy(legate.driver.config.Config, "__init__") - system_spy = mocker.spy(legate.utils.system.System, "__init__") + system_spy = mocker.spy(legate.util.system.System, "__init__") driver_spy = mocker.spy(legate.driver.driver.Driver, "__init__") mocker.patch("legate.driver.driver.Driver.run", return_value=123) @@ -48,7 +48,7 @@ def test_main(mocker: MockerFixture) -> None: assert driver_spy.call_count == 1 assert len(driver_spy.call_args[0]) == 3 assert isinstance(driver_spy.call_args[0][1], legate.driver.config.Config) - assert isinstance(driver_spy.call_args[0][2], legate.utils.system.System) + assert isinstance(driver_spy.call_args[0][2], legate.util.system.System) assert driver_spy.call_args[1] == {} assert result == 123 diff --git a/tests/unit/legate/tester/stages/__init__.py b/tests/unit/legate/tester/stages/__init__.py index 9028537bac..a955e39e08 100644 --- a/tests/unit/legate/tester/stages/__init__.py +++ b/tests/unit/legate/tester/stages/__init__.py @@ -17,7 +17,7 @@ from typing import Any from legate.tester.test_system import TestSystem -from legate.utils.types import CPUInfo, GPUInfo +from legate.util.types import CPUInfo, GPUInfo class FakeSystem(TestSystem): diff --git a/tests/unit/legate/utils/__init__.py b/tests/unit/legate/util/__init__.py similarity index 100% rename from tests/unit/legate/utils/__init__.py rename to tests/unit/legate/util/__init__.py diff --git a/tests/unit/legate/utils/sample_cmake_cache.txt b/tests/unit/legate/util/sample_cmake_cache.txt similarity index 100% rename from tests/unit/legate/utils/sample_cmake_cache.txt rename to tests/unit/legate/util/sample_cmake_cache.txt diff --git a/tests/unit/legate/utils/sample_header.h b/tests/unit/legate/util/sample_header.h similarity index 100% rename from tests/unit/legate/utils/sample_header.h rename to tests/unit/legate/util/sample_header.h diff --git a/tests/unit/legate/utils/test_args.py b/tests/unit/legate/util/test_args.py similarity index 99% rename from tests/unit/legate/utils/test_args.py rename to tests/unit/legate/util/test_args.py index fc54f53745..5662884d02 100644 --- a/tests/unit/legate/utils/test_args.py +++ b/tests/unit/legate/util/test_args.py @@ -18,7 +18,7 @@ import pytest -import legate.utils.args as m +import legate.util.args as m from ...util import Capsys diff --git a/tests/unit/legate/utils/test_colors.py b/tests/unit/legate/util/test_colors.py similarity index 98% rename from tests/unit/legate/utils/test_colors.py rename to tests/unit/legate/util/test_colors.py index 84a4b33bc3..873f3dc53a 100644 --- a/tests/unit/legate/utils/test_colors.py +++ b/tests/unit/legate/util/test_colors.py @@ -20,7 +20,7 @@ from pytest_mock import MockerFixture from typing_extensions import TypeAlias -import legate.utils.colors as m +import legate.util.colors as m try: import colorama # type: ignore diff --git a/tests/unit/legate/utils/test_fs.py b/tests/unit/legate/util/test_fs.py similarity index 98% rename from tests/unit/legate/utils/test_fs.py rename to tests/unit/legate/util/test_fs.py index 93720b43a9..32cd452b37 100644 --- a/tests/unit/legate/utils/test_fs.py +++ b/tests/unit/legate/util/test_fs.py @@ -18,7 +18,7 @@ import pytest -import legate.utils.fs as m +import legate.util.fs as m HEADER_PATH = Path(__file__).parent / "sample_header.h" diff --git a/tests/unit/legate/utils/test_system.py b/tests/unit/legate/util/test_system.py similarity index 95% rename from tests/unit/legate/utils/test_system.py rename to tests/unit/legate/util/test_system.py index 51aed4e587..3ae242b6f1 100644 --- a/tests/unit/legate/utils/test_system.py +++ b/tests/unit/legate/util/test_system.py @@ -20,7 +20,7 @@ import pytest from pytest_mock import MockerFixture -import legate.utils.system as m +import legate.util.system as m def test___all__() -> None: @@ -74,7 +74,7 @@ def test_LIBPATH_Darwin(self, mocker: MockerFixture) -> None: def test_legate_paths(self, mocker: MockerFixture) -> None: mocker.patch( - "legate.utils.system.get_legate_paths", + "legate.util.system.get_legate_paths", return_value="legate paths", ) @@ -84,7 +84,7 @@ def test_legate_paths(self, mocker: MockerFixture) -> None: def test_legion_paths(self, mocker: MockerFixture) -> None: mocker.patch( - "legate.utils.system.get_legion_paths", + "legate.util.system.get_legion_paths", return_value="legion paths", ) diff --git a/tests/unit/legate/utils/test_types.py b/tests/unit/legate/util/test_types.py similarity index 97% rename from tests/unit/legate/utils/test_types.py rename to tests/unit/legate/util/test_types.py index fbe65ff04e..01835f8822 100644 --- a/tests/unit/legate/utils/test_types.py +++ b/tests/unit/legate/util/test_types.py @@ -19,7 +19,7 @@ from dataclasses import dataclass -import legate.utils.types as m +import legate.util.types as m class TestCPUInfo: diff --git a/tests/unit/legate/utils/test_ui.py b/tests/unit/legate/util/test_ui.py similarity index 99% rename from tests/unit/legate/utils/test_ui.py rename to tests/unit/legate/util/test_ui.py index d33d776297..a9ac7d8901 100644 --- a/tests/unit/legate/utils/test_ui.py +++ b/tests/unit/legate/util/test_ui.py @@ -21,7 +21,7 @@ from pytest_mock import MockerFixture from typing_extensions import TypeAlias -from legate.utils import colors, ui as m +from legate.util import colors, ui as m try: import colorama # type: ignore From 4e28d9192565db786e4ec3530d0dbd10ea0dc381 Mon Sep 17 00:00:00 2001 From: Bryan Van de Ven Date: Thu, 29 Sep 2022 10:44:35 -0700 Subject: [PATCH 14/16] use cwd for default test_root --- legate/tester/config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/legate/tester/config.py b/legate/tester/config.py index 6f3581d2eb..a621ba8c39 100644 --- a/legate/tester/config.py +++ b/legate/tester/config.py @@ -82,7 +82,9 @@ def root_dir(self) -> Path: """Path to the directory containing the tests.""" if self.test_root: return Path(self.test_root) - return Path(__file__).parents[2] + + # if not explicitly given, just use cwd assuming we are at a repo top + return Path(os.getcwd()) @property def test_files(self) -> tuple[Path, ...]: From c7788800fda5959a9cf3d4ab38e5389d08881fa4 Mon Sep 17 00:00:00 2001 From: Bryan Van de Ven Date: Thu, 29 Sep 2022 11:39:56 -0700 Subject: [PATCH 15/16] move custom argparse action to util --- legate/tester/args.py | 71 ++---------------- legate/util/args.py | 72 +++++++++++++++++-- .../legate/tester/stages/test_test_stage.py | 6 +- tests/unit/legate/tester/test_args.py | 43 ----------- tests/unit/legate/util/test_args.py | 49 ++++++++++++- 5 files changed, 123 insertions(+), 118 deletions(-) diff --git a/legate/tester/args.py b/legate/tester/args.py index d97ebf603d..6c3f24962f 100644 --- a/legate/tester/args.py +++ b/legate/tester/args.py @@ -17,20 +17,12 @@ """ from __future__ import annotations -from argparse import Action, ArgumentParser, Namespace -from typing import ( - Any, - Generic, - Iterable, - Iterator, - Literal, - Sequence, - TypeVar, - Union, -) +from argparse import ArgumentParser +from typing import Literal, Union from typing_extensions import TypeAlias +from ..util.args import ExtendAction, MultipleChoices from . import ( DEFAULT_CPUS_PER_NODE, DEFAULT_GPU_DELAY, @@ -41,8 +33,6 @@ FEATURES, ) -T = TypeVar("T") - PinOptionsType: TypeAlias = Union[ Literal["partial"], Literal["none"], @@ -56,57 +46,6 @@ ) -class MultipleChoices(Generic[T]): - """A container that reports True for any item or subset inclusion. - - Parameters - ---------- - choices: Iterable[T] - The values to populate the containter. - - Examples - -------- - - >>> choices = MultipleChoices(["a", "b", "c"]) - - >>> "a" in choices - True - - >>> ("b", "c") in choices - True - - """ - - def __init__(self, choices: Iterable[T]) -> None: - self.choices = set(choices) - - def __contains__(self, x: Union[T, Iterable[T]]) -> bool: - if isinstance(x, (list, tuple)): - return set(x).issubset(self.choices) - return x in self.choices - - def __iter__(self) -> Iterator[T]: - return self.choices.__iter__() - - -class ExtendAction(Action): - """A custom argparse action to collect multiple values into a list.""" - - def __call__( - self, - parser: ArgumentParser, - namespace: Namespace, - values: Union[str, Sequence[Any], None], - option_string: Union[str, None] = None, - ) -> None: - items = getattr(namespace, self.dest, None) or [] - if isinstance(values, list): - items.extend(values) - else: - items.append(values) - setattr(namespace, self.dest, items) - - #: The argument parser for test.py parser = ArgumentParser( description="Run the Cunumeric test suite", @@ -122,9 +61,7 @@ def __call__( dest="features", action=ExtendAction, choices=MultipleChoices(sorted(FEATURES)), - # argpase evidently only expects string returns from the type converter - # here, but returning a list of strings seems to work in practice - type=lambda s: s.split(","), # type: ignore[return-value, arg-type] + type=lambda s: s.split(","), # type: ignore help="Test Legate with features (also via USE_*)", ) diff --git a/legate/util/args.py b/legate/util/args.py index 993150cb65..e8fdc0c345 100644 --- a/legate/util/args.py +++ b/legate/util/args.py @@ -16,9 +16,19 @@ import sys import warnings -from argparse import ArgumentParser, Namespace +from argparse import Action, ArgumentParser, Namespace from dataclasses import dataclass, fields -from typing import Any, Iterable, Literal, Sequence, Type, TypeVar, Union +from typing import ( + Any, + Generic, + Iterable, + Iterator, + Literal, + Sequence, + Type, + TypeVar, + Union, +) from typing_extensions import TypeAlias @@ -29,8 +39,10 @@ class _UnsetType: Unset = _UnsetType() -_T = TypeVar("_T") -NotRequired = Union[_UnsetType, _T] + +T = TypeVar("T") + +NotRequired = Union[_UnsetType, T] # https://docs.python.org/3/library/argparse.html#action @@ -76,6 +88,58 @@ def entries(obj: Any) -> Iterable[tuple[str, Any]]: yield (f.name, value) +class MultipleChoices(Generic[T]): + """A container that reports True for any item or subset inclusion. + + Parameters + ---------- + choices: Iterable[T] + The values to populate the containter. + + Examples + -------- + + >>> choices = MultipleChoices(["a", "b", "c"]) + + >>> "a" in choices + True + + >>> ("b", "c") in choices + True + + """ + + def __init__(self, choices: Iterable[T]) -> None: + self._choices = set(choices) + + def __contains__(self, x: Union[T, Sequence[T]]) -> bool: + if isinstance(x, (list, tuple)): + return set(x).issubset(self._choices) + return x in self._choices + + def __iter__(self) -> Iterator[T]: + return self._choices.__iter__() + + +class ExtendAction(Action, Generic[T]): + """A custom argparse action to collect multiple values into a list.""" + + def __call__( + self, + parser: ArgumentParser, + namespace: Namespace, + values: Union[str, Sequence[T], None], + option_string: Union[str, None] = None, + ) -> None: + items = getattr(namespace, self.dest) or [] + if isinstance(values, (list, tuple)): + items.extend(values) + else: + items.append(values) + # removing any duplicates before storing + setattr(namespace, self.dest, list(set(items))) + + def parse_library_command_args( libname: str, args: Iterable[Argument] ) -> Namespace: diff --git a/tests/unit/legate/tester/stages/test_test_stage.py b/tests/unit/legate/tester/stages/test_test_stage.py index 590f9d237e..90edfaed44 100644 --- a/tests/unit/legate/tester/stages/test_test_stage.py +++ b/tests/unit/legate/tester/stages/test_test_stage.py @@ -24,7 +24,7 @@ from legate.tester.config import Config from legate.tester.stages import test_stage as m from legate.tester.stages.util import StageResult, StageSpec -from legate.tester.test_system import ProcessResult, TestSystem +from legate.tester.test_system import ProcessResult, TestSystem as _TestSystem from . import FakeSystem @@ -39,10 +39,10 @@ class MockTestStage(m.TestStage): args = ["-foo", "-bar"] - def __init__(self, config: Config, system: TestSystem) -> None: + def __init__(self, config: Config, system: _TestSystem) -> None: self._init(config, system) - def compute_spec(self, config: Config, system: TestSystem) -> StageSpec: + def compute_spec(self, config: Config, system: _TestSystem) -> StageSpec: return StageSpec(2, [(0,), (1,), (2,)]) diff --git a/tests/unit/legate/tester/test_args.py b/tests/unit/legate/tester/test_args.py index 5ae20dbcae..c307a70808 100644 --- a/tests/unit/legate/tester/test_args.py +++ b/tests/unit/legate/tester/test_args.py @@ -17,11 +17,6 @@ """ from __future__ import annotations -from itertools import chain, combinations -from typing import Iterable, TypeVar - -import pytest - from legate.tester import ( DEFAULT_CPUS_PER_NODE, DEFAULT_GPU_DELAY, @@ -32,14 +27,6 @@ args as m, ) -T = TypeVar("T") - - -# https://docs.python.org/3/library/itertools.html#itertools-recipes -def powerset(iterable: Iterable[T]) -> Iterable[Iterable[T]]: - xs = list(iterable) - return chain.from_iterable(combinations(xs, n) for n in range(len(xs) + 1)) - class TestParserDefaults: def test_featurs(self) -> None: @@ -100,33 +87,3 @@ def test_parser_epilog(self) -> None: def test_parser_description(self) -> None: assert m.parser.description == "Run the Cunumeric test suite" - - -class TestMultipleChoices: - @pytest.mark.parametrize("choices", ([1, 2, 3], range(4), ("a", "b"))) - def test_init(self, choices: Iterable[T]) -> None: - mc = m.MultipleChoices(choices) - assert mc.choices == set(choices) - - def test_contains_item(self) -> None: - choices = [1, 2, 3] - mc = m.MultipleChoices(choices) - for item in choices: - assert item in mc - - def test_contains_subset(self) -> None: - choices = [1, 2, 3] - mc = m.MultipleChoices(choices) - for subset in powerset(choices): - assert subset in mc - - def test_iter(self) -> None: - choices = [1, 2, 3] - mc = m.MultipleChoices(choices) - assert list(mc) == choices - - -# Testing this directly would require getting into argparse -# internals. See test_config.py for indirect tests with --use -class TestExtendAction: - pass diff --git a/tests/unit/legate/util/test_args.py b/tests/unit/legate/util/test_args.py index 5662884d02..02d01a58c8 100644 --- a/tests/unit/legate/util/test_args.py +++ b/tests/unit/legate/util/test_args.py @@ -14,13 +14,60 @@ # import sys +from argparse import ArgumentParser from dataclasses import dataclass +from typing import Iterable, TypeVar import pytest import legate.util.args as m -from ...util import Capsys +from ...util import Capsys, powerset + +T = TypeVar("T") + + +class TestMultipleChoices: + @pytest.mark.parametrize("choices", ([1, 2, 3], range(4), ("a", "b"))) + def test_init(self, choices: Iterable[T]) -> None: + mc = m.MultipleChoices(choices) + assert mc._choices == set(choices) + + def test_contains_item(self) -> None: + choices = [1, 2, 3] + mc = m.MultipleChoices(choices) + for item in choices: + assert item in mc + + def test_contains_subset(self) -> None: + choices = [1, 2, 3] + mc = m.MultipleChoices(choices) + for subset in powerset(choices): + assert subset in mc + + def test_iter(self) -> None: + choices = [1, 2, 3] + mc = m.MultipleChoices(choices) + assert list(mc) == choices + + +class TestExtendAction: + parser = ArgumentParser() + parser.add_argument( + "--foo", dest="foo", action=m.ExtendAction, choices=("a", "b", "c") + ) + + def test_single(self) -> None: + ns = self.parser.parse_args(["--foo", "a"]) + assert ns.foo == ["a"] + + def test_multi(self) -> None: + ns = self.parser.parse_args(["--foo", "a", "--foo", "b"]) + assert sorted(ns.foo) == ["a", "b"] + + def test_repeat(self) -> None: + ns = self.parser.parse_args(["--foo", "a", "--foo", "a"]) + assert ns.foo == ["a"] @dataclass(frozen=True) From 592ffa17e0b7061a1e1ef59efa7154c58316d3ff Mon Sep 17 00:00:00 2001 From: Bryan Van de Ven Date: Fri, 30 Sep 2022 12:26:49 -0700 Subject: [PATCH 16/16] fix test after merge --- tests/unit/legate/driver/test_driver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/legate/driver/test_driver.py b/tests/unit/legate/driver/test_driver.py index 959190d794..fad492a2f4 100644 --- a/tests/unit/legate/driver/test_driver.py +++ b/tests/unit/legate/driver/test_driver.py @@ -155,7 +155,7 @@ def test_verbose_nonero_rank_id( run_out = scrub(capsys.readouterr()[0]).strip() - print_verbose(driver.system, driver) + m.print_verbose(driver.system, driver) pv_out = scrub(capsys.readouterr()[0]).strip()