diff --git a/23b/spack-environments b/23b/spack-environments new file mode 160000 index 0000000000000000000000000000000000000000..91ba21f40ff9e635da15a4c154360f5e969d6fe4 --- /dev/null +++ b/23b/spack-environments @@ -0,0 +1 @@ +Subproject commit 91ba21f40ff9e635da15a4c154360f5e969d6fe4 diff --git a/src/mpsd_software_manager/mpsd_software.py b/src/mpsd_software_manager/mpsd_software.py index b26344a4b570773dd2e997dba95dd06e70ca3847..95c7909f07717f7b41ebfc74a1507cc24a58a82e 100755 --- a/src/mpsd_software_manager/mpsd_software.py +++ b/src/mpsd_software_manager/mpsd_software.py @@ -16,6 +16,7 @@ import time from functools import cache from pathlib import Path from typing import List, Tuple, Union +from rich import print as rprint __version__ = importlib.metadata.version(__package__ or __name__) @@ -1259,8 +1260,36 @@ def start_new_environment(release, from_release, target_dir): raise NotImplementedError(msg) -def environment_status(mpsd_release: str, root_dir: Path) -> Union[dict, None]: - """Show status of release in installation. +def list_installed_releases(root_dir: Path, print_output: bool = False) -> List[str]: + """ + List installed releases. + + Parameters + ---------- + root_dir : pathlib.Path + + Returns + ------- + installed_releases : list + A list of strings representing the installed releases. + """ + plog = logging.getLogger("print") + list_of_files = os.listdir(root_dir) + installed_releases = [ + x for x in list_of_files if (root_dir / x / "spack-environments").exists() + ] + if print_output: + plog.info("Available MPSD software releases:") + for release in installed_releases: + plog.info(f" {release}") + return installed_releases + + +def list_installed_toolchains( + mpsd_release: str, root_dir: Path, print_output: bool = False +) -> Union[dict, None]: + """ + List installed toolchains. Parameters ---------- @@ -1270,6 +1299,8 @@ def environment_status(mpsd_release: str, root_dir: Path) -> Union[dict, None]: A Path object pointing to the root directory of the installation. Expect a subfolder root/mpsd_release in which we search for the toolchains. + print_output : bool, optional + A boolean indicating whether to print the output to the terminal. Returns ------- @@ -1280,10 +1311,7 @@ def environment_status(mpsd_release: str, root_dir: Path) -> Union[dict, None]: Note: only toolchains can be reported at the moment (i.e. package_sets such as global and global_generic are missing, even if installed). - """ - msg = f"Showing status of release {mpsd_release} in {root_dir}" - logging.info(msg) plog = logging.getLogger("print") release_base_dir = root_dir / mpsd_release microarch = get_native_microarchitecture() @@ -1334,16 +1362,182 @@ def environment_status(mpsd_release: str, root_dir: Path) -> Union[dict, None]: # pretty print the toolchain map key as the heading # and the value as the list of toolchains - plog.info(f"Installed toolchains ({mpsd_release}):\n") - for microarch, toolchains in toolchain_map.items(): - plog.info(f"- {microarch}") - for toolchain in toolchains: - plog.info(f" {toolchain}") - plog.info(f" [module use {str(release_base_dir / microarch / 'lmod/Core')}]") - plog.info("") + if print_output: + plog.info(f"Installed toolchains ({mpsd_release}):\n") + for microarch, toolchains in toolchain_map.items(): + plog.info(f"- {microarch}") + for toolchain in toolchains: + plog.info(f" {toolchain}") + plog.info( + f" [module use {str(release_base_dir / microarch / 'lmod/Core')}]" + ) + plog.info("") return toolchain_map +def pretty_print_spec(spec: str) -> None: + """ + Print the specs with colours using rich. + + - packages in white (everything until first %) + - compiler in green (everything between % and first+) + - variants in cyan (everything that starts with +) + - build_system in yellow (everything that starts with build_system=) + - architecture in purple (everything that starts with arch=) + """ + # Note that this implementation necessitates the definition of + # flags in the order in which we ask spack to format the output + # also for flags that need the same colour because they are + # interchangeable (like `+` and `~`) we need to define them together + colour_map = { + "%": "green", + "+": "cyan", + "~": "cyan", + "build_system=": "yellow", + "libs=": "blue", + "arch=": "purple", + } + + prev_colour = "" + + for flag in colour_map.keys(): + # If the flag is in the spec string, + # replace it with: previous closing colour, new colour, flag + if flag in spec: + if ( + colour_map[flag] not in prev_colour + ): # avoid duplicates for eg when having both ~ and + + spec = spec.replace(flag, f"{prev_colour}[{colour_map[flag]}]{flag}", 1) + prev_colour = f"[/{colour_map[flag]}]" # for next iter + + # Add the final closing tag to the spec string + spec += prev_colour + rprint(spec) + + +def list_installed_packages( + mpsd_release: str, root_dir: Path, package_set: str, microarch: str +) -> Union[List[str], None]: + """ + List installed packages and their specs. + + Uses `spack -e package_set find` to list the installed packages, + in the following format + "{name}{@versions}{%compiler.name}{@compiler.versions}{compiler_flags}{variants}{arch=architecture}" + + Parameters + ---------- + mpsd_release : str + A string representing the MPSD release version. + root_dir : pathlib.Path + A Path object pointing to the root directory of the installation. + Expect a subfolder root/mpsd_release in which we search for the + toolchains. + package_set : str + A string representing the package_sets to show the packages for. + microarch : str + A string representing the microarchitecture to show the packages for. + + Returns + ------- + list + A list of strings representing the packages installed for the + specified package_sets and microarch. + If the release is not installed/found, None is returned. + + """ + plog = logging.getLogger("print") + plog.info(f"listing packages installed for {package_set=}, {microarch=}") + spack_dir = root_dir / mpsd_release / microarch / "spack" + spack_env = spack_dir / "share" / "spack" / "setup-env.sh" + commands_to_execute = [ + f"export SPACK_ROOT={spack_dir}", # need to set SPACK_ROOT in dash and sh + f". {spack_env}", + f"spack -e {package_set}" + " find --format " + r"{name}{@versions}{%compiler.name}{@compiler.versions}{compiler_flags}{variants}{arch=architecture}", + ] + process = run( + " && ".join(commands_to_execute), shell=True, check=True, capture_output=True + ) + package_list = process.stdout.decode().strip().split("\n") + for package in package_list: + pretty_print_spec(package) + return package_list + + +def environment_status( + mpsd_release: str, root_dir: Path, package_set="NONE" +) -> Union[dict, List[str], None]: + """Show status of release in installation. + + - 1) If no mpsd_release, list available releases + - 2) If mpsd_release, list available toolchains + - 3) If mpsd_release and toolchain, list available packages + + Parameters + ---------- + mpsd_release : str + A string representing the MPSD release version. + root_dir : pathlib.Path + A Path object pointing to the root directory of the installation. + Expect a subfolder root/mpsd_release in which we search for the + toolchains. + package_set : str, optional + A string representing the package_sets to show the status for. + + Returns + ------- + installed_release : List[str] + A list of installed (valid) releases. + + OR + toolchain_map : dict + A dictionary containing available microarchitectures as keys and + a list of available package_sets as values for each microarchitecture. + If the release is not installed/found, None is returned. + + Note: only toolchains can be reported at the moment (i.e. package_sets + such as global and global_generic are missing, even if installed). + + OR + package_list : List[str] + A list of strings representing the packages installed for the + specified package_sets and microarch. + + """ + msg = f"Showing status of release {mpsd_release} in {root_dir}" + logging.info(msg) + if not mpsd_release: + # 1) if no mpsd_release is specified, list available releases + return list_installed_releases(root_dir=root_dir, print_output=True) + # 2) if mpsd_release is specified, list installed toolchains + # Test is the mpsd_release is valid + if mpsd_release not in list_installed_releases(root_dir=root_dir): + logging.error(f"MPSD release '{mpsd_release}' is not available.") + return None + if package_set == "NONE": + return list_installed_toolchains( + mpsd_release=mpsd_release, root_dir=root_dir, print_output=True + ) + # 3) if mpsd_release and toolchain is specified, list installed packages + # check that the package-set is a valid toolchain + if ( + package_set + not in list_installed_toolchains(mpsd_release=mpsd_release, root_dir=root_dir)[ + get_native_microarchitecture() + ] + ): + logging.error(f"Package-set '{package_set}' is not available.") + return None + return list_installed_packages( + mpsd_release=mpsd_release, + root_dir=root_dir, + package_set=package_set, + microarch=get_native_microarchitecture(), + ) + + def initialise_environment(root_dir: Path) -> None: """Initialize the software environment. @@ -1478,13 +1672,13 @@ def main(): else: # most commands except need a release version - if cmd in ["install", "prepare", "reinstall", "remove", "status"]: + if cmd in ["install", "prepare", "reinstall", "remove"]: subp.add_argument( "release", type=str, help="Release version to prepare, install, reinstall or remove", ) - elif cmd in ["available"]: + elif cmd in ["available", "status"]: # for some commands the release version is optional subp.add_argument( "release", @@ -1508,6 +1702,7 @@ def main(): default="NONE", help=package_set_help, ) + # TODO Move the enable-build-cache flag to only 'install' cmd subp.add_argument( "--enable-build-cache", action="store_true", @@ -1516,6 +1711,14 @@ def main(): "consumes time and disk space." ), ) + if cmd in ["status"]: + subp.add_argument( + "package_set", + type=str, + nargs="?", + default="NONE", + help="Package set to show status for.", + ) # Carry out the action args = parser.parse_args() @@ -1558,8 +1761,10 @@ def main(): args.loglevel, apex_log_file, ) - - record_script_execution_summary(root_dir, apex_log_file) + if args.action not in ["status", "available"]: + # record the script execution summary only if + # the action is one that changes files on disk + record_script_execution_summary(root_dir, apex_log_file) # Check the command and run related function if args.action == "remove": remove_environment(args.release, root_dir, args.package_set) @@ -1570,7 +1775,7 @@ def main(): args.release, args.package_set, root_dir, args.enable_build_cache ) elif args.action == "status": - _ = environment_status(args.release, root_dir) + environment_status(args.release, root_dir, args.package_set) elif args.action == "prepare": prepare_environment(args.release, root_dir) elif args.action == "available": diff --git a/tests/test_mpsd_software.py b/tests/test_mpsd_software.py index 0a503315a89740ec09b1d98dab7b272e5c25d27b..8e0375d59ec6a1b8cbcebf0efca9106053ed9330 100644 --- a/tests/test_mpsd_software.py +++ b/tests/test_mpsd_software.py @@ -482,6 +482,8 @@ def create_fake_environment(tmp_path, mpsd_release, expected_toolchain_map=None) test_microarch = mod.get_native_microarchitecture() expected_toolchain_map = {test_microarch: ["foss2021a", "intel2021a"]} + spe_folder = tmp_path / mpsd_release / "spack-environments" + spe_folder.mkdir(parents=True, exist_ok=True) for microarch in expected_toolchain_map.keys(): toolchain_lmod_folder = ( tmp_path / mpsd_release / microarch / "lmod" / "Core" / "toolchains" @@ -501,18 +503,68 @@ def create_fake_environment(tmp_path, mpsd_release, expected_toolchain_map=None) return expected_toolchain_map -def test_environment_status(tmp_path): - """Test that the environment status is correct.""" +def check_for_valid_spec_syntax(spec: str, package_name: str): + """Check if the spec is valid. + + Assuming the format of the spec as: + {name}{@versions}{%compiler.name}{@compiler.versions}{compiler_flags}{variants}{arch=architecture} + we ensure that: + - package name is correct + - there are atleast 2 @ symbols (version for package and compiler) + - there is atleast 1 % symbol (compiler specification) + - there is atleast 1 arch= + """ + assert spec.count("@") >= 2 + assert spec.count("%") >= 1 + assert "build_system=" in spec + assert "arch=" in spec + assert spec.split("@")[0] == package_name + + +def test_environment_status(tmp_path, simple_toolchain): + """Test that the environment status is correct. + + The status command has the following three usage: + + - 1) If no mpsd_release, list available releases + - 2) If mpsd_release, list available toolchains + - 3) If mpsd_release and toolchain, list available packages + + We need to test all the three cases. + """ + + # 1) If no mpsd_release, list available releases + list_of_release_in_empty_dir = mod.environment_status(None, tmp_path) + assert list_of_release_in_empty_dir == [] + (tmp_path / "test_case1" / "dev-23a" / "spack-environments").mkdir( + parents=True, exist_ok=True + ) + (tmp_path / "test_case1" / "fake_release").mkdir(parents=True, exist_ok=True) + list_of_release = mod.environment_status(None, tmp_path / "test_case1") + assert list_of_release == ["dev-23a"] + + # 2) If mpsd_release, list available toolchains toolchain_map = mod.environment_status("fake-release", tmp_path) assert toolchain_map is None mpsd_release = "dev-23a" expected_toolchain_map = create_fake_environment(tmp_path, mpsd_release) - # check that the environment statuxis is correct + # check that the environment status is is correct toolchain_map = mod.environment_status(mpsd_release, tmp_path) # convert each list to a set to ensure that the order doesn't matter for microarch in expected_toolchain_map.keys(): assert set(toolchain_map[microarch]) == set(expected_toolchain_map[microarch]) + # 3) If mpsd_release and toolchain, list available packages + install_test_release(tmp_path / "test_case3", simple_toolchain) + package_list = mod.environment_status( + mpsd_release, tmp_path / "test_case3", "toolchain2" + ) + assert len(package_list) == 2 # we installed zlib and zstd only + check_for_valid_spec_syntax( + package_list[0], "zlib" + ) # the list is always in alphabetical order + check_for_valid_spec_syntax(package_list[1], "zstd") + def test_initialise_environment(tmp_path): """Test that init_file is created as expected."""