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 + diff --git a/legate/core/__init__.py b/legate/core/__init__.py index 4ad4c308b3..8a6beee0ae 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 ..util.args import parse_library_command_args check_legion() diff --git a/legate/core/runtime.py b/legate/core/runtime.py index b47624378f..c30bc62370 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 ..util.args import ArgSpec, Argument, parse_library_command_args from . import ffi # Make sure we only have one ffi instance from . import ( Fence, @@ -855,7 +854,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/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/args.py b/legate/driver/args.py index 2e919a2bc3..7397221709 100755 --- a/legate/driver/args.py +++ b/legate/driver/args.py @@ -18,8 +18,8 @@ from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser +from ..util.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..7d11c2c9b9 100644 --- a/legate/driver/command.py +++ b/legate/driver/command.py @@ -16,13 +16,13 @@ from typing import TYPE_CHECKING -from .ui import warn +from ..util.ui import warn if TYPE_CHECKING: + from ..util.system import System + from ..util.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..c4acb3c412 100644 --- a/legate/driver/config.py +++ b/legate/driver/config.py @@ -23,10 +23,14 @@ from pathlib import Path from typing import Any +from ..util.types import ( + ArgList, + DataclassMixin, + LauncherType, + object_to_dataclass, +) +from ..util.ui import warn from .args import parser -from .types import ArgList, DataclassMixin, LauncherType -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 57dd653d0e..7a3e00c409 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 ..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 -from .system import System -from .types import Command, EnvDict -from .ui import warn -from .util import print_verbose -__all__ = ("Driver",) +if TYPE_CHECKING: + from ..util.types import Command, EnvDict + +__all__ = ("Driver", "print_verbose") _DARWIN_GDB_WARN = """\ You must start the debugging session with the following command, @@ -113,3 +117,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/launcher.py b/legate/driver/launcher.py index 922eb4f6f9..009b0cf6bb 100644 --- a/legate/driver/launcher.py +++ b/legate/driver/launcher.py @@ -17,12 +17,15 @@ 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 +from ..util.fs import read_c_define +from ..util.ui import warn + +if TYPE_CHECKING: + 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 1173e84864..261ab6dd5e 100644 --- a/legate/driver/logs.py +++ b/legate/driver/logs.py @@ -22,13 +22,15 @@ 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 +from ..util.ui import warn + +if TYPE_CHECKING: + from ..util.system import System + from ..util.types import Command + from .config import Config + from .launcher import Launcher __all__ = ( "DebuggingHandler", diff --git a/legate/driver/main.py b/legate/driver/main.py index c2e0ac5770..2ca3f04bed 100644 --- a/legate/driver/main.py +++ b/legate/driver/main.py @@ -34,9 +34,10 @@ def main(argv: list[str]) -> int: int, a process return code """ - from . import Config, Driver, System - from .ui import error - from .util import print_verbose + from ..util.system import System + from ..util.ui import error + from . import Config, Driver + from .driver import print_verbose try: config = Config(argv) diff --git a/legate/driver/ui.py b/legate/driver/ui.py deleted file mode 100644 index e6f5ee37d7..0000000000 --- a/legate/driver/ui.py +++ /dev/null @@ -1,246 +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 - -import re -import sys -from typing import Any, Iterable - -__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. - - 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 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. - - Parameters - ---------- - text : str - The text to format - - Returns - ------- - str - - """ - return magenta(f"WARNING: {text}") diff --git a/legate/rc.py b/legate/rc.py index 6a54cc530f..bd4abca515 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 @@ -35,6 +27,13 @@ legion_python directly. """ +# TODO (bv) temp transitive imports until cunumeric is updated +from .util.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. @@ -55,96 +54,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/tester/__init__.py b/legate/tester/__init__.py new file mode 100644 index 0000000000..270abcf8d3 --- /dev/null +++ b/legate/tester/__init__.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. +# +"""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", +} + +#: 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..6c3f24962f --- /dev/null +++ b/legate/tester/args.py @@ -0,0 +1,223 @@ +# 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 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, + DEFAULT_GPU_MEMORY_BUDGET, + DEFAULT_GPUS_PER_NODE, + DEFAULT_OMPS_PER_NODE, + DEFAULT_OMPTHREADS, + FEATURES, +) + +PinOptionsType: TypeAlias = Union[ + Literal["partial"], + Literal["none"], + Literal["strict"], +] + +PIN_OPTIONS: tuple[PinOptionsType, ...] = ( + "partial", + "none", + "strict", +) + + +#: 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)), + type=lambda s: s.split(","), # type: ignore + 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..a621ba8c39 --- /dev/null +++ b/legate/tester/config.py @@ -0,0 +1,163 @@ +# 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 ..util.types import ArgList, EnvDict +from . import DEFAULT_PROCESS_ENV, FEATURES, SKIPPED_EXAMPLES, FeatureType +from .args import parser + + +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) + + # 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, ...]: + """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..deb5610a6b --- /dev/null +++ b/legate/tester/stages/_linux/cpu.py @@ -0,0 +1,83 @@ +# 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 typing import TYPE_CHECKING + +from ..test_stage import TestStage +from ..util import ( + CUNUMERIC_TEST_ARG, + UNPIN_ENV, + Shard, + StageSpec, + adjust_workers, +) + +if TYPE_CHECKING: + from ....util.types import ArgList, EnvDict + from ... import FeatureType + from ...config import Config + from ...test_system import TestSystem + + +class CPU(TestStage): + """A test stage for exercising CPU features. + + Parameters + ---------- + config: Config + Test runner configuration + + system: TestSystem + Process execution wrapper + + """ + + kind: FeatureType = "cpus" + + args = [CUNUMERIC_TEST_ARG] + + def __init__(self, config: Config, system: TestSystem) -> None: + self._init(config, system) + + 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: + 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: TestSystem) -> 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..cc9a08d5a4 --- /dev/null +++ b/legate/tester/stages/_linux/eager.py @@ -0,0 +1,75 @@ +# 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 TYPE_CHECKING + +from ..test_stage import TestStage +from ..util import Shard, StageSpec, adjust_workers + +if TYPE_CHECKING: + from ....util.types import ArgList, EnvDict + from ... import FeatureType + from ...config import Config + from ...test_system import TestSystem + + +class Eager(TestStage): + """A test stage for exercising Eager Numpy execution features. + + Parameters + ---------- + config: Config + Test runner configuration + + system: TestSystem + Process execution wrapper + + """ + + kind: FeatureType = "eager" + + args: ArgList = [] + + def __init__(self, config: Config, system: TestSystem) -> None: + self._init(config, system) + + 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", + "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: TestSystem) -> 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..f1a222fc0f --- /dev/null +++ b/legate/tester/stages/_linux/gpu.py @@ -0,0 +1,85 @@ +# 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 typing import TYPE_CHECKING + +from ..test_stage import TestStage +from ..util import CUNUMERIC_TEST_ARG, Shard, StageSpec, adjust_workers + +if TYPE_CHECKING: + from ....util.types import ArgList, EnvDict + from ... import FeatureType + from ...config import Config + from ...test_system import TestSystem + +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: TestSystem + Process execution wrapper + + """ + + kind: FeatureType = "cuda" + + args = [CUNUMERIC_TEST_ARG] + + def __init__(self, config: Config, system: TestSystem) -> None: + self._init(config, system) + + def env(self, config: Config, system: TestSystem) -> EnvDict: + return {} + + 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: + 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: TestSystem) -> 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..f7af3e9d0f --- /dev/null +++ b/legate/tester/stages/_linux/omp.py @@ -0,0 +1,87 @@ +# 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 typing import TYPE_CHECKING + +from ..test_stage import TestStage +from ..util import ( + CUNUMERIC_TEST_ARG, + UNPIN_ENV, + Shard, + StageSpec, + adjust_workers, +) + +if TYPE_CHECKING: + from ....util.types import ArgList, EnvDict + from ... import FeatureType + from ...config import Config + from ...test_system import TestSystem + + +class OMP(TestStage): + """A test stage for exercising OpenMP features. + + Parameters + ---------- + config: Config + Test runner configuration + + system: TestSystem + Process execution wrapper + + """ + + kind: FeatureType = "openmp" + + args = [CUNUMERIC_TEST_ARG] + + def __init__(self, config: Config, system: TestSystem) -> None: + self._init(config, system) + + 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: + 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: TestSystem) -> 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..182a6d76b0 --- /dev/null +++ b/legate/tester/stages/_osx/cpu.py @@ -0,0 +1,68 @@ +# 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 TYPE_CHECKING + +from ..test_stage import TestStage +from ..util import ( + CUNUMERIC_TEST_ARG, + UNPIN_ENV, + Shard, + StageSpec, + adjust_workers, +) + +if TYPE_CHECKING: + from ....util.types import ArgList, EnvDict + from ... import FeatureType + from ...config import Config + from ...test_system import TestSystem + + +class CPU(TestStage): + """A test stage for exercising CPU features. + + Parameters + ---------- + config: Config + Test runner configuration + + system: TestSystem + Process execution wrapper + + """ + + kind: FeatureType = "cpus" + + args = [CUNUMERIC_TEST_ARG] + + def __init__(self, config: Config, system: TestSystem) -> None: + self._init(config, system) + + 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: TestSystem) -> 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..b32feb17db --- /dev/null +++ b/legate/tester/stages/_osx/eager.py @@ -0,0 +1,68 @@ +# 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 TYPE_CHECKING + +from ..test_stage import TestStage +from ..util import UNPIN_ENV, Shard, StageSpec, adjust_workers + +if TYPE_CHECKING: + from ....util.types import ArgList, EnvDict + from ... import FeatureType + from ...config import Config + from ...test_system import TestSystem + + +class Eager(TestStage): + """A test stage for exercising Eager Numpy execution features. + + Parameters + ---------- + config: Config + Test runner configuration + + system: TestSystem + Process execution wrapper + + """ + + kind: FeatureType = "eager" + + args: ArgList = [] + + def __init__(self, config: Config, system: TestSystem) -> None: + self._init(config, system) + + 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", + "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: TestSystem) -> 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..2a15974942 --- /dev/null +++ b/legate/tester/stages/_osx/gpu.py @@ -0,0 +1,54 @@ +# 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 typing import TYPE_CHECKING + +from ..test_stage import TestStage +from ..util import CUNUMERIC_TEST_ARG, UNPIN_ENV, Shard + +if TYPE_CHECKING: + from ....util.types import ArgList, EnvDict + from ... import FeatureType + from ...config import Config + from ...test_system import TestSystem + + +class GPU(TestStage): + """A test stage for exercising GPU features. + + Parameters + ---------- + config: Config + Test runner configuration + + system: TestSystem + Process execution wrapper + + """ + + kind: FeatureType = "cuda" + + args: ArgList = [CUNUMERIC_TEST_ARG] + + def __init__(self, config: Config, system: TestSystem) -> None: + raise RuntimeError("GPU test are not supported on OSX") + + def env(self, config: Config, system: TestSystem) -> EnvDict: + return UNPIN_ENV + + 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 new file mode 100644 index 0000000000..eb279791ac --- /dev/null +++ b/legate/tester/stages/_osx/omp.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. +# +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..test_stage import TestStage +from ..util import ( + CUNUMERIC_TEST_ARG, + UNPIN_ENV, + Shard, + StageSpec, + adjust_workers, +) + +if TYPE_CHECKING: + from ....util.types import ArgList, EnvDict + from ... import FeatureType + from ...config import Config + from ...test_system import TestSystem + + +class OMP(TestStage): + """A test stage for exercising OpenMP features. + + Parameters + ---------- + config: Config + Test runner configuration + + system: TestSystem + Process execution wrapper + + """ + + kind: FeatureType = "openmp" + + args = [CUNUMERIC_TEST_ARG] + + def __init__(self, config: Config, system: TestSystem) -> None: + self._init(config, system) + + def env(self, config: Config, system: TestSystem) -> 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: TestSystem) -> 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..c21fdd6307 --- /dev/null +++ b/legate/tester/stages/test_stage.py @@ -0,0 +1,268 @@ +# 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 ...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 +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: TestSystem + 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: TestSystem) -> None: + ... + + def env(self, config: Config, system: TestSystem) -> EnvDict: + """Generate stage-specific customizations to the process env + + Parameters + ---------- + config: Config + Test runner configuration + + system: TestSystem + Process execution wrapper + + """ + ... + + def delay(self, shard: Shard, config: Config, system: TestSystem) -> 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: TestSystem + 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: TestSystem) -> 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: TestSystem + Process execution wrapper + + """ + ... + + # --- Shared implementation methods + + def __call__(self, config: Config, system: TestSystem) -> None: + """Execute this test stage. + + Parameters + ---------- + config: Config + Test runner configuration + + system: TestSystem + 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: TestSystem + ) -> 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: TestSystem + 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: TestSystem) -> EnvDict: + env = dict(config.env) + env.update(self.env(config, system)) + return env + + 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: TestSystem + ) -> 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..2d65148775 --- /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 ...util.ui import failed, passed, shell, skipped +from ..config import Config +from ..logger import LOG +from ..test_system import ProcessResult + +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/test_plan.py b/legate/tester/test_plan.py new file mode 100644 index 0000000000..cc877f7a47 --- /dev/null +++ b/legate/tester/test_plan.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. +# +"""Provide a TestPlan class to coordinate multiple feature test stages. + +""" +from __future__ import annotations + +from datetime import timedelta +from itertools import chain + +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 +from .test_system import TestSystem + + +class TestPlan: + """Encapsulate an entire test run with multiple feature test stages. + + Parameters + ---------- + config: Config + Test runner configuration + + system: TestSystem + Process execution wrapper + + """ + + def __init__(self, config: Config, system: TestSystem) -> 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(pad=4)}") + + 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"* TestSystem 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/test_system.py b/legate/tester/test_system.py new file mode 100644 index 0000000000..2c4e9949ff --- /dev/null +++ b/legate/tester/test_system.py @@ -0,0 +1,123 @@ +# 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 +from dataclasses import dataclass +from pathlib import Path +from subprocess import PIPE, STDOUT, run as stdlib_run +from typing import Sequence + +from ..util.system import System +from ..util.types import EnvDict + +__all__ = ("TestSystem",) + + +@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 TestSystem(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, + ) diff --git a/tests/unit/legate/driver/test_types.py b/legate/util/__init__.py similarity index 100% rename from tests/unit/legate/driver/test_types.py rename to legate/util/__init__.py diff --git a/legate/util/args.py b/legate/util/args.py new file mode 100644 index 0000000000..e8fdc0c345 --- /dev/null +++ b/legate/util/args.py @@ -0,0 +1,182 @@ +# 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 Action, ArgumentParser, Namespace +from dataclasses import dataclass, fields +from typing import ( + Any, + Generic, + Iterable, + Iterator, + 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) + + +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: + """ """ + 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/util/colors.py b/legate/util/colors.py new file mode 100644 index 0000000000..5bb0b14b36 --- /dev/null +++ b/legate/util/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/legate/driver/util.py b/legate/util/fs.py similarity index 81% rename from legate/driver/util.py rename to legate/util/fs.py index 499b250e37..e05e15279c 100644 --- a/legate/driver/util.py +++ b/legate/util/fs.py @@ -17,100 +17,18 @@ import re import sys from pathlib import Path -from shlex import quote -from textwrap import indent -from typing import TYPE_CHECKING, Type, TypeVar -from .types import DataclassProtocol, LegatePaths, LegionPaths -from .ui import kvtable, rule, section, value - -if TYPE_CHECKING: - from .driver import Driver - from .system import System +from .types import LegatePaths, LegionPaths __all__ = ( "get_legate_build_dir", "get_legate_paths", "get_legion_paths", - "object_to_dataclass", - "print_verbose", "read_c_define", "read_cmake_cache_value", ) -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) - - def read_c_define(header_path: Path, name: str) -> str | None: """Open a C header file and read the value of a #define @@ -321,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" diff --git a/legate/driver/system.py b/legate/util/system.py similarity index 51% rename from legate/driver/system.py rename to legate/util/system.py index 57f9ec2268..702514cc27 100644 --- a/legate/driver/system.py +++ b/legate/util/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/legate/driver/types.py b/legate/util/types.py similarity index 68% rename from legate/driver/types.py rename to legate/util/types.py index 0bde4643b8..2a81663739 100644 --- a/legate/driver/types.py +++ b/legate/util/types.py @@ -12,14 +12,14 @@ # 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 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 @@ -29,14 +29,37 @@ "ArgList", "Command", "CommandPart", + "CPUInfo", "DataclassMixin", "DataclassProtocol", "EnvDict", + "GPUInfo", "LauncherType", "LegatePaths", "LegionPaths", + "object_to_dataclass", ) + +@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"] @@ -73,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/legate/util/ui.py b/legate/util/ui.py new file mode 100644 index 0000000000..9cf74b0940 --- /dev/null +++ b/legate/util/ui.py @@ -0,0 +1,345 @@ +# 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 datetime import timedelta +from typing import Any, Iterable + +from typing_extensions import TypeAlias + +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 = " " +) -> 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 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]. + + 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 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 + An amount of padding to put in front of the rule + + char: str, optional + A character to use for the rule (default: "-") + + N : int, optional + Character width for the rule (default: 80) + + Returns + ------- + str + + """ + 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: + """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)) + + +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/__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..09c8c7d180 100644 --- a/tests/unit/legate/driver/conftest.py +++ b/tests/unit/legate/driver/conftest.py @@ -19,10 +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 import Config, Launcher from legate.driver.config import MultiNode +from legate.util.system import System + +from .util import GenConfig, GenSystem @pytest.fixture diff --git a/tests/unit/legate/driver/test_command.py b/tests/unit/legate/driver/test_command.py index f7188990f9..29d4a86323 100644 --- a/tests/unit/legate/driver/test_command.py +++ b/tests/unit/legate/driver/test_command.py @@ -18,12 +18,14 @@ 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 legate.util.colors import scrub +from legate.util.types import LauncherType + +from ...util import Capsys, powerset_nonempty +from .util import GenObjs def test___all__() -> None: diff --git a/tests/unit/legate/driver/test_config.py b/tests/unit/legate/driver/test_config.py index 0523b1db91..5362892217 100644 --- a/tests/unit/legate/driver/test_config.py +++ b/tests/unit/legate/driver/test_config.py @@ -20,12 +20,13 @@ 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 legate.util.colors import scrub +from legate.util.types import DataclassMixin + +from ...util import Capsys, powerset, powerset_nonempty DEFAULTS_ENV_VARS = ( "LEGATE_EAGER_ALLOC_PERCENTAGE", diff --git a/tests/unit/legate/driver/test_driver.py b/tests/unit/legate/driver/test_driver.py index e346210d38..fad492a2f4 100644 --- a/tests/unit/legate/driver/test_driver.py +++ b/tests/unit/legate/driver/test_driver.py @@ -15,19 +15,22 @@ from __future__ import annotations import re +from shlex import quote import pytest from pytest_mock import MockerFixture -from util import Capsys, GenConfig 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 RANK_ENV_VARS, 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.util.colors import scrub +from legate.util.system import System +from legate.util.types import LauncherType + +from ...util import Capsys +from .util import GenConfig SYSTEM = System() @@ -123,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() @@ -152,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() @@ -180,3 +183,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_launcher.py b/tests/unit/legate/driver/test_launcher.py index 1c5b451af4..ecf980d873 100644 --- a/tests/unit/legate/driver/test_launcher.py +++ b/tests/unit/legate/driver/test_launcher.py @@ -17,12 +17,14 @@ 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 legate.util.system import System +from legate.util.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..44e89a3644 100644 --- a/tests/unit/legate/driver/test_logs.py +++ b/tests/unit/legate/driver/test_logs.py @@ -16,12 +16,14 @@ 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 legate.util.colors import scrub + +from ...util import Capsys, powerset_nonempty +from .util import GenObjs class MockHandler(m.LogHandler): diff --git a/tests/unit/legate/driver/test_main.py b/tests/unit/legate/driver/test_main.py index 0784246a34..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.driver.system + import legate.util.system config_spy = mocker.spy(legate.driver.config.Config, "__init__") - system_spy = mocker.spy(legate.driver.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.driver.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/driver/test_ui.py b/tests/unit/legate/driver/test_ui.py deleted file mode 100644 index 33b8b03eb3..0000000000 --- a/tests/unit/legate/driver/test_ui.py +++ /dev/null @@ -1,254 +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 typing import Any - -import pytest -from pytest_mock import MockerFixture -from typing_extensions import TypeAlias - -import legate.driver.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", 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") -def test_error(use_plain_text: UsePlainTextFixture) -> None: - assert m.error("some message") == m.red("ERROR: some message") - - -def test_error_plain(use_plain_text: UsePlainTextFixture) -> None: - assert m.error("some message") == "ERROR: some message" - - -@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_plain(use_plain_text: UsePlainTextFixture) -> None: - assert m.key("some key") == "some key" - - -@pytest.mark.skipif(colorama is None, reason="colorama required") -def test_value(use_plain_text: UsePlainTextFixture) -> None: - assert m.value("some value") == m.yellow("some value") - - -def test_value_plain(use_plain_text: UsePlainTextFixture) -> None: - assert m.value("some value") == "some value" - - -class Test_kvtable: - ONE = {"foo": 10} - TWO = {"foo": 10, "barbaz": "some value"} - THREE = {"foo": 10, "barbaz": "some value", "a": 1.2} - - @pytest.mark.skipif(colorama is None, reason="colorama required") - @pytest.mark.parametrize("items", (ONE, TWO, THREE)) - def test_default(self, items: dict[str, Any]) -> None: - N = max(len(m.key(k)) for k in items) - assert m.kvtable(items) == "\n".join( - f"{m.key(k): <{N}} : {m.value(str(items[k]))}" for k in items - ) - - @pytest.mark.parametrize("items", (ONE, TWO, THREE)) - def test_default_plain( - self, use_plain_text: UsePlainTextFixture, items: dict[str, Any] - ) -> None: - N = max(len(k) for k in items) - assert m.kvtable(items) == "\n".join( - f"{k: <{N}} : {items[k]}" for k in items - ) - - @pytest.mark.skipif(colorama is None, reason="colorama required") - @pytest.mark.parametrize("items", (ONE, TWO, THREE)) - def test_delim(self, items: dict[str, Any]) -> None: - N = max(len(m.key(k)) for k in items) - assert m.kvtable(items, delim="/") == "\n".join( - f"{m.key(k): <{N}}/{m.value(str(items[k]))}" for k in items - ) - - @pytest.mark.parametrize("items", (ONE, TWO, THREE)) - def test_delim_plain( - self, use_plain_text: UsePlainTextFixture, items: dict[str, Any] - ) -> None: - N = max(len(k) for k in items) - assert m.kvtable(items, delim="/") == "\n".join( - f"{k: <{N}}/{items[k]}" for k in items - ) - - @pytest.mark.skipif(colorama is None, reason="colorama required") - @pytest.mark.parametrize("items", (ONE, TWO, THREE)) - def test_align_False(self, items: dict[str, Any]) -> None: - assert m.kvtable(items, align=False) == "\n".join( - f"{m.key(k)} : {m.value(str(items[k]))}" for k in items - ) - - @pytest.mark.parametrize("items", (ONE, TWO, THREE)) - def test_align_False_plain( - self, use_plain_text: UsePlainTextFixture, items: dict[str, Any] - ) -> None: - assert m.kvtable(items, align=False) == "\n".join( - f"{k} : {items[k]}" for k in items - ) - - @pytest.mark.skipif(colorama is None, reason="colorama required") - def test_keys(self) -> None: - items = self.THREE - keys = ("foo", "a") - N = max(len(m.key(k)) for k in items) - - assert m.kvtable(self.THREE, keys=keys) == "\n".join( - f"{m.key(k): <{N}} : {m.value(str(items[k]))}" for k in keys - ) - - def test_keys_plain(self, use_plain_text: UsePlainTextFixture) -> None: - items = self.THREE - keys = ("foo", "a") - N = max(len(m.key(k)) for k in items) - - assert m.kvtable(items, keys=keys) == "\n".join( - f"{k: <{N}} : {items[k]}" for k in keys - ) - - -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) - - @pytest.mark.skipif(colorama is None, reason="colorama required") - def test_char(self) -> None: - assert m.rule(char="a") == m.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) - - @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) - - def test_text_plain(self, use_plain_text: UsePlainTextFixture) -> None: - assert m.rule("foo bar") == "--- foo bar " + "-" * 68 - - def test_char_plain(self, use_plain_text: UsePlainTextFixture) -> None: - assert m.rule(char="a") == "a" * 80 - - def test_N_plain(self, use_plain_text: UsePlainTextFixture) -> None: - assert m.rule(N=60) == "-" * 60 - - def test_N_with_text_plain( - self, use_plain_text: UsePlainTextFixture - ) -> None: - assert m.rule("foo bar", N=65) == "--- foo bar " + "-" * 53 - - -@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: - assert m.section("some section") == m.bright(m.white("some section")) - - -def test_section_plain(use_plain_text: UsePlainTextFixture) -> None: - assert m.section("some section") == "some section" - - -@pytest.mark.skipif(colorama is None, reason="colorama required") -def test_warn(use_plain_text: UsePlainTextFixture) -> None: - assert m.warn("some message") == m.magenta("WARNING: some message") - - -def test_warn_plain(use_plain_text: UsePlainTextFixture) -> None: - assert m.warn("some message") == "WARNING: some message" diff --git a/tests/unit/legate/driver/test_util.py b/tests/unit/legate/driver/test_util.py deleted file mode 100644 index a864ddc8c9..0000000000 --- a/tests/unit/legate/driver/test_util.py +++ /dev/null @@ -1,131 +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 pathlib import Path -from shlex import quote - -import pytest -from util import Capsys - -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.driver.ui import scrub - - -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}") - - -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/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/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..a955e39e08 --- /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.test_system import TestSystem +from legate.util.types import CPUInfo, GPUInfo + + +class FakeSystem(TestSystem): + 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..90edfaed44 --- /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.test_system import ProcessResult, TestSystem as _TestSystem + +from . import FakeSystem + +s = FakeSystem() + + +class MockTestStage(m.TestStage): + + kind: FeatureType = "eager" + + name = "mock" + + args = ["-foo", "-bar"] + + def __init__(self, config: Config, system: _TestSystem) -> None: + self._init(config, system) + + def compute_spec(self, config: Config, system: _TestSystem) -> 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..6431469ff4 --- /dev/null +++ b/tests/unit/legate/tester/test___init__.py @@ -0,0 +1,69 @@ +# 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, +) + + +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_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..c307a70808 --- /dev/null +++ b/tests/unit/legate/tester/test_args.py @@ -0,0 +1,89 @@ +# 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, + args as m, +) + + +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" 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_test_system.py b/tests/unit/legate/tester/test_test_system.py new file mode 100644 index 0000000000..268a6a32fc --- /dev/null +++ b/tests/unit/legate/tester/test_test_system.py @@ -0,0 +1,65 @@ +# 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 +from subprocess import CompletedProcess +from unittest.mock import MagicMock + +import pytest +from pytest_mock import MockerFixture + +from legate.tester import test_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.TestSystem() + assert s.dry_run is False + + def test_run(self, mock_subprocess_run: MagicMock) -> None: + s = m.TestSystem() + + 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.TestSystem(dry_run=True) + + result = s.run(CMD.split(), Path("test/file")) + mock_subprocess_run.assert_not_called() + + assert result.output == "" + assert result.skipped diff --git a/tests/unit/legate/util/__init__.py b/tests/unit/legate/util/__init__.py new file mode 100644 index 0000000000..f0b271624d --- /dev/null +++ b/tests/unit/legate/util/__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/sample_cmake_cache.txt b/tests/unit/legate/util/sample_cmake_cache.txt similarity index 100% rename from tests/unit/legate/driver/sample_cmake_cache.txt rename to tests/unit/legate/util/sample_cmake_cache.txt diff --git a/tests/unit/legate/driver/sample_header.h b/tests/unit/legate/util/sample_header.h similarity index 100% rename from tests/unit/legate/driver/sample_header.h rename to tests/unit/legate/util/sample_header.h diff --git a/tests/unit/legate/util/test_args.py b/tests/unit/legate/util/test_args.py new file mode 100644 index 0000000000..02d01a58c8 --- /dev/null +++ b/tests/unit/legate/util/test_args.py @@ -0,0 +1,187 @@ +# 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 argparse import ArgumentParser +from dataclasses import dataclass +from typing import Iterable, TypeVar + +import pytest + +import legate.util.args as m + +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) +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_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_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_library_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_library_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_library_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_library_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_library_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_library_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/legate/util/test_colors.py b/tests/unit/legate/util/test_colors.py new file mode 100644 index 0000000000..873f3dc53a --- /dev/null +++ b/tests/unit/legate/util/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.util.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" diff --git a/tests/unit/legate/util/test_fs.py b/tests/unit/legate/util/test_fs.py new file mode 100644 index 0000000000..32cd452b37 --- /dev/null +++ b/tests/unit/legate/util/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.util.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/util/test_system.py similarity index 83% rename from tests/unit/legate/driver/test_system.py rename to tests/unit/legate/util/test_system.py index a1b9054969..3ae242b6f1 100644 --- a/tests/unit/legate/driver/test_system.py +++ b/tests/unit/legate/util/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.util.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.util.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.util.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 diff --git a/tests/unit/legate/util/test_types.py b/tests/unit/legate/util/test_types.py new file mode 100644 index 0000000000..01835f8822 --- /dev/null +++ b/tests/unit/legate/util/test_types.py @@ -0,0 +1,57 @@ +# 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 dataclasses import dataclass + +import legate.util.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"} + + +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 diff --git a/tests/unit/legate/util/test_ui.py b/tests/unit/legate/util/test_ui.py new file mode 100644 index 0000000000..a9ac7d8901 --- /dev/null +++ b/tests/unit/legate/util/test_ui.py @@ -0,0 +1,375 @@ +# 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 datetime import timedelta +from typing import Any + +import pytest +from pytest_mock import MockerFixture +from typing_extensions import TypeAlias + +from legate.util import colors, 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) + mocker.patch.object(m, "yellow", colors._text) + 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") + + +def test_error_plain(use_plain_text: UsePlainTextFixture) -> None: + assert m.error("some message") == "ERROR: some message" + + +@pytest.mark.skipif(colorama is None, reason="colorama required") +def test_key() -> None: + assert m.key("some key") == colors.dim(colors.green("some key")) + + +def test_key_plain(use_plain_text: UsePlainTextFixture) -> None: + assert m.key("some key") == "some key" + + +@pytest.mark.skipif(colorama is None, reason="colorama required") +def test_value() -> None: + assert m.value("some value") == m.yellow("some value") + + +def test_value_plain(use_plain_text: UsePlainTextFixture) -> None: + assert m.value("some value") == "some value" + + +class Test_kvtable: + ONE = {"foo": 10} + TWO = {"foo": 10, "barbaz": "some value"} + THREE = {"foo": 10, "barbaz": "some value", "a": 1.2} + + @pytest.mark.skipif(colorama is None, reason="colorama required") + @pytest.mark.parametrize("items", (ONE, TWO, THREE)) + def test_default(self, items: dict[str, Any]) -> None: + N = max(len(m.key(k)) for k in items) + assert m.kvtable(items) == "\n".join( + f"{m.key(k): <{N}} : {m.value(str(items[k]))}" for k in items + ) + + @pytest.mark.parametrize("items", (ONE, TWO, THREE)) + def test_default_plain( + self, use_plain_text: UsePlainTextFixture, items: dict[str, Any] + ) -> None: + N = max(len(k) for k in items) + assert m.kvtable(items) == "\n".join( + f"{k: <{N}} : {items[k]}" for k in items + ) + + @pytest.mark.skipif(colorama is None, reason="colorama required") + @pytest.mark.parametrize("items", (ONE, TWO, THREE)) + def test_delim(self, items: dict[str, Any]) -> None: + N = max(len(m.key(k)) for k in items) + assert m.kvtable(items, delim="/") == "\n".join( + f"{m.key(k): <{N}}/{m.value(str(items[k]))}" for k in items + ) + + @pytest.mark.parametrize("items", (ONE, TWO, THREE)) + def test_delim_plain( + self, use_plain_text: UsePlainTextFixture, items: dict[str, Any] + ) -> None: + N = max(len(k) for k in items) + assert m.kvtable(items, delim="/") == "\n".join( + f"{k: <{N}}/{items[k]}" for k in items + ) + + @pytest.mark.skipif(colorama is None, reason="colorama required") + @pytest.mark.parametrize("items", (ONE, TWO, THREE)) + def test_align_False(self, items: dict[str, Any]) -> None: + assert m.kvtable(items, align=False) == "\n".join( + f"{m.key(k)} : {m.value(str(items[k]))}" for k in items + ) + + @pytest.mark.parametrize("items", (ONE, TWO, THREE)) + def test_align_False_plain( + self, use_plain_text: UsePlainTextFixture, items: dict[str, Any] + ) -> None: + assert m.kvtable(items, align=False) == "\n".join( + f"{k} : {items[k]}" for k in items + ) + + @pytest.mark.skipif(colorama is None, reason="colorama required") + def test_keys(self) -> None: + items = self.THREE + keys = ("foo", "a") + N = max(len(m.key(k)) for k in items) + + assert m.kvtable(self.THREE, keys=keys) == "\n".join( + f"{m.key(k): <{N}} : {m.value(str(items[k]))}" for k in keys + ) + + def test_keys_plain(self, use_plain_text: UsePlainTextFixture) -> None: + items = self.THREE + keys = ("foo", "a") + N = max(len(m.key(k)) for k in items) + + assert m.kvtable(items, keys=keys) == "\n".join( + f"{k: <{N}} : {items[k]}" for k in keys + ) + + +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: + 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" * m.UI_WIDTH) + + @pytest.mark.skipif(colorama is None, reason="colorama required") + def test_N(self) -> None: + assert m.rule(N=60) == colors.cyan("-" * 60) + + @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( + 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: + 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" * m.UI_WIDTH + + def test_N_plain(self, use_plain_text: UsePlainTextFixture) -> None: + assert m.rule(N=60) == "-" * 60 + + def test_N_with_text_plain( + self, use_plain_text: UsePlainTextFixture + ) -> None: + front = "--- foo bar " + assert m.rule("foo bar", N=65) == front + "-" * (65 - len(front)) + + +@pytest.mark.skipif(colorama is None, reason="colorama required") +def test_section() -> None: + assert m.section("some section") == m.bright(m.white("some section")) + + +def test_section_plain(use_plain_text: UsePlainTextFixture) -> None: + assert m.section("some section") == "some section" + + +@pytest.mark.skipif(colorama is None, reason="colorama required") +def test_warn() -> None: + assert m.warn("some message") == m.magenta("WARNING: some message") + + +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" + ) 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))